authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-24 21:51:25-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-25 20:26:21-07:00
log1b0d6e5c862e65471df75c1239a1a111b9c11302
treebf5201a6d1ad36b0748cf5b351f0d1b9261d2f73
parentdc52eff3f6dfc98dc61d48517051cac6ad04d9de
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

refactor: move page interaction from the snowbound host into canvas

Hit layers, drags, the placement grid, picture handles, outline and picture chrome, key routing, scrolling, zoom, caret blink and the AccessKit tree lived in the winit host, mixed with window plumbing. They now live in canvas::interaction as PageView: hosts translate their events into its calls, carry out the requests it returns (date picker, clipboard, character picker) and follow its changes. Snowbound keeps the window, surface, AppKit pickers, clipboard and saving, and shrinks from 3.8k to under 1k lines. Behaviour is unchanged; the host tests moved with the logic, plus one driving PageView end to end. Assisted-by: claude-opus-5.5

15 files changed, 5470 insertions(+), 4895 deletions(-)

Cargo.lock+2-1
......@@ -393,6 +393,8 @@ dependencies = [
393393name = "canvas"
394394version = "0.1.0"
395395dependencies = [
396 "accesskit",
397 "accesskit_consumer",
396398 "bytemuck",
397399 "image",
398400 "onestore",
......@@ -2627,7 +2629,6 @@ name = "snowbound"
26272629version = "0.1.0"
26282630dependencies = [
26292631 "accesskit",
2630 "accesskit_consumer",
26312632 "accesskit_winit",
26322633 "arboard",
26332634 "canvas",
crates/canvas/Cargo.toml+4
......@@ -6,6 +6,8 @@ publish = false
66
77[features]
88gpu = ["dep:bytemuck", "dep:swash", "dep:wgpu", "dep:image", "dep:roxmltree"]
9# How a page responds to input and presents itself to assistive technology.
10interaction = ["gpu", "dep:accesskit"]
911
1012[dependencies]
1113onestore = { path = "../onestore" }
......@@ -19,6 +21,7 @@ swash = { version = "0.2.10", optional = true }
1921wgpu = { workspace = true, optional = true }
2022image = { version = "0.25", default-features = false, features = ["gif", "jpeg", "png"], optional = true }
2123roxmltree = { version = "0.21", optional = true }
24accesskit = { version = "0.25.0", optional = true }
2225
2326[[bin]]
2427name = "layout-probe"
......@@ -29,6 +32,7 @@ name = "page-probe"
2932path = "src/bin/page_probe.rs"
3033
3134[dev-dependencies]
35accesskit_consumer = "0.39.0"
3236pollster = "0.4"
3337png = "0.18"
3438
crates/canvas/src/interaction/accessibility.rs created+1478
......@@ -0,0 +1,1478 @@
1use crate::gpu::Viewport;
2use crate::{
3 date::DateField,
4 document::TextPosition,
5 editor::{CanvasEditor, Selection, TextOutline},
6};
7use accesskit::{
8 Action, Affine, Node, NodeId, Rect, Role, TextDirection, TextSelection, TreeId, TreeInfo,
9 TreeUpdate,
10};
11use onestore::page::text::EditError;
12use parley::Affinity;
13use std::{collections::HashMap, ops::Range};
14
15pub const ROOT: NodeId = NodeId(0);
16
17struct Run {
18 outline: onestore::ExGuid,
19 id: NodeId,
20 node: Node,
21 positions: Vec<TextPosition>,
22}
23
24#[derive(Hash, PartialEq, Eq)]
25struct ParagraphKey {
26 layout: u64,
27 source: usize,
28 next: Option<usize>,
29 origin: [u32; 2],
30}
31
32pub struct Accessibility {
33 outlines: Vec<(onestore::ExGuid, NodeId)>,
34 runs: Vec<Run>,
35 paragraphs: HashMap<ParagraphKey, Range<usize>>,
36 read_only: Vec<NodeId>,
37 dates: Vec<(DateField, NodeId)>,
38 next_id: u64,
39}
40
41impl Default for Accessibility {
42 fn default() -> Self {
43 Self {
44 outlines: Vec::new(),
45 runs: Vec::new(),
46 paragraphs: HashMap::new(),
47 read_only: Vec::new(),
48 dates: Vec::new(),
49 next_id: 1,
50 }
51 }
52}
53
54impl Accessibility {
55 /// Retire published runs without recycling their IDs.
56 pub fn deactivate(&mut self) {
57 self.runs.clear();
58 self.paragraphs.clear();
59 self.outlines.clear();
60 self.read_only.clear();
61 self.dates.clear();
62 }
63
64 pub fn append_page_fields(
65 &mut self,
66 update: &mut TreeUpdate,
67 scene: Option<&(crate::gpu::page::PageScene, [f32; 2])>,
68 editor: &CanvasEditor,
69 viewport: Viewport,
70 focus: Option<usize>,
71 ) {
72 let mut count = 0;
73 if let Some((scene, offset)) = scene {
74 for (index, object) in scene.read_only(Some(editor)).enumerate() {
75 if index == self.read_only.len() {
76 let id = self.allocate();
77 self.read_only.push(id);
78 }
79 let id = self.read_only[index];
80 let mut node = Node::new(Role::Label);
81 node.set_value(object.message);
82 node.set_read_only();
83 node.add_action(Action::Focus);
84 if focus == Some(index) {
85 update.focus = id;
86 }
87 let [x0, y0, x1, y1] = object.rect();
88 let x = |v| f64::from((v + offset[0]) * viewport.scale + viewport.origin[0]);
89 let y = |v| f64::from((v + offset[1]) * viewport.scale + viewport.origin[1]);
90 node.set_bounds(Rect::new(x(x0), y(y0), x(x1), y(y1)));
91 update
92 .nodes
93 .iter_mut()
94 .find(|(id, _)| *id == ROOT)
95 .unwrap()
96 .1
97 .push_child(id);
98 update.nodes.push((id, node));
99 count += 1;
100 }
101 }
102 self.read_only.truncate(count);
103 let mut date_count = 0;
104 if let Some((scene, offset)) = scene {
105 for (index, (field, rect)) in scene.date_fields(editor).enumerate() {
106 if index == self.dates.len() {
107 let id = self.allocate();
108 self.dates.push((field, id));
109 }
110 self.dates[index].0 = field;
111 let id = self.dates[index].1;
112 let mut node = Node::new(Role::Button);
113 node.set_label(super::DATE_LABELS[field as usize]);
114 node.set_value(
115 editor.date().unwrap().source().paragraphs[index]
116 .text()
117 .unwrap()
118 .text
119 .text(),
120 );
121 node.add_action(Action::Click);
122 let x = |v| f64::from((v + offset[0]) * viewport.scale + viewport.origin[0]);
123 let y = |v| f64::from((v + offset[1]) * viewport.scale + viewport.origin[1]);
124 node.set_bounds(Rect::new(x(rect[0]), y(rect[1]), x(rect[2]), y(rect[3])));
125 update
126 .nodes
127 .iter_mut()
128 .find(|(id, _)| *id == ROOT)
129 .unwrap()
130 .1
131 .push_child(id);
132 update.nodes.push((id, node));
133 date_count += 1;
134 }
135 }
136 self.dates.truncate(date_count);
137 }
138
139 fn allocate(&mut self) -> NodeId {
140 let id = NodeId(self.next_id);
141 self.next_id = self
142 .next_id
143 .checked_add(1)
144 .expect("Accessibility node IDs exhausted");
145 id
146 }
147
148 pub fn read_only_for_node(&self, node: NodeId) -> Option<usize> {
149 self.read_only.iter().position(|id| *id == node)
150 }
151
152 pub fn date_for_node(&self, node: NodeId) -> Option<DateField> {
153 self.dates
154 .iter()
155 .find(|(_, id)| *id == node)
156 .map(|(field, _)| *field)
157 }
158
159 pub fn outline_for_node(&self, node: NodeId) -> Option<onestore::ExGuid> {
160 self.outlines
161 .iter()
162 .find(|(_, id)| *id == node)
163 .map(|(outline, _)| *outline)
164 }
165
166 pub fn update(
167 &mut self,
168 editor: &CanvasEditor,
169 viewport: Viewport,
170 title: &str,
171 preview: Option<(onestore::ExGuid, [f32; 2])>,
172 ) -> Result<TreeUpdate, EditError> {
173 let mut runs = Vec::new();
174 let mut outlines = Vec::new();
175 let mut paragraph_ranges = HashMap::new();
176 for outline in editor.visible_outlines().chain(editor.caret_outline()) {
177 let field = self
178 .outlines
179 .iter()
180 .find(|(id, _)| *id == outline.id)
181 .map(|(_, node)| *node)
182 .unwrap_or_else(|| self.allocate());
183 outlines.push((outline.id, field));
184 let mut paragraphs = outline.layouts().peekable();
185 while let Some((paragraph, shaped)) = paragraphs.next() {
186 let layout = &shaped.text;
187 let origin = shaped.origin;
188 let key = ParagraphKey {
189 layout: layout.id(),
190 source: paragraph,
191 next: paragraphs.peek().map(|(next, _)| *next),
192 origin: origin.map(f32::to_bits),
193 };
194 let start = runs.len();
195 if let Some(range) = self.paragraphs.get(&key) {
196 runs.extend(
197 self.runs[range.clone()]
198 .iter()
199 .map(|run| (run.outline, run.node.clone(), run.positions.clone())),
200 );
201 paragraph_ranges.insert(key, start..runs.len());
202 continue;
203 }
204 let projection = &shaped.projection;
205 let text = projection.text().text();
206 let boundaries: Vec<_> = projection.source_boundaries().collect();
207 let source_position = |byte| -> Result<TextPosition, EditError> {
208 let index = boundaries
209 .binary_search_by_key(&byte, |(byte, _)| *byte)
210 .map_err(|_| EditError::InvalidRange)?;
211 Ok(TextPosition {
212 paragraph,
213 offset: boundaries[index].1,
214 })
215 };
216 let line_count = layout.lines().count();
217 for (line_index, (line, bounds)) in layout.lines().enumerate() {
218 let line_start = runs.len();
219 let mut x = line.metrics().offset;
220 let mut logical_runs = Vec::new();
221 for run in line.runs() {
222 let advance = run.advance();
223 logical_runs.push((run, x));
224 x += advance;
225 }
226 logical_runs.sort_by_key(|(run, _)| run.text_range().start);
227 for (run, x) in logical_runs {
228 // AccessKit stores each character's byte length in a u8.
229 let mut characters = Vec::new();
230 for cluster in run.clusters() {
231 let range = cluster.text_range();
232 if range.is_empty() || range.start == text.len() {
233 continue;
234 }
235 let cluster_text =
236 text.get(range.clone()).ok_or(EditError::InvalidRange)?;
237 if range.len() <= u8::MAX as usize {
238 characters.push((
239 range,
240 cluster.advance(),
241 cluster.is_word_boundary(),
242 ));
243 } else {
244 for (index, (offset, ch)) in cluster_text.char_indices().enumerate()
245 {
246 characters.push((
247 range.start + offset..range.start + offset + ch.len_utf8(),
248 if index == 0 { cluster.advance() } else { 0.0 },
249 index == 0 && cluster.is_word_boundary(),
250 ));
251 }
252 }
253 }
254 let mut advance = 0.0;
255 for chunk in characters.chunks(256) {
256 let width: f32 = chunk.iter().map(|(_, width, _)| width).sum();
257 let left = x + if run.is_rtl() {
258 run.advance() - advance - width
259 } else {
260 advance
261 };
262 let mut node = Node::new(Role::TextRun);
263 node.set_bounds(Rect::new(
264 f64::from(left + origin[0]),
265 f64::from(bounds.top + origin[1]),
266 f64::from(left + width + origin[0]),
267 f64::from(bounds.top + bounds.height + origin[1]),
268 ));
269 node.set_text_direction(if run.is_rtl() {
270 TextDirection::RightToLeft
271 } else {
272 TextDirection::LeftToRight
273 });
274 node.set_font_size(run.font_size());
275 let start = chunk[0].0.start;
276 let end = chunk.last().unwrap().0.end;
277 node.set_value(text.get(start..end).ok_or(EditError::InvalidRange)?);
278 node.set_character_lengths(
279 chunk
280 .iter()
281 .map(|(range, _, _)| u8::try_from(range.len()).unwrap())
282 .collect::<Vec<_>>(),
283 );
284 let mut offset = 0.0;
285 node.set_character_positions(
286 chunk
287 .iter()
288 .map(|(_, width, _)| {
289 let position = offset;
290 offset += width;
291 position
292 })
293 .collect::<Vec<_>>(),
294 );
295 node.set_character_widths(
296 chunk.iter().map(|(_, width, _)| *width).collect::<Vec<_>>(),
297 );
298 node.set_word_starts(
299 chunk
300 .iter()
301 .enumerate()
302 .filter_map(|(index, (_, _, word))| word.then_some(index as u8))
303 .collect::<Vec<_>>(),
304 );
305 let positions = chunk
306 .iter()
307 .map(|(range, _, _)| source_position(range.start))
308 .chain(std::iter::once(source_position(end)))
309 .collect::<Result<Vec<_>, _>>()?;
310 runs.push((outline.id, node, positions));
311 advance += width;
312 }
313 }
314 // Empty lines still need a text position for selection and insertion.
315 if runs.len() == line_start {
316 let mut node = Node::new(Role::TextRun);
317 node.set_value("");
318 node.set_character_lengths(Vec::<u8>::new());
319 node.set_character_positions(Vec::<f32>::new());
320 node.set_character_widths(Vec::<f32>::new());
321 node.set_text_direction(TextDirection::LeftToRight);
322 node.set_bounds(Rect::new(
323 f64::from(origin[0]),
324 f64::from(bounds.top + origin[1]),
325 f64::from(origin[0]),
326 f64::from(bounds.top + bounds.height + origin[1]),
327 ));
328 runs.push((
329 outline.id,
330 node,
331 vec![source_position(bounds.source.start)?],
332 ));
333 }
334 if line_index + 1 == line_count
335 && let Some((next, _)) = paragraphs.peek()
336 {
337 let (_, node, source_positions) = runs.last_mut().unwrap();
338 let mut value = node.value().unwrap().to_owned();
339 value.push('\n');
340 node.set_value(value);
341 let mut lengths = node.character_lengths().to_vec();
342 lengths.push(1);
343 node.set_character_lengths(lengths);
344 if let Some(rect) = node.bounds() {
345 let mut positions =
346 node.character_positions().unwrap_or_default().to_vec();
347 positions.push(rect.width() as f32);
348 node.set_character_positions(positions);
349 let mut widths = node.character_widths().unwrap_or_default().to_vec();
350 widths.push(4.0);
351 node.set_character_widths(widths);
352 }
353 source_positions.push(TextPosition {
354 paragraph: *next,
355 offset: 0,
356 });
357 }
358 }
359 if !shaped.tags.is_empty() {
360 let descriptions = shaped
361 .tags
362 .iter()
363 .map(|tag| {
364 use crate::outline::TagIcon;
365 let fallback = match tag.icon {
366 TagIcon::CheckBox { .. } => "To do",
367 TagIcon::Question => "Question",
368 TagIcon::Music => "Music",
369 TagIcon::Exclamation => "Critical",
370 TagIcon::RedSquare => "Project A",
371 TagIcon::YellowSquare => "Project B",
372 TagIcon::BlueSquare => "Project C",
373 };
374 let label = if tag.label.is_empty() {
375 fallback
376 } else {
377 &tag.label
378 };
379 let state = match tag.icon {
380 TagIcon::CheckBox { checked: true } => ", completed",
381 TagIcon::CheckBox { checked: false } => ", incomplete",
382 _ => "",
383 };
384 format!(
385 "{label}{state}{}",
386 if tag.disabled { ", disabled" } else { "" }
387 )
388 })
389 .collect::<Vec<_>>()
390 .join("; ");
391 runs[start].1.set_description(descriptions);
392 }
393 paragraph_ranges.insert(key, start..runs.len());
394 }
395 }
396 let mut runs: Vec<_> = runs
397 .into_iter()
398 .enumerate()
399 .map(|(index, (outline, mut node, positions))| {
400 node.clear_next_on_line();
401 node.clear_previous_on_line();
402 let id = match self.runs.get(index) {
403 Some(old)
404 if old.outline == outline
405 && old.positions == positions
406 && old.node.value() == node.value() =>
407 {
408 old.id
409 }
410 _ => self.allocate(),
411 };
412 Run {
413 outline,
414 id,
415 node,
416 positions,
417 }
418 })
419 .collect();
420 for index in 1..runs.len() {
421 if runs[index - 1].outline == runs[index].outline
422 && runs[index - 1].node.bounds().map(|r| r.y0)
423 == runs[index].node.bounds().map(|r| r.y0)
424 {
425 let (before, after) = runs.split_at_mut(index);
426 before[index - 1].node.set_next_on_line(after[0].id);
427 after[0].node.set_previous_on_line(before[index - 1].id);
428 }
429 }
430 self.runs = runs;
431 self.paragraphs = paragraph_ranges;
432 self.outlines = outlines;
433 let mut root = Node::new(Role::Window);
434 root.set_label(title);
435 root.set_children(self.outlines.iter().map(|(_, id)| *id).collect::<Vec<_>>());
436 root.set_bounds(Rect::new(
437 0.0,
438 0.0,
439 viewport.size[0] as f64,
440 viewport.size[1] as f64,
441 ));
442 let mut nodes = vec![(ROOT, root)];
443 let mut focus = ROOT;
444 for (index, (outline, (_, id))) in editor
445 .visible_outlines()
446 .chain(editor.caret_outline())
447 .zip(&self.outlines)
448 .enumerate()
449 {
450 let mut field = Node::new(Role::MultilineTextInput);
451 field.set_label(if outline.title {
452 "Page title".into()
453 } else if editor
454 .caret_outline()
455 .is_some_and(|caret| caret.id == outline.id)
456 {
457 "Text input".into()
458 } else {
459 format!("Text outline {}", index + 1)
460 });
461 field.add_action(Action::Focus);
462 field.add_action(Action::SetTextSelection);
463 field.add_action(Action::ReplaceSelectedText);
464 field.add_action(Action::SetValue);
465 let runs = self.runs.iter().filter(|run| run.outline == outline.id);
466 field.set_value(
467 runs.clone()
468 .filter_map(|run| run.node.value())
469 .collect::<String>(),
470 );
471 field.set_children(runs.map(|run| run.id).collect::<Vec<_>>());
472 let origin = preview
473 .filter(|(id, _)| *id == outline.id)
474 .map(|(_, origin)| origin)
475 .unwrap_or_else(|| outline.origin());
476 field.set_transform(Affine::new([
477 f64::from(viewport.scale),
478 0.0,
479 0.0,
480 f64::from(viewport.scale),
481 f64::from(viewport.origin[0]) + f64::from(origin[0]) * f64::from(viewport.scale),
482 f64::from(viewport.origin[1]) + f64::from(origin[1]) * f64::from(viewport.scale),
483 ]));
484 let bounds = outline.bounds();
485 field.set_bounds(Rect::new(0.0, 0.0, bounds.width(), bounds.height()));
486 if outline.id == editor.active_outline().id {
487 let Selection {
488 positions: [anchor, caret],
489 affinities,
490 } = editor.selection();
491 field.set_text_selection(TextSelection {
492 anchor: self.position(outline, anchor, affinities[0])?,
493 focus: self.position(outline, caret, affinities[1])?,
494 });
495 focus = *id;
496 }
497 nodes.push((*id, field));
498 }
499 nodes.extend(self.runs.iter().map(|run| (run.id, run.node.clone())));
500 Ok(TreeUpdate {
501 nodes,
502 tree: Some(TreeInfo::new(ROOT)),
503 tree_id: TreeId::ROOT,
504 focus,
505 })
506 }
507
508 fn position(
509 &self,
510 outline: &TextOutline,
511 mut position: TextPosition,
512 affinity: Affinity,
513 ) -> Result<accesskit::TextPosition, EditError> {
514 let shaped = outline.paragraph_layout(position.paragraph)?;
515 let projection = &shaped.projection;
516 let layout = &shaped.text;
517 let origin = shaped.origin;
518 let cursor = layout.cursor(
519 projection
520 .text()
521 .byte_offset(projection.visible_offset(position.offset)?)?,
522 affinity,
523 );
524 position.offset = projection.source_offset(
525 projection.text().utf16_offset(cursor.index())?,
526 onestore::page::text::Affinity::Downstream,
527 )?;
528 let caret = layout.caret(cursor, 0.0);
529 let mut positions = self
530 .runs
531 .iter()
532 .filter(|run| {
533 run.outline == outline.id
534 && run
535 .node
536 .bounds()
537 .is_some_and(|rect| rect.y0 == f64::from(caret.y0 as f32 + origin[1]))
538 })
539 .filter_map(|run| {
540 run.positions
541 .iter()
542 .position(|p| *p == position)
543 .map(|character_index| accesskit::TextPosition {
544 node: run.id,
545 character_index,
546 })
547 });
548 match affinity {
549 Affinity::Upstream => positions.next(),
550 Affinity::Downstream => positions.next_back(),
551 }
552 .ok_or(EditError::InvalidRange)
553 }
554
555 pub fn selection(
556 &self,
557 outline: onestore::ExGuid,
558 selection: TextSelection,
559 ) -> Result<Selection, EditError> {
560 let position = |position: accesskit::TextPosition| {
561 let run = self
562 .runs
563 .iter()
564 .find(|run| run.outline == outline && run.id == position.node)
565 .ok_or(EditError::InvalidRange)?;
566 let source = *run
567 .positions
568 .get(position.character_index)
569 .ok_or(EditError::InvalidRange)?;
570 let affinity = if position.character_index > 0
571 && position.character_index + 1 == run.positions.len()
572 {
573 Affinity::Upstream
574 } else {
575 Affinity::Downstream
576 };
577 Ok::<_, EditError>((source, affinity))
578 };
579 let (anchor, anchor_affinity) = position(selection.anchor)?;
580 let (focus, focus_affinity) = position(selection.focus)?;
581 Ok(Selection {
582 positions: [anchor, focus],
583 affinities: [anchor_affinity, focus_affinity],
584 })
585 }
586}
587
588#[cfg(test)]
589mod tests {
590 use super::*;
591 use crate::{document::TextDocument, layout::TextEngine};
592 use onestore::document::Format;
593 use onestore::page::text::Paragraph;
594
595 #[test]
596 fn title_flow_updates_read_only_hit_and_accessibility_bounds() {
597 use onestore::page::{Page, PageObject, Title, Unsupported};
598 let mut engine = TextEngine::default();
599 let mut title = TextOutline::new(
600 &mut engine,
601 TextDocument::new(vec![Paragraph::new(
602 "Header".into(),
603 Format {
604 font: Some("Arial".into()),
605 font_size: Some(8.0),
606 line_spacing: Some(20.0),
607 ..Default::default()
608 },
609 )])
610 .unwrap(),
611 468.0,
612 [0.0; 2],
613 )
614 .unwrap()
615 .snapshot();
616 title.title = true;
617 let page = Page {
618 identity: None,
619 title: "Header".into(),
620 created: None,
621 margin_origin: [36.0, 14.4],
622 definitions: Default::default(),
623 objects: vec![
624 PageObject::Title(Title {
625 id: Default::default(),
626 date: None,
627 layout: Default::default(),
628 outlines: vec![title],
629 }),
630 PageObject::Unsupported(Unsupported {
631 id: Default::default(),
632 jcid: 0xdead,
633 layout: onestore::document::Layout {
634 x: Some(120.0),
635 y: Some(80.0),
636 max_width: Some(200.0),
637 max_height: Some(60.0),
638 ..Default::default()
639 },
640 }),
641 ],
642 };
643 let (scene, mut editor) =
644 crate::gpu::page::PageScene::from_page(page, &mut engine).unwrap();
645 let scene = (scene, [30.0, 40.0]);
646 let viewport = Viewport {
647 size: [800, 600],
648 origin: [10.0, -20.0],
649 scale: 2.0,
650 };
651 let mut access = Accessibility::default();
652 let mut identity = None;
653 for phase in 0..4 {
654 match phase {
655 1 => {
656 editor.select_all().unwrap();
657 editor.insert(&mut engine, "Header\u{000b}Second\u{000b}Third\u{000b}Fourth\u{000b}Fifth\u{000b}Sixth").unwrap();
658 }
659 2 => {
660 editor.undo(&mut engine).unwrap();
661 }
662 3 => {
663 editor.redo(&mut engine).unwrap();
664 }
665 _ => {}
666 }
667 let mut update = access.update(&editor, viewport, "Test", None).unwrap();
668 access.append_page_fields(&mut update, Some(&scene), &editor, viewport, Some(0));
669 let (id, node) = update
670 .nodes
671 .iter()
672 .find(|(_, node)| node.role() == Role::Label)
673 .unwrap();
674 if let Some(identity) = identity {
675 assert_eq!(*id, identity);
676 }
677 identity = Some(*id);
678 let y = if phase % 2 == 0 { 80.0 } else { 145.92 };
679 let expected = Rect::new(
680 310.0,
681 f64::from((y + 40.0) * 2.0 - 20.0),
682 710.0,
683 f64::from((y + 100.0) * 2.0 - 20.0),
684 );
685 let bounds = node.bounds().unwrap();
686 assert_eq!(bounds.x0, expected.x0);
687 assert_eq!(bounds.x1, expected.x1);
688 assert!((bounds.y0 - expected.y0).abs() < 0.001);
689 assert!((bounds.y1 - expected.y1).abs() < 0.001);
690 assert_eq!(
691 super::super::page_hit_test(&editor, Some(&scene), [155.0, y + 45.0], 1.0),
692 Some(super::super::Hit::ReadOnly(0))
693 );
694 if phase % 2 == 1 {
695 assert_ne!(
696 super::super::page_hit_test(&editor, Some(&scene), [155.0, 125.0], 1.0),
697 Some(super::super::Hit::ReadOnly(0))
698 );
699 }
700 }
701 }
702
703 #[test]
704 fn read_only_objects_keep_accessibility_identity_through_edits_and_view_changes() {
705 use crate::gpu::page::PageScene;
706 use onestore::page::{Page, PageObject, Unsupported};
707 let page = Page {
708 identity: None,
709 created: None,
710 title: String::new(),
711 margin_origin: [0.0; 2],
712 definitions: Default::default(),
713 objects: vec![PageObject::Unsupported(Unsupported {
714 id: Default::default(),
715 jcid: 0xdead,
716 layout: onestore::document::Layout {
717 x: Some(10.0),
718 y: Some(20.0),
719 max_width: Some(200.0),
720 max_height: Some(60.0),
721 ..Default::default()
722 },
723 })],
724 };
725 let mut engine = TextEngine::default();
726 let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap();
727 let scene = (scene, [30.0, 40.0]);
728 let mut access = Accessibility::default();
729 let mut identity = None;
730 for scale in [1.0, 2.0, 0.5] {
731 editor.insert(&mut engine, "annotation ").unwrap();
732 let viewport = Viewport {
733 size: [800, 600],
734 origin: [-200.0, 10.0],
735 scale,
736 };
737 let mut update = access.update(&editor, viewport, "Test", None).unwrap();
738 access.append_page_fields(&mut update, Some(&scene), &editor, viewport, None);
739 let (id, node) = update
740 .nodes
741 .iter()
742 .find(|(_, node)| node.role() == Role::Label)
743 .unwrap();
744 if let Some(identity) = identity {
745 assert_eq!(*id, identity);
746 }
747 identity = Some(*id);
748 assert_eq!(node.value(), Some("Unsupported content\nRead-only"));
749 assert!(node.is_read_only());
750 assert!(node.supports_action(Action::Focus));
751 for action in [
752 Action::SetValue,
753 Action::ReplaceSelectedText,
754 Action::SetTextSelection,
755 ] {
756 assert!(!node.supports_action(action));
757 }
758 assert_eq!(
759 node.bounds(),
760 Some(Rect::new(
761 f64::from(40.0 * scale - 200.0),
762 f64::from(60.0 * scale + 10.0),
763 f64::from(240.0 * scale - 200.0),
764 f64::from(120.0 * scale + 10.0)
765 ))
766 );
767 assert_eq!(access.outline_for_node(*id), None);
768 let tree = accesskit_consumer::Tree::new(update, true);
769 assert_eq!(
770 tree.state().focus().unwrap().document_range().text(),
771 "annotation "
772 );
773 let mut focused = access.update(&editor, viewport, "Test", None).unwrap();
774 access.append_page_fields(&mut focused, Some(&scene), &editor, viewport, Some(0));
775 assert_eq!(focused.focus, identity.unwrap());
776 assert_eq!(access.read_only_for_node(focused.focus), Some(0));
777 let tree = accesskit_consumer::Tree::new(focused, true);
778 assert_eq!(
779 tree.state().focus().unwrap().value().as_deref(),
780 Some("Unsupported content\nRead-only")
781 );
782 editor.undo(&mut engine).unwrap();
783 }
784 access.deactivate();
785 assert!(access.read_only.is_empty());
786 }
787
788 #[test]
789 fn tag_descriptions_preserve_plain_text_and_survive_edit_undo_and_cache_reuse() {
790 use onestore::page::{Definition, Outline};
791 use onestore::{
792 ExGuid,
793 document::{Kind, Layout, Tag},
794 };
795 use std::collections::BTreeMap;
796 let text = TextDocument::new(
797 ["task one", "task two", "question", "music"]
798 .into_iter()
799 .map(|text| Paragraph::new(text.into(), Format::default()))
800 .collect(),
801 )
802 .unwrap();
803 let mut nodes = text.nodes().to_vec();
804 let mut definitions = BTreeMap::new();
805 for (index, (shape, label, status)) in [
806 (3, Some("Rehearsal"), 0),
807 (3, Some("Rehearsal"), 1),
808 (15, None, 1),
809 (121, None, 3),
810 ]
811 .into_iter()
812 .enumerate()
813 {
814 let id = ExGuid {
815 n: index as u32 + 500,
816 ..ExGuid::default()
817 };
818 definitions.insert(
819 id,
820 Definition {
821 kind: Kind::TagDefinition {
822 label: label.map(str::to_owned),
823 action_type: None,
824 shape: Some(shape),
825 color: None,
826 highlight: None,
827 },
828 format: Format::default(),
829 },
830 );
831 nodes[index].text_mut().unwrap().tags.push(Tag {
832 definition: Some(id),
833 action_type: None,
834 status,
835 created: None,
836 completed: None,
837 start: None,
838 due: None,
839 task_id: None,
840 extra_set: 0,
841 });
842 }
843 let mut engine = TextEngine::default();
844 let mut editor = CanvasEditor::from_outlines(
845 &mut engine,
846 vec![Outline {
847 title: false,
848 min_width: None,
849 id: ExGuid {
850 n: 900,
851 ..ExGuid::default()
852 },
853 layout: Layout {
854 max_width: Some(120.0),
855 ..Layout::default()
856 },
857 indents: vec![18.0, 0.0],
858 paragraphs: nodes,
859 unsupported: Vec::new(),
860 }],
861 definitions,
862 )
863 .unwrap();
864 let mut access = Accessibility::default();
865 let viewport = Viewport {
866 size: [800, 600],
867 origin: [48.0; 2],
868 scale: 2.0,
869 };
870 for step in 0..4 {
871 if step == 2 {
872 editor.insert(&mut engine, "new ").unwrap();
873 }
874 if step == 3 {
875 editor.undo(&mut engine).unwrap();
876 }
877 let update = access.update(&editor, viewport, "Test", None).unwrap();
878 assert_eq!(
879 update
880 .nodes
881 .iter()
882 .filter_map(|(_, n)| n.description())
883 .collect::<Vec<_>>(),
884 [
885 "Rehearsal, incomplete",
886 "Rehearsal, completed",
887 "Question",
888 "Music, disabled"
889 ]
890 );
891 let tree = accesskit_consumer::Tree::new(update, true);
892 let text = tree.state().focus().unwrap().document_range().text();
893 assert_eq!(
894 text,
895 if step == 2 {
896 "new task one\ntask two\nquestion\nmusic"
897 } else {
898 "task one\ntask two\nquestion\nmusic"
899 }
900 );
901 }
902 }
903
904 #[test]
905 fn collapsed_paragraphs_keep_visible_accessibility_ranges_in_source_coordinates() {
906 let document = TextDocument::new(
907 [
908 "root",
909 "folded",
910 "hidden",
911 "last",
912 "folded tail",
913 "hidden tail",
914 ]
915 .into_iter()
916 .map(|text| Paragraph::new(text.into(), Format::default()))
917 .collect(),
918 )
919 .unwrap();
920 let mut nodes = document.nodes().to_vec();
921 for (child, parent, level) in [(1, 0, 2), (2, 1, 3), (4, 3, 2), (5, 4, 3)] {
922 nodes[child].parent = Some(nodes[parent].id);
923 nodes[child].level = level;
924 }
925 nodes[1].collapsed = true;
926 nodes[4].collapsed = true;
927 let mut engine = TextEngine::default();
928 let mut editor =
929 CanvasEditor::new(&mut engine, TextDocument::from_nodes(nodes).unwrap(), 180.0)
930 .unwrap();
931 let mut access = Accessibility::default();
932 let viewport = Viewport {
933 size: [900, 700],
934 scale: 2.0,
935 origin: [13.0, 19.0],
936 };
937 let update = access.update(&editor, viewport, "Test", None).unwrap();
938 let tree = accesskit_consumer::Tree::new(update, true);
939 let field = tree.state().focus().unwrap();
940 assert_eq!(
941 field.document_range().text(),
942 "root\nfolded\nlast\nfolded tail"
943 );
944 assert!(
945 access
946 .runs
947 .iter()
948 .flat_map(|run| &run.positions)
949 .all(|position| ![2, 5].contains(&position.paragraph))
950 );
951 let source = access
952 .selection(
953 editor.active_outline().id,
954 field.document_range().to_text_selection(),
955 )
956 .unwrap();
957 assert_eq!(
958 source.positions[0],
959 TextPosition {
960 paragraph: 0,
961 offset: 0
962 }
963 );
964 assert_eq!(
965 source.positions[1],
966 TextPosition {
967 paragraph: 4,
968 offset: 11
969 }
970 );
971 editor.select(source).unwrap();
972 let update = access.update(&editor, viewport, "Test", None).unwrap();
973 let tree = accesskit_consumer::Tree::new(update, true);
974 assert_eq!(
975 tree.state()
976 .focus()
977 .unwrap()
978 .text_selection()
979 .unwrap()
980 .text(),
981 "root\nfolded\nlast\nfolded tail"
982 );
983 editor
984 .select(
985 [TextPosition {
986 paragraph: 4,
987 offset: 0,
988 }; 2]
989 .into(),
990 )
991 .unwrap();
992 access.update(&editor, viewport, "Test", None).unwrap();
993 let caret = editor.caret(1.0).unwrap();
994 assert_eq!(caret.x0, 27.0);
995 let position = access
996 .position(
997 editor.active_outline(),
998 editor.selection().positions[1],
999 Affinity::Downstream,
1000 )
1001 .unwrap();
1002 let run = access
1003 .runs
1004 .iter()
1005 .find(|run| run.id == position.node)
1006 .unwrap();
1007 assert_eq!(run.node.bounds().unwrap().x0, 27.0);
1008 assert_eq!(run.node.bounds().unwrap().y0, caret.y0);
1009 }
1010
1011 #[test]
1012 fn consumer_reads_unicode_blank_paragraphs_and_hidden_fields() {
1013 let cases = [
1014 "",
1015 "a\n\nשלום\nend",
1016 "café e\u{301} 🌳 👩‍👩‍👧‍👦 words wrap here",
1017 "\n\n",
1018 "a\nb\n",
1019 ];
1020 let mut engine = TextEngine::default();
1021 for text in cases
1022 .into_iter()
1023 .map(str::to_owned)
1024 .chain([format!("a{}", "\u{301}".repeat(300)), "x".repeat(600)])
1025 {
1026 for width in [24.0, 480.0, 10000.0] {
1027 let document = TextDocument::new(
1028 text.split('\n')
1029 .map(|line| {
1030 Paragraph::from_runs([
1031 (
1032 String::from("secret"),
1033 Format {
1034 hidden: Some(true),
1035 ..Format::default()
1036 },
1037 ),
1038 (line.to_owned(), Format::default()),
1039 ])
1040 })
1041 .collect(),
1042 )
1043 .unwrap();
1044 let mut editor = CanvasEditor::new(&mut engine, document, width).unwrap();
1045 let mut access = Accessibility::default();
1046 let viewport = Viewport {
1047 size: [800, 600],
1048 origin: [48.0; 2],
1049 scale: 2.0,
1050 };
1051 let update = access.update(&editor, viewport, "Test", None).unwrap();
1052 for (_, node) in &update.nodes {
1053 if node.role() == Role::TextRun {
1054 assert_eq!(
1055 node.character_lengths()
1056 .iter()
1057 .map(|n| *n as usize)
1058 .sum::<usize>(),
1059 node.value().unwrap().len()
1060 );
1061 if let Some(positions) = node.character_positions() {
1062 assert_eq!(positions.len(), node.character_lengths().len());
1063 }
1064 if let Some(widths) = node.character_widths() {
1065 assert_eq!(widths.len(), node.character_lengths().len());
1066 }
1067 }
1068 }
1069 let tree = accesskit_consumer::Tree::new(update, true);
1070 let field = tree.state().focus().unwrap();
1071 assert_eq!(field.document_range().text(), text, "width {width}");
1072 let selection = field.document_range().to_text_selection();
1073 let source_selection = access
1074 .selection(editor.active_outline().id, selection)
1075 .unwrap();
1076 editor.select(source_selection).unwrap();
1077 let selected = access.update(&editor, viewport, "Test", None).unwrap();
1078 let tree = accesskit_consumer::Tree::new(selected, true);
1079 assert_eq!(
1080 tree.state()
1081 .focus()
1082 .unwrap()
1083 .text_selection()
1084 .unwrap()
1085 .text(),
1086 text
1087 );
1088 let stable_ids: Vec<_> = access.runs.iter().map(|run| run.id).collect();
1089 access
1090 .update(
1091 &editor,
1092 Viewport {
1093 scale: 3.0,
1094 ..viewport
1095 },
1096 "Test",
1097 None,
1098 )
1099 .unwrap();
1100 assert_eq!(
1101 stable_ids,
1102 access.runs.iter().map(|run| run.id).collect::<Vec<_>>()
1103 );
1104 access.deactivate();
1105 access.update(&editor, viewport, "Test", None).unwrap();
1106 assert!(
1107 access
1108 .selection(editor.active_outline().id, selection)
1109 .is_err()
1110 );
1111 let selection = access
1112 .update(&editor, viewport, "Test", None)
1113 .unwrap()
1114 .nodes[1]
1115 .1
1116 .text_selection()
1117 .copied()
1118 .unwrap();
1119 editor.insert(&mut engine, "replacement").unwrap();
1120 access.update(&editor, viewport, "Test", None).unwrap();
1121 assert!(
1122 access
1123 .selection(editor.active_outline().id, selection)
1124 .is_err()
1125 );
1126 editor.undo(&mut engine).unwrap();
1127 let update = access.update(&editor, viewport, "Test", None).unwrap();
1128 let tree = accesskit_consumer::Tree::new(update, true);
1129 assert_eq!(tree.state().focus().unwrap().document_range().text(), text);
1130 }
1131 }
1132 }
1133
1134 #[test]
1135 fn caret_geometry_uses_visible_offsets_and_canvas_line_metrics() {
1136 let mut engine = TextEngine::default();
1137 let document = TextDocument::new(vec![
1138 Paragraph::from_runs([
1139 ("abc ".into(), Format::default()),
1140 (
1141 "hidden field".into(),
1142 Format {
1143 hidden: Some(true),
1144 ..Format::default()
1145 },
1146 ),
1147 ("e\u{301} 🌳 words wrap here".into(), Format::default()),
1148 ]),
1149 Paragraph::new("".into(), Format::default()),
1150 Paragraph::new("abc שלום def عالم end".into(), Format::default()),
1151 Paragraph::new("last\nline\n".into(), Format::default()),
1152 ])
1153 .unwrap();
1154 let positions: Vec<_> = document
1155 .paragraphs()
1156 .enumerate()
1157 .flat_map(|(paragraph, text)| {
1158 text.text()
1159 .char_indices()
1160 .map(|(byte, _)| text.utf16_offset(byte).unwrap())
1161 .chain(std::iter::once(
1162 text.utf16_offset(text.text().len()).unwrap(),
1163 ))
1164 .map(move |offset| TextPosition { paragraph, offset })
1165 })
1166 .collect();
1167 let mut editor = CanvasEditor::new(&mut engine, document, 48.0).unwrap();
1168 let mut access = Accessibility::default();
1169 let viewport = Viewport {
1170 size: [800, 600],
1171 origin: [31.0, -57.0],
1172 scale: 2.5,
1173 };
1174 for (position, affinity) in positions.into_iter().flat_map(|position| {
1175 [Affinity::Upstream, Affinity::Downstream].map(|affinity| (position, affinity))
1176 }) {
1177 editor
1178 .select(Selection {
1179 positions: [position; 2],
1180 affinities: [affinity; 2],
1181 })
1182 .unwrap();
1183 let caret = editor.caret(0.0).unwrap();
1184 let update = access.update(&editor, viewport, "Test", None).unwrap();
1185 let tree = accesskit_consumer::Tree::new(update, true);
1186 let field = tree.state().focus().unwrap();
1187 assert_eq!(
1188 field.document_range().text(),
1189 "abc e\u{301} 🌳 words wrap here\n\nabc שלום def عالم end\nlast\nline\n"
1190 );
1191 let rects = field.text_selection().unwrap().bounding_boxes();
1192 assert_eq!(rects.len(), 1, "{position:?}");
1193 let rect = rects[0];
1194 for (actual, expected) in [
1195 (rect.x0, caret.x0 * 2.5 + 31.0),
1196 (rect.y0, caret.y0 * 2.5 - 57.0),
1197 (rect.y1, caret.y1 * 2.5 - 57.0),
1198 ] {
1199 assert!(
1200 (actual - expected).abs() < 0.0001,
1201 "{position:?} {affinity:?}: {rect:?} vs {caret:?}"
1202 );
1203 }
1204 let selection = access
1205 .selection(
1206 editor.active_outline().id,
1207 field.text_selection().unwrap().to_text_selection(),
1208 )
1209 .unwrap();
1210 editor.select(selection).unwrap();
1211 assert_eq!(
1212 editor.caret(0.0).unwrap(),
1213 caret,
1214 "{position:?} {affinity:?}: AX selection moved the caret"
1215 );
1216 }
1217 }
1218 #[test]
1219 fn keyboard_and_accessibility_preserve_visual_caret_at_wraps() {
1220 use crate::editor::Movement;
1221 let mut engine = TextEngine::default();
1222 let document = TextDocument::new(vec![
1223 Paragraph::new(
1224 "first words wrap here and continue".into(),
1225 Format::default(),
1226 ),
1227 Paragraph::new("abc שלום def عالم end".into(), Format::default()),
1228 Paragraph::new("last\nline".into(), Format::default()),
1229 ])
1230 .unwrap();
1231 let mut editor = CanvasEditor::new(&mut engine, document, 72.0).unwrap();
1232 let mut access = Accessibility::default();
1233 let viewport = Viewport {
1234 size: [800, 600],
1235 origin: [0.0; 2],
1236 scale: 1.0,
1237 };
1238 for movement in [
1239 Movement::LineEnd,
1240 Movement::Right,
1241 Movement::Left,
1242 Movement::Down,
1243 Movement::LineStart,
1244 Movement::LineEnd,
1245 Movement::Down,
1246 Movement::Down,
1247 Movement::Down,
1248 Movement::LineEnd,
1249 Movement::Left,
1250 Movement::Left,
1251 Movement::Right,
1252 Movement::LineStart,
1253 Movement::Up,
1254 Movement::Right,
1255 Movement::LineEnd,
1256 ] {
1257 editor.move_selection(&mut engine, movement, false).unwrap();
1258 let caret = editor.caret(0.0).unwrap();
1259 let tree = accesskit_consumer::Tree::new(
1260 access.update(&editor, viewport, "Test", None).unwrap(),
1261 true,
1262 );
1263 let field = tree.state().focus().unwrap();
1264 let selection = field.text_selection().unwrap();
1265 let rects = selection.bounding_boxes();
1266 assert_eq!(rects.len(), 1);
1267 let rect = rects[0];
1268 for (actual, expected) in [
1269 (rect.x0, caret.x0),
1270 (rect.y0, caret.y0),
1271 (rect.y1, caret.y1),
1272 ] {
1273 assert!(
1274 (actual - expected).abs() < 0.0001,
1275 "{movement:?}: {rect:?} vs {caret:?}"
1276 );
1277 }
1278 let source_selection = access
1279 .selection(editor.active_outline().id, selection.to_text_selection())
1280 .unwrap();
1281 editor.select(source_selection).unwrap();
1282 let restored = editor.caret(0.0).unwrap();
1283 assert_eq!(
1284 restored, caret,
1285 "{movement:?}: AX selection moved the caret"
1286 );
1287 }
1288 }
1289 #[test]
1290 fn cached_runs_match_reconstruction_through_editing_and_view_changes() {
1291 let mut engine = TextEngine::default();
1292 let document = TextDocument::new(
1293 [
1294 "one two three",
1295 "",
1296 "אבג 日本 👩🏽‍💻 e\u{301}",
1297 "last paragraph",
1298 ]
1299 .into_iter()
1300 .map(|text| Paragraph::new(text.into(), Format::default()))
1301 .collect(),
1302 )
1303 .unwrap();
1304 let mut editor = CanvasEditor::new(&mut engine, document, 72.0).unwrap();
1305 let mut cached = Accessibility::default();
1306 let mut rebuilt = Accessibility::default();
1307 let mut compare = |editor: &CanvasEditor, step: usize| {
1308 let viewport = Viewport {
1309 size: [800, 600],
1310 origin: [step as f32 * 0.125, -17.25],
1311 scale: 0.75 + (step % 5) as f32 * 0.5,
1312 };
1313 let preview = step
1314 .is_multiple_of(2)
1315 .then_some((editor.active_outline().id, [13.5, -7.25]));
1316 for repeat in 0..2 {
1317 rebuilt.paragraphs.clear();
1318 let actual = cached.update(editor, viewport, "Test", preview).unwrap();
1319 let expected = rebuilt.update(editor, viewport, "Test", preview).unwrap();
1320 assert_eq!(actual.nodes, expected.nodes, "step {step}, repeat {repeat}");
1321 assert_eq!(actual.focus, expected.focus);
1322 let tree = accesskit_consumer::Tree::new(actual, true);
1323 let field = tree.state().focus().unwrap();
1324 let selection = field.document_range().to_text_selection();
1325 assert_eq!(
1326 cached
1327 .selection(editor.active_outline().id, selection)
1328 .unwrap(),
1329 rebuilt
1330 .selection(editor.active_outline().id, selection)
1331 .unwrap()
1332 );
1333 let count = editor
1334 .outlines()
1335 .iter()
1336 .map(|o| o.layouts().count())
1337 .sum::<usize>();
1338 assert_eq!(cached.paragraphs.len(), count);
1339 }
1340 };
1341 compare(&editor, 0);
1342 let mut seed = 0x923e_u64;
1343 let mut next = || {
1344 seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
1345 (seed >> 32) as usize
1346 };
1347 for step in 1..=160 {
1348 let id = editor.outlines()[next() % editor.outlines().len()].id;
1349 editor.focus_outline(id).unwrap();
1350 let positions: Vec<_> = editor
1351 .active_outline()
1352 .document()
1353 .paragraphs()
1354 .enumerate()
1355 .flat_map(|(paragraph, text)| {
1356 (0..=text.text().len()).filter_map(move |byte| {
1357 text.utf16_offset(byte)
1358 .ok()
1359 .map(|offset| TextPosition { paragraph, offset })
1360 })
1361 })
1362 .collect();
1363 let anchor = positions[next() % positions.len()];
1364 let focus = if step % 3 == 0 {
1365 positions[next() % positions.len()]
1366 } else {
1367 anchor
1368 };
1369 editor.select([anchor, focus].into()).unwrap();
1370 match step % 10 {
1371 0 => editor.insert(&mut engine, "\n").unwrap(),
1372 1 => editor.insert(&mut engine, "abc 👩🏽‍💻 שלום e\u{301}").unwrap(),
1373 2 => {
1374 editor.delete(&mut engine, true).unwrap();
1375 }
1376 3 => editor
1377 .resize(&mut engine, 48.0 + (step % 7) as f32 * 31.25)
1378 .unwrap(),
1379 4 => {
1380 editor
1381 .create_outline(&mut engine, [step as f32, -5.0], 97.0)
1382 .unwrap();
1383 editor.insert(&mut engine, "annotation").unwrap();
1384 }
1385 5 => {
1386 editor.undo(&mut engine).unwrap();
1387 }
1388 6 => {
1389 editor.redo(&mut engine).unwrap();
1390 }
1391 7 => editor.move_outline(id, [17.5, step as f32]).unwrap(),
1392 8 => {
1393 editor.compose(&mut engine, "仮\n名".into(), 1..1).unwrap();
1394 compare(&editor, step);
1395 editor.cancel_composition(&mut engine).unwrap();
1396 }
1397 _ => {}
1398 }
1399 compare(&editor, step);
1400 }
1401 while editor.undo(&mut engine).unwrap() {
1402 compare(&editor, 161);
1403 }
1404 while editor.redo(&mut engine).unwrap() {
1405 compare(&editor, 162);
1406 }
1407 }
1408 #[test]
1409 fn outlines_have_independent_accessibility_identity_and_selection() {
1410 let mut engine = TextEngine::default();
1411 let document =
1412 TextDocument::new(vec![Paragraph::new("lyrics".into(), Format::default())]).unwrap();
1413 let mut editor = CanvasEditor::new(&mut engine, document, 240.0).unwrap();
1414 let first = editor.active_outline().id;
1415 let second = editor
1416 .create_outline(&mut engine, [300.0, 40.0], 120.0)
1417 .unwrap();
1418 editor.insert(&mut engine, "annotation 🌳").unwrap();
1419 let mut access = Accessibility::default();
1420 let viewport = Viewport {
1421 size: [1000, 720],
1422 scale: 2.0,
1423 origin: [48.0; 2],
1424 };
1425 let update = access.update(&editor, viewport, "Test", None).unwrap();
1426 let fields: Vec<_> = update
1427 .nodes
1428 .iter()
1429 .filter(|(_, node)| node.role() == Role::MultilineTextInput)
1430 .map(|(id, node)| (*id, node.value().unwrap().to_owned()))
1431 .collect();
1432 assert_eq!(
1433 fields
1434 .iter()
1435 .map(|(_, text)| text.as_str())
1436 .collect::<Vec<_>>(),
1437 ["lyrics", "annotation 🌳"]
1438 );
1439 assert_ne!(fields[0].0, fields[1].0);
1440 assert_eq!(access.outline_for_node(fields[0].0), Some(first));
1441 assert_eq!(access.outline_for_node(fields[1].0), Some(second));
1442 let tree = accesskit_consumer::Tree::new(update, true);
1443 let field = tree.state().focus().unwrap();
1444 assert_eq!(field.document_range().text(), "annotation 🌳");
1445 let selection = field.document_range().to_text_selection();
1446 assert!(access.selection(first, selection).is_err());
1447 let source_selection = access.selection(second, selection).unwrap();
1448 editor.select(source_selection).unwrap();
1449 let preview = access
1450 .update(&editor, viewport, "Test", Some((second, [400.0, 50.0])))
1451 .unwrap();
1452 let preview_tree = accesskit_consumer::Tree::new(preview, true);
1453 let preview_field = preview_tree.state().focus().unwrap();
1454 let rect = preview_field.document_range().bounding_boxes()[0];
1455 assert_eq!(rect.x0, 848.0);
1456 assert_eq!(rect.y0, 148.0);
1457 assert_eq!(editor.active_outline().origin(), [300.0, 40.0]);
1458 editor.move_outline(second, [400.0, 50.0]).unwrap();
1459 access.update(&editor, viewport, "Test", None).unwrap();
1460 assert_eq!(access.outline_for_node(fields[1].0), Some(second));
1461 assert!(access.selection(second, selection).is_ok());
1462 editor.undo(&mut engine).unwrap();
1463 editor.undo(&mut engine).unwrap();
1464 editor.undo(&mut engine).unwrap();
1465 let update = access.update(&editor, viewport, "Test", None).unwrap();
1466 assert_eq!(
1467 update
1468 .nodes
1469 .iter()
1470 .filter(|(_, node)| node.role() == Role::MultilineTextInput)
1471 .count(),
1472 1
1473 );
1474 assert_eq!(access.outline_for_node(fields[1].0), None);
1475 assert!(access.selection(second, selection).is_err());
1476 assert_eq!(access.outline_for_node(fields[0].0), Some(first));
1477 }
1478}
crates/canvas/src/interaction/mod.rs created+1785
......@@ -0,0 +1,1785 @@
1//! How a page responds to pointer, keyboard and text input as OneNote does: hit layers, drags,
2//! the placement grid, picture handles, outline and picture chrome, and key routing. Hosts
3//! translate their platform's events into these calls and carry out the returned requests.
4
5pub mod accessibility;
6#[cfg(test)]
7mod profile;
8mod scroll;
9#[cfg(test)]
10mod tests;
11
12use crate::gpu::{Primitive, Stroke, Viewport, page::PageScene};
13use crate::{
14 date::DateField,
15 editor::{
16 CanvasEditor, DEFAULT_OUTLINE_WIDTH, Movement, Selection, SelectionUnit, TextOutline,
17 },
18 layout::TextEngine,
19};
20use std::{
21 error::Error,
22 time::{Duration, Instant},
23};
24
25const HANDLE_HEIGHT: f32 = 6.75;
26/// Accessible names of the page date's fields.
27pub const DATE_LABELS: [&str; 2] = ["Page date", "Page time"];
28
29pub type Result<T> = std::result::Result<T, Box<dyn Error>>;
30
31#[derive(Clone, Debug, PartialEq)]
32pub enum Key {
33 Named(NamedKey),
34 Character(String),
35}
36
37#[derive(Clone, Copy, Debug, PartialEq)]
38pub enum NamedKey {
39 Escape,
40 Tab,
41 Space,
42 Enter,
43 Backspace,
44 Delete,
45 ArrowLeft,
46 ArrowRight,
47 ArrowUp,
48 ArrowDown,
49 Home,
50 End,
51 /// Shift, Control, Option or Command pressed alone.
52 Modifier,
53 Other,
54}
55
56#[derive(Clone, Copy, Debug, Default, PartialEq)]
57pub struct Modifiers {
58 pub shift: bool,
59 pub control: bool,
60 pub option: bool,
61 pub command: bool,
62}
63
64#[derive(Clone, Copy, Debug, PartialEq)]
65pub enum Cursor {
66 Default,
67 Text,
68 Pointer,
69 Move,
70 EwResize,
71 NsResize,
72 NwseResize,
73 NeswResize,
74}
75
76/// Work only the host can do, asked for by an event.
77#[derive(Debug, PartialEq)]
78pub enum Request {
79 /// Show the date or time picker, then report the choice through `PageView::change_date`.
80 EditDate(DateField),
81 /// Put this text on the clipboard.
82 Copy(String),
83 /// Read the clipboard and hand its text to `PageView::commit_text`.
84 Paste,
85 /// Show the platform's character picker.
86 CharacterPalette,
87}
88
89/// What an event did: `changed` means the page, selection or view changed (the host saves,
90/// updates accessibility and redraws); `redraw` alone repaints hover feedback.
91#[must_use]
92#[derive(Debug, Default, PartialEq)]
93pub struct Response {
94 pub changed: bool,
95 pub redraw: bool,
96 pub request: Option<Request>,
97}
98
99impl Response {
100 fn changed() -> Self {
101 Self {
102 changed: true,
103 redraw: true,
104 request: None,
105 }
106 }
107
108 fn redraw() -> Self {
109 Self {
110 redraw: true,
111 ..Self::default()
112 }
113 }
114
115 fn request(request: Request) -> Self {
116 Self {
117 request: Some(request),
118 ..Self::changed()
119 }
120 }
121}
122
123/// The platform's caret and text selection colours, linear RGBA.
124#[derive(Clone, Copy, Debug)]
125pub struct TextColors {
126 pub caret: [f32; 4],
127 pub selection: [f32; 4],
128}
129
130/// How to paint an outline: whether the caret is in its blink-on phase, the view scale,
131/// document points per device pixel, and the text colours.
132#[derive(Clone, Copy)]
133struct Paint {
134 show_caret: bool,
135 scale: f32,
136 pixel: f32,
137 colors: TextColors,
138}
139
140enum Drag {
141 Text {
142 anchor: Selection,
143 unit: SelectionUnit,
144 },
145 Scrollbar {
146 axis: usize,
147 grab: f32,
148 },
149 Resize {
150 outline: Option<Box<TextOutline>>,
151 grab: f32,
152 },
153 Outline {
154 id: onestore::ExGuid,
155 grab: [f32; 2],
156 pending_press: Option<[f32; 2]>,
157 },
158 Image {
159 id: onestore::ExGuid,
160 handle: [i8; 2],
161 /// Document point of the press.
162 press: [f32; 2],
163 pending_press: Option<[f32; 2]>,
164 },
165}
166
167#[derive(Clone, Copy)]
168enum PointerFeedback<'a> {
169 Hover(onestore::ExGuid),
170 Move(onestore::ExGuid, [f32; 2]),
171 Resize(&'a TextOutline),
172 /// A picture being moved or resized, drawn at this origin and size.
173 Image(onestore::ExGuid, [f32; 2], [f32; 2]),
174}
175
176/// A non-text object holding focus, which hides the text caret and suspends typing.
177#[derive(Clone, Copy, Debug, PartialEq)]
178pub enum ObjectFocus {
179 ReadOnly(usize),
180 Image(onestore::ExGuid),
181}
182
183impl ObjectFocus {
184 pub fn read_only(self) -> Option<usize> {
185 match self {
186 Self::ReadOnly(index) => Some(index),
187 Self::Image(_) => None,
188 }
189 }
190}
191
192fn read_only_shortcut(key: &Key, modifiers: Modifiers) -> bool {
193 key == &Key::Named(NamedKey::Escape)
194 || (modifiers.control && key == &Key::Named(NamedKey::Tab))
195 || (modifiers.command
196 && matches!(key, Key::Character(value) if matches!(value.as_str(), "+" | "=" | "-" | "0") || (modifiers.shift && value.eq_ignore_ascii_case("n"))))
197}
198
199/// A page on screen: the editor, the drawn scene, the view onto them and the pointer and
200/// keyboard state between events.
201pub struct PageView {
202 pub editor: CanvasEditor,
203 pub engine: TextEngine,
204 /// The page's noneditable content and where it sits in the view.
205 pub scene: Option<(PageScene, [f32; 2])>,
206 pub viewport: Viewport,
207 /// Device pixels per logical pixel.
208 display_scale: f32,
209 double_click: Duration,
210 pointer: [f32; 2],
211 pointer_inside: bool,
212 last_click: Option<(Instant, [f32; 2], u8)>,
213 drag: Option<Drag>,
214 object_focus: Option<ObjectFocus>,
215 modifiers: Modifiers,
216 focused: bool,
217 caret: bool,
218 blink_at: Instant,
219}
220
221impl PageView {
222 /// `size` is in device pixels; `double_click` is the platform's double-click interval.
223 pub fn new(
224 editor: CanvasEditor,
225 engine: TextEngine,
226 scene: Option<(PageScene, [f32; 2])>,
227 size: [u32; 2],
228 display_scale: f32,
229 double_click: Duration,
230 ) -> Self {
231 Self {
232 editor,
233 engine,
234 scene,
235 viewport: Viewport {
236 size,
237 scale: display_scale * 96.0 / 72.0,
238 origin: [48.0 * display_scale; 2],
239 },
240 display_scale,
241 double_click,
242 pointer: [0.0; 2],
243 pointer_inside: false,
244 last_click: None,
245 drag: None,
246 object_focus: None,
247 modifiers: Modifiers::default(),
248 focused: true,
249 caret: true,
250 blink_at: Instant::now() + Duration::from_millis(500),
251 }
252 }
253
254 /// Shows a page reloaded from storage in place of the edited one.
255 pub fn replace(&mut self, editor: CanvasEditor, scene: Option<(PageScene, [f32; 2])>) {
256 self.editor = editor;
257 self.scene = scene;
258 self.drag = None;
259 self.object_focus = None;
260 }
261
262 pub fn modifiers(&self) -> Modifiers {
263 self.modifiers
264 }
265
266 pub fn object_focus(&self) -> Option<ObjectFocus> {
267 self.object_focus
268 }
269
270 /// Text input goes to the editor unless an object holds focus.
271 pub fn accepts_text(&self) -> bool {
272 self.object_focus.is_none()
273 }
274
275 /// The outline being dragged and where it would land.
276 pub fn outline_preview(&self) -> Option<(onestore::ExGuid, [f32; 2])> {
277 let Drag::Outline {
278 id,
279 grab,
280 pending_press,
281 } = self.drag.as_ref()?
282 else {
283 return None;
284 };
285 let origin = self
286 .editor
287 .outlines()
288 .iter()
289 .find(|outline| outline.id == *id)?
290 .origin();
291 if pending_press.is_some() {
292 return Some((*id, origin));
293 }
294 let point = self.viewport.document_point(self.pointer);
295 let position = [point[0] - grab[0], point[1] - grab[1]];
296 Some((
297 *id,
298 if self.modifiers.option {
299 position
300 } else {
301 snap_to_grid(position, self.editor.margin_origin())
302 },
303 ))
304 }
305
306 fn image_preview(&self) -> Option<(onestore::ExGuid, [f32; 2], [f32; 2])> {
307 let Drag::Image {
308 id,
309 handle,
310 press,
311 pending_press: None,
312 } = self.drag.as_ref()?
313 else {
314 return None;
315 };
316 let (origin, size) = self.editor.image_placement(*id)?;
317 let point = self.viewport.document_point(self.pointer);
318 let delta = [point[0] - press[0], point[1] - press[1]];
319 if *handle != [0, 0] {
320 let (origin, size) = resize_image(origin, size, *handle, delta);
321 return Some((*id, origin, size));
322 }
323 // A picture in an outline's flow keeps its place.
324 if self.editor.image_in_outline(*id) {
325 return Some((*id, origin, size));
326 }
327 let origin = [origin[0] + delta[0], origin[1] + delta[1]];
328 let origin = if self.modifiers.option {
329 origin
330 } else {
331 snap_to_grid(origin, self.editor.margin_origin())
332 };
333 Some((*id, origin, size))
334 }
335
336 fn pixel(&self) -> f32 {
337 self.display_scale / self.viewport.scale
338 }
339
340 /// The selected picture's handles lie above the page; its body keeps its paint order.
341 fn hit_test(&self, point: [f32; 2]) -> Option<Hit> {
342 if let Some(ObjectFocus::Image(id)) = self.object_focus
343 && let Some((origin, size)) = self.editor.image_placement(id)
344 && let Some(handle) = image_handle_at(image_rect(origin, size), self.pixel(), point)
345 {
346 return Some(Hit::Image { id, handle });
347 }
348 page_hit_test(&self.editor, self.scene.as_ref(), point, self.pixel())
349 }
350
351 fn set_object_focus(&mut self, focus: Option<ObjectFocus>) {
352 if self.object_focus != focus {
353 self.editor.finish_composition();
354 self.drag = None;
355 self.object_focus = focus;
356 }
357 }
358
359 /// Page coordinates of a focused object.
360 fn object_rect(&self, focus: ObjectFocus) -> [f32; 4] {
361 let (scene, offset) = self.scene.as_ref().unwrap();
362 let [x0, y0, x1, y1] = match focus {
363 ObjectFocus::ReadOnly(index) => scene
364 .read_only(Some(&self.editor))
365 .nth(index)
366 .unwrap()
367 .rect(),
368 ObjectFocus::Image(id) => {
369 let (origin, size) = self.editor.image_placement(id).unwrap();
370 image_rect(origin, size)
371 }
372 };
373 [
374 x0 + offset[0],
375 y0 + offset[1],
376 x1 + offset[0],
377 y1 + offset[1],
378 ]
379 }
380
381 /// Keeps the view in bounds and restarts the caret blink after a change.
382 fn changed(&mut self) -> Result<Response> {
383 self.scroll().clamp(&mut self.viewport);
384 self.caret = true;
385 self.blink_at = Instant::now() + Duration::from_millis(500);
386 Ok(Response::changed())
387 }
388
389 /// The caret's rectangle in device pixels, for placing the platform's input method.
390 pub fn caret_area(&self) -> Result<[f32; 4]> {
391 let mut rect = self.editor.caret(1.0)?;
392 let origin = self
393 .outline_preview()
394 .map(|(_, origin)| origin)
395 .unwrap_or_else(|| self.editor.active_outline().origin());
396 rect.x0 += f64::from(origin[0]);
397 rect.y0 += f64::from(origin[1]);
398 rect.x1 += f64::from(origin[0]);
399 rect.y1 += f64::from(origin[1]);
400 let v = self.viewport;
401 let x = rect.x0 as f32 * v.scale + v.origin[0];
402 let y = rect.y0 as f32 * v.scale + v.origin[1];
403 Ok([
404 x,
405 y,
406 x + (rect.width() as f32 * v.scale).max(1.0),
407 y + (rect.height() as f32 * v.scale).max(1.0),
408 ])
409 }
410
411 fn reveal_focus(&mut self) -> Result<()> {
412 if self.viewport.size.contains(&0) {
413 return Ok(());
414 }
415 let rect = if let Some(focus) = self.object_focus {
416 let [x0, y0, x1, y1] = self.object_rect(focus).map(f64::from);
417 parley::BoundingBox { x0, y0, x1, y1 }
418 } else {
419 let mut rect = self.editor.caret(1.0)?;
420 let origin = self.editor.active_outline().origin();
421 rect.x0 += f64::from(origin[0]);
422 rect.x1 += f64::from(origin[0]);
423 rect.y0 += f64::from(origin[1]);
424 rect.y1 += f64::from(origin[1]);
425 rect
426 };
427 let outline = self.editor.active_outline().bounds();
428 for (axis, (mut start, mut end)) in [(rect.x0, rect.x1), (rect.y0, rect.y1)]
429 .into_iter()
430 .enumerate()
431 {
432 let size = f64::from(self.viewport.size[axis]);
433 let margin = f64::from(16.0 * self.display_scale).min(size * 0.25);
434 let (outline_start, outline_end) =
435 [(outline.x0, outline.x1), (outline.y0, outline.y1)][axis];
436 let outline_start = outline_start.min(start);
437 let outline_end = outline_end.max(end);
438 if self.object_focus.is_none()
439 && (outline_end - outline_start) * f64::from(self.viewport.scale)
440 <= size - margin * 2.0
441 {
442 start = outline_start;
443 end = outline_end;
444 }
445 let start =
446 start * f64::from(self.viewport.scale) + f64::from(self.viewport.origin[axis]);
447 let end = end * f64::from(self.viewport.scale) + f64::from(self.viewport.origin[axis]);
448 let shift = if start < margin {
449 margin - start
450 } else if end > size - margin {
451 size - margin - end
452 } else {
453 0.0
454 };
455 self.viewport.origin[axis] += shift as f32;
456 }
457 Ok(())
458 }
459
460 /// Reveals the caret or focused object after an edit, then reports the change.
461 fn edited(&mut self) -> Result<Response> {
462 self.reveal_focus()?;
463 self.changed()
464 }
465
466 fn zoom(&mut self, factor: f32) {
467 let point = self.viewport.document_point(self.pointer);
468 let dpr = self.display_scale;
469 self.viewport.scale = (self.viewport.scale * factor).clamp(dpr / 3.0, dpr * 16.0 / 3.0);
470 self.viewport.origin = [
471 self.pointer[0] - point[0] * self.viewport.scale,
472 self.pointer[1] - point[1] * self.viewport.scale,
473 ];
474 }
475
476 fn scroll(&self) -> scroll::Scroll {
477 let editable = self
478 .editor
479 .visible_outlines()
480 .chain(self.editor.caret_outline())
481 .map(|outline| {
482 let rect = outline.bounds();
483 let offset = self
484 .scene
485 .as_ref()
486 .filter(|_| self.editor.has_page_outline(outline.id))
487 .map(|(_, offset)| *offset)
488 .unwrap_or([0.0; 2]);
489 [
490 rect.x0 as f32 + offset[0],
491 rect.y0 as f32 + offset[1],
492 rect.x1 as f32 + offset[0],
493 rect.y1 as f32 + offset[1],
494 ]
495 });
496 let fixed = self.scene.iter().flat_map(|(scene, offset)| {
497 scene.content_bounds(&self.editor).map(|rect| {
498 [
499 rect[0] + offset[0],
500 rect[1] + offset[1],
501 rect[2] + offset[0],
502 rect[3] + offset[1],
503 ]
504 })
505 });
506 scroll::Scroll::new(self.viewport, editable.chain(fixed))
507 }
508
509 /// Everything to draw this frame, scrollbars included.
510 pub fn primitives(&self, colors: TextColors) -> Result<Vec<Primitive<'_>>> {
511 let preview = match &self.drag {
512 Some(Drag::Resize {
513 outline: Some(outline),
514 ..
515 }) => Some(PointerFeedback::Resize(outline)),
516 _ => self
517 .outline_preview()
518 .map(|(id, origin)| PointerFeedback::Move(id, origin))
519 .or_else(|| {
520 self.image_preview()
521 .map(|(id, origin, size)| PointerFeedback::Image(id, origin, size))
522 })
523 .or_else(|| {
524 if !self.pointer_inside {
525 return None;
526 }
527 match page_hit_test(
528 &self.editor,
529 self.scene.as_ref(),
530 self.viewport.document_point(self.pointer),
531 self.pixel(),
532 ) {
533 Some(
534 Hit::Text { id, .. } | Hit::Handle { id, .. } | Hit::Resize { id, .. },
535 ) => Some(PointerFeedback::Hover(id)),
536 _ => None,
537 }
538 }),
539 };
540 let mut primitives = page_primitives(
541 &self.editor,
542 self.scene.as_ref(),
543 preview,
544 self.object_focus,
545 Paint {
546 show_caret: self.caret
547 && self.focused
548 && !matches!(
549 self.drag,
550 Some(Drag::Outline { .. } | Drag::Resize { .. } | Drag::Image { .. })
551 ),
552 scale: self.viewport.scale,
553 pixel: self.pixel(),
554 colors,
555 },
556 )?;
557 self.scroll()
558 .append(self.viewport, self.display_scale, &mut primitives);
559 Ok(primitives)
560 }
561
562 /// The pointer shape at the pointer's position.
563 pub fn cursor(&self) -> Cursor {
564 let hit = self.hit_test(self.viewport.document_point(self.pointer));
565 let scrollbar = self
566 .scroll()
567 .hit_test(self.viewport, self.display_scale, self.pointer)
568 .is_some();
569 match (&self.drag, hit) {
570 (Some(Drag::Scrollbar { .. }), _) => Cursor::Default,
571 (None, _) if scrollbar => Cursor::Default,
572 (Some(Drag::Image { handle, .. }), _) => handle_cursor(*handle),
573 (None, Some(Hit::Image { handle, .. })) => handle_cursor(handle),
574 (Some(Drag::Resize { .. }), _) | (None, Some(Hit::Resize { .. })) => Cursor::EwResize,
575 (Some(Drag::Outline { .. }), _) | (None, Some(Hit::Handle { .. })) => Cursor::Move,
576 (None, Some(Hit::Date(_))) => Cursor::Pointer,
577 (None, Some(Hit::ReadOnly(_))) => Cursor::Default,
578 _ => Cursor::Text,
579 }
580 }
581
582 /// Advances the caret blink; returns whether to repaint and when to call again.
583 pub fn blink(&mut self, now: Instant) -> (bool, Option<Instant>) {
584 let [anchor, focus] = self.editor.selection().positions;
585 if !(self.focused && self.object_focus.is_none() && anchor == focus) {
586 return (false, None);
587 }
588 let repaint = now >= self.blink_at;
589 if repaint {
590 self.caret = !self.caret;
591 self.blink_at = now + Duration::from_millis(500);
592 }
593 (repaint, Some(self.blink_at))
594 }
595
596 /// `size` in device pixels.
597 pub fn resized(&mut self, size: [u32; 2]) -> Result<Response> {
598 self.viewport.size = size;
599 if size.contains(&0) {
600 return Ok(Response::default());
601 }
602 self.changed()
603 }
604
605 pub fn scale_factor_changed(&mut self, scale: f32) -> Result<Response> {
606 let ratio = scale / self.display_scale;
607 self.viewport.scale *= ratio;
608 self.viewport.origin[0] *= ratio;
609 self.viewport.origin[1] *= ratio;
610 self.display_scale = scale;
611 self.changed()
612 }
613
614 pub fn focus_changed(&mut self, focused: bool) -> Result<Response> {
615 self.focused = focused;
616 if !focused {
617 self.drag = None;
618 }
619 self.changed()
620 }
621
622 pub fn modifiers_changed(&mut self, modifiers: Modifiers) -> Result<Response> {
623 self.modifiers = modifiers;
624 if matches!(self.drag, Some(Drag::Outline { .. })) {
625 return self.changed();
626 }
627 Ok(Response::default())
628 }
629
630 pub fn pointer_left(&mut self) -> Response {
631 self.pointer_inside = false;
632 Response::redraw()
633 }
634
635 /// `position` in device pixels from the view's top-left.
636 pub fn pointer_moved(&mut self, position: [f32; 2]) -> Result<Response> {
637 self.pointer_inside = true;
638 self.pointer = position;
639 match &mut self.drag {
640 Some(Drag::Scrollbar { axis, grab }) => {
641 let (axis, grab) = (*axis, *grab);
642 self.scroll().drag(
643 &mut self.viewport,
644 self.display_scale,
645 axis,
646 self.pointer[axis],
647 grab,
648 );
649 self.changed()
650 }
651 Some(Drag::Text { anchor, unit }) => {
652 let (anchor, unit) = (*anchor, *unit);
653 let point = self.viewport.document_point(self.pointer);
654 let origin = self.editor.active_outline().origin();
655 let target =
656 self.editor
657 .selection_at(point[0] - origin[0], point[1] - origin[1], unit)?;
658 self.editor.select(drag_selection(anchor, target, unit))?;
659 self.changed()
660 }
661 Some(Drag::Outline { pending_press, .. } | Drag::Image { pending_press, .. }) => {
662 if pending_press.is_some_and(|press| {
663 (0..2)
664 .any(|axis| (position[axis] - press[axis]).abs() > 2.0 * self.display_scale)
665 }) {
666 *pending_press = None;
667 }
668 self.changed()
669 }
670 Some(Drag::Resize { outline, grab }) => {
671 let point = self.viewport.document_point(self.pointer);
672 let width = (point[0] - self.editor.active_outline().origin()[0] - *grab).max(36.0);
673 if outline.is_some()
674 || (width - self.editor.active_outline().bounds().width() as f32).abs()
675 * self.viewport.scale
676 > 2.0 * self.display_scale
677 {
678 *outline = Some(Box::new(
679 self.editor.preview_resize(&mut self.engine, width)?,
680 ));
681 return self.changed();
682 }
683 Ok(Response::default())
684 }
685 None => Ok(Response::redraw()),
686 }
687 }
688
689 /// A primary-button press at the pointer's position, at `now` for click counting.
690 pub fn pointer_pressed(&mut self, now: Instant) -> Result<Response> {
691 let point = self.viewport.document_point(self.pointer);
692 let count = self
693 .last_click
694 .filter(|(time, point, _)| {
695 now.duration_since(*time) <= self.double_click
696 && (0..2).all(|axis| {
697 (point[axis] - self.pointer[axis]).abs() <= 4.0 * self.display_scale
698 })
699 })
700 .map_or(1, |(_, _, count)| (count % 3) + 1);
701 self.last_click = Some((now, self.pointer, count));
702 if let Some((axis, grab)) =
703 self.scroll()
704 .hit_test(self.viewport, self.display_scale, self.pointer)
705 {
706 self.drag = Some(Drag::Scrollbar { axis, grab });
707 return self.changed();
708 }
709 match self.hit_test(point) {
710 Some(Hit::Date(field)) => {
711 self.editor.finish_composition();
712 self.drag = None;
713 return Ok(Response::request(Request::EditDate(field)));
714 }
715 Some(Hit::ReadOnly(index)) => self.set_object_focus(Some(ObjectFocus::ReadOnly(index))),
716 Some(Hit::Image { id, handle }) => {
717 self.set_object_focus(Some(ObjectFocus::Image(id)));
718 self.drag = Some(Drag::Image {
719 id,
720 handle,
721 press: point,
722 pending_press: Some(self.pointer),
723 });
724 }
725 Some(Hit::Handle { id, grab }) => {
726 self.set_object_focus(None);
727 self.editor.focus_outline(id)?;
728 self.drag = Some(Drag::Outline {
729 id,
730 grab,
731 pending_press: Some(self.pointer),
732 });
733 }
734 Some(Hit::Resize { id, grab }) => {
735 self.set_object_focus(None);
736 self.editor.focus_outline(id)?;
737 self.drag = Some(Drag::Resize {
738 outline: None,
739 grab,
740 });
741 }
742 Some(Hit::Text { id, point }) => {
743 let extend = self.modifiers.shift
744 && self.object_focus.is_none()
745 && id == self.editor.active_outline().id;
746 self.set_object_focus(None);
747 self.editor.focus_outline(id)?;
748 let previous = self.editor.selection();
749 self.editor.select_below(&mut self.engine, id, point)?;
750 let unit = match count {
751 2 => SelectionUnit::Word,
752 3 => SelectionUnit::Paragraph,
753 _ => SelectionUnit::Grapheme,
754 };
755 let selection = self.editor.selection_at(point[0], point[1], unit)?;
756 let selection = if extend {
757 Selection {
758 positions: [previous.positions[0], selection.positions[1]],
759 affinities: [previous.affinities[0], selection.affinities[1]],
760 }
761 } else {
762 selection
763 };
764 self.editor.select(selection)?;
765 self.drag = Some(Drag::Text {
766 anchor: selection,
767 unit,
768 });
769 }
770 None => {
771 // A fresh click places text 7 px above the pointer, on the grid.
772 let position = [point[0], point[1] - 7.0 * self.pixel()];
773 let position = if self.modifiers.option {
774 position
775 } else {
776 snap_to_grid(position, self.editor.margin_origin())
777 };
778 self.editor
779 .place_caret(&mut self.engine, position, DEFAULT_OUTLINE_WIDTH)?;
780 self.set_object_focus(None);
781 self.drag = Some(Drag::Text {
782 anchor: self.editor.selection(),
783 unit: SelectionUnit::Grapheme,
784 });
785 }
786 }
787 self.changed()
788 }
789
790 pub fn pointer_released(&mut self) -> Result<Response> {
791 let preview = self.outline_preview();
792 if let Some((id, origin, size)) = self.image_preview() {
793 self.editor
794 .place_image(&mut self.engine, id, origin, size)?;
795 }
796 if let Some(Drag::Resize {
797 outline: Some(outline),
798 ..
799 }) = self.drag.take()
800 {
801 self.editor
802 .resize(&mut self.engine, outline.bounds().width() as f32)?;
803 }
804 if let Some((id, origin)) = preview {
805 self.editor.move_outline(id, origin)?;
806 }
807 self.changed()
808 }
809
810 /// `delta` in device pixels; Command zooms about the pointer instead of scrolling.
811 pub fn wheel(&mut self, delta: [f32; 2]) -> Result<Response> {
812 if self.modifiers.command {
813 self.zoom((delta[1] * 0.005).exp());
814 } else {
815 self.viewport.origin[0] += delta[0];
816 self.viewport.origin[1] += delta[1];
817 }
818 self.changed()
819 }
820
821 /// Text the platform inserts outside key events, such as the character picker's;
822 /// control characters are not text here.
823 pub fn insert_text(&mut self, text: String) -> Result<Response> {
824 if text.is_empty() || text.chars().any(char::is_control) {
825 return Ok(Response::default());
826 }
827 self.commit_text(text)
828 }
829
830 /// Text committed by an input method or pasted, line breaks included.
831 pub fn commit_text(&mut self, text: String) -> Result<Response> {
832 if !self.accepts_text() {
833 return Ok(Response::default());
834 }
835 self.editor.commit_text(&mut self.engine, text)?;
836 self.edited()
837 }
838
839 /// An input method's marked text; `cursor` is its UTF-8 selection within `text`.
840 pub fn compose(&mut self, text: String, cursor: Option<(usize, usize)>) -> Result<Response> {
841 if !self.accepts_text() {
842 return Ok(Response::default());
843 }
844 if text.is_empty() {
845 self.editor.cancel_composition(&mut self.engine)?;
846 } else {
847 let (start, end) = cursor.unwrap_or((text.len(), text.len()));
848 let utf16 = |end: usize| -> Result<u32> {
849 Ok(text
850 .get(..end)
851 .ok_or("IME range splits UTF-8")?
852 .encode_utf16()
853 .count()
854 .try_into()?)
855 };
856 let range = utf16(start)?..utf16(end)?;
857 self.editor.compose(&mut self.engine, text, range)?;
858 }
859 self.edited()
860 }
861
862 /// The input method was switched off mid-composition.
863 pub fn cancel_composition(&mut self) -> Result<Response> {
864 if !self.accepts_text() {
865 return Ok(Response::default());
866 }
867 self.editor.cancel_composition(&mut self.engine)?;
868 self.edited()
869 }
870
871 /// The date or time chosen after `Request::EditDate`.
872 pub fn change_date(&mut self, timestamp: u64, text: [String; 2]) -> Result<Response> {
873 self.editor.change_date(&mut self.engine, timestamp, text)?;
874 self.changed()
875 }
876
877 /// Gives keyboard focus to a read-only object, as an assistive technology asks.
878 pub fn focus_read_only(&mut self, index: usize) -> Result<Response> {
879 self.set_object_focus(Some(ObjectFocus::ReadOnly(index)));
880 self.edited()
881 }
882
883 /// Returns focus to text after the host acted on the editor directly, as an assistive
884 /// technology's selection or value change does.
885 pub fn focus_text(&mut self) -> Result<Response> {
886 self.set_object_focus(None);
887 self.edited()
888 }
889
890 pub fn key(&mut self, key: &Key, text: Option<&str>) -> Result<Response> {
891 let Modifiers {
892 shift,
893 control,
894 option,
895 command,
896 } = self.modifiers;
897 if let Some(ObjectFocus::Image(id)) = self.object_focus
898 && matches!(key, Key::Named(NamedKey::Backspace | NamedKey::Delete))
899 {
900 self.editor.remove_image(&mut self.engine, id)?;
901 self.set_object_focus(None);
902 return self.changed();
903 }
904 if let Some(ObjectFocus::Image(id)) = self.object_focus
905 && !(shift || command || option || control)
906 && let Key::Named(NamedKey::ArrowLeft | NamedKey::ArrowRight) = key
907 && self.editor.step_from_image(
908 &mut self.engine,
909 id,
910 key == &Key::Named(NamedKey::ArrowRight),
911 )?
912 {
913 self.set_object_focus(None);
914 return self.edited();
915 }
916 if let Some(focus) = self.object_focus {
917 let undo = matches!(focus, ObjectFocus::Image(_))
918 && command
919 && matches!(key, Key::Character(value) if value.eq_ignore_ascii_case("z"));
920 if !undo && !read_only_shortcut(key, self.modifiers) {
921 return Ok(Response::default());
922 }
923 if key == &Key::Named(NamedKey::Escape) {
924 self.set_object_focus(None);
925 return self.edited();
926 }
927 }
928 if command && control && key == &Key::Named(NamedKey::Space) {
929 return Ok(Response {
930 request: Some(Request::CharacterPalette),
931 ..Response::default()
932 });
933 }
934 if matches!(
935 self.drag,
936 Some(Drag::Outline { .. } | Drag::Resize { .. } | Drag::Image { .. })
937 ) {
938 if key == &Key::Named(NamedKey::Modifier) {
939 return Ok(Response::default());
940 }
941 self.drag = None;
942 if key == &Key::Named(NamedKey::Escape) {
943 return self.changed();
944 }
945 }
946 if command && option {
947 let delta = match key {
948 Key::Named(NamedKey::ArrowLeft) => Some([-1.0, 0.0]),
949 Key::Named(NamedKey::ArrowRight) => Some([1.0, 0.0]),
950 Key::Named(NamedKey::ArrowUp) => Some([0.0, -1.0]),
951 Key::Named(NamedKey::ArrowDown) => Some([0.0, 1.0]),
952 _ => None,
953 };
954 if let Some(delta) = delta {
955 let outline = self.editor.active_outline();
956 let origin = outline.origin();
957 let step = if shift { 10.0 } else { 1.0 };
958 self.editor.move_outline(
959 outline.id,
960 [origin[0] + delta[0] * step, origin[1] + delta[1] * step],
961 )?;
962 return self.edited();
963 }
964 }
965 if control && key == &Key::Named(NamedKey::Tab) {
966 let outlines = self.editor.outlines();
967 let count = outlines.len()
968 + self
969 .scene
970 .as_ref()
971 .map_or(0, |(scene, _)| scene.read_only(Some(&self.editor)).count());
972 if count == 0 {
973 return self.changed();
974 }
975 let index = self
976 .object_focus
977 .and_then(ObjectFocus::read_only)
978 .map_or_else(
979 || {
980 outlines
981 .iter()
982 .position(|outline| outline.id == self.editor.active_outline().id)
983 },
984 |index| Some(outlines.len() + index),
985 );
986 let next = index.map_or(if shift { count - 1 } else { 0 }, |index| {
987 if shift {
988 (index + count - 1) % count
989 } else {
990 (index + 1) % count
991 }
992 });
993 if next < outlines.len() {
994 self.editor.focus_outline(outlines[next].id)?;
995 self.set_object_focus(None);
996 } else {
997 self.set_object_focus(Some(ObjectFocus::ReadOnly(next - outlines.len())));
998 }
999 return self.edited();
1000 }
1001 let mut request = None;
1002 if command && let Key::Character(key) = key {
1003 match key.to_lowercase().as_str() {
1004 "n" if shift => {
1005 let position = if let Some(focus) = self.object_focus {
1006 let rect = self.object_rect(focus);
1007 [rect[2] + 24.0, rect[1]]
1008 } else {
1009 let bounds = self.editor.active_outline().bounds();
1010 [bounds.x1 as f32 + 24.0, bounds.y0 as f32]
1011 };
1012 self.editor.place_caret(
1013 &mut self.engine,
1014 snap_to_grid(position, self.editor.margin_origin()),
1015 DEFAULT_OUTLINE_WIDTH,
1016 )?;
1017 self.set_object_focus(None);
1018 }
1019 "a" => self.editor.select_all()?,
1020 "z" => {
1021 if shift {
1022 self.editor.redo(&mut self.engine)?;
1023 } else {
1024 self.editor.undo(&mut self.engine)?;
1025 }
1026 if let Some(ObjectFocus::Image(id)) = self.object_focus
1027 && self.editor.image_placement(id).is_none()
1028 {
1029 self.set_object_focus(None);
1030 }
1031 }
1032 "c" | "x" => {
1033 let [anchor, focus] = self.editor.selection().positions;
1034 let selected = self
1035 .editor
1036 .active_outline()
1037 .document()
1038 .slice(anchor.min(focus)..anchor.max(focus))?;
1039 let text = selected
1040 .iter()
1041 .map(|paragraph| {
1042 paragraph
1043 .project()
1044 .map(|projection| projection.text().text().to_owned())
1045 })
1046 .collect::<std::result::Result<Vec<_>, _>>()?
1047 .join("\n");
1048 if !text.is_empty() {
1049 if key.eq_ignore_ascii_case("x") {
1050 self.editor.insert(&mut self.engine, "")?;
1051 }
1052 request = Some(Request::Copy(text));
1053 }
1054 }
1055 "v" => request = Some(Request::Paste),
1056 "+" | "=" => {
1057 self.zoom(1.1);
1058 return self.changed();
1059 }
1060 "-" => {
1061 self.zoom(1.0 / 1.1);
1062 return self.changed();
1063 }
1064 "0" => {
1065 self.zoom((self.display_scale * 96.0 / 72.0) / self.viewport.scale);
1066 return self.changed();
1067 }
1068 _ => return Ok(Response::default()),
1069 }
1070 } else {
1071 let movement = match key {
1072 Key::Named(NamedKey::ArrowLeft) => Some(if command {
1073 Movement::LineStart
1074 } else if option {
1075 Movement::WordLeft
1076 } else {
1077 Movement::Left
1078 }),
1079 Key::Named(NamedKey::ArrowRight) => Some(if command {
1080 Movement::LineEnd
1081 } else if option {
1082 Movement::WordRight
1083 } else {
1084 Movement::Right
1085 }),
1086 Key::Named(NamedKey::ArrowUp) => Some(if command {
1087 Movement::DocumentStart
1088 } else if option {
1089 Movement::ParagraphStart
1090 } else {
1091 Movement::Up
1092 }),
1093 Key::Named(NamedKey::ArrowDown) => Some(if command {
1094 Movement::DocumentEnd
1095 } else if option {
1096 Movement::ParagraphEnd
1097 } else {
1098 Movement::Down
1099 }),
1100 Key::Named(NamedKey::Home) if shift => Some(Movement::DocumentStart),
1101 Key::Named(NamedKey::End) if shift => Some(Movement::DocumentEnd),
1102 Key::Character(key) if control && !option => match key.as_str() {
1103 "a" => Some(Movement::LineStart),
1104 "e" => Some(Movement::LineEnd),
1105 "b" => Some(Movement::Left),
1106 "f" => Some(Movement::Right),
1107 "p" => Some(Movement::Up),
1108 "n" => Some(Movement::Down),
1109 _ => None,
1110 },
1111 _ => None,
1112 };
1113 if let Some(movement) = movement {
1114 self.editor
1115 .move_selection(&mut self.engine, movement, shift)?;
1116 } else if self.editor.marked_range().is_none() {
1117 match key {
1118 Key::Named(NamedKey::Backspace) if command || option => {
1119 self.editor.delete_to(
1120 &mut self.engine,
1121 if command {
1122 Movement::LineStart
1123 } else {
1124 Movement::WordLeft
1125 },
1126 )?;
1127 }
1128 Key::Named(NamedKey::Delete) if command || option => {
1129 self.editor.delete_to(
1130 &mut self.engine,
1131 if command {
1132 Movement::LineEnd
1133 } else {
1134 Movement::WordRight
1135 },
1136 )?;
1137 }
1138 Key::Named(NamedKey::Backspace) => {
1139 self.editor.delete(&mut self.engine, true)?;
1140 }
1141 Key::Named(NamedKey::Delete) => {
1142 self.editor.delete(&mut self.engine, false)?;
1143 }
1144 Key::Named(end @ (NamedKey::Home | NamedKey::End)) => {
1145 let limits = self.scroll();
1146 self.viewport.origin[1] = -if *end == NamedKey::Home {
1147 limits.min[1]
1148 } else {
1149 limits.max[1]
1150 };
1151 return self.changed();
1152 }
1153 Key::Character(key) if control && !option => match key.as_str() {
1154 "h" => {
1155 self.editor.delete(&mut self.engine, true)?;
1156 }
1157 "d" => {
1158 self.editor.delete(&mut self.engine, false)?;
1159 }
1160 "k" if !self.editor.delete_to(&mut self.engine, Movement::LineEnd)? => {
1161 self.editor.delete(&mut self.engine, false)?;
1162 }
1163 _ => {}
1164 },
1165 Key::Named(NamedKey::Enter) => {
1166 self.editor.enter(&mut self.engine, shift)?;
1167 }
1168 Key::Named(NamedKey::Tab) => {
1169 self.editor.tab(&mut self.engine, shift)?;
1170 }
1171 _ if !command && !control => {
1172 if let Some(text) = text
1173 .filter(|text| !text.is_empty() && !text.chars().any(char::is_control))
1174 {
1175 self.editor.insert(&mut self.engine, text)?;
1176 }
1177 }
1178 _ => {}
1179 }
1180 } else if key == &Key::Named(NamedKey::Escape) {
1181 self.editor.cancel_composition(&mut self.engine)?;
1182 }
1183 }
1184 Ok(Response {
1185 request,
1186 ..self.edited()?
1187 })
1188 }
1189}
1190
1191/// What a document point lands on.
1192#[derive(Debug, PartialEq)]
1193pub enum Hit {
1194 Date(DateField),
1195 Resize {
1196 id: onestore::ExGuid,
1197 grab: f32,
1198 },
1199 Text {
1200 id: onestore::ExGuid,
1201 point: [f32; 2],
1202 },
1203 Handle {
1204 id: onestore::ExGuid,
1205 grab: [f32; 2],
1206 },
1207 ReadOnly(usize),
1208 /// `handle` is [0, 0] on the picture and a direction on the selected picture's handles.
1209 Image {
1210 id: onestore::ExGuid,
1211 handle: [i8; 2],
1212 },
1213}
1214
1215/// What a document point lands on; `pixel` is document points per device pixel.
1216pub fn page_hit_test(
1217 editor: &CanvasEditor,
1218 scene: Option<&(PageScene, [f32; 2])>,
1219 point: [f32; 2],
1220 pixel: f32,
1221) -> Option<Hit> {
1222 /// Grips and text resolve front to back before any outline's padding, as a native
1223 /// width handle stays reachable under the next outline's left padding; the typing room
1224 /// below an outline comes last.
1225 #[derive(Clone, Copy, PartialEq)]
1226 enum Layer {
1227 Grips,
1228 Body,
1229 Below,
1230 }
1231 let hit = |outline: &TextOutline, offset: [f32; 2], layer: Layer| {
1232 if layer == Layer::Below {
1233 let outline = editor
1234 .outlines()
1235 .iter()
1236 .find(|source| source.id == outline.id)?;
1237 let local = [
1238 point[0] - offset[0] - outline.origin()[0],
1239 point[1] - offset[1] - outline.origin()[1],
1240 ];
1241 return outline.contains_extension(local).then_some(Hit::Text {
1242 id: outline.id,
1243 point: local,
1244 });
1245 }
1246 let (bounds, body_top) = outline_chrome(outline, pixel);
1247 let local = [point[0] - offset[0], point[1] - offset[1]];
1248 let [x, y] = local;
1249 if layer == Layer::Grips {
1250 // Width handles: 15 px inside to 6 px outside the header's right end, and 6 px
1251 // either side of the right border below it.
1252 let inside = if y < body_top { 15.0 } else { 6.0 };
1253 if !outline.title
1254 && (bounds[2] - inside * pixel..=bounds[2] + 6.0 * pixel).contains(&x)
1255 && (bounds[1]..=bounds[3]).contains(&y)
1256 {
1257 return Some(Hit::Resize {
1258 id: outline.id,
1259 grab: point[0] - outline.bounds().x1 as f32,
1260 });
1261 }
1262 if x >= bounds[0] && x <= bounds[2] && y >= bounds[1] && y < body_top {
1263 return Some(Hit::Handle {
1264 id: outline.id,
1265 grab: [
1266 point[0] - outline.origin()[0],
1267 point[1] - outline.origin()[1],
1268 ],
1269 });
1270 }
1271 let text = outline.bounds();
1272 if !(text.x0..=text.x1).contains(&f64::from(x))
1273 || !(text.y0..=text.y1).contains(&f64::from(y))
1274 {
1275 return None;
1276 }
1277 }
1278 let inner = [x - outline.origin()[0], y - outline.origin()[1]];
1279 if let Some(picture) = outline.shaped().objects.iter().find(|object| {
1280 matches!(object.kind, crate::outline::ObjectKind::Picture)
1281 && (object.rect[0]..=object.rect[2]).contains(&inner[0])
1282 && (object.rect[1]..=object.rect[3]).contains(&inner[1])
1283 }) {
1284 return Some(Hit::Image {
1285 id: picture.id,
1286 handle: [0, 0],
1287 });
1288 }
1289 if (x >= bounds[0] && x <= bounds[2] && y >= body_top && y <= bounds[3])
1290 || outline.layouts().any(|(_, paragraph)| {
1291 paragraph.tags.iter().any(|tag| {
1292 let origin = outline.origin();
1293 let x = origin[0] + outline.shaped().tag_column_offset() + tag.origin[0];
1294 let y = origin[1] + paragraph.origin[1] + tag.origin[1];
1295 let size = crate::outline::ParagraphTag::SIZE;
1296 (x..=x + size).contains(&local[0]) && (y..=y + size).contains(&local[1])
1297 })
1298 })
1299 {
1300 return Some(Hit::Text {
1301 id: outline.id,
1302 point: [
1303 local[0] - outline.origin()[0],
1304 local[1] - outline.origin()[1],
1305 ],
1306 });
1307 }
1308 None
1309 };
1310 let pass = |layer| {
1311 if let Some(hit) = editor
1312 .visible_outlines()
1313 .rev()
1314 .filter(|outline| scene.is_none() || !editor.has_page_outline(outline.id))
1315 .find_map(|outline| hit(outline, [0.0; 2], layer))
1316 {
1317 return Some(hit);
1318 }
1319 let (scene, offset) = scene?;
1320 match scene.hit_test(
1321 [point[0] - offset[0], point[1] - offset[1]],
1322 Some(editor),
1323 |id| {
1324 editor
1325 .visible_outlines()
1326 .find(|outline| outline.id == id)
1327 .and_then(|outline| hit(outline, *offset, layer))
1328 },
1329 )? {
1330 crate::gpu::page::SceneHit::Outline(hit) => Some(hit),
1331 crate::gpu::page::SceneHit::Date(field) => Some(Hit::Date(field)),
1332 crate::gpu::page::SceneHit::ReadOnly(index) => Some(Hit::ReadOnly(index)),
1333 crate::gpu::page::SceneHit::Image(id) => Some(Hit::Image { id, handle: [0, 0] }),
1334 }
1335 };
1336 [Layer::Grips, Layer::Body, Layer::Below]
1337 .into_iter()
1338 .find_map(pass)
1339}
1340
1341fn page_primitives<'a>(
1342 editor: &'a CanvasEditor,
1343 scene: Option<&'a (PageScene, [f32; 2])>,
1344 preview: Option<PointerFeedback<'a>>,
1345 object_focus: Option<ObjectFocus>,
1346 paint: Paint,
1347) -> Result<Vec<Primitive<'a>>> {
1348 let mut primitives = Vec::new();
1349 let draw_outline = |id, offset: [f32; 2], primitives: &mut Vec<_>| {
1350 let outline = editor
1351 .visible_outlines()
1352 .find(|outline| outline.id == id)
1353 .ok_or(crate::gpu::page::SceneError::MissingOutline)?;
1354 let outline = match preview {
1355 Some(PointerFeedback::Resize(resized)) if resized.id == outline.id => resized,
1356 _ => outline,
1357 };
1358 let origin = match preview {
1359 Some(PointerFeedback::Move(id, origin)) if id == outline.id => origin,
1360 _ => outline.origin(),
1361 };
1362 if (object_focus.is_none() && outline.id == editor.active_outline().id)
1363 || matches!(preview, Some(PointerFeedback::Hover(id) | PointerFeedback::Move(id, _)) if id == outline.id)
1364 || matches!(preview, Some(PointerFeedback::Resize(resized)) if resized.id == outline.id)
1365 {
1366 append_outline_chrome(
1367 outline,
1368 [origin[0] + offset[0], origin[1] + offset[1]],
1369 paint.pixel,
1370 primitives,
1371 );
1372 }
1373 append_outline(
1374 (object_focus.is_none()
1375 && outline.id == editor.active_outline().id
1376 && !matches!(preview, Some(PointerFeedback::Resize(_))))
1377 .then_some(editor),
1378 outline,
1379 [origin[0] + offset[0], origin[1] + offset[1]],
1380 paint,
1381 primitives,
1382 )?;
1383 if let Some((scene, _)) = scene {
1384 let moving = match preview {
1385 Some(PointerFeedback::Image(id, origin, size)) => {
1386 let [x0, y0, x1, y1] = image_rect(origin, size);
1387 Some((
1388 id,
1389 [
1390 x0 + offset[0],
1391 y0 + offset[1],
1392 x1 + offset[0],
1393 y1 + offset[1],
1394 ],
1395 ))
1396 }
1397 _ => None,
1398 };
1399 scene.append_outline_objects(
1400 outline.shaped(),
1401 [origin[0] + offset[0], origin[1] + offset[1]],
1402 moving,
1403 primitives,
1404 );
1405 }
1406 Ok::<_, Box<dyn Error>>(())
1407 };
1408 if let Some((scene, origin)) = scene {
1409 let moving = match preview {
1410 Some(PointerFeedback::Image(id, origin, size)) => Some((id, image_rect(origin, size))),
1411 _ => None,
1412 };
1413 scene.append_primitives_with(
1414 &mut primitives,
1415 *origin,
1416 Some(editor),
1417 moving,
1418 &draw_outline,
1419 )?;
1420 }
1421 for outline in editor
1422 .visible_outlines()
1423 .filter(|outline| scene.is_none() || !editor.has_page_outline(outline.id))
1424 {
1425 draw_outline(outline.id, [0.0; 2], &mut primitives)?;
1426 }
1427 if object_focus.is_none()
1428 && let Some(outline) = editor.caret_outline()
1429 {
1430 append_outline(
1431 Some(editor),
1432 outline,
1433 outline.origin(),
1434 paint,
1435 &mut primitives,
1436 )?;
1437 }
1438 if let Some(ObjectFocus::Image(id)) = object_focus {
1439 let (origin, size) = match preview {
1440 Some(PointerFeedback::Image(moving, origin, size)) if moving == id => (origin, size),
1441 _ => editor
1442 .image_placement(id)
1443 .ok_or("The selected picture is missing.")?,
1444 };
1445 append_image_chrome(image_rect(origin, size), paint.pixel, &mut primitives);
1446 }
1447 if let Some(ObjectFocus::ReadOnly(index)) = object_focus {
1448 let (scene, offset) = scene.unwrap();
1449 let [x0, y0, x1, y1] = scene.read_only(Some(editor)).nth(index).unwrap().rect();
1450 let [x0, y0, x1, y1] = [
1451 x0 + offset[0],
1452 y0 + offset[1],
1453 x1 + offset[0],
1454 y1 + offset[1],
1455 ];
1456 let border = 2.0 / paint.scale;
1457 for rect in [
1458 [x0, y0, x1, y0 + border],
1459 [x0, y1 - border, x1, y1],
1460 [x0, y0, x0 + border, y1],
1461 [x1 - border, y0, x1, y1],
1462 ] {
1463 primitives.push(Primitive::Rect {
1464 rect,
1465 color: [0.25, 0.45, 0.7, 1.0],
1466 });
1467 }
1468 }
1469 Ok(primitives)
1470}
1471
1472/// Native picture handles sit on a selection border drawn 5 px outside the picture, named
1473/// by their direction from its center.
1474fn image_handles(rect: [f32; 4], pixel: f32) -> impl Iterator<Item = ([i8; 2], [f32; 2])> {
1475 let border = [
1476 rect[0] - 5.0 * pixel,
1477 rect[1] - 5.0 * pixel,
1478 rect[2] + 5.0 * pixel,
1479 rect[3] + 5.0 * pixel,
1480 ];
1481 [-1, 0, 1]
1482 .into_iter()
1483 .flat_map(|y| [-1, 0, 1].map(|x| [x, y]))
1484 .filter(|handle| *handle != [0, 0])
1485 .map(move |handle| {
1486 (
1487 handle,
1488 std::array::from_fn(|axis| {
1489 let [start, end] = [border[axis], border[axis + 2]];
1490 start + (end - start) * f32::from(handle[axis] + 1) * 0.5
1491 }),
1492 )
1493 })
1494}
1495
1496fn image_handle_at(rect: [f32; 4], pixel: f32, point: [f32; 2]) -> Option<[i8; 2]> {
1497 image_handles(rect, pixel)
1498 .find(|(_, center)| (0..2).all(|axis| (point[axis] - center[axis]).abs() <= 5.0 * pixel))
1499 .map(|(handle, _)| handle)
1500}
1501
1502fn handle_cursor(handle: [i8; 2]) -> Cursor {
1503 match handle {
1504 [0, 0] => Cursor::Move,
1505 [_, 0] => Cursor::EwResize,
1506 [0, _] => Cursor::NsResize,
1507 [x, y] if x == y => Cursor::NwseResize,
1508 _ => Cursor::NeswResize,
1509 }
1510}
1511
1512fn image_rect(origin: [f32; 2], size: [f32; 2]) -> [f32; 4] {
1513 [
1514 origin[0],
1515 origin[1],
1516 origin[0] + size[0],
1517 origin[1] + size[1],
1518 ]
1519}
1520
1521/// Drags a picture's `handle` by `delta`: edges stretch one axis, corners keep the aspect
1522/// ratio at the larger of the two scales, and the opposite side stays fixed.
1523fn resize_image(
1524 origin: [f32; 2],
1525 size: [f32; 2],
1526 handle: [i8; 2],
1527 delta: [f32; 2],
1528) -> ([f32; 2], [f32; 2]) {
1529 let mut scale: [f32; 2] = std::array::from_fn(|axis| {
1530 (size[axis] + f32::from(handle[axis]) * delta[axis]) / size[axis]
1531 });
1532 if !handle.contains(&0) {
1533 scale = [scale[0].max(scale[1]); 2];
1534 }
1535 let resized: [f32; 2] = std::array::from_fn(|axis| (size[axis] * scale[axis]).max(1.0));
1536 let origin = std::array::from_fn(|axis| {
1537 if handle[axis] < 0 {
1538 origin[axis] + size[axis] - resized[axis]
1539 } else {
1540 origin[axis]
1541 }
1542 });
1543 (origin, resized)
1544}
1545
1546fn append_image_chrome(rect: [f32; 4], pixel: f32, primitives: &mut Vec<Primitive<'_>>) {
1547 let mut tint = crate::gpu::colorref(0x00e0d2e6);
1548 // Native pictures take this tint at 25% in sRGB; 10% in linear light matches it.
1549 tint[3] = 0.1;
1550 primitives.push(Primitive::Rect { rect, color: tint });
1551 primitives.push(Primitive::RoundedRect {
1552 rect: [
1553 rect[0] - 5.0 * pixel,
1554 rect[1] - 5.0 * pixel,
1555 rect[2] + 5.0 * pixel,
1556 rect[3] + 5.0 * pixel,
1557 ],
1558 radius: [0.0; 2],
1559 stroke: Some(Stroke::Dashed(pixel)),
1560 color: crate::gpu::colorref(0x00ff9a31),
1561 });
1562 for (handle, [x, y]) in image_handles(rect, pixel) {
1563 let (half, radius) = if handle.contains(&0) {
1564 (3.5, 0.0)
1565 } else {
1566 (4.0, 4.0)
1567 };
1568 let rect = [
1569 x - half * pixel,
1570 y - half * pixel,
1571 x + half * pixel,
1572 y + half * pixel,
1573 ];
1574 primitives.push(Primitive::RoundedRect {
1575 rect,
1576 radius: [radius * pixel; 2],
1577 stroke: None,
1578 color: crate::gpu::colorref(0x00ffefe7),
1579 });
1580 primitives.push(Primitive::RoundedRect {
1581 rect,
1582 radius: [radius * pixel; 2],
1583 stroke: Some(Stroke::Solid(pixel)),
1584 color: crate::gpu::colorref(0x00dea67b),
1585 });
1586 }
1587}
1588
1589fn drag_selection(anchor: Selection, target: Selection, unit: SelectionUnit) -> Selection {
1590 if unit == SelectionUnit::Grapheme {
1591 return Selection {
1592 positions: [anchor.positions[0], target.positions[1]],
1593 affinities: [anchor.affinities[0], target.affinities[1]],
1594 };
1595 }
1596 let backwards = target.positions[0] < anchor.positions[0].min(anchor.positions[1]);
1597 let start = usize::from((anchor.positions[0] > anchor.positions[1]) != backwards);
1598 let end = usize::from((target.positions[0] > target.positions[1]) == backwards);
1599 Selection {
1600 positions: [anchor.positions[start], target.positions[end]],
1601 affinities: [anchor.affinities[start], target.affinities[end]],
1602 }
1603}
1604
1605fn snap_to_grid(point: [f32; 2], margin: [f32; 2]) -> [f32; 2] {
1606 std::array::from_fn(|axis| {
1607 let offset = margin[axis];
1608 let cell = (point[axis] - offset) / 18.0;
1609 let nearest = cell.round();
1610 // Native midpoints remain free; account for the source coordinate's float precision.
1611 if ((cell - nearest).abs() - 0.5).abs() <= f32::EPSILON * cell.abs().max(1.0) {
1612 point[axis]
1613 } else {
1614 nearest * 18.0 + offset
1615 }
1616 })
1617}
1618
1619fn outline_chrome(outline: &TextOutline, pixel: f32) -> ([f32; 4], f32) {
1620 let bounds = outline.bounds();
1621 // Native chrome combines page-scaled gutters with a fixed screen inset.
1622 let inset = 5.0 * pixel;
1623 let body_top = bounds.y0 as f32 - inset;
1624 if outline.title {
1625 let (_, paragraph) = outline.layouts().last().unwrap();
1626 let bottom = outline.origin()[1] + paragraph.origin[1] + paragraph.text.height();
1627 return (
1628 [
1629 bounds.x0 as f32 - inset - 6.0 * pixel,
1630 body_top,
1631 bounds.x1 as f32 + inset - 2.0 * pixel,
1632 bottom + inset,
1633 ],
1634 body_top,
1635 );
1636 }
1637 (
1638 [
1639 bounds.x0 as f32 - 7.5 - inset,
1640 body_top - HANDLE_HEIGHT,
1641 bounds.x1 as f32 + inset,
1642 bounds.y1 as f32 + HANDLE_HEIGHT + inset,
1643 ],
1644 body_top,
1645 )
1646}
1647
1648fn append_outline_chrome(
1649 outline: &TextOutline,
1650 origin: [f32; 2],
1651 pixel: f32,
1652 primitives: &mut Vec<Primitive<'_>>,
1653) {
1654 let [x, y] = origin;
1655 let (bounds, body_top) = outline_chrome(outline, pixel);
1656 let [dx, dy] = [x - outline.origin()[0], y - outline.origin()[1]];
1657 let [left, top, right, bottom] = [
1658 bounds[0] + dx,
1659 bounds[1] + dy,
1660 bounds[2] + dx,
1661 bounds[3] + dy,
1662 ];
1663 let body_top = body_top + dy;
1664 if outline.title {
1665 primitives.push(Primitive::RoundedRect {
1666 rect: [left, top, right, bottom],
1667 radius: [6.0 * pixel, (bottom - top) * 0.5],
1668 stroke: Some(Stroke::Dashed(pixel)),
1669 color: crate::gpu::colorref(0x007f7f7f),
1670 });
1671 return;
1672 }
1673 primitives.push(Primitive::RoundedRect {
1674 rect: [left, top, right, body_top],
1675 radius: [3.0 * pixel; 2],
1676 stroke: None,
1677 color: crate::gpu::colorref(0x00e8ebed),
1678 });
1679 primitives.push(Primitive::RoundedRect {
1680 rect: [right - 9.0 * pixel, top, right, body_top],
1681 radius: [3.0 * pixel; 2],
1682 stroke: None,
1683 color: crate::gpu::colorref(0x00e5dee7),
1684 });
1685 primitives.push(Primitive::RoundedRect {
1686 rect: [left, top, right, bottom],
1687 radius: [3.0 * pixel; 2],
1688 stroke: Some(Stroke::Solid(pixel)),
1689 color: crate::gpu::colorref(0x00d9cfd8),
1690 });
1691 let middle = (top + body_top) * 0.5;
1692 for offset in [-3.0, 0.0, 3.0] {
1693 let center = (left + right) * 0.5 + offset * pixel;
1694 primitives.push(Primitive::RoundedRect {
1695 rect: [
1696 center - pixel * 0.5,
1697 middle - pixel * 0.5,
1698 center + pixel * 0.5,
1699 middle + pixel * 0.5,
1700 ],
1701 radius: [pixel * 0.5; 2],
1702 stroke: None,
1703 color: crate::gpu::colorref(0x00b4a5b4),
1704 });
1705 }
1706 for column in [0.0, 1.0, 2.0] {
1707 let half = (column + 0.5) * pixel;
1708 for x in [
1709 right - (8.0 - column) * pixel,
1710 right - (2.0 + column) * pixel,
1711 ] {
1712 primitives.push(Primitive::Rect {
1713 rect: [x, middle - half, x + pixel, middle + half],
1714 color: crate::gpu::colorref(0x00b4a5b4),
1715 });
1716 }
1717 }
1718}
1719
1720fn append_outline<'a>(
1721 editor: Option<&'a CanvasEditor>,
1722 outline: &'a TextOutline,
1723 origin: [f32; 2],
1724 paint: Paint,
1725 primitives: &mut Vec<Primitive<'a>>,
1726) -> Result<()> {
1727 let [x, y] = origin;
1728 let Paint {
1729 show_caret,
1730 scale,
1731 pixel,
1732 colors,
1733 } = paint;
1734 outline.shaped().append_table_primitives(primitives, origin);
1735 outline
1736 .shaped()
1737 .append_background_primitives(primitives, origin);
1738 if let Some(editor) = editor {
1739 for rect in editor.selection_rects()? {
1740 primitives.push(Primitive::Rect {
1741 rect: [
1742 rect.x0 as f32 + x,
1743 rect.y0 as f32 + y,
1744 rect.x1 as f32 + x,
1745 rect.y1 as f32 + y,
1746 ],
1747 color: colors.selection,
1748 });
1749 }
1750 }
1751 for (index, (_, paragraph)) in outline.layouts().enumerate() {
1752 outline
1753 .shaped()
1754 .append_paragraph_primitives(index, paragraph, origin, primitives);
1755 }
1756 if let Some(editor) = editor {
1757 for rect in editor.marked_rects()? {
1758 primitives.push(Primitive::Rect {
1759 rect: [
1760 rect.x0 as f32 + x,
1761 rect.y1 as f32 + y - 1.0 / scale,
1762 rect.x1 as f32 + x,
1763 rect.y1 as f32 + y,
1764 ],
1765 color: [0.0, 0.0, 0.0, 1.0],
1766 });
1767 }
1768 let [anchor, focus] = editor.selection().positions;
1769 if show_caret && anchor == focus {
1770 let rect = editor.caret(2.0 * pixel)?;
1771 primitives.push(Primitive::RoundedRect {
1772 rect: [
1773 rect.x0 as f32 + x,
1774 rect.y0 as f32 + y,
1775 rect.x1 as f32 + x,
1776 rect.y1 as f32 + y,
1777 ],
1778 radius: [pixel; 2],
1779 stroke: None,
1780 color: colors.caret,
1781 });
1782 }
1783 }
1784 Ok(())
1785}
crates/canvas/src/interaction/profile.rs created+209
......@@ -0,0 +1,209 @@
1use super::*;
2use crate::document::TextDocument;
3use crate::document::TextPosition;
4use onestore::document::Format;
5use onestore::page::Page;
6use onestore::page::text::Paragraph;
7use std::sync::Arc;
8use std::{hint::black_box, process::Command};
9
10fn timed<T>(case: &str, phase: &str, sample: usize, operation: impl FnOnce() -> T) -> T {
11 let start = Instant::now();
12 let result = operation();
13 eprintln!(
14 "canvas_profile\t{case}\t{phase}\t{sample}\t{}",
15 start.elapsed().as_nanos()
16 );
17 result
18}
19
20fn resident(case: &str, phase: &str, sample: usize) {
21 let output = Command::new("/bin/ps")
22 .args(["-o", "rss=", "-p", &std::process::id().to_string()])
23 .output()
24 .unwrap();
25 assert!(output.status.success());
26 let kib: u64 = std::str::from_utf8(&output.stdout)
27 .unwrap()
28 .trim()
29 .parse()
30 .unwrap();
31 eprintln!("canvas_rss_kib\t{case}\t{phase}\t{sample}\t{kib}");
32}
33
34fn layout_ids(editor: &CanvasEditor) -> Vec<u64> {
35 editor
36 .outlines()
37 .iter()
38 .flat_map(|outline| outline.layouts().map(|(_, paragraph)| paragraph.text.id()))
39 .collect()
40}
41
42fn editing_cost(
43 case: &str,
44 engine: &mut TextEngine,
45 editor: &mut CanvasEditor,
46 scene: Option<&(PageScene, [f32; 2])>,
47) {
48 let viewport = Viewport {
49 size: [1000, 720],
50 scale: 96.0 / 72.0,
51 origin: [48.0; 2],
52 };
53 let initial: Vec<_> = timed(case, "opening_snapshot", 0, || {
54 editor
55 .outlines()
56 .iter()
57 .map(|outline| outline.document().clone())
58 .collect()
59 });
60 let mut access = accessibility::Accessibility::default();
61 let update = timed(case, "ax_first", 0, || {
62 access.update(editor, viewport, "Profile", None).unwrap()
63 });
64 eprintln!("canvas_count\t{case}\tax_nodes\t0\t{}", update.nodes.len());
65 drop(update);
66 let count: usize = editor
67 .outlines()
68 .iter()
69 .map(|outline| outline.document().nodes().len())
70 .sum();
71 eprintln!("canvas_count\t{case}\tparagraphs\t0\t{count}");
72 for sample in 0..32 {
73 let index = sample % editor.outlines().len();
74 let id = editor.outlines()[index].id;
75 editor.focus_outline(id).unwrap();
76 let paragraph = if sample % 2 == 0 {
77 0
78 } else {
79 editor.active_outline().layouts().last().unwrap().0
80 };
81 editor
82 .select(
83 [TextPosition {
84 paragraph,
85 offset: 0,
86 }; 2]
87 .into(),
88 )
89 .unwrap();
90 let original_layouts = layout_ids(editor);
91 timed(case, "insert", sample, || {
92 editor.insert(engine, "x").unwrap()
93 });
94 let edited_layouts = layout_ids(editor);
95 let changed = original_layouts
96 .iter()
97 .zip(&edited_layouts)
98 .filter(|(a, b)| a != b)
99 .count();
100 assert_eq!(changed, 1);
101 eprintln!("canvas_count\t{case}\tchanged_layouts\t{sample}\t{changed}");
102 let primitives = timed(case, "frame_primitives", sample, || {
103 let primitives = page_primitives(
104 editor,
105 scene,
106 None,
107 None,
108 super::tests::paint(true, viewport.scale, 1.0),
109 )
110 .unwrap();
111 black_box(&primitives);
112 primitives.len()
113 });
114 eprintln!("canvas_count\t{case}\tprimitives\t{sample}\t{primitives}");
115 timed(case, "ax_insert", sample, || {
116 black_box(access.update(editor, viewport, "Profile", None).unwrap());
117 });
118 assert_eq!(edited_layouts, layout_ids(editor));
119 timed(case, "undo", sample, || {
120 assert!(editor.undo(engine).unwrap())
121 });
122 timed(case, "ax_undo", sample, || {
123 black_box(access.update(editor, viewport, "Profile", None).unwrap());
124 });
125 assert_eq!(editor.active_outline().document(), &initial[index]);
126 }
127 resident(case, "after_edits", 0);
128}
129
130#[test]
131#[ignore = "manual release canvas pipeline timing probe"]
132fn canvas_pipeline_cost() {
133 if let Some(path) = std::env::var_os("CANVAS_TEST_SECTION") {
134 let bytes = timed("native", "read", 0, || std::fs::read(path).unwrap());
135 let title = std::env::var("CANVAS_TEST_PAGE").unwrap();
136 let mut engine = timed("native", "engine", 0, TextEngine::default);
137 if let Some(font) = std::env::var_os("CANVAS_TEST_SUBSTITUTE") {
138 timed("native", "font", 0, || {
139 engine
140 .register_substitute(parley::fontique::Blob::new(Arc::new(
141 std::fs::read(font).unwrap(),
142 )))
143 .unwrap()
144 });
145 }
146 let cycles = std::env::var("CANVAS_PROFILE_CYCLES")
147 .map(|value| value.parse::<usize>().unwrap())
148 .unwrap_or(16);
149 assert!(cycles > 0);
150 for cycle in 0..cycles {
151 let page = timed("native", "parse", cycle, || {
152 let store = onestore::Store::parse(&bytes).unwrap();
153 let index = onestore::RevisionIndex::parse(&store).unwrap();
154 Page::from_document(
155 &onestore::document::Document::parse(&index).unwrap(),
156 &title,
157 )
158 .unwrap()
159 });
160 let (scene, mut editor) = timed("native", "scene", cycle, || {
161 PageScene::from_page(page, &mut engine).unwrap()
162 });
163 let scene = (scene, [0.0; 2]);
164 if cycle == 0 {
165 editing_cost("native", &mut engine, &mut editor, Some(&scene));
166 } else {
167 black_box(
168 page_primitives(
169 &editor,
170 Some(&scene),
171 None,
172 None,
173 super::tests::paint(false, 96.0 / 72.0, 1.0),
174 )
175 .unwrap(),
176 );
177 }
178 resident("native", "open", cycle);
179 drop(editor);
180 drop(scene);
181 resident("native", "closed", cycle);
182 }
183 } else {
184 for count in [100, 1_000, 5_000] {
185 let case = format!("synthetic_{count}");
186 let mut engine = timed(&case, "engine", 0, TextEngine::default);
187 let document = timed(&case, "document", 0, || {
188 TextDocument::new(
189 (0..count)
190 .map(|_| {
191 Paragraph::new(
192 "A paragraph of plain text with several words.".into(),
193 Format::default(),
194 )
195 })
196 .collect(),
197 )
198 .unwrap()
199 });
200 let mut editor = timed(&case, "scene", 0, || {
201 CanvasEditor::new(&mut engine, document, 480.0).unwrap()
202 });
203 editing_cost(&case, &mut engine, &mut editor, None);
204 drop(editor);
205 drop(engine);
206 resident(&case, "closed", 0);
207 }
208 }
209}
crates/canvas/src/interaction/scroll.rs created+134
......@@ -0,0 +1,134 @@
1use crate::gpu::{Primitive, Viewport};
2
3pub struct Scroll {
4 pub min: [f32; 2],
5 pub max: [f32; 2],
6}
7
8impl Scroll {
9 pub fn new(viewport: Viewport, bounds: impl Iterator<Item = [f32; 4]>) -> Self {
10 let mut min = [0.0_f32; 2];
11 let mut max = [0.0_f32; 2];
12 for rect in bounds {
13 for axis in 0..2 {
14 min[axis] = min[axis].min(rect[axis]);
15 max[axis] = max[axis].max(rect[axis + 2]);
16 }
17 }
18 // OneNote stops at the page origin or the object furthest above or left of it.
19 for axis in 0..2 {
20 min[axis] *= viewport.scale;
21 max[axis] =
22 ((max[axis] + 36.0) * viewport.scale - viewport.size[axis] as f32).max(min[axis]);
23 }
24 Self { min, max }
25 }
26
27 pub fn clamp(&self, viewport: &mut Viewport) {
28 for axis in 0..2 {
29 viewport.origin[axis] = -(-viewport.origin[axis]).clamp(self.min[axis], self.max[axis]);
30 }
31 }
32
33 pub fn thumb(&self, viewport: Viewport, dpr: f32, axis: usize) -> Option<[f32; 4]> {
34 let range = self.max[axis] - self.min[axis];
35 let length = viewport.size[axis] as f32;
36 let track = length - 20.0 * dpr;
37 if range <= 0.0 || track <= 0.0 {
38 return None;
39 }
40 let size = (track * length / (length + range))
41 .max(28.0 * dpr)
42 .min(track);
43 let position = (-viewport.origin[axis] - self.min[axis]) / range;
44 let start = 4.0 * dpr + position.clamp(0.0, 1.0) * (track - size);
45 let cross = viewport.size[1 - axis] as f32 - 4.0 * dpr;
46 Some(if axis == 0 {
47 [start, cross - 6.0 * dpr, start + size, cross]
48 } else {
49 [cross - 6.0 * dpr, start, cross, start + size]
50 })
51 }
52
53 pub fn drag(&self, viewport: &mut Viewport, dpr: f32, axis: usize, pointer: f32, grab: f32) {
54 let Some(rect) = self.thumb(*viewport, dpr, axis) else {
55 return;
56 };
57 let travel = viewport.size[axis] as f32 - 20.0 * dpr - (rect[axis + 2] - rect[axis]);
58 if travel > 0.0 {
59 let fraction = ((pointer - grab - 4.0 * dpr) / travel).clamp(0.0, 1.0);
60 viewport.origin[axis] =
61 -(self.min[axis] + fraction * (self.max[axis] - self.min[axis]));
62 }
63 }
64
65 pub fn hit_test(&self, viewport: Viewport, dpr: f32, point: [f32; 2]) -> Option<(usize, f32)> {
66 (0..2).find_map(|axis| {
67 let rect = self.thumb(viewport, dpr, axis)?;
68 let inset = 3.0 * dpr;
69 ((rect[0] - inset..=rect[2] + inset).contains(&point[0])
70 && (rect[1] - inset..=rect[3] + inset).contains(&point[1]))
71 .then_some((axis, point[axis] - rect[axis]))
72 })
73 }
74
75 pub fn append(&self, viewport: Viewport, dpr: f32, primitives: &mut Vec<Primitive<'_>>) {
76 for axis in 0..2 {
77 if let Some(rect) = self.thumb(viewport, dpr, axis) {
78 let start = viewport.document_point([rect[0], rect[1]]);
79 let end = viewport.document_point([rect[2], rect[3]]);
80 primitives.push(Primitive::RoundedRect {
81 rect: [start[0], start[1], end[0], end[1]],
82 radius: [3.0 * dpr / viewport.scale; 2],
83 stroke: None,
84 color: [0.18, 0.18, 0.18, 0.45],
85 });
86 }
87 }
88 }
89}
90
91#[cfg(test)]
92mod tests {
93 use super::*;
94
95 #[test]
96 fn bounds_include_negative_objects_and_clamp_both_ends() {
97 let mut viewport = Viewport {
98 size: [800, 600],
99 scale: 2.0,
100 origin: [9999.0; 2],
101 };
102 let scroll = Scroll::new(viewport, [[-80.0, -20.0, 1000.0, 1200.0]].into_iter());
103 scroll.clamp(&mut viewport);
104 assert_eq!(viewport.origin, [160.0, 40.0]);
105 viewport.origin = [-9999.0; 2];
106 scroll.clamp(&mut viewport);
107 assert_eq!(viewport.origin, [-1272.0, -1872.0]);
108 }
109
110 #[test]
111 fn small_page_has_no_scrollbars_and_thumb_drag_reaches_endpoints() {
112 for dpr in [1.0, 2.0] {
113 let mut viewport = Viewport {
114 size: [(800.0 * dpr) as u32, (600.0 * dpr) as u32],
115 scale: dpr * 4.0 / 3.0,
116 origin: [48.0 * dpr; 2],
117 };
118 let scroll = Scroll::new(viewport, [[0.0, 0.0, 72.0, 14.0]].into_iter());
119 assert!(scroll.thumb(viewport, dpr, 0).is_none());
120 assert!(scroll.thumb(viewport, dpr, 1).is_none());
121 let scroll = Scroll::new(viewport, [[0.0, 0.0, 2000.0, 2000.0]].into_iter());
122 for axis in 0..2 {
123 scroll.drag(&mut viewport, dpr, axis, -1000.0, 3.0 * dpr);
124 assert_eq!(-viewport.origin[axis], scroll.min[axis]);
125 let rect = scroll.thumb(viewport, dpr, axis).unwrap();
126 assert!((rect[axis] - 4.0 * dpr).abs() < 0.001);
127 scroll.drag(&mut viewport, dpr, axis, 10000.0, 3.0 * dpr);
128 assert_eq!(-viewport.origin[axis], scroll.max[axis]);
129 let rect = scroll.thumb(viewport, dpr, axis).unwrap();
130 assert!((rect[axis + 2] - (viewport.size[axis] as f32 - 16.0 * dpr)).abs() < 0.001);
131 }
132 }
133 }
134}
crates/canvas/src/interaction/tests.rs created+1610
......@@ -0,0 +1,1610 @@
1use super::*;
2use crate::document::TextDocument;
3use onestore::document::Format;
4use onestore::page::Page;
5use onestore::page::text::Paragraph;
6use std::sync::Arc;
7
8const COLORS: TextColors = TextColors {
9 caret: [0.1, 0.3, 0.9, 1.0],
10 selection: [0.7, 0.8, 1.0, 1.0],
11};
12
13/// Paint for a view at `scale` device pixels per point on a display of `display_scale`.
14pub(super) fn paint(show_caret: bool, scale: f32, display_scale: f32) -> Paint {
15 Paint {
16 show_caret,
17 scale,
18 pixel: display_scale / scale,
19 colors: COLORS,
20 }
21}
22
23#[test]
24fn date_buttons_keep_accessibility_identity_and_match_mouse_hits_after_reflow() {
25 let mut engine = TextEngine::default();
26 let mut fields = [vec!["Header"], vec!["Monday", "6:14 AM"]].map(|text| {
27 TextOutline::new(
28 &mut engine,
29 TextDocument::new(
30 text.into_iter()
31 .map(|text| Paragraph::new(text.into(), Default::default()))
32 .collect(),
33 )
34 .unwrap(),
35 468.0,
36 [0.0; 2],
37 )
38 .unwrap()
39 .snapshot()
40 });
41 fields[0].title = true;
42 let page = Page {
43 identity: None,
44 title: "Header".into(),
45 created: Some(1),
46 margin_origin: [36.0, 14.4],
47 definitions: Default::default(),
48 objects: vec![onestore::page::PageObject::Title(onestore::page::Title {
49 id: onestore::ExGuid::default(),
50 date: Some(fields[1].id),
51 layout: Default::default(),
52 outlines: fields.into(),
53 })],
54 };
55 let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap();
56 let scene = (scene, [20.0, 40.0]);
57 let viewport = Viewport {
58 size: [1000, 700],
59 scale: 2.0,
60 origin: [-30.0, -50.0],
61 };
62 let mut access = accessibility::Accessibility::default();
63 let mut identities = Vec::new();
64 for phase in 0..3 {
65 if phase == 1 {
66 editor
67 .insert(&mut engine, &"A wrapped title ".repeat(25))
68 .unwrap();
69 }
70 if phase == 2 {
71 editor
72 .change_date(&mut engine, 2, ["Wednesday".into(), "8:40 AM".into()])
73 .unwrap();
74 }
75 let mut update = access.update(&editor, viewport, "Header", None).unwrap();
76 access.append_page_fields(&mut update, Some(&scene), &editor, viewport, None);
77 let buttons: Vec<_> = update
78 .nodes
79 .iter()
80 .filter(|(_, node)| node.role() == accesskit::Role::Button)
81 .collect();
82 assert_eq!(buttons.len(), 2);
83 for (index, (id, node)) in buttons.iter().enumerate() {
84 let field = [DateField::Date, DateField::Time][index];
85 assert_eq!(access.date_for_node(*id), Some(field));
86 assert!(node.supports_action(accesskit::Action::Click));
87 let rect = node.bounds().unwrap();
88 let point = viewport.document_point([
89 ((rect.x0 + rect.x1) * 0.5) as f32,
90 ((rect.y0 + rect.y1) * 0.5) as f32,
91 ]);
92 assert_eq!(
93 page_hit_test(&editor, Some(&scene), point, 0.5),
94 Some(Hit::Date(field))
95 );
96 if phase == 0 {
97 identities.push(*id);
98 } else {
99 assert_eq!(*id, identities[index]);
100 }
101 }
102 if phase == 2 {
103 assert_eq!(buttons[1].1.value(), Some("8:40 AM"));
104 }
105 accesskit_consumer::Tree::new(update, true);
106 }
107}
108
109#[test]
110fn title_chrome_selects_text_and_exposes_a_named_editable_field() {
111 let mut engine = TextEngine::default();
112 let mut source = TextOutline::new(
113 &mut engine,
114 TextDocument::new(vec![Paragraph::new(
115 "Header".into(),
116 Format {
117 font_size: Some(8.0),
118 line_spacing: Some(20.751953),
119 ..Default::default()
120 },
121 )])
122 .unwrap(),
123 468.0,
124 [36.0, 14.4],
125 )
126 .unwrap()
127 .snapshot();
128 source.title = true;
129 source.min_width = Some(162.0);
130 source.layout.max_width = None;
131 source.layout.width_set_by_user = None;
132 source.layout.max_height = Some(21.6);
133 let outline = TextOutline::from_outline(&mut engine, &source, &Default::default()).unwrap();
134 let editor = CanvasEditor::from_text_outlines(vec![outline], Default::default(), None).unwrap();
135 assert_eq!(editor.active_outline().bounds().width(), 162.0);
136 assert_eq!(editor.active_outline().snapshot().min_width, Some(162.0));
137 let (native_rect, _) = outline_chrome(editor.active_outline(), 0.75);
138 for (axis, (actual, expected)) in native_rect
139 .iter()
140 .zip([85.0, 98.0, 315.0, 134.0])
141 .enumerate()
142 {
143 let origin = if axis % 2 == 0 { 48.0 } else { 83.0 };
144 assert!((actual / 0.75 + origin - expected).abs() < 1.5);
145 }
146 let id = editor.active_outline().id;
147 let (rect, _) = outline_chrome(editor.active_outline(), 1.0);
148 for point in [
149 [rect[0] + 1.0, rect[1] + 1.0],
150 [rect[2] - 1.0, rect[1] + 1.0],
151 [40.0, 20.0],
152 ] {
153 assert!(
154 matches!(page_hit_test(&editor, None, point, 1.0), Some(Hit::Text { id: hit, .. }) if hit == id)
155 );
156 }
157 let primitives = page_primitives(&editor, None, None, None, paint(false, 1.0, 1.0)).unwrap();
158 let borders = primitives
159 .iter()
160 .filter_map(|p| match p {
161 Primitive::RoundedRect { stroke, .. } => Some(*stroke),
162 _ => None,
163 })
164 .collect::<Vec<_>>();
165 assert_eq!(borders, [Some(Stroke::Dashed(1.0))]);
166 assert!(primitives.iter().any(|primitive| matches!(primitive, Primitive::RoundedRect { rect, radius, stroke: Some(Stroke::Dashed(_)), .. } if radius[0] == 6.0 && radius[1] * 2.0 == rect[3] - rect[1])));
167 let mut access = accessibility::Accessibility::default();
168 let update = access
169 .update(
170 &editor,
171 Viewport {
172 size: [800, 600],
173 scale: 1.0,
174 origin: [0.0; 2],
175 },
176 "Header",
177 None,
178 )
179 .unwrap();
180 assert!(update.nodes.iter().any(|(_, node)| node.role()
181 == accesskit::Role::MultilineTextInput
182 && node.label() == Some("Page title")));
183}
184
185#[test]
186fn provisional_lines_share_one_drawn_hit_tested_and_accessible_outline() {
187 let mut engine = TextEngine::default();
188 let mut editor = CanvasEditor::new(
189 &mut engine,
190 TextDocument::new(vec![Paragraph::new("body".into(), Format::default())]).unwrap(),
191 240.0,
192 )
193 .unwrap();
194 let id = editor.active_outline().id;
195 for _ in 0..2 {
196 editor
197 .move_selection(&mut engine, Movement::Down, false)
198 .unwrap();
199 }
200 let primitives = page_primitives(&editor, None, None, None, paint(true, 1.0, 1.0)).unwrap();
201 assert_eq!(
202 primitives
203 .iter()
204 .filter(|p| matches!(p, Primitive::Text { .. }))
205 .count(),
206 3
207 );
208 let caret = editor.caret(1.0).unwrap();
209 assert!(
210 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)
211 );
212 let mut access = accessibility::Accessibility::default();
213 let update = access
214 .update(
215 &editor,
216 Viewport {
217 size: [800, 600],
218 scale: 1.0,
219 origin: [0.0; 2],
220 },
221 "Test",
222 None,
223 )
224 .unwrap();
225 let fields = update
226 .nodes
227 .iter()
228 .filter(|(_, node)| node.role() == accesskit::Role::MultilineTextInput)
229 .collect::<Vec<_>>();
230 assert_eq!(fields.len(), 1);
231 assert_eq!(fields[0].1.value(), Some("body\n\n"));
232 assert_eq!(editor.outlines()[0].document().nodes().len(), 1);
233 editor
234 .place_caret(&mut engine, [300.0, 200.0], 240.0)
235 .unwrap();
236 assert_eq!(
237 editor
238 .visible_outlines()
239 .next()
240 .unwrap()
241 .document()
242 .nodes()
243 .len(),
244 1
245 );
246}
247
248#[test]
249fn blank_caret_and_composition_have_accessible_text_without_outline_chrome() {
250 let mut engine = TextEngine::default();
251 let mut editor = CanvasEditor::new(
252 &mut engine,
253 TextDocument::new(vec![Paragraph::new(String::new(), Default::default())]).unwrap(),
254 240.0,
255 )
256 .unwrap();
257 editor
258 .place_caret(&mut engine, [120.0, 100.0], 240.0)
259 .unwrap();
260 let mut access = accessibility::Accessibility::default();
261 let viewport = Viewport {
262 size: [1000, 720],
263 scale: 96.0 / 72.0,
264 origin: [48.0; 2],
265 };
266 let initial = access.update(&editor, viewport, "Page", None).unwrap();
267 let id = editor.active_outline().id;
268 assert_eq!(access.outline_for_node(initial.focus), Some(id));
269 let rectangles = |editor: &CanvasEditor| {
270 page_primitives(editor, None, None, None, paint(true, viewport.scale, 1.0))
271 .unwrap()
272 .into_iter()
273 .filter_map(|p| match p {
274 Primitive::Rect { rect, color } | Primitive::RoundedRect { rect, color, .. } => {
275 Some((rect, color))
276 }
277 _ => None,
278 })
279 .collect::<Vec<_>>()
280 };
281 assert_eq!(rectangles(&editor).len(), 1);
282 assert_eq!(
283 page_hit_test(&editor, None, [120.0, 100.0], 1.0 / viewport.scale),
284 None
285 );
286 editor.compose(&mut engine, "に".into(), 1..1).unwrap();
287 assert!(editor.outlines().is_empty());
288 assert!(
289 rectangles(&editor)
290 .iter()
291 .all(|(_, color)| *color == [0.0, 0.0, 0.0, 1.0] || *color == COLORS.caret)
292 );
293 let preedit = access.update(&editor, viewport, "Page", None).unwrap();
294 assert_eq!(preedit.focus, initial.focus);
295 editor.commit_text(&mut engine, "日本".into()).unwrap();
296 let committed = access.update(&editor, viewport, "Page", None).unwrap();
297 assert_eq!(committed.focus, initial.focus);
298 assert!(rectangles(&editor).len() > 1);
299 editor.undo(&mut engine).unwrap();
300 assert!(editor.outlines().is_empty());
301 assert_eq!(rectangles(&editor).len(), 1);
302 let empty = access.update(&editor, viewport, "Page", None).unwrap();
303 assert_eq!(empty.focus, committed.focus);
304 assert_eq!(access.outline_for_node(committed.focus), Some(id));
305 editor.redo(&mut engine).unwrap();
306 editor.select_all().unwrap();
307 editor.delete(&mut engine, true).unwrap();
308 assert!(editor.outlines().is_empty());
309 assert_eq!(rectangles(&editor).len(), 1);
310 let retired = access.update(&editor, viewport, "Page", None).unwrap();
311 assert_eq!(retired.focus, committed.focus);
312 assert_eq!(access.outline_for_node(retired.focus), Some(id));
313 editor.undo(&mut engine).unwrap();
314 assert!(rectangles(&editor).len() > 1);
315 let restored = access.update(&editor, viewport, "Page", None).unwrap();
316 assert_eq!(restored.focus, committed.focus);
317 editor.redo(&mut engine).unwrap();
318 assert_eq!(rectangles(&editor).len(), 1);
319 assert!(editor.caret_outline().unwrap().is_empty());
320 editor.insert(&mut engine, " ").unwrap();
321 assert!(editor.caret_outline().unwrap().is_empty());
322 editor.compose(&mut engine, "に".into(), 1..1).unwrap();
323 assert!(!editor.caret_outline().unwrap().is_empty());
324}
325
326#[test]
327#[ignore = "requires CANVAS_TEST_SECTION and CANVAS_TEST_PAGE private fixture inputs"]
328fn imported_page_widgets_and_accessibility_follow_edits() {
329 let bytes = std::fs::read(std::env::var_os("CANVAS_TEST_SECTION").unwrap()).unwrap();
330 let page = {
331 let store = onestore::Store::parse(&bytes).unwrap();
332 let index = onestore::RevisionIndex::parse(&store).unwrap();
333 Page::from_document(
334 &onestore::document::Document::parse(&index).unwrap(),
335 &std::env::var("CANVAS_TEST_PAGE").unwrap(),
336 )
337 .unwrap()
338 };
339 drop(bytes);
340 let mut engine = TextEngine::default();
341 if let Some(font) = std::env::var_os("CANVAS_TEST_SUBSTITUTE") {
342 engine
343 .register_substitute(parley::fontique::Blob::new(Arc::new(
344 std::fs::read(font).unwrap(),
345 )))
346 .unwrap();
347 }
348 let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap();
349 let scene = (scene, [0.0; 2]);
350 let viewport = Viewport {
351 size: [1000, 720],
352 scale: 96.0 / 72.0,
353 origin: [48.0; 2],
354 };
355 let mut tag_hits = 0;
356 for outline in editor.outlines() {
357 for (_, paragraph) in outline.layouts() {
358 for tag in &paragraph.tags {
359 let point = [
360 outline.origin()[0] + tag.origin[0] + crate::outline::ParagraphTag::SIZE / 2.0,
361 outline.origin()[1]
362 + paragraph.origin[1]
363 + tag.origin[1]
364 + crate::outline::ParagraphTag::SIZE / 2.0,
365 ];
366 assert!(
367 matches!(page_hit_test(&editor, Some(&scene), point, 1.0), Some(Hit::Text { id, .. }) if id == outline.id),
368 "Tag center {point:?} did not target outline {:?}",
369 outline.id
370 );
371 tag_hits += 1;
372 }
373 }
374 }
375 eprintln!("Verified {tag_hits} imported tag-gutter targets");
376 let mut access = accessibility::Accessibility::default();
377 let tags = |editor: &CanvasEditor| {
378 page_primitives(
379 editor,
380 Some(&scene),
381 None,
382 None,
383 paint(true, viewport.scale, 1.0),
384 )
385 .unwrap()
386 .into_iter()
387 .filter_map(|primitive| match primitive {
388 Primitive::Tag { tag, origin } => Some((tag.clone(), origin)),
389 _ => None,
390 })
391 .collect::<Vec<_>>()
392 };
393 let original_tags = tags(&editor);
394 let ids: Vec<_> = editor.outlines().iter().map(|outline| outline.id).collect();
395 for id in ids {
396 editor.focus_outline(id).unwrap();
397 let original = editor.active_outline().document().clone();
398 editor
399 .select(
400 [crate::document::TextPosition {
401 paragraph: 0,
402 offset: 0,
403 }; 2]
404 .into(),
405 )
406 .unwrap();
407 let before = access.update(&editor, viewport, "Test", None).unwrap();
408 let before = accesskit_consumer::Tree::new(before, true);
409 let before = before.state().focus().unwrap().document_range().text();
410 editor.insert(&mut engine, "CANVAS CHECK ").unwrap();
411 {
412 let update = access.update(&editor, viewport, "Test", None).unwrap();
413 let tree = accesskit_consumer::Tree::new(update, true);
414 assert_eq!(
415 tree.state().focus().unwrap().document_range().text(),
416 format!("CANVAS CHECK {before}")
417 );
418 let primitives = page_primitives(
419 &editor,
420 Some(&scene),
421 None,
422 None,
423 paint(true, viewport.scale, 1.0),
424 )
425 .unwrap();
426 assert!(!primitives.is_empty());
427 let edited_tags = tags(&editor);
428 assert_eq!(edited_tags.len(), original_tags.len());
429 for ((edited, _), (original, _)) in edited_tags.iter().zip(&original_tags) {
430 assert_eq!(edited.icon, original.icon);
431 assert_eq!(edited.label, original.label);
432 assert_eq!(edited.disabled, original.disabled);
433 }
434 }
435 editor.undo(&mut engine).unwrap();
436 assert_eq!(editor.active_outline().document(), &original);
437 assert_eq!(tags(&editor), original_tags);
438 let update = access.update(&editor, viewport, "Test", None).unwrap();
439 let tree = accesskit_consumer::Tree::new(update, true);
440 assert_eq!(
441 tree.state().focus().unwrap().document_range().text(),
442 before
443 );
444 }
445 eprintln!(
446 "Rendered and restored {} imported outline widgets and accessibility fields",
447 editor.outlines().len()
448 );
449}
450
451#[test]
452fn extension_hits_yield_to_objects_and_keep_their_source_coordinate_frame() {
453 let mut engine = TextEngine::default();
454 let upper = TextOutline::new(
455 &mut engine,
456 TextDocument::new(vec![Paragraph::new("Upper".into(), Default::default())]).unwrap(),
457 240.0,
458 [36.0, 90.0],
459 )
460 .unwrap();
461 let bottom = upper.bounds().y1 as f32;
462 let id = upper.id;
463 let lower = TextOutline::new(
464 &mut engine,
465 TextDocument::new(vec![Paragraph::new("Lower".into(), Default::default())]).unwrap(),
466 240.0,
467 [36.0, bottom + 20.0],
468 )
469 .unwrap();
470 let lower_id = lower.id;
471 let source = upper.snapshot();
472 let mut editor =
473 CanvasEditor::from_text_outlines(vec![lower, upper], Default::default(), None).unwrap();
474 assert!(
475 matches!(page_hit_test(&editor, None, [60.0, bottom + 22.0], 0.75), Some(Hit::Text { id, .. }) if id == lower_id)
476 );
477 editor.move_outline(lower_id, [600.0, 500.0]).unwrap();
478 for pixel in [0.375, 0.75, 1.5] {
479 let hit = page_hit_test(&editor, None, [60.0, bottom + 26.0], pixel).unwrap();
480 assert!(
481 matches!(hit, Hit::Text { id: hit_id, point } if hit_id == id && (point[1] - (bottom + 26.0 - 90.0)).abs() < 0.001)
482 );
483 assert_eq!(
484 page_hit_test(&editor, None, [60.0, bottom + 30.0], pixel),
485 None
486 );
487 }
488 let page = onestore::page::Page {
489 identity: None,
490 title: String::new(),
491 created: None,
492 margin_origin: [0.0; 2],
493 definitions: Default::default(),
494 objects: vec![onestore::page::PageObject::Outline(source)],
495 };
496 let (scene, editor) = PageScene::from_page(page, &mut engine).unwrap();
497 let offset = [50.0, 300.0];
498 assert!(
499 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)
500 );
501}
502
503#[test]
504fn chrome_follows_focus_hover_and_drag_without_changing_hit_geometry() {
505 let mut engine = TextEngine::default();
506 let outlines = [[0.0, 0.0], [220.0, 0.0]]
507 .into_iter()
508 .map(|origin| {
509 TextOutline::new(
510 &mut engine,
511 TextDocument::new(vec![Paragraph::new("text".into(), Format::default())]).unwrap(),
512 100.0,
513 origin,
514 )
515 .unwrap()
516 })
517 .collect();
518 let editor = CanvasEditor::from_text_outlines(outlines, Default::default(), None).unwrap();
519 let second = editor.outlines()[1].id;
520 let borders = |feedback| {
521 page_primitives(&editor, None, feedback, None, paint(false, 1.0, 1.0))
522 .unwrap()
523 .into_iter()
524 .filter_map(|p| match p {
525 Primitive::RoundedRect {
526 rect,
527 stroke: Some(Stroke::Solid(1.0)),
528 ..
529 } => Some(rect),
530 _ => None,
531 })
532 .collect::<Vec<_>>()
533 };
534 assert_eq!(borders(None).len(), 1);
535 let hovered = borders(Some(PointerFeedback::Hover(second)));
536 assert_eq!(hovered.len(), 2);
537 let moved = borders(Some(PointerFeedback::Move(second, [240.0, 50.0])));
538 assert_eq!(moved[1][0] - hovered[1][0], 20.0);
539 assert_eq!(moved[1][1] - hovered[1][1], 50.0);
540 assert!(
541 matches!(page_hit_test(&editor, None, [225.0, 5.0], 1.0), Some(Hit::Text { id, .. }) if id == second)
542 );
543}
544
545#[test]
546fn a_covered_width_handle_stays_reachable_beside_the_outline_above_it() {
547 let mut engine = TextEngine::default();
548 let pixel = 0.75;
549 let mut first = TextOutline::new(
550 &mut engine,
551 TextDocument::new(vec![Paragraph::new("text".into(), Format::default())]).unwrap(),
552 100.0,
553 [36.0, 68.4],
554 )
555 .unwrap();
556 let editor =
557 CanvasEditor::from_text_outlines(vec![first.clone()], Default::default(), None).unwrap();
558 first = editor.preview_resize(&mut engine, 226.5).unwrap();
559 let (frame, body_top) = outline_chrome(&first, pixel);
560 // The casual night page: the next outline starts 7.5 pt right of the first one.
561 let second = TextOutline::new(
562 &mut engine,
563 TextDocument::new(vec![Paragraph::new("text".into(), Format::default())]).unwrap(),
564 100.0,
565 [first.bounds().x1 as f32 + 7.5, 68.4],
566 )
567 .unwrap();
568 let (first_id, second_id) = (first.id, second.id);
569 let editor =
570 CanvasEditor::from_text_outlines(vec![first, second], Default::default(), None).unwrap();
571 let header = (frame[1] + body_top) / 2.0;
572 let hit = |x| page_hit_test(&editor, None, [x, header], pixel);
573 assert!(matches!(hit(frame[2] - 12.0 * pixel), Some(Hit::Resize { id, .. }) if id == first_id));
574 assert!(matches!(hit(frame[2] - 2.0 * pixel), Some(Hit::Handle { id, .. }) if id == second_id));
575 let body = |x| page_hit_test(&editor, None, [x, body_top + 20.0], pixel);
576 assert!(matches!(body(frame[2] - 4.0 * pixel), Some(Hit::Resize { id, .. }) if id == first_id));
577 assert!(matches!(body(frame[2] - 8.0 * pixel), Some(Hit::Text { id, .. }) if id == second_id));
578 assert!(matches!(body(frame[2] - 12.0 * pixel), Some(Hit::Text { id, .. }) if id == first_id));
579}
580
581#[test]
582fn a_picture_in_an_outline_takes_the_click_over_its_text() {
583 use onestore::page::{Image, Page, PageObject, ParagraphContent};
584 let mut engine = TextEngine::default();
585 let mut source = TextOutline::new(
586 &mut engine,
587 TextDocument::new(vec![
588 Paragraph::new("Before".into(), Default::default()),
589 Paragraph::new("After".into(), Default::default()),
590 ])
591 .unwrap(),
592 240.0,
593 [36.0, 36.0],
594 )
595 .unwrap()
596 .snapshot();
597 let mut picture = source.paragraphs[0].clone();
598 picture.id = onestore::page::text::new_id().unwrap();
599 let id = onestore::page::text::new_id().unwrap();
600 picture.content = ParagraphContent::Image(Image {
601 size: Some([40.0, 30.0]),
602 id,
603 layout: Default::default(),
604 bytes: None,
605 alt: None,
606 background: false,
607 });
608 source.paragraphs.insert(1, picture);
609 let editor = CanvasEditor::from_page(
610 Page {
611 title: String::new(),
612 identity: None,
613 created: None,
614 margin_origin: [36.0, 14.4],
615 definitions: Default::default(),
616 objects: vec![PageObject::Outline(source)],
617 },
618 &mut engine,
619 )
620 .unwrap();
621 let (origin, size) = editor.image_placement(id).unwrap();
622 let center = [origin[0] + size[0] / 2.0, origin[1] + size[1] / 2.0];
623 assert_eq!(
624 page_hit_test(&editor, None, center, 1.0),
625 Some(Hit::Image { id, handle: [0, 0] })
626 );
627 assert!(matches!(
628 page_hit_test(&editor, None, [origin[0] + 2.0, origin[1] - 4.0], 1.0),
629 Some(Hit::Text { .. })
630 ));
631}
632
633#[test]
634fn picture_handles_resize_as_onenote_does() {
635 // Native drags of the 333 x 200.1 pt mockup on "av: casual night in the trees".
636 let (origin, size) = ([468.0, 86.4], [333.0, 200.1]);
637 let (corner_origin, corner) = resize_image(origin, size, [1, -1], [-75.0, 30.75]);
638 assert!((corner[0] - 281.827).abs() < 0.01 && (corner[1] - 169.351).abs() < 0.01);
639 assert!((corner_origin[1] - 117.15).abs() < 0.01 && corner_origin[0] == 468.0);
640 assert_eq!(
641 resize_image(origin, size, [1, 0], [46.5, 10.0]),
642 (origin, [379.5, 200.1])
643 );
644 let (left_origin, left) = resize_image(origin, size, [-1, 0], [400.0, 0.0]);
645 assert_eq!(left, [1.0, 200.1]);
646 assert_eq!(left_origin[0], 468.0 + 333.0 - 1.0);
647 let pixel = 0.75;
648 let rect = image_rect(origin, size);
649 assert_eq!(
650 image_handle_at(rect, pixel, [468.0 - 3.75, 86.4 - 3.75]),
651 Some([-1, -1])
652 );
653 assert_eq!(
654 image_handle_at(rect, pixel, [468.0 + 166.5, 286.5 + 3.75]),
655 Some([0, 1])
656 );
657 assert_eq!(image_handle_at(rect, pixel, [600.0, 150.0]), None);
658}
659
660#[test]
661fn grouped_drag_keeps_the_initial_word_when_reversing_direction() {
662 let selection = |start, end| {
663 Selection::from([start, end].map(|offset| crate::document::TextPosition {
664 paragraph: 0,
665 offset,
666 }))
667 };
668 let anchor = selection(6, 10);
669 assert_eq!(
670 drag_selection(anchor, selection(11, 16), SelectionUnit::Word).positions,
671 selection(6, 16).positions
672 );
673 assert_eq!(
674 drag_selection(anchor, selection(0, 5), SelectionUnit::Word).positions,
675 selection(10, 0).positions
676 );
677 assert_eq!(
678 drag_selection(anchor, anchor, SelectionUnit::Word).positions,
679 anchor.positions
680 );
681 let anchor = selection(10, 3);
682 assert_eq!(
683 drag_selection(anchor, selection(7, 7), SelectionUnit::Grapheme).positions,
684 selection(10, 7).positions
685 );
686}
687
688const DEFAULT_MARGIN: [f32; 2] = [36.0, 14.4];
689
690#[test]
691fn native_grid_follows_the_page_margin_origin() {
692 // "av: casual night in the trees": margin 36.75; a 22.5 x 15 pt drag lands one cell on.
693 let margin = [36.75, 14.4];
694 let result = snap_to_grid([468.75 + 22.5, 86.4 + 15.0], margin);
695 assert!((result[0] - 486.75).abs() < 0.0001);
696 assert!((result[1] - 104.4).abs() < 0.0001);
697}
698
699#[test]
700fn native_grid_matches_drag_offsets_midpoints_and_zoom() {
701 for (pixels, points) in [
702 (5.0, 0.0),
703 (6.0, 0.0),
704 (7.0, 0.0),
705 (8.0, 0.0),
706 (11.0, 0.0),
707 (12.0, 9.0),
708 (13.0, 18.0),
709 (17.0, 18.0),
710 (18.0, 18.0),
711 (19.0, 18.0),
712 (25.0, 18.0),
713 (37.0, 36.0),
714 (-6.0, 0.0),
715 (-7.0, 0.0),
716 (-12.0, -9.0),
717 (-13.0, -18.0),
718 (-19.0, -18.0),
719 ] {
720 let origin = [486.0, 230.40001];
721 let proposed = origin.map(|v| v + pixels * 72.0 / 96.0);
722 let result = snap_to_grid(proposed, DEFAULT_MARGIN);
723 for axis in 0..2 {
724 assert!((result[axis] - origin[axis] - points).abs() < 0.00004);
725 }
726 }
727 for pixels in [6.0, 7.0, 12.0, 13.0, 19.0] {
728 let result = snap_to_grid(
729 [491.25 + pixels * 0.75, 235.65 + pixels * 0.75],
730 DEFAULT_MARGIN,
731 );
732 assert!((result[0] - 504.0).abs() < 0.00004);
733 assert!((result[1] - 248.4).abs() < 0.00004);
734 }
735 for (scale, delta, expected) in [
736 (96.0 / 72.0, [27.0, 31.0], [54.0, 104.4]),
737 (96.0 / 72.0, [13.0, 17.0], [54.0, 104.4]),
738 (192.0 / 72.0, [13.0, 17.0], [36.0, 104.4]),
739 ] {
740 for dpr in [1.0, 2.0] {
741 let origin = [36.0, 90.0];
742 let proposed =
743 std::array::from_fn(|axis| origin[axis] + delta[axis] * dpr / (scale * dpr));
744 let result = snap_to_grid(proposed, DEFAULT_MARGIN);
745 for axis in 0..2 {
746 assert!((result[axis] - expected[axis]).abs() < 0.00004);
747 }
748 }
749 }
750 for point in [[9.0, 5.4], [-9.0, -12.6], [423.0, 239.4]] {
751 assert_eq!(snap_to_grid(point, DEFAULT_MARGIN), point);
752 }
753}
754
755#[test]
756fn native_fresh_click_grid_uses_screen_offset_at_both_zoom_levels() {
757 for (x, y, scale, expected) in [
758 (900.0, 500.0, 96.0 / 72.0, [639.0, 302.4]),
759 (901.0, 500.0, 96.0 / 72.0, [648.0, 302.4]),
760 (906.0, 500.0, 96.0 / 72.0, [648.0, 302.4]),
761 (913.0, 500.0, 96.0 / 72.0, [648.0, 302.4]),
762 (900.0, 501.0, 96.0 / 72.0, [639.0, 302.4]),
763 (900.0, 506.0, 96.0 / 72.0, [639.0, 320.4]),
764 (900.0, 520.0, 96.0 / 72.0, [639.0, 320.4]),
765 (900.0, 500.0, 192.0 / 72.0, [324.0, 158.4]),
766 ] {
767 for dpr in [1.0, 2.0] {
768 let viewport = Viewport {
769 size: [(1600.0 * dpr) as u32, (900.0 * dpr) as u32],
770 origin: [48.0 * dpr, 83.0 * dpr],
771 scale: scale * dpr,
772 };
773 let point = viewport.document_point([x * dpr, y * dpr]);
774 let result = snap_to_grid(
775 [point[0], point[1] - 7.0 * dpr / viewport.scale],
776 DEFAULT_MARGIN,
777 );
778 for axis in 0..2 {
779 assert!((result[axis] - expected[axis]).abs() < 0.00004);
780 }
781 }
782 }
783}
784
785#[test]
786fn table_glyphs_highlights_and_selection_share_cell_paint_bounds() {
787 let mut engine = TextEngine::default();
788 let mut editor = CanvasEditor::new(
789 &mut engine,
790 TextDocument::new(vec![Paragraph::new(
791 "M".into(),
792 Format {
793 font_size: Some(130.0),
794 highlight: Some(0xffff),
795 ..Default::default()
796 },
797 )])
798 .unwrap(),
799 180.0,
800 )
801 .unwrap();
802 editor
803 .move_selection(&mut engine, Movement::LineEnd, false)
804 .unwrap();
805 editor.tab(&mut engine, false).unwrap();
806 editor.insert(&mut engine, "R").unwrap();
807 editor.tab(&mut engine, true).unwrap();
808 let mut primitives = Vec::new();
809 append_outline(
810 Some(&editor),
811 editor.active_outline(),
812 [24.0, 48.0],
813 Paint {
814 show_caret: false,
815 scale: 1.0,
816 pixel: 1.0,
817 colors: COLORS,
818 },
819 &mut primitives,
820 )
821 .unwrap();
822 let bounds = editor.active_outline().shaped().tables[0]
823 .cells
824 .iter()
825 .map(|cell| {
826 let [left, top, right, bottom] = cell.text_bounds();
827 [left + 24.0, top + 48.0, right + 24.0, bottom + 48.0]
828 })
829 .collect::<Vec<_>>();
830 let text = primitives
831 .iter()
832 .filter_map(|primitive| match primitive {
833 Primitive::Text { clip, .. } => Some(clip.unwrap()),
834 _ => None,
835 })
836 .collect::<Vec<_>>();
837 assert_eq!(text, bounds);
838 let highlights = primitives
839 .iter()
840 .filter_map(|primitive| match primitive {
841 Primitive::Rect { rect, color } if *color == crate::gpu::colorref(0xffff) => {
842 Some(*rect)
843 }
844 _ => None,
845 })
846 .collect::<Vec<_>>();
847 assert_eq!(highlights.len(), 2);
848 for (rect, bounds) in highlights.iter().zip(&bounds) {
849 assert_eq!(rect[2], bounds[2]);
850 assert!(rect[0] >= bounds[0] && rect[1] >= bounds[1] && rect[3] <= bounds[3]);
851 }
852 let selection = primitives
853 .iter()
854 .find_map(|primitive| match primitive {
855 Primitive::Rect { rect, color } if *color == COLORS.selection => Some(*rect),
856 _ => None,
857 })
858 .unwrap();
859 assert_eq!(selection[2], bounds[0][2]);
860 assert!(editor.caret(1.0).unwrap().x0 + 24.0 > f64::from(bounds[0][2]));
861}
862
863#[test]
864fn editable_tables_paint_borders_before_selection_and_cell_text() {
865 let mut engine = TextEngine::default();
866 let mut editor = CanvasEditor::new(
867 &mut engine,
868 TextDocument::new(vec![Paragraph::new("Left".into(), Format::default())]).unwrap(),
869 180.0,
870 )
871 .unwrap();
872 editor
873 .move_selection(&mut engine, Movement::LineEnd, false)
874 .unwrap();
875 editor.tab(&mut engine, false).unwrap();
876 editor.insert(&mut engine, "Right").unwrap();
877 editor.tab(&mut engine, true).unwrap();
878 let mut primitives = Vec::new();
879 append_outline(
880 Some(&editor),
881 editor.active_outline(),
882 [24.0, 48.0],
883 Paint {
884 show_caret: false,
885 scale: 1.0,
886 pixel: 1.0,
887 colors: COLORS,
888 },
889 &mut primitives,
890 )
891 .unwrap();
892 let color = crate::gpu::colorref(0x00a3a3a3);
893 assert!(matches!(primitives[0], Primitive::RoundedRect {
894 stroke: Some(crate::gpu::Stroke::Solid(0.75)), color: actual, ..
895 } if actual == color));
896 assert!(matches!(primitives[1], Primitive::Rect { color: actual, .. } if actual == color));
897 assert!(matches!(primitives[2], Primitive::Rect { color, .. } if color == COLORS.selection));
898 let painted = primitives
899 .iter()
900 .filter_map(|primitive| match primitive {
901 Primitive::Text { layout, origin, .. } => Some((layout.id(), *origin)),
902 _ => None,
903 })
904 .collect::<Vec<_>>();
905 let expected = editor
906 .active_outline()
907 .layouts()
908 .map(|(_, paragraph)| {
909 (
910 paragraph.text.id(),
911 [paragraph.origin[0] + 24.0, paragraph.origin[1] + 48.0],
912 )
913 })
914 .collect::<Vec<_>>();
915 assert_eq!(painted, expected);
916 assert_eq!(painted.len(), 2);
917}
918
919#[test]
920fn resize_preview_paints_reflowed_text_without_changing_the_editor() {
921 let mut engine = TextEngine::default();
922 let editor = CanvasEditor::new(
923 &mut engine,
924 TextDocument::new(vec![Paragraph::new(
925 "A paragraph with enough words to wrap when the resize handle moves inward.".into(),
926 Format::default(),
927 )])
928 .unwrap(),
929 240.0,
930 )
931 .unwrap();
932 let original = editor.active_outline().snapshot();
933 let original_layout = editor
934 .active_outline()
935 .paragraph_layout(0)
936 .unwrap()
937 .text
938 .id();
939 let resized = editor.preview_resize(&mut engine, 72.0).unwrap();
940 let primitives = page_primitives(
941 &editor,
942 None,
943 Some(PointerFeedback::Resize(&resized)),
944 None,
945 paint(false, 96.0 / 72.0, 1.0),
946 )
947 .unwrap();
948 let painted = primitives
949 .iter()
950 .find_map(|primitive| match primitive {
951 Primitive::Text { layout, .. } => Some(*layout),
952 _ => None,
953 })
954 .unwrap();
955 assert_eq!(painted.id(), resized.paragraph_layout(0).unwrap().text.id());
956 assert!(
957 painted.lines().count()
958 > editor
959 .active_outline()
960 .paragraph_layout(0)
961 .unwrap()
962 .text
963 .lines()
964 .count()
965 );
966 assert_eq!(editor.active_outline().layout(), &original.layout);
967 assert_eq!(
968 editor
969 .active_outline()
970 .paragraph_layout(0)
971 .unwrap()
972 .text
973 .id(),
974 original_layout
975 );
976 assert!(primitives.iter().any(
977 |primitive| matches!(primitive, Primitive::RoundedRect { stroke, .. } if stroke.is_some())
978 ));
979}
980
981#[test]
982fn native_chrome_geometry_preserves_text_and_matches_hit_regions_across_zoom_and_dpi() {
983 let mut engine = TextEngine::default();
984 let format = Format {
985 font: Some("Arial".into()),
986 font_size: Some(11.0),
987 ..Default::default()
988 };
989 let mut editor = CanvasEditor::new(&mut engine, TextDocument::new(vec![
990 Paragraph::new("HAMBURGEFONTS abcdefghijklmnopqrstuvwxyz 0123456789 HAMBURGEFONTS abcdefghijklmnopqrstuvwxyz 0123456789 HAMBURGEFONTS abcdefghijklmnopqrstuvwxyz 0123456789".into(), format.clone()),
991 Paragraph::new("Second paragraph abcdefghijklmnopqrstuvwxyz.".into(), format),
992 ]).unwrap(), 220.0).unwrap();
993 let id = editor.active_outline().id;
994 editor.move_outline(id, [36.0, 90.0]).unwrap();
995 let document = editor.active_outline().document().clone();
996 let layout_ids: Vec<_> = editor
997 .active_outline()
998 .layouts()
999 .map(|(_, p)| p.text.id())
1000 .collect();
1001 for (zoom, expected) in [
1002 (0.5, [62.0, 134.0, 223.0, 219.0]),
1003 (1.0, [81.0, 189.0, 394.0, 348.0]),
1004 (1.5, [101.0, 245.0, 565.0, 479.0]),
1005 (2.0, [120.0, 300.0, 736.0, 608.0]),
1006 ] {
1007 for dpr in [1.0, 2.0] {
1008 let scale = zoom * (96.0 / 72.0) * dpr;
1009 let pixel = dpr / scale;
1010 let (frame, body_top) = outline_chrome(editor.active_outline(), pixel);
1011 for (index, coordinate) in frame.iter().enumerate() {
1012 let origin = if index % 2 == 0 { 48.0 } else { 83.0 };
1013 let screen = coordinate * scale / dpr + origin;
1014 assert!(
1015 (screen - expected[index]).abs() <= 1.5,
1016 "zoom={zoom} dpr={dpr} edge={index}: {screen} vs {}",
1017 expected[index]
1018 );
1019 }
1020 let header = [36.0, (frame[1] + body_top) / 2.0];
1021 assert!(
1022 matches!(page_hit_test(&editor, None, header, pixel), Some(Hit::Handle {id: hit, ..}) if hit == id)
1023 );
1024 assert!(matches!(
1025 page_hit_test(&editor, None, [frame[2] - 4.0 * pixel, header[1]], pixel),
1026 Some(Hit::Resize { id: hit, .. }) if hit == id
1027 ));
1028 let padding = [frame[0] + pixel, 90.0];
1029 assert!(
1030 matches!(page_hit_test(&editor, None, padding, pixel), Some(Hit::Text {id: hit, ..}) if hit == id)
1031 );
1032 assert_eq!(
1033 page_hit_test(&editor, None, [frame[0] - pixel, 90.0], pixel),
1034 None
1035 );
1036 let primitives =
1037 page_primitives(&editor, None, None, None, paint(false, scale, dpr)).unwrap();
1038 assert!(
1039 matches!(&primitives[0], Primitive::RoundedRect {rect, ..} if *rect == [frame[0], frame[1], frame[2], body_top])
1040 );
1041 assert!(
1042 primitives
1043 .iter()
1044 .any(|p| matches!(p, Primitive::Text {origin, ..} if *origin == [36.0, 90.0]))
1045 );
1046 }
1047 }
1048 assert_eq!(editor.active_outline().document(), &document);
1049 assert_eq!(
1050 editor
1051 .active_outline()
1052 .layouts()
1053 .map(|(_, p)| p.text.id())
1054 .collect::<Vec<_>>(),
1055 layout_ids
1056 );
1057}
1058
1059#[test]
1060fn a_later_text_body_takes_priority_over_an_earlier_drag_handle() {
1061 let mut engine = TextEngine::default();
1062 let mut editor = CanvasEditor::new(
1063 &mut engine,
1064 TextDocument::new(vec![Paragraph::new("first".into(), Default::default())]).unwrap(),
1065 240.0,
1066 )
1067 .unwrap();
1068 let first = editor.active_outline().id;
1069 editor.move_outline(first, [0.0, 20.0]).unwrap();
1070 assert_eq!(
1071 page_hit_test(&editor, None, [5.0, 12.0], 1.0),
1072 Some(Hit::Handle {
1073 id: first,
1074 grab: [5.0, -8.0]
1075 })
1076 );
1077 let second = editor
1078 .create_outline(&mut engine, [0.0, 10.0], 240.0)
1079 .unwrap();
1080 assert_eq!(
1081 page_hit_test(&editor, None, [5.0, 12.0], 1.0),
1082 Some(Hit::Text {
1083 id: second,
1084 point: [5.0, 2.0]
1085 })
1086 );
1087 editor.undo(&mut engine).unwrap();
1088 assert_eq!(
1089 page_hit_test(&editor, None, [5.0, 12.0], 1.0),
1090 Some(Hit::Handle {
1091 id: first,
1092 grab: [5.0, -8.0]
1093 })
1094 );
1095}
1096
1097#[test]
1098fn overlapping_objects_follow_paint_order_through_creation_movement_and_undo() {
1099 use onestore::page::{Outline, PageObject, Unsupported};
1100 for readonly_on_top in [false, true] {
1101 let mut engine = TextEngine::default();
1102 let document = TextDocument::new(vec![Paragraph::new(
1103 "Imported text".into(),
1104 Default::default(),
1105 )])
1106 .unwrap();
1107 let id = onestore::ExGuid {
1108 guid: [5; 16],
1109 n: 1,
1110 };
1111 let mut objects = vec![
1112 PageObject::Outline(Outline {
1113 id,
1114 title: false,
1115 min_width: None,
1116 layout: onestore::document::Layout {
1117 x: Some(30.0),
1118 y: Some(40.0),
1119 max_width: Some(180.0),
1120 ..Default::default()
1121 },
1122 indents: vec![18.0, 0.0],
1123 paragraphs: document.nodes().to_vec(),
1124 unsupported: Vec::new(),
1125 }),
1126 PageObject::Unsupported(Unsupported {
1127 id: Default::default(),
1128 jcid: 0xdead,
1129 layout: onestore::document::Layout {
1130 x: Some(20.0),
1131 y: Some(20.0),
1132 max_width: Some(200.0),
1133 max_height: Some(100.0),
1134 ..Default::default()
1135 },
1136 }),
1137 ];
1138 if !readonly_on_top {
1139 objects.reverse();
1140 }
1141 let page = Page {
1142 identity: None,
1143 created: None,
1144 title: String::new(),
1145 margin_origin: [0.0; 2],
1146 definitions: Default::default(),
1147 objects,
1148 };
1149 let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap();
1150 let scene = (scene, [0.0; 2]);
1151 let imported_layout = editor
1152 .active_outline()
1153 .layouts()
1154 .next()
1155 .unwrap()
1156 .1
1157 .text
1158 .id();
1159 let texts: Vec<_> =
1160 page_primitives(&editor, Some(&scene), None, None, paint(false, 1.0, 1.0))
1161 .unwrap()
1162 .into_iter()
1163 .filter_map(|p| match p {
1164 Primitive::Text { layout, .. } => Some(layout.id()),
1165 _ => None,
1166 })
1167 .collect();
1168 assert_eq!(texts.len(), 2);
1169 assert_eq!(texts[usize::from(!readonly_on_top)], imported_layout);
1170 let expected = if readonly_on_top {
1171 Hit::ReadOnly(0)
1172 } else {
1173 Hit::Text {
1174 id,
1175 point: [10.0, 5.0],
1176 }
1177 };
1178 assert_eq!(
1179 page_hit_test(&editor, Some(&scene), [40.0, 45.0], 1.0),
1180 Some(expected)
1181 );
1182 let header = page_hit_test(&editor, Some(&scene), [40.0, 32.0], 1.0);
1183 if readonly_on_top {
1184 assert_eq!(header, Some(Hit::ReadOnly(0)));
1185 } else {
1186 assert_eq!(
1187 header,
1188 Some(Hit::Handle {
1189 id,
1190 grab: [10.0, -8.0]
1191 })
1192 );
1193 }
1194 let annotation = editor
1195 .create_outline(&mut engine, [30.0, 40.0], 180.0)
1196 .unwrap();
1197 assert_eq!(
1198 page_hit_test(&editor, Some(&scene), [40.0, 45.0], 1.0),
1199 Some(Hit::Text {
1200 id: annotation,
1201 point: [10.0, 5.0]
1202 })
1203 );
1204 assert_eq!(
1205 page_hit_test(&editor, Some(&scene), [40.0, 32.0], 1.0),
1206 Some(Hit::Handle {
1207 id: annotation,
1208 grab: [10.0, -8.0]
1209 })
1210 );
1211 let texts: Vec<_> =
1212 page_primitives(&editor, Some(&scene), None, None, paint(false, 1.0, 1.0))
1213 .unwrap()
1214 .into_iter()
1215 .filter_map(|p| match p {
1216 Primitive::Text { layout, .. } => Some(layout.id()),
1217 _ => None,
1218 })
1219 .collect();
1220 assert_eq!(
1221 *texts.last().unwrap(),
1222 editor
1223 .active_outline()
1224 .layouts()
1225 .next()
1226 .unwrap()
1227 .1
1228 .text
1229 .id()
1230 );
1231 editor.move_outline(annotation, [500.0, 500.0]).unwrap();
1232 assert!(
1233 !matches!(page_hit_test(&editor, Some(&scene), [40.0,45.0], 1.0), Some(Hit::Text { id, .. }) if id == annotation)
1234 );
1235 editor.undo(&mut engine).unwrap();
1236 assert_eq!(
1237 page_hit_test(&editor, Some(&scene), [40.0, 45.0], 1.0),
1238 Some(Hit::Text {
1239 id: annotation,
1240 point: [10.0, 5.0]
1241 })
1242 );
1243 editor.undo(&mut engine).unwrap();
1244 assert_eq!(editor.outlines().len(), 1);
1245 assert_eq!(editor.active_outline().document(), &document);
1246 assert_eq!(
1247 page_hit_test(&editor, Some(&scene), [10.0, 10.0], 1.0),
1248 None
1249 );
1250 }
1251}
1252
1253#[test]
1254fn read_only_focus_accepts_navigation_without_edit_shortcuts() {
1255 for modifiers in [
1256 Modifiers::default(),
1257 Modifiers {
1258 shift: true,
1259 ..Modifiers::default()
1260 },
1261 Modifiers {
1262 option: true,
1263 ..Modifiers::default()
1264 },
1265 Modifiers {
1266 command: true,
1267 ..Modifiers::default()
1268 },
1269 Modifiers {
1270 command: true,
1271 shift: true,
1272 ..Modifiers::default()
1273 },
1274 Modifiers {
1275 command: true,
1276 option: true,
1277 ..Modifiers::default()
1278 },
1279 ] {
1280 for key in [
1281 Key::Character("a".into()),
1282 Key::Character("v".into()),
1283 Key::Character("c".into()),
1284 Key::Character("x".into()),
1285 Key::Character("z".into()),
1286 Key::Character("🌳".into()),
1287 Key::Named(NamedKey::Backspace),
1288 Key::Named(NamedKey::Delete),
1289 Key::Named(NamedKey::Enter),
1290 Key::Named(NamedKey::ArrowLeft),
1291 Key::Named(NamedKey::ArrowRight),
1292 Key::Named(NamedKey::ArrowUp),
1293 Key::Named(NamedKey::ArrowDown),
1294 ] {
1295 assert!(
1296 !read_only_shortcut(&key, modifiers),
1297 "{key:?} {modifiers:?}"
1298 );
1299 }
1300 }
1301 for (key, modifiers) in [
1302 (Key::Named(NamedKey::Escape), Modifiers::default()),
1303 (
1304 Key::Named(NamedKey::Tab),
1305 Modifiers {
1306 control: true,
1307 ..Modifiers::default()
1308 },
1309 ),
1310 (
1311 Key::Named(NamedKey::Tab),
1312 Modifiers {
1313 control: true,
1314 shift: true,
1315 ..Modifiers::default()
1316 },
1317 ),
1318 (
1319 Key::Character("N".into()),
1320 Modifiers {
1321 command: true,
1322 shift: true,
1323 ..Modifiers::default()
1324 },
1325 ),
1326 (
1327 Key::Character("+".into()),
1328 Modifiers {
1329 command: true,
1330 ..Modifiers::default()
1331 },
1332 ),
1333 (
1334 Key::Character("0".into()),
1335 Modifiers {
1336 command: true,
1337 ..Modifiers::default()
1338 },
1339 ),
1340 (
1341 Key::Character("-".into()),
1342 Modifiers {
1343 command: true,
1344 ..Modifiers::default()
1345 },
1346 ),
1347 ] {
1348 assert!(read_only_shortcut(&key, modifiers));
1349 }
1350 assert!(!read_only_shortcut(
1351 &Key::Character("n".into()),
1352 Modifiers {
1353 command: true,
1354 ..Modifiers::default()
1355 }
1356 ));
1357 assert!(!read_only_shortcut(
1358 &Key::Named(NamedKey::Tab),
1359 Modifiers::default()
1360 ));
1361}
1362
1363#[test]
1364fn read_only_focus_retires_text_overlays_and_draws_a_scaled_focus_border() {
1365 use onestore::page::{PageObject, Unsupported};
1366 let mut engine = TextEngine::default();
1367 let mut editor = CanvasEditor::new(
1368 &mut engine,
1369 TextDocument::new(vec![Paragraph::new("Draft".into(), Default::default())]).unwrap(),
1370 240.0,
1371 )
1372 .unwrap();
1373 let page = Page {
1374 identity: None,
1375 created: None,
1376 title: String::new(),
1377 margin_origin: [0.0; 2],
1378 definitions: Default::default(),
1379 objects: vec![PageObject::Unsupported(Unsupported {
1380 id: Default::default(),
1381 jcid: 0xdead,
1382 layout: onestore::document::Layout {
1383 x: Some(100.0),
1384 y: Some(80.0),
1385 max_width: Some(180.0),
1386 max_height: Some(80.0),
1387 ..Default::default()
1388 },
1389 })],
1390 };
1391 let scene = (PageScene::new(page, &mut engine).unwrap(), [20.0, 30.0]);
1392 let original = editor.active_outline().document().clone();
1393 let rectangles = |primitives: Vec<Primitive<'_>>| {
1394 primitives
1395 .into_iter()
1396 .filter_map(|p| match p {
1397 Primitive::Rect { rect, color } | Primitive::RoundedRect { rect, color, .. } => {
1398 Some((rect, color))
1399 }
1400 _ => None,
1401 })
1402 .collect::<Vec<_>>()
1403 };
1404 for scale in [1.0, 2.0] {
1405 let text = rectangles(
1406 page_primitives(&editor, Some(&scene), None, None, paint(true, scale, 1.0)).unwrap(),
1407 );
1408 assert!(text.iter().any(|(_, color)| *color == COLORS.caret));
1409 let selected = rectangles(
1410 page_primitives(
1411 &editor,
1412 Some(&scene),
1413 None,
1414 Some(ObjectFocus::ReadOnly(0)),
1415 paint(true, scale, 1.0),
1416 )
1417 .unwrap(),
1418 );
1419 assert!(!selected.iter().any(|(_, color)| *color == COLORS.caret));
1420 assert_eq!(
1421 selected,
1422 rectangles(
1423 page_primitives(
1424 &editor,
1425 Some(&scene),
1426 None,
1427 Some(ObjectFocus::ReadOnly(0)),
1428 paint(false, scale, 1.0)
1429 )
1430 .unwrap()
1431 )
1432 );
1433 assert_eq!(
1434 selected.last().unwrap().0,
1435 [300.0 - 2.0 / scale, 110.0, 300.0, 190.0]
1436 );
1437 }
1438 editor.select_all().unwrap();
1439 let text = rectangles(
1440 page_primitives(&editor, Some(&scene), None, None, paint(false, 1.0, 1.0)).unwrap(),
1441 );
1442 assert!(text.iter().any(|(_, color)| *color == COLORS.selection));
1443 let selected = rectangles(
1444 page_primitives(
1445 &editor,
1446 Some(&scene),
1447 None,
1448 Some(ObjectFocus::ReadOnly(0)),
1449 paint(true, 1.0, 1.0),
1450 )
1451 .unwrap(),
1452 );
1453 assert!(!selected.iter().any(|(_, color)| *color == COLORS.selection));
1454 assert_eq!(editor.active_outline().document(), &original);
1455}
1456
1457/// A view at 100% on a 1x display over one outline holding "Before", a 40 x 30 pt picture and
1458/// "After" at (36, 36), with the picture's identity.
1459fn picture_view() -> (PageView, onestore::ExGuid) {
1460 use onestore::page::{Image, PageObject, ParagraphContent};
1461 let mut engine = TextEngine::default();
1462 let mut source = TextOutline::new(
1463 &mut engine,
1464 TextDocument::new(vec![
1465 Paragraph::new("Before".into(), Default::default()),
1466 Paragraph::new("After".into(), Default::default()),
1467 ])
1468 .unwrap(),
1469 240.0,
1470 [36.0, 36.0],
1471 )
1472 .unwrap()
1473 .snapshot();
1474 let mut picture = source.paragraphs[0].clone();
1475 picture.id = onestore::page::text::new_id().unwrap();
1476 let id = onestore::page::text::new_id().unwrap();
1477 picture.content = ParagraphContent::Image(Image {
1478 size: Some([40.0, 30.0]),
1479 id,
1480 layout: Default::default(),
1481 bytes: None,
1482 alt: None,
1483 background: false,
1484 });
1485 source.paragraphs.insert(1, picture);
1486 let editor = CanvasEditor::from_page(
1487 Page {
1488 title: String::new(),
1489 identity: None,
1490 created: None,
1491 margin_origin: [36.0, 14.4],
1492 definitions: Default::default(),
1493 objects: vec![PageObject::Outline(source)],
1494 },
1495 &mut engine,
1496 )
1497 .unwrap();
1498 let mut view = PageView::new(
1499 editor,
1500 engine,
1501 None,
1502 [800, 600],
1503 1.0,
1504 Duration::from_millis(500),
1505 );
1506 view.viewport.scale = 1.0;
1507 view.viewport.origin = [0.0; 2];
1508 (view, id)
1509}
1510
1511fn click(view: &mut PageView, point: [f32; 2], now: Instant) {
1512 let _ = view.pointer_moved(point).unwrap();
1513 assert!(view.pointer_pressed(now).unwrap().changed);
1514 assert!(view.pointer_released().unwrap().changed);
1515}
1516
1517#[test]
1518fn events_route_through_the_view_as_the_host_delivers_them() {
1519 let (mut view, picture) = picture_view();
1520 let now = Instant::now();
1521 let outline = view.editor.active_outline().id;
1522 let bounds = view.editor.active_outline().bounds();
1523
1524 // Typing at a clicked caret edits the outline and asks for nothing else.
1525 click(
1526 &mut view,
1527 [bounds.x0 as f32 + 2.0, bounds.y0 as f32 + 6.0],
1528 now,
1529 );
1530 assert_eq!(view.cursor(), Cursor::Text);
1531 let typed = view.key(&Key::Character("X".into()), Some("X")).unwrap();
1532 assert!(typed.changed && typed.request.is_none());
1533 assert!(
1534 view.editor
1535 .active_outline()
1536 .document()
1537 .paragraphs()
1538 .next()
1539 .unwrap()
1540 .text()
1541 .starts_with('X')
1542 );
1543
1544 // Command-C asks the host to copy; Command-V asks it to paste, then commits its text.
1545 let _ = view
1546 .modifiers_changed(Modifiers {
1547 shift: true,
1548 ..Modifiers::default()
1549 })
1550 .unwrap();
1551 let _ = view.key(&Key::Named(NamedKey::ArrowRight), None).unwrap();
1552 let _ = view
1553 .modifiers_changed(Modifiers {
1554 command: true,
1555 ..Modifiers::default()
1556 })
1557 .unwrap();
1558 assert_eq!(
1559 view.key(&Key::Character("c".into()), None).unwrap().request,
1560 Some(Request::Copy("B".into()))
1561 );
1562 assert_eq!(
1563 view.key(&Key::Character("v".into()), None).unwrap().request,
1564 Some(Request::Paste)
1565 );
1566 assert!(view.commit_text("line\nbreak".into()).unwrap().changed);
1567 assert_eq!(
1568 view.editor.active_outline().document().paragraphs().count(),
1569 3
1570 );
1571 let _ = view.modifiers_changed(Modifiers::default()).unwrap();
1572
1573 // A click on the picture selects it, stops text input, and Delete removes it.
1574 let (origin, size) = view.editor.image_placement(picture).unwrap();
1575 let center = [origin[0] + size[0] / 2.0, origin[1] + size[1] / 2.0];
1576 click(&mut view, center, now + Duration::from_secs(1));
1577 assert_eq!(view.object_focus(), Some(ObjectFocus::Image(picture)));
1578 assert!(!view.accepts_text());
1579 assert_eq!(view.cursor(), Cursor::Move);
1580 assert_eq!(
1581 view.commit_text("ignored".into()).unwrap(),
1582 Response::default()
1583 );
1584 assert!(
1585 view.key(&Key::Named(NamedKey::Delete), None)
1586 .unwrap()
1587 .changed
1588 );
1589 assert!(view.editor.image_placement(picture).is_none());
1590 assert!(view.accepts_text());
1591
1592 // The header drags the outline onto the grid anchored at the page margin.
1593 let bounds = view.editor.active_outline().bounds();
1594 let header = [(bounds.x0 + bounds.x1) as f32 / 2.0, bounds.y0 as f32 - 8.0];
1595 let _ = view.pointer_moved(header).unwrap();
1596 let _ = view.pointer_pressed(now + Duration::from_secs(2)).unwrap();
1597 assert_eq!(view.cursor(), Cursor::Move);
1598 let _ = view
1599 .pointer_moved([header[0] + 30.0, header[1] + 20.0])
1600 .unwrap();
1601 assert_eq!(view.outline_preview().map(|(id, _)| id), Some(outline));
1602 let _ = view.pointer_released().unwrap();
1603 let origin = view.editor.active_outline().origin();
1604 assert_eq!(origin, [72.0, 50.4]);
1605
1606 // An empty spot places a new caret there, 7 px above the pointer on the grid.
1607 click(&mut view, [400.0, 300.0], now + Duration::from_secs(3));
1608 let caret = view.editor.caret_outline().unwrap().origin();
1609 assert_eq!(caret, snap_to_grid([400.0, 293.0], [36.0, 14.4]));
1610}
crates/canvas/src/lib.rs+2
......@@ -7,6 +7,8 @@ pub mod outline;
77pub mod date;
88#[cfg(feature = "gpu")]
99pub mod gpu;
10#[cfg(feature = "interaction")]
11pub mod interaction;
1012
1113/// Parley's caret affinity mapped onto the page model's hidden-field affinity.
1214pub fn affinity(affinity: parley::Affinity) -> onestore::page::text::Affinity {
crates/snowbound/Cargo.toml+1-3
......@@ -5,7 +5,7 @@ edition = "2024"
55publish = false
66
77[dependencies]
8canvas = { path = "../canvas", features = ["gpu"] }
8canvas = { path = "../canvas", features = ["interaction"] }
99onestore = { path = "../onestore" }
1010notebook = { path = "../notebook" }
1111pollster = "0.4"
......@@ -19,5 +19,3 @@ objc2 = "0.5.2"
1919objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSAlert", "NSButton", "NSControl", "NSView", "NSResponder", "NSWindow", "NSApplication", "NSEvent", "NSColor", "NSColorSpace", "NSDatePicker", "NSDatePickerCell"] }
2020objc2-foundation = { version = "0.2.2", default-features = false, features = ["NSString", "NSThread", "NSAttributedString", "NSDate", "NSCalendar", "NSDateFormatter", "NSLocale", "NSTimeZone"] }
2121
22[dev-dependencies]
23accesskit_consumer = "0.39.0"
crates/snowbound/src/accessibility.rs deleted-1478
......@@ -1,1478 +0,0 @@
1use accesskit::{
2 Action, Affine, Node, NodeId, Rect, Role, TextDirection, TextSelection, TreeId, TreeInfo,
3 TreeUpdate,
4};
5use canvas::gpu::Viewport;
6use canvas::{
7 date::DateField,
8 document::TextPosition,
9 editor::{CanvasEditor, Selection, TextOutline},
10};
11use onestore::page::text::EditError;
12use parley::Affinity;
13use std::{collections::HashMap, ops::Range};
14
15pub const ROOT: NodeId = NodeId(0);
16
17struct Run {
18 outline: onestore::ExGuid,
19 id: NodeId,
20 node: Node,
21 positions: Vec<TextPosition>,
22}
23
24#[derive(Hash, PartialEq, Eq)]
25struct ParagraphKey {
26 layout: u64,
27 source: usize,
28 next: Option<usize>,
29 origin: [u32; 2],
30}
31
32pub struct Accessibility {
33 outlines: Vec<(onestore::ExGuid, NodeId)>,
34 runs: Vec<Run>,
35 paragraphs: HashMap<ParagraphKey, Range<usize>>,
36 read_only: Vec<NodeId>,
37 dates: Vec<(DateField, NodeId)>,
38 next_id: u64,
39}
40
41impl Default for Accessibility {
42 fn default() -> Self {
43 Self {
44 outlines: Vec::new(),
45 runs: Vec::new(),
46 paragraphs: HashMap::new(),
47 read_only: Vec::new(),
48 dates: Vec::new(),
49 next_id: 1,
50 }
51 }
52}
53
54impl Accessibility {
55 /// Retire published runs without recycling their IDs.
56 pub fn deactivate(&mut self) {
57 self.runs.clear();
58 self.paragraphs.clear();
59 self.outlines.clear();
60 self.read_only.clear();
61 self.dates.clear();
62 }
63
64 pub fn append_page_fields(
65 &mut self,
66 update: &mut TreeUpdate,
67 scene: Option<&(canvas::gpu::page::PageScene, [f32; 2])>,
68 editor: &CanvasEditor,
69 viewport: Viewport,
70 focus: Option<usize>,
71 ) {
72 let mut count = 0;
73 if let Some((scene, offset)) = scene {
74 for (index, object) in scene.read_only(Some(editor)).enumerate() {
75 if index == self.read_only.len() {
76 let id = self.allocate();
77 self.read_only.push(id);
78 }
79 let id = self.read_only[index];
80 let mut node = Node::new(Role::Label);
81 node.set_value(object.message);
82 node.set_read_only();
83 node.add_action(Action::Focus);
84 if focus == Some(index) {
85 update.focus = id;
86 }
87 let [x0, y0, x1, y1] = object.rect();
88 let x = |v| f64::from((v + offset[0]) * viewport.scale + viewport.origin[0]);
89 let y = |v| f64::from((v + offset[1]) * viewport.scale + viewport.origin[1]);
90 node.set_bounds(Rect::new(x(x0), y(y0), x(x1), y(y1)));
91 update
92 .nodes
93 .iter_mut()
94 .find(|(id, _)| *id == ROOT)
95 .unwrap()
96 .1
97 .push_child(id);
98 update.nodes.push((id, node));
99 count += 1;
100 }
101 }
102 self.read_only.truncate(count);
103 let mut date_count = 0;
104 if let Some((scene, offset)) = scene {
105 for (index, (field, rect)) in scene.date_fields(editor).enumerate() {
106 if index == self.dates.len() {
107 let id = self.allocate();
108 self.dates.push((field, id));
109 }
110 self.dates[index].0 = field;
111 let id = self.dates[index].1;
112 let mut node = Node::new(Role::Button);
113 node.set_label(crate::DATE_LABELS[field as usize]);
114 node.set_value(
115 editor.date().unwrap().source().paragraphs[index]
116 .text()
117 .unwrap()
118 .text
119 .text(),
120 );
121 node.add_action(Action::Click);
122 let x = |v| f64::from((v + offset[0]) * viewport.scale + viewport.origin[0]);
123 let y = |v| f64::from((v + offset[1]) * viewport.scale + viewport.origin[1]);
124 node.set_bounds(Rect::new(x(rect[0]), y(rect[1]), x(rect[2]), y(rect[3])));
125 update
126 .nodes
127 .iter_mut()
128 .find(|(id, _)| *id == ROOT)
129 .unwrap()
130 .1
131 .push_child(id);
132 update.nodes.push((id, node));
133 date_count += 1;
134 }
135 }
136 self.dates.truncate(date_count);
137 }
138
139 fn allocate(&mut self) -> NodeId {
140 let id = NodeId(self.next_id);
141 self.next_id = self
142 .next_id
143 .checked_add(1)
144 .expect("Accessibility node IDs exhausted");
145 id
146 }
147
148 pub fn read_only_for_node(&self, node: NodeId) -> Option<usize> {
149 self.read_only.iter().position(|id| *id == node)
150 }
151
152 pub fn date_for_node(&self, node: NodeId) -> Option<DateField> {
153 self.dates
154 .iter()
155 .find(|(_, id)| *id == node)
156 .map(|(field, _)| *field)
157 }
158
159 pub fn outline_for_node(&self, node: NodeId) -> Option<onestore::ExGuid> {
160 self.outlines
161 .iter()
162 .find(|(_, id)| *id == node)
163 .map(|(outline, _)| *outline)
164 }
165
166 pub fn update(
167 &mut self,
168 editor: &CanvasEditor,
169 viewport: Viewport,
170 title: &str,
171 preview: Option<(onestore::ExGuid, [f32; 2])>,
172 ) -> Result<TreeUpdate, EditError> {
173 let mut runs = Vec::new();
174 let mut outlines = Vec::new();
175 let mut paragraph_ranges = HashMap::new();
176 for outline in editor.visible_outlines().chain(editor.caret_outline()) {
177 let field = self
178 .outlines
179 .iter()
180 .find(|(id, _)| *id == outline.id)
181 .map(|(_, node)| *node)
182 .unwrap_or_else(|| self.allocate());
183 outlines.push((outline.id, field));
184 let mut paragraphs = outline.layouts().peekable();
185 while let Some((paragraph, shaped)) = paragraphs.next() {
186 let layout = &shaped.text;
187 let origin = shaped.origin;
188 let key = ParagraphKey {
189 layout: layout.id(),
190 source: paragraph,
191 next: paragraphs.peek().map(|(next, _)| *next),
192 origin: origin.map(f32::to_bits),
193 };
194 let start = runs.len();
195 if let Some(range) = self.paragraphs.get(&key) {
196 runs.extend(
197 self.runs[range.clone()]
198 .iter()
199 .map(|run| (run.outline, run.node.clone(), run.positions.clone())),
200 );
201 paragraph_ranges.insert(key, start..runs.len());
202 continue;
203 }
204 let projection = &shaped.projection;
205 let text = projection.text().text();
206 let boundaries: Vec<_> = projection.source_boundaries().collect();
207 let source_position = |byte| -> Result<TextPosition, EditError> {
208 let index = boundaries
209 .binary_search_by_key(&byte, |(byte, _)| *byte)
210 .map_err(|_| EditError::InvalidRange)?;
211 Ok(TextPosition {
212 paragraph,
213 offset: boundaries[index].1,
214 })
215 };
216 let line_count = layout.lines().count();
217 for (line_index, (line, bounds)) in layout.lines().enumerate() {
218 let line_start = runs.len();
219 let mut x = line.metrics().offset;
220 let mut logical_runs = Vec::new();
221 for run in line.runs() {
222 let advance = run.advance();
223 logical_runs.push((run, x));
224 x += advance;
225 }
226 logical_runs.sort_by_key(|(run, _)| run.text_range().start);
227 for (run, x) in logical_runs {
228 // AccessKit stores each character's byte length in a u8.
229 let mut characters = Vec::new();
230 for cluster in run.clusters() {
231 let range = cluster.text_range();
232 if range.is_empty() || range.start == text.len() {
233 continue;
234 }
235 let cluster_text =
236 text.get(range.clone()).ok_or(EditError::InvalidRange)?;
237 if range.len() <= u8::MAX as usize {
238 characters.push((
239 range,
240 cluster.advance(),
241 cluster.is_word_boundary(),
242 ));
243 } else {
244 for (index, (offset, ch)) in cluster_text.char_indices().enumerate()
245 {
246 characters.push((
247 range.start + offset..range.start + offset + ch.len_utf8(),
248 if index == 0 { cluster.advance() } else { 0.0 },
249 index == 0 && cluster.is_word_boundary(),
250 ));
251 }
252 }
253 }
254 let mut advance = 0.0;
255 for chunk in characters.chunks(256) {
256 let width: f32 = chunk.iter().map(|(_, width, _)| width).sum();
257 let left = x + if run.is_rtl() {
258 run.advance() - advance - width
259 } else {
260 advance
261 };
262 let mut node = Node::new(Role::TextRun);
263 node.set_bounds(Rect::new(
264 f64::from(left + origin[0]),
265 f64::from(bounds.top + origin[1]),
266 f64::from(left + width + origin[0]),
267 f64::from(bounds.top + bounds.height + origin[1]),
268 ));
269 node.set_text_direction(if run.is_rtl() {
270 TextDirection::RightToLeft
271 } else {
272 TextDirection::LeftToRight
273 });
274 node.set_font_size(run.font_size());
275 let start = chunk[0].0.start;
276 let end = chunk.last().unwrap().0.end;
277 node.set_value(text.get(start..end).ok_or(EditError::InvalidRange)?);
278 node.set_character_lengths(
279 chunk
280 .iter()
281 .map(|(range, _, _)| u8::try_from(range.len()).unwrap())
282 .collect::<Vec<_>>(),
283 );
284 let mut offset = 0.0;
285 node.set_character_positions(
286 chunk
287 .iter()
288 .map(|(_, width, _)| {
289 let position = offset;
290 offset += width;
291 position
292 })
293 .collect::<Vec<_>>(),
294 );
295 node.set_character_widths(
296 chunk.iter().map(|(_, width, _)| *width).collect::<Vec<_>>(),
297 );
298 node.set_word_starts(
299 chunk
300 .iter()
301 .enumerate()
302 .filter_map(|(index, (_, _, word))| word.then_some(index as u8))
303 .collect::<Vec<_>>(),
304 );
305 let positions = chunk
306 .iter()
307 .map(|(range, _, _)| source_position(range.start))
308 .chain(std::iter::once(source_position(end)))
309 .collect::<Result<Vec<_>, _>>()?;
310 runs.push((outline.id, node, positions));
311 advance += width;
312 }
313 }
314 // Empty lines still need a text position for selection and insertion.
315 if runs.len() == line_start {
316 let mut node = Node::new(Role::TextRun);
317 node.set_value("");
318 node.set_character_lengths(Vec::<u8>::new());
319 node.set_character_positions(Vec::<f32>::new());
320 node.set_character_widths(Vec::<f32>::new());
321 node.set_text_direction(TextDirection::LeftToRight);
322 node.set_bounds(Rect::new(
323 f64::from(origin[0]),
324 f64::from(bounds.top + origin[1]),
325 f64::from(origin[0]),
326 f64::from(bounds.top + bounds.height + origin[1]),
327 ));
328 runs.push((
329 outline.id,
330 node,
331 vec![source_position(bounds.source.start)?],
332 ));
333 }
334 if line_index + 1 == line_count
335 && let Some((next, _)) = paragraphs.peek()
336 {
337 let (_, node, source_positions) = runs.last_mut().unwrap();
338 let mut value = node.value().unwrap().to_owned();
339 value.push('\n');
340 node.set_value(value);
341 let mut lengths = node.character_lengths().to_vec();
342 lengths.push(1);
343 node.set_character_lengths(lengths);
344 if let Some(rect) = node.bounds() {
345 let mut positions =
346 node.character_positions().unwrap_or_default().to_vec();
347 positions.push(rect.width() as f32);
348 node.set_character_positions(positions);
349 let mut widths = node.character_widths().unwrap_or_default().to_vec();
350 widths.push(4.0);
351 node.set_character_widths(widths);
352 }
353 source_positions.push(TextPosition {
354 paragraph: *next,
355 offset: 0,
356 });
357 }
358 }
359 if !shaped.tags.is_empty() {
360 let descriptions = shaped
361 .tags
362 .iter()
363 .map(|tag| {
364 use canvas::outline::TagIcon;
365 let fallback = match tag.icon {
366 TagIcon::CheckBox { .. } => "To do",
367 TagIcon::Question => "Question",
368 TagIcon::Music => "Music",
369 TagIcon::Exclamation => "Critical",
370 TagIcon::RedSquare => "Project A",
371 TagIcon::YellowSquare => "Project B",
372 TagIcon::BlueSquare => "Project C",
373 };
374 let label = if tag.label.is_empty() {
375 fallback
376 } else {
377 &tag.label
378 };
379 let state = match tag.icon {
380 TagIcon::CheckBox { checked: true } => ", completed",
381 TagIcon::CheckBox { checked: false } => ", incomplete",
382 _ => "",
383 };
384 format!(
385 "{label}{state}{}",
386 if tag.disabled { ", disabled" } else { "" }
387 )
388 })
389 .collect::<Vec<_>>()
390 .join("; ");
391 runs[start].1.set_description(descriptions);
392 }
393 paragraph_ranges.insert(key, start..runs.len());
394 }
395 }
396 let mut runs: Vec<_> = runs
397 .into_iter()
398 .enumerate()
399 .map(|(index, (outline, mut node, positions))| {
400 node.clear_next_on_line();
401 node.clear_previous_on_line();
402 let id = match self.runs.get(index) {
403 Some(old)
404 if old.outline == outline
405 && old.positions == positions
406 && old.node.value() == node.value() =>
407 {
408 old.id
409 }
410 _ => self.allocate(),
411 };
412 Run {
413 outline,
414 id,
415 node,
416 positions,
417 }
418 })
419 .collect();
420 for index in 1..runs.len() {
421 if runs[index - 1].outline == runs[index].outline
422 && runs[index - 1].node.bounds().map(|r| r.y0)
423 == runs[index].node.bounds().map(|r| r.y0)
424 {
425 let (before, after) = runs.split_at_mut(index);
426 before[index - 1].node.set_next_on_line(after[0].id);
427 after[0].node.set_previous_on_line(before[index - 1].id);
428 }
429 }
430 self.runs = runs;
431 self.paragraphs = paragraph_ranges;
432 self.outlines = outlines;
433 let mut root = Node::new(Role::Window);
434 root.set_label(title);
435 root.set_children(self.outlines.iter().map(|(_, id)| *id).collect::<Vec<_>>());
436 root.set_bounds(Rect::new(
437 0.0,
438 0.0,
439 viewport.size[0] as f64,
440 viewport.size[1] as f64,
441 ));
442 let mut nodes = vec![(ROOT, root)];
443 let mut focus = ROOT;
444 for (index, (outline, (_, id))) in editor
445 .visible_outlines()
446 .chain(editor.caret_outline())
447 .zip(&self.outlines)
448 .enumerate()
449 {
450 let mut field = Node::new(Role::MultilineTextInput);
451 field.set_label(if outline.title {
452 "Page title".into()
453 } else if editor
454 .caret_outline()
455 .is_some_and(|caret| caret.id == outline.id)
456 {
457 "Text input".into()
458 } else {
459 format!("Text outline {}", index + 1)
460 });
461 field.add_action(Action::Focus);
462 field.add_action(Action::SetTextSelection);
463 field.add_action(Action::ReplaceSelectedText);
464 field.add_action(Action::SetValue);
465 let runs = self.runs.iter().filter(|run| run.outline == outline.id);
466 field.set_value(
467 runs.clone()
468 .filter_map(|run| run.node.value())
469 .collect::<String>(),
470 );
471 field.set_children(runs.map(|run| run.id).collect::<Vec<_>>());
472 let origin = preview
473 .filter(|(id, _)| *id == outline.id)
474 .map(|(_, origin)| origin)
475 .unwrap_or_else(|| outline.origin());
476 field.set_transform(Affine::new([
477 f64::from(viewport.scale),
478 0.0,
479 0.0,
480 f64::from(viewport.scale),
481 f64::from(viewport.origin[0]) + f64::from(origin[0]) * f64::from(viewport.scale),
482 f64::from(viewport.origin[1]) + f64::from(origin[1]) * f64::from(viewport.scale),
483 ]));
484 let bounds = outline.bounds();
485 field.set_bounds(Rect::new(0.0, 0.0, bounds.width(), bounds.height()));
486 if outline.id == editor.active_outline().id {
487 let Selection {
488 positions: [anchor, caret],
489 affinities,
490 } = editor.selection();
491 field.set_text_selection(TextSelection {
492 anchor: self.position(outline, anchor, affinities[0])?,
493 focus: self.position(outline, caret, affinities[1])?,
494 });
495 focus = *id;
496 }
497 nodes.push((*id, field));
498 }
499 nodes.extend(self.runs.iter().map(|run| (run.id, run.node.clone())));
500 Ok(TreeUpdate {
501 nodes,
502 tree: Some(TreeInfo::new(ROOT)),
503 tree_id: TreeId::ROOT,
504 focus,
505 })
506 }
507
508 fn position(
509 &self,
510 outline: &TextOutline,
511 mut position: TextPosition,
512 affinity: Affinity,
513 ) -> Result<accesskit::TextPosition, EditError> {
514 let shaped = outline.paragraph_layout(position.paragraph)?;
515 let projection = &shaped.projection;
516 let layout = &shaped.text;
517 let origin = shaped.origin;
518 let cursor = layout.cursor(
519 projection
520 .text()
521 .byte_offset(projection.visible_offset(position.offset)?)?,
522 affinity,
523 );
524 position.offset = projection.source_offset(
525 projection.text().utf16_offset(cursor.index())?,
526 onestore::page::text::Affinity::Downstream,
527 )?;
528 let caret = layout.caret(cursor, 0.0);
529 let mut positions = self
530 .runs
531 .iter()
532 .filter(|run| {
533 run.outline == outline.id
534 && run
535 .node
536 .bounds()
537 .is_some_and(|rect| rect.y0 == f64::from(caret.y0 as f32 + origin[1]))
538 })
539 .filter_map(|run| {
540 run.positions
541 .iter()
542 .position(|p| *p == position)
543 .map(|character_index| accesskit::TextPosition {
544 node: run.id,
545 character_index,
546 })
547 });
548 match affinity {
549 Affinity::Upstream => positions.next(),
550 Affinity::Downstream => positions.next_back(),
551 }
552 .ok_or(EditError::InvalidRange)
553 }
554
555 pub fn selection(
556 &self,
557 outline: onestore::ExGuid,
558 selection: TextSelection,
559 ) -> Result<Selection, EditError> {
560 let position = |position: accesskit::TextPosition| {
561 let run = self
562 .runs
563 .iter()
564 .find(|run| run.outline == outline && run.id == position.node)
565 .ok_or(EditError::InvalidRange)?;
566 let source = *run
567 .positions
568 .get(position.character_index)
569 .ok_or(EditError::InvalidRange)?;
570 let affinity = if position.character_index > 0
571 && position.character_index + 1 == run.positions.len()
572 {
573 Affinity::Upstream
574 } else {
575 Affinity::Downstream
576 };
577 Ok::<_, EditError>((source, affinity))
578 };
579 let (anchor, anchor_affinity) = position(selection.anchor)?;
580 let (focus, focus_affinity) = position(selection.focus)?;
581 Ok(Selection {
582 positions: [anchor, focus],
583 affinities: [anchor_affinity, focus_affinity],
584 })
585 }
586}
587
588#[cfg(test)]
589mod tests {
590 use super::*;
591 use canvas::{document::TextDocument, layout::TextEngine};
592 use onestore::document::Format;
593 use onestore::page::text::Paragraph;
594
595 #[test]
596 fn title_flow_updates_read_only_hit_and_accessibility_bounds() {
597 use onestore::page::{Page, PageObject, Title, Unsupported};
598 let mut engine = TextEngine::default();
599 let mut title = TextOutline::new(
600 &mut engine,
601 TextDocument::new(vec![Paragraph::new(
602 "Header".into(),
603 Format {
604 font: Some("Arial".into()),
605 font_size: Some(8.0),
606 line_spacing: Some(20.0),
607 ..Default::default()
608 },
609 )])
610 .unwrap(),
611 468.0,
612 [0.0; 2],
613 )
614 .unwrap()
615 .snapshot();
616 title.title = true;
617 let page = Page {
618 identity: None,
619 title: "Header".into(),
620 created: None,
621 margin_origin: [36.0, 14.4],
622 definitions: Default::default(),
623 objects: vec![
624 PageObject::Title(Title {
625 id: Default::default(),
626 date: None,
627 layout: Default::default(),
628 outlines: vec![title],
629 }),
630 PageObject::Unsupported(Unsupported {
631 id: Default::default(),
632 jcid: 0xdead,
633 layout: onestore::document::Layout {
634 x: Some(120.0),
635 y: Some(80.0),
636 max_width: Some(200.0),
637 max_height: Some(60.0),
638 ..Default::default()
639 },
640 }),
641 ],
642 };
643 let (scene, mut editor) =
644 canvas::gpu::page::PageScene::from_page(page, &mut engine).unwrap();
645 let scene = (scene, [30.0, 40.0]);
646 let viewport = Viewport {
647 size: [800, 600],
648 origin: [10.0, -20.0],
649 scale: 2.0,
650 };
651 let mut access = Accessibility::default();
652 let mut identity = None;
653 for phase in 0..4 {
654 match phase {
655 1 => {
656 editor.select_all().unwrap();
657 editor.insert(&mut engine, "Header\u{000b}Second\u{000b}Third\u{000b}Fourth\u{000b}Fifth\u{000b}Sixth").unwrap();
658 }
659 2 => {
660 editor.undo(&mut engine).unwrap();
661 }
662 3 => {
663 editor.redo(&mut engine).unwrap();
664 }
665 _ => {}
666 }
667 let mut update = access.update(&editor, viewport, "Test", None).unwrap();
668 access.append_page_fields(&mut update, Some(&scene), &editor, viewport, Some(0));
669 let (id, node) = update
670 .nodes
671 .iter()
672 .find(|(_, node)| node.role() == Role::Label)
673 .unwrap();
674 if let Some(identity) = identity {
675 assert_eq!(*id, identity);
676 }
677 identity = Some(*id);
678 let y = if phase % 2 == 0 { 80.0 } else { 145.92 };
679 let expected = Rect::new(
680 310.0,
681 f64::from((y + 40.0) * 2.0 - 20.0),
682 710.0,
683 f64::from((y + 100.0) * 2.0 - 20.0),
684 );
685 let bounds = node.bounds().unwrap();
686 assert_eq!(bounds.x0, expected.x0);
687 assert_eq!(bounds.x1, expected.x1);
688 assert!((bounds.y0 - expected.y0).abs() < 0.001);
689 assert!((bounds.y1 - expected.y1).abs() < 0.001);
690 assert_eq!(
691 crate::page_hit_test(&editor, Some(&scene), [155.0, y + 45.0], 1.0),
692 Some(crate::Hit::ReadOnly(0))
693 );
694 if phase % 2 == 1 {
695 assert_ne!(
696 crate::page_hit_test(&editor, Some(&scene), [155.0, 125.0], 1.0),
697 Some(crate::Hit::ReadOnly(0))
698 );
699 }
700 }
701 }
702
703 #[test]
704 fn read_only_objects_keep_accessibility_identity_through_edits_and_view_changes() {
705 use canvas::gpu::page::PageScene;
706 use onestore::page::{Page, PageObject, Unsupported};
707 let page = Page {
708 identity: None,
709 created: None,
710 title: String::new(),
711 margin_origin: [0.0; 2],
712 definitions: Default::default(),
713 objects: vec![PageObject::Unsupported(Unsupported {
714 id: Default::default(),
715 jcid: 0xdead,
716 layout: onestore::document::Layout {
717 x: Some(10.0),
718 y: Some(20.0),
719 max_width: Some(200.0),
720 max_height: Some(60.0),
721 ..Default::default()
722 },
723 })],
724 };
725 let mut engine = TextEngine::default();
726 let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap();
727 let scene = (scene, [30.0, 40.0]);
728 let mut access = Accessibility::default();
729 let mut identity = None;
730 for scale in [1.0, 2.0, 0.5] {
731 editor.insert(&mut engine, "annotation ").unwrap();
732 let viewport = Viewport {
733 size: [800, 600],
734 origin: [-200.0, 10.0],
735 scale,
736 };
737 let mut update = access.update(&editor, viewport, "Test", None).unwrap();
738 access.append_page_fields(&mut update, Some(&scene), &editor, viewport, None);
739 let (id, node) = update
740 .nodes
741 .iter()
742 .find(|(_, node)| node.role() == Role::Label)
743 .unwrap();
744 if let Some(identity) = identity {
745 assert_eq!(*id, identity);
746 }
747 identity = Some(*id);
748 assert_eq!(node.value(), Some("Unsupported content\nRead-only"));
749 assert!(node.is_read_only());
750 assert!(node.supports_action(Action::Focus));
751 for action in [
752 Action::SetValue,
753 Action::ReplaceSelectedText,
754 Action::SetTextSelection,
755 ] {
756 assert!(!node.supports_action(action));
757 }
758 assert_eq!(
759 node.bounds(),
760 Some(Rect::new(
761 f64::from(40.0 * scale - 200.0),
762 f64::from(60.0 * scale + 10.0),
763 f64::from(240.0 * scale - 200.0),
764 f64::from(120.0 * scale + 10.0)
765 ))
766 );
767 assert_eq!(access.outline_for_node(*id), None);
768 let tree = accesskit_consumer::Tree::new(update, true);
769 assert_eq!(
770 tree.state().focus().unwrap().document_range().text(),
771 "annotation "
772 );
773 let mut focused = access.update(&editor, viewport, "Test", None).unwrap();
774 access.append_page_fields(&mut focused, Some(&scene), &editor, viewport, Some(0));
775 assert_eq!(focused.focus, identity.unwrap());
776 assert_eq!(access.read_only_for_node(focused.focus), Some(0));
777 let tree = accesskit_consumer::Tree::new(focused, true);
778 assert_eq!(
779 tree.state().focus().unwrap().value().as_deref(),
780 Some("Unsupported content\nRead-only")
781 );
782 editor.undo(&mut engine).unwrap();
783 }
784 access.deactivate();
785 assert!(access.read_only.is_empty());
786 }
787
788 #[test]
789 fn tag_descriptions_preserve_plain_text_and_survive_edit_undo_and_cache_reuse() {
790 use onestore::page::{Definition, Outline};
791 use onestore::{
792 ExGuid,
793 document::{Kind, Layout, Tag},
794 };
795 use std::collections::BTreeMap;
796 let text = TextDocument::new(
797 ["task one", "task two", "question", "music"]
798 .into_iter()
799 .map(|text| Paragraph::new(text.into(), Format::default()))
800 .collect(),
801 )
802 .unwrap();
803 let mut nodes = text.nodes().to_vec();
804 let mut definitions = BTreeMap::new();
805 for (index, (shape, label, status)) in [
806 (3, Some("Rehearsal"), 0),
807 (3, Some("Rehearsal"), 1),
808 (15, None, 1),
809 (121, None, 3),
810 ]
811 .into_iter()
812 .enumerate()
813 {
814 let id = ExGuid {
815 n: index as u32 + 500,
816 ..ExGuid::default()
817 };
818 definitions.insert(
819 id,
820 Definition {
821 kind: Kind::TagDefinition {
822 label: label.map(str::to_owned),
823 action_type: None,
824 shape: Some(shape),
825 color: None,
826 highlight: None,
827 },
828 format: Format::default(),
829 },
830 );
831 nodes[index].text_mut().unwrap().tags.push(Tag {
832 definition: Some(id),
833 action_type: None,
834 status,
835 created: None,
836 completed: None,
837 start: None,
838 due: None,
839 task_id: None,
840 extra_set: 0,
841 });
842 }
843 let mut engine = TextEngine::default();
844 let mut editor = CanvasEditor::from_outlines(
845 &mut engine,
846 vec![Outline {
847 title: false,
848 min_width: None,
849 id: ExGuid {
850 n: 900,
851 ..ExGuid::default()
852 },
853 layout: Layout {
854 max_width: Some(120.0),
855 ..Layout::default()
856 },
857 indents: vec![18.0, 0.0],
858 paragraphs: nodes,
859 unsupported: Vec::new(),
860 }],
861 definitions,
862 )
863 .unwrap();
864 let mut access = Accessibility::default();
865 let viewport = Viewport {
866 size: [800, 600],
867 origin: [48.0; 2],
868 scale: 2.0,
869 };
870 for step in 0..4 {
871 if step == 2 {
872 editor.insert(&mut engine, "new ").unwrap();
873 }
874 if step == 3 {
875 editor.undo(&mut engine).unwrap();
876 }
877 let update = access.update(&editor, viewport, "Test", None).unwrap();
878 assert_eq!(
879 update
880 .nodes
881 .iter()
882 .filter_map(|(_, n)| n.description())
883 .collect::<Vec<_>>(),
884 [
885 "Rehearsal, incomplete",
886 "Rehearsal, completed",
887 "Question",
888 "Music, disabled"
889 ]
890 );
891 let tree = accesskit_consumer::Tree::new(update, true);
892 let text = tree.state().focus().unwrap().document_range().text();
893 assert_eq!(
894 text,
895 if step == 2 {
896 "new task one\ntask two\nquestion\nmusic"
897 } else {
898 "task one\ntask two\nquestion\nmusic"
899 }
900 );
901 }
902 }
903
904 #[test]
905 fn collapsed_paragraphs_keep_visible_accessibility_ranges_in_source_coordinates() {
906 let document = TextDocument::new(
907 [
908 "root",
909 "folded",
910 "hidden",
911 "last",
912 "folded tail",
913 "hidden tail",
914 ]
915 .into_iter()
916 .map(|text| Paragraph::new(text.into(), Format::default()))
917 .collect(),
918 )
919 .unwrap();
920 let mut nodes = document.nodes().to_vec();
921 for (child, parent, level) in [(1, 0, 2), (2, 1, 3), (4, 3, 2), (5, 4, 3)] {
922 nodes[child].parent = Some(nodes[parent].id);
923 nodes[child].level = level;
924 }
925 nodes[1].collapsed = true;
926 nodes[4].collapsed = true;
927 let mut engine = TextEngine::default();
928 let mut editor =
929 CanvasEditor::new(&mut engine, TextDocument::from_nodes(nodes).unwrap(), 180.0)
930 .unwrap();
931 let mut access = Accessibility::default();
932 let viewport = Viewport {
933 size: [900, 700],
934 scale: 2.0,
935 origin: [13.0, 19.0],
936 };
937 let update = access.update(&editor, viewport, "Test", None).unwrap();
938 let tree = accesskit_consumer::Tree::new(update, true);
939 let field = tree.state().focus().unwrap();
940 assert_eq!(
941 field.document_range().text(),
942 "root\nfolded\nlast\nfolded tail"
943 );
944 assert!(
945 access
946 .runs
947 .iter()
948 .flat_map(|run| &run.positions)
949 .all(|position| ![2, 5].contains(&position.paragraph))
950 );
951 let source = access
952 .selection(
953 editor.active_outline().id,
954 field.document_range().to_text_selection(),
955 )
956 .unwrap();
957 assert_eq!(
958 source.positions[0],
959 TextPosition {
960 paragraph: 0,
961 offset: 0
962 }
963 );
964 assert_eq!(
965 source.positions[1],
966 TextPosition {
967 paragraph: 4,
968 offset: 11
969 }
970 );
971 editor.select(source).unwrap();
972 let update = access.update(&editor, viewport, "Test", None).unwrap();
973 let tree = accesskit_consumer::Tree::new(update, true);
974 assert_eq!(
975 tree.state()
976 .focus()
977 .unwrap()
978 .text_selection()
979 .unwrap()
980 .text(),
981 "root\nfolded\nlast\nfolded tail"
982 );
983 editor
984 .select(
985 [TextPosition {
986 paragraph: 4,
987 offset: 0,
988 }; 2]
989 .into(),
990 )
991 .unwrap();
992 access.update(&editor, viewport, "Test", None).unwrap();
993 let caret = editor.caret(1.0).unwrap();
994 assert_eq!(caret.x0, 27.0);
995 let position = access
996 .position(
997 editor.active_outline(),
998 editor.selection().positions[1],
999 Affinity::Downstream,
1000 )
1001 .unwrap();
1002 let run = access
1003 .runs
1004 .iter()
1005 .find(|run| run.id == position.node)
1006 .unwrap();
1007 assert_eq!(run.node.bounds().unwrap().x0, 27.0);
1008 assert_eq!(run.node.bounds().unwrap().y0, caret.y0);
1009 }
1010
1011 #[test]
1012 fn consumer_reads_unicode_blank_paragraphs_and_hidden_fields() {
1013 let cases = [
1014 "",
1015 "a\n\nשלום\nend",
1016 "café e\u{301} 🌳 👩‍👩‍👧‍👦 words wrap here",
1017 "\n\n",
1018 "a\nb\n",
1019 ];
1020 let mut engine = TextEngine::default();
1021 for text in cases
1022 .into_iter()
1023 .map(str::to_owned)
1024 .chain([format!("a{}", "\u{301}".repeat(300)), "x".repeat(600)])
1025 {
1026 for width in [24.0, 480.0, 10000.0] {
1027 let document = TextDocument::new(
1028 text.split('\n')
1029 .map(|line| {
1030 Paragraph::from_runs([
1031 (
1032 String::from("secret"),
1033 Format {
1034 hidden: Some(true),
1035 ..Format::default()
1036 },
1037 ),
1038 (line.to_owned(), Format::default()),
1039 ])
1040 })
1041 .collect(),
1042 )
1043 .unwrap();
1044 let mut editor = CanvasEditor::new(&mut engine, document, width).unwrap();
1045 let mut access = Accessibility::default();
1046 let viewport = Viewport {
1047 size: [800, 600],
1048 origin: [48.0; 2],
1049 scale: 2.0,
1050 };
1051 let update = access.update(&editor, viewport, "Test", None).unwrap();
1052 for (_, node) in &update.nodes {
1053 if node.role() == Role::TextRun {
1054 assert_eq!(
1055 node.character_lengths()
1056 .iter()
1057 .map(|n| *n as usize)
1058 .sum::<usize>(),
1059 node.value().unwrap().len()
1060 );
1061 if let Some(positions) = node.character_positions() {
1062 assert_eq!(positions.len(), node.character_lengths().len());
1063 }
1064 if let Some(widths) = node.character_widths() {
1065 assert_eq!(widths.len(), node.character_lengths().len());
1066 }
1067 }
1068 }
1069 let tree = accesskit_consumer::Tree::new(update, true);
1070 let field = tree.state().focus().unwrap();
1071 assert_eq!(field.document_range().text(), text, "width {width}");
1072 let selection = field.document_range().to_text_selection();
1073 let source_selection = access
1074 .selection(editor.active_outline().id, selection)
1075 .unwrap();
1076 editor.select(source_selection).unwrap();
1077 let selected = access.update(&editor, viewport, "Test", None).unwrap();
1078 let tree = accesskit_consumer::Tree::new(selected, true);
1079 assert_eq!(
1080 tree.state()
1081 .focus()
1082 .unwrap()
1083 .text_selection()
1084 .unwrap()
1085 .text(),
1086 text
1087 );
1088 let stable_ids: Vec<_> = access.runs.iter().map(|run| run.id).collect();
1089 access
1090 .update(
1091 &editor,
1092 Viewport {
1093 scale: 3.0,
1094 ..viewport
1095 },
1096 "Test",
1097 None,
1098 )
1099 .unwrap();
1100 assert_eq!(
1101 stable_ids,
1102 access.runs.iter().map(|run| run.id).collect::<Vec<_>>()
1103 );
1104 access.deactivate();
1105 access.update(&editor, viewport, "Test", None).unwrap();
1106 assert!(
1107 access
1108 .selection(editor.active_outline().id, selection)
1109 .is_err()
1110 );
1111 let selection = access
1112 .update(&editor, viewport, "Test", None)
1113 .unwrap()
1114 .nodes[1]
1115 .1
1116 .text_selection()
1117 .copied()
1118 .unwrap();
1119 editor.insert(&mut engine, "replacement").unwrap();
1120 access.update(&editor, viewport, "Test", None).unwrap();
1121 assert!(
1122 access
1123 .selection(editor.active_outline().id, selection)
1124 .is_err()
1125 );
1126 editor.undo(&mut engine).unwrap();
1127 let update = access.update(&editor, viewport, "Test", None).unwrap();
1128 let tree = accesskit_consumer::Tree::new(update, true);
1129 assert_eq!(tree.state().focus().unwrap().document_range().text(), text);
1130 }
1131 }
1132 }
1133
1134 #[test]
1135 fn caret_geometry_uses_visible_offsets_and_canvas_line_metrics() {
1136 let mut engine = TextEngine::default();
1137 let document = TextDocument::new(vec![
1138 Paragraph::from_runs([
1139 ("abc ".into(), Format::default()),
1140 (
1141 "hidden field".into(),
1142 Format {
1143 hidden: Some(true),
1144 ..Format::default()
1145 },
1146 ),
1147 ("e\u{301} 🌳 words wrap here".into(), Format::default()),
1148 ]),
1149 Paragraph::new("".into(), Format::default()),
1150 Paragraph::new("abc שלום def عالم end".into(), Format::default()),
1151 Paragraph::new("last\nline\n".into(), Format::default()),
1152 ])
1153 .unwrap();
1154 let positions: Vec<_> = document
1155 .paragraphs()
1156 .enumerate()
1157 .flat_map(|(paragraph, text)| {
1158 text.text()
1159 .char_indices()
1160 .map(|(byte, _)| text.utf16_offset(byte).unwrap())
1161 .chain(std::iter::once(
1162 text.utf16_offset(text.text().len()).unwrap(),
1163 ))
1164 .map(move |offset| TextPosition { paragraph, offset })
1165 })
1166 .collect();
1167 let mut editor = CanvasEditor::new(&mut engine, document, 48.0).unwrap();
1168 let mut access = Accessibility::default();
1169 let viewport = Viewport {
1170 size: [800, 600],
1171 origin: [31.0, -57.0],
1172 scale: 2.5,
1173 };
1174 for (position, affinity) in positions.into_iter().flat_map(|position| {
1175 [Affinity::Upstream, Affinity::Downstream].map(|affinity| (position, affinity))
1176 }) {
1177 editor
1178 .select(Selection {
1179 positions: [position; 2],
1180 affinities: [affinity; 2],
1181 })
1182 .unwrap();
1183 let caret = editor.caret(0.0).unwrap();
1184 let update = access.update(&editor, viewport, "Test", None).unwrap();
1185 let tree = accesskit_consumer::Tree::new(update, true);
1186 let field = tree.state().focus().unwrap();
1187 assert_eq!(
1188 field.document_range().text(),
1189 "abc e\u{301} 🌳 words wrap here\n\nabc שלום def عالم end\nlast\nline\n"
1190 );
1191 let rects = field.text_selection().unwrap().bounding_boxes();
1192 assert_eq!(rects.len(), 1, "{position:?}");
1193 let rect = rects[0];
1194 for (actual, expected) in [
1195 (rect.x0, caret.x0 * 2.5 + 31.0),
1196 (rect.y0, caret.y0 * 2.5 - 57.0),
1197 (rect.y1, caret.y1 * 2.5 - 57.0),
1198 ] {
1199 assert!(
1200 (actual - expected).abs() < 0.0001,
1201 "{position:?} {affinity:?}: {rect:?} vs {caret:?}"
1202 );
1203 }
1204 let selection = access
1205 .selection(
1206 editor.active_outline().id,
1207 field.text_selection().unwrap().to_text_selection(),
1208 )
1209 .unwrap();
1210 editor.select(selection).unwrap();
1211 assert_eq!(
1212 editor.caret(0.0).unwrap(),
1213 caret,
1214 "{position:?} {affinity:?}: AX selection moved the caret"
1215 );
1216 }
1217 }
1218 #[test]
1219 fn keyboard_and_accessibility_preserve_visual_caret_at_wraps() {
1220 use canvas::editor::Movement;
1221 let mut engine = TextEngine::default();
1222 let document = TextDocument::new(vec![
1223 Paragraph::new(
1224 "first words wrap here and continue".into(),
1225 Format::default(),
1226 ),
1227 Paragraph::new("abc שלום def عالم end".into(), Format::default()),
1228 Paragraph::new("last\nline".into(), Format::default()),
1229 ])
1230 .unwrap();
1231 let mut editor = CanvasEditor::new(&mut engine, document, 72.0).unwrap();
1232 let mut access = Accessibility::default();
1233 let viewport = Viewport {
1234 size: [800, 600],
1235 origin: [0.0; 2],
1236 scale: 1.0,
1237 };
1238 for movement in [
1239 Movement::LineEnd,
1240 Movement::Right,
1241 Movement::Left,
1242 Movement::Down,
1243 Movement::LineStart,
1244 Movement::LineEnd,
1245 Movement::Down,
1246 Movement::Down,
1247 Movement::Down,
1248 Movement::LineEnd,
1249 Movement::Left,
1250 Movement::Left,
1251 Movement::Right,
1252 Movement::LineStart,
1253 Movement::Up,
1254 Movement::Right,
1255 Movement::LineEnd,
1256 ] {
1257 editor.move_selection(&mut engine, movement, false).unwrap();
1258 let caret = editor.caret(0.0).unwrap();
1259 let tree = accesskit_consumer::Tree::new(
1260 access.update(&editor, viewport, "Test", None).unwrap(),
1261 true,
1262 );
1263 let field = tree.state().focus().unwrap();
1264 let selection = field.text_selection().unwrap();
1265 let rects = selection.bounding_boxes();
1266 assert_eq!(rects.len(), 1);
1267 let rect = rects[0];
1268 for (actual, expected) in [
1269 (rect.x0, caret.x0),
1270 (rect.y0, caret.y0),
1271 (rect.y1, caret.y1),
1272 ] {
1273 assert!(
1274 (actual - expected).abs() < 0.0001,
1275 "{movement:?}: {rect:?} vs {caret:?}"
1276 );
1277 }
1278 let source_selection = access
1279 .selection(editor.active_outline().id, selection.to_text_selection())
1280 .unwrap();
1281 editor.select(source_selection).unwrap();
1282 let restored = editor.caret(0.0).unwrap();
1283 assert_eq!(
1284 restored, caret,
1285 "{movement:?}: AX selection moved the caret"
1286 );
1287 }
1288 }
1289 #[test]
1290 fn cached_runs_match_reconstruction_through_editing_and_view_changes() {
1291 let mut engine = TextEngine::default();
1292 let document = TextDocument::new(
1293 [
1294 "one two three",
1295 "",
1296 "אבג 日本 👩🏽‍💻 e\u{301}",
1297 "last paragraph",
1298 ]
1299 .into_iter()
1300 .map(|text| Paragraph::new(text.into(), Format::default()))
1301 .collect(),
1302 )
1303 .unwrap();
1304 let mut editor = CanvasEditor::new(&mut engine, document, 72.0).unwrap();
1305 let mut cached = Accessibility::default();
1306 let mut rebuilt = Accessibility::default();
1307 let mut compare = |editor: &CanvasEditor, step: usize| {
1308 let viewport = Viewport {
1309 size: [800, 600],
1310 origin: [step as f32 * 0.125, -17.25],
1311 scale: 0.75 + (step % 5) as f32 * 0.5,
1312 };
1313 let preview = step
1314 .is_multiple_of(2)
1315 .then_some((editor.active_outline().id, [13.5, -7.25]));
1316 for repeat in 0..2 {
1317 rebuilt.paragraphs.clear();
1318 let actual = cached.update(editor, viewport, "Test", preview).unwrap();
1319 let expected = rebuilt.update(editor, viewport, "Test", preview).unwrap();
1320 assert_eq!(actual.nodes, expected.nodes, "step {step}, repeat {repeat}");
1321 assert_eq!(actual.focus, expected.focus);
1322 let tree = accesskit_consumer::Tree::new(actual, true);
1323 let field = tree.state().focus().unwrap();
1324 let selection = field.document_range().to_text_selection();
1325 assert_eq!(
1326 cached
1327 .selection(editor.active_outline().id, selection)
1328 .unwrap(),
1329 rebuilt
1330 .selection(editor.active_outline().id, selection)
1331 .unwrap()
1332 );
1333 let count = editor
1334 .outlines()
1335 .iter()
1336 .map(|o| o.layouts().count())
1337 .sum::<usize>();
1338 assert_eq!(cached.paragraphs.len(), count);
1339 }
1340 };
1341 compare(&editor, 0);
1342 let mut seed = 0x923e_u64;
1343 let mut next = || {
1344 seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
1345 (seed >> 32) as usize
1346 };
1347 for step in 1..=160 {
1348 let id = editor.outlines()[next() % editor.outlines().len()].id;
1349 editor.focus_outline(id).unwrap();
1350 let positions: Vec<_> = editor
1351 .active_outline()
1352 .document()
1353 .paragraphs()
1354 .enumerate()
1355 .flat_map(|(paragraph, text)| {
1356 (0..=text.text().len()).filter_map(move |byte| {
1357 text.utf16_offset(byte)
1358 .ok()
1359 .map(|offset| TextPosition { paragraph, offset })
1360 })
1361 })
1362 .collect();
1363 let anchor = positions[next() % positions.len()];
1364 let focus = if step % 3 == 0 {
1365 positions[next() % positions.len()]
1366 } else {
1367 anchor
1368 };
1369 editor.select([anchor, focus].into()).unwrap();
1370 match step % 10 {
1371 0 => editor.insert(&mut engine, "\n").unwrap(),
1372 1 => editor.insert(&mut engine, "abc 👩🏽‍💻 שלום e\u{301}").unwrap(),
1373 2 => {
1374 editor.delete(&mut engine, true).unwrap();
1375 }
1376 3 => editor
1377 .resize(&mut engine, 48.0 + (step % 7) as f32 * 31.25)
1378 .unwrap(),
1379 4 => {
1380 editor
1381 .create_outline(&mut engine, [step as f32, -5.0], 97.0)
1382 .unwrap();
1383 editor.insert(&mut engine, "annotation").unwrap();
1384 }
1385 5 => {
1386 editor.undo(&mut engine).unwrap();
1387 }
1388 6 => {
1389 editor.redo(&mut engine).unwrap();
1390 }
1391 7 => editor.move_outline(id, [17.5, step as f32]).unwrap(),
1392 8 => {
1393 editor.compose(&mut engine, "仮\n名".into(), 1..1).unwrap();
1394 compare(&editor, step);
1395 editor.cancel_composition(&mut engine).unwrap();
1396 }
1397 _ => {}
1398 }
1399 compare(&editor, step);
1400 }
1401 while editor.undo(&mut engine).unwrap() {
1402 compare(&editor, 161);
1403 }
1404 while editor.redo(&mut engine).unwrap() {
1405 compare(&editor, 162);
1406 }
1407 }
1408 #[test]
1409 fn outlines_have_independent_accessibility_identity_and_selection() {
1410 let mut engine = TextEngine::default();
1411 let document =
1412 TextDocument::new(vec![Paragraph::new("lyrics".into(), Format::default())]).unwrap();
1413 let mut editor = CanvasEditor::new(&mut engine, document, 240.0).unwrap();
1414 let first = editor.active_outline().id;
1415 let second = editor
1416 .create_outline(&mut engine, [300.0, 40.0], 120.0)
1417 .unwrap();
1418 editor.insert(&mut engine, "annotation 🌳").unwrap();
1419 let mut access = Accessibility::default();
1420 let viewport = Viewport {
1421 size: [1000, 720],
1422 scale: 2.0,
1423 origin: [48.0; 2],
1424 };
1425 let update = access.update(&editor, viewport, "Test", None).unwrap();
1426 let fields: Vec<_> = update
1427 .nodes
1428 .iter()
1429 .filter(|(_, node)| node.role() == Role::MultilineTextInput)
1430 .map(|(id, node)| (*id, node.value().unwrap().to_owned()))
1431 .collect();
1432 assert_eq!(
1433 fields
1434 .iter()
1435 .map(|(_, text)| text.as_str())
1436 .collect::<Vec<_>>(),
1437 ["lyrics", "annotation 🌳"]
1438 );
1439 assert_ne!(fields[0].0, fields[1].0);
1440 assert_eq!(access.outline_for_node(fields[0].0), Some(first));
1441 assert_eq!(access.outline_for_node(fields[1].0), Some(second));
1442 let tree = accesskit_consumer::Tree::new(update, true);
1443 let field = tree.state().focus().unwrap();
1444 assert_eq!(field.document_range().text(), "annotation 🌳");
1445 let selection = field.document_range().to_text_selection();
1446 assert!(access.selection(first, selection).is_err());
1447 let source_selection = access.selection(second, selection).unwrap();
1448 editor.select(source_selection).unwrap();
1449 let preview = access
1450 .update(&editor, viewport, "Test", Some((second, [400.0, 50.0])))
1451 .unwrap();
1452 let preview_tree = accesskit_consumer::Tree::new(preview, true);
1453 let preview_field = preview_tree.state().focus().unwrap();
1454 let rect = preview_field.document_range().bounding_boxes()[0];
1455 assert_eq!(rect.x0, 848.0);
1456 assert_eq!(rect.y0, 148.0);
1457 assert_eq!(editor.active_outline().origin(), [300.0, 40.0]);
1458 editor.move_outline(second, [400.0, 50.0]).unwrap();
1459 access.update(&editor, viewport, "Test", None).unwrap();
1460 assert_eq!(access.outline_for_node(fields[1].0), Some(second));
1461 assert!(access.selection(second, selection).is_ok());
1462 editor.undo(&mut engine).unwrap();
1463 editor.undo(&mut engine).unwrap();
1464 editor.undo(&mut engine).unwrap();
1465 let update = access.update(&editor, viewport, "Test", None).unwrap();
1466 assert_eq!(
1467 update
1468 .nodes
1469 .iter()
1470 .filter(|(_, node)| node.role() == Role::MultilineTextInput)
1471 .count(),
1472 1
1473 );
1474 assert_eq!(access.outline_for_node(fields[1].0), None);
1475 assert!(access.selection(second, selection).is_err());
1476 assert_eq!(access.outline_for_node(fields[0].0), Some(first));
1477 }
1478}
crates/snowbound/src/macos.rs+1-1
......@@ -116,7 +116,7 @@ pub fn edit_date(
116116 picker.setMinDate(Some(&NSDate::dateWithTimeIntervalSince1970(
117117 -11_644_473_600.0,
118118 )));
119 let label = NSString::from_str(crate::DATE_LABELS[field as usize]);
119 let label = NSString::from_str(canvas::interaction::DATE_LABELS[field as usize]);
120120 let _: () = msg_send![&picker, setAccessibilityLabel: &*label];
121121 picker.sizeToFit();
122122 let alert = NSAlert::new(mtm);
crates/snowbound/src/main.rs+235-3080
......@@ -1,39 +1,29 @@
1mod accessibility;
21mod macos;
3#[cfg(test)]
4mod profile;
5mod scroll;
62
7use canvas::gpu::{Primitive, Renderer, Stroke, Viewport, page::PageScene};
3use canvas::gpu::{Renderer, page::PageScene};
4use canvas::interaction::{
5 Cursor, Key as PageKey, Modifiers, NamedKey as PageNamedKey, PageView, Request, Response,
6 TextColors, accessibility,
7};
88use canvas::{
99 date::DateField,
1010 document::TextDocument,
11 editor::{
12 CanvasEditor, DEFAULT_OUTLINE_WIDTH, Movement, Selection, SelectionUnit, TextOutline,
13 },
11 editor::{CanvasEditor, DEFAULT_OUTLINE_WIDTH, TextOutline},
1412 layout::TextEngine,
1513};
1614use onestore::document::Format;
1715use onestore::page::Page;
1816use onestore::page::text::Paragraph;
19use std::{
20 error::Error,
21 path::PathBuf,
22 sync::Arc,
23 time::{Duration, Instant},
24};
17use std::{error::Error, path::PathBuf, sync::Arc, time::Instant};
2518use winit::{
2619 application::ApplicationHandler,
2720 dpi::{LogicalSize, PhysicalPosition, PhysicalSize},
2821 event::{ElementState, Ime, MouseButton, MouseScrollDelta, WindowEvent},
2922 event_loop::{ActiveEventLoop, ControlFlow, EventLoopProxy},
3023 keyboard::{Key, ModifiersState, NamedKey},
31 window::{Window, WindowId},
24 window::{CursorIcon, Window, WindowId},
3225};
3326
34const HANDLE_HEIGHT: f32 = 6.75;
35const DATE_LABELS: [&str; 2] = ["Page date", "Page time"];
36
3727#[derive(Debug)]
3828enum UserEvent {
3929 Quit,
......@@ -55,11 +45,54 @@ fn trace_input(event: &impl std::fmt::Debug) {
5545 }
5646}
5747
58fn read_only_shortcut(key: &Key, modifiers: ModifiersState) -> bool {
59 key == &Key::Named(NamedKey::Escape)
60 || (modifiers.control_key() && key == &Key::Named(NamedKey::Tab))
61 || (modifiers.super_key()
62 && matches!(key, Key::Character(value) if matches!(value.as_str(), "+" | "=" | "-" | "0") || (modifiers.shift_key() && value.eq_ignore_ascii_case("n"))))
48fn page_key(key: &Key) -> PageKey {
49 match key {
50 Key::Character(text) => PageKey::Character(text.to_string()),
51 Key::Named(named) => PageKey::Named(match named {
52 NamedKey::Escape => PageNamedKey::Escape,
53 NamedKey::Tab => PageNamedKey::Tab,
54 NamedKey::Space => PageNamedKey::Space,
55 NamedKey::Enter => PageNamedKey::Enter,
56 NamedKey::Backspace => PageNamedKey::Backspace,
57 NamedKey::Delete => PageNamedKey::Delete,
58 NamedKey::ArrowLeft => PageNamedKey::ArrowLeft,
59 NamedKey::ArrowRight => PageNamedKey::ArrowRight,
60 NamedKey::ArrowUp => PageNamedKey::ArrowUp,
61 NamedKey::ArrowDown => PageNamedKey::ArrowDown,
62 NamedKey::Home => PageNamedKey::Home,
63 NamedKey::End => PageNamedKey::End,
64 NamedKey::Alt
65 | NamedKey::AltGraph
66 | NamedKey::Control
67 | NamedKey::Shift
68 | NamedKey::Super
69 | NamedKey::Meta => PageNamedKey::Modifier,
70 _ => PageNamedKey::Other,
71 }),
72 _ => PageKey::Named(PageNamedKey::Other),
73 }
74}
75
76fn page_modifiers(modifiers: ModifiersState) -> Modifiers {
77 Modifiers {
78 shift: modifiers.shift_key(),
79 control: modifiers.control_key(),
80 option: modifiers.alt_key(),
81 command: modifiers.super_key(),
82 }
83}
84
85fn cursor_icon(cursor: Cursor) -> CursorIcon {
86 match cursor {
87 Cursor::Default => CursorIcon::Default,
88 Cursor::Text => CursorIcon::Text,
89 Cursor::Pointer => CursorIcon::Pointer,
90 Cursor::Move => CursorIcon::Move,
91 Cursor::EwResize => CursorIcon::EwResize,
92 Cursor::NsResize => CursorIcon::NsResize,
93 Cursor::NwseResize => CursorIcon::NwseResize,
94 Cursor::NeswResize => CursorIcon::NeswResize,
95 }
6396}
6497
6598struct App {
......@@ -105,103 +138,25 @@ impl Session {
105138 }
106139}
107140
108enum Drag {
109 Text {
110 anchor: Selection,
111 unit: SelectionUnit,
112 },
113 Scrollbar {
114 axis: usize,
115 grab: f32,
116 },
117 Resize {
118 outline: Option<Box<TextOutline>>,
119 grab: f32,
120 },
121 Outline {
122 id: onestore::ExGuid,
123 grab: [f32; 2],
124 pending_press: Option<[f32; 2]>,
125 },
126 Image {
127 id: onestore::ExGuid,
128 handle: [i8; 2],
129 /// Document point of the press.
130 press: [f32; 2],
131 pending_press: Option<[f32; 2]>,
132 },
133}
134
135#[derive(Clone, Copy)]
136enum PointerFeedback<'a> {
137 Hover(onestore::ExGuid),
138 Move(onestore::ExGuid, [f32; 2]),
139 Resize(&'a TextOutline),
140 /// A picture being moved or resized, drawn at this origin and size.
141 Image(onestore::ExGuid, [f32; 2], [f32; 2]),
142}
143
144/// A non-text object holding focus, which hides the text caret and suspends typing.
145#[derive(Clone, Copy, Debug, PartialEq)]
146enum ObjectFocus {
147 ReadOnly(usize),
148 Image(onestore::ExGuid),
149}
150
151impl ObjectFocus {
152 fn read_only(self) -> Option<usize> {
153 match self {
154 Self::ReadOnly(index) => Some(index),
155 Self::Image(_) => None,
156 }
157 }
158}
159
160141struct State {
161142 window: Arc<Window>,
162143 instance: wgpu::Instance,
163144 surface: wgpu::Surface<'static>,
164145 config: wgpu::SurfaceConfiguration,
165146 renderer: Renderer,
166 engine: TextEngine,
167 editor: CanvasEditor,
147 view: PageView,
168148 session: Option<Session>,
169149 initial: Vec<(onestore::ExGuid, TextDocument)>,
170150 initial_layouts: Vec<(onestore::ExGuid, onestore::document::Layout)>,
171151 initial_date: Option<u64>,
172 scene: Option<(PageScene, [f32; 2])>,
173 viewport: Viewport,
174 display_scale: f32,
175 pointer: [f32; 2],
176 pointer_inside: bool,
177 last_click: Option<(Instant, [f32; 2], u8)>,
178 drag: Option<Drag>,
179 object_focus: Option<ObjectFocus>,
180 modifiers: ModifiersState,
181 focused: bool,
182152 occluded: bool,
183 caret: bool,
184 blink_at: Instant,
153 ime_allowed: bool,
185154 clipboard: arboard::Clipboard,
186155 access_adapter: accesskit_winit::Adapter,
187156 accessibility: accessibility::Accessibility,
188157}
189158
190159impl State {
191 fn edit_date(&mut self, field: DateField) -> Result<(), Box<dyn Error>> {
192 let Some(date) = self.editor.date() else {
193 return Ok(());
194 };
195 let timestamp = date.timestamp();
196 self.editor.finish_composition();
197 macos::clear_marked_text(&self.window);
198 self.drag = None;
199 if let Some((timestamp, text)) = macos::edit_date(timestamp, field)? {
200 self.editor.change_date(&mut self.engine, timestamp, text)?;
201 }
202 self.changed()
203 }
204
205160 async fn new(
206161 event_loop: &ActiveEventLoop,
207162 proxy: EventLoopProxy<UserEvent>,
......@@ -309,7 +264,7 @@ impl State {
309264 .collect();
310265 let dpr = window.scale_factor() as f32;
311266 window.set_ime_allowed(true);
312 window.set_cursor(winit::window::CursorIcon::Text);
267 window.set_cursor(CursorIcon::Text);
313268 window.request_redraw();
314269 eprintln!("Canvas GPU: {:?}; scale factor {dpr}", adapter.get_info());
315270 Ok(Self {
......@@ -318,187 +273,99 @@ impl State {
318273 surface,
319274 config,
320275 renderer,
321 engine,
322 editor,
276 view: PageView::new(
277 editor,
278 engine,
279 scene,
280 [size.width, size.height],
281 dpr,
282 macos::double_click_interval(),
283 ),
323284 session,
324285 initial,
325286 initial_date,
326287 initial_layouts,
327 scene,
328 viewport: Viewport {
329 size: [size.width, size.height],
330 scale: dpr * 96.0 / 72.0,
331 origin: [48.0 * dpr; 2],
332 },
333 display_scale: dpr,
334 pointer: [0.0; 2],
335 pointer_inside: false,
336 last_click: None,
337 drag: None,
338 object_focus: None,
339 modifiers: ModifiersState::empty(),
340 focused: true,
341288 occluded: false,
342 caret: true,
343 blink_at: Instant::now() + Duration::from_millis(500),
289 ime_allowed: true,
344290 clipboard: arboard::Clipboard::new()?,
345291 access_adapter,
346292 accessibility: accessibility::Accessibility::default(),
347293 })
348294 }
349295
350 fn preview(&self) -> Option<(onestore::ExGuid, [f32; 2])> {
351 let Drag::Outline {
352 id,
353 grab,
354 pending_press,
355 } = self.drag.as_ref()?
356 else {
357 return None;
358 };
359 let origin = self
360 .editor
361 .outlines()
362 .iter()
363 .find(|outline| outline.id == *id)?
364 .origin();
365 if pending_press.is_some() {
366 return Some((*id, origin));
296 /// Carries out what a page event asked of the platform and follows its changes: the
297 /// input method's position, accessibility, saving, the window title and a repaint.
298 fn respond(&mut self, response: Response) -> Result<(), Box<dyn Error>> {
299 match response.request {
300 Some(Request::EditDate(field)) => self.edit_date(field)?,
301 Some(Request::Copy(text)) => self.clipboard.set_text(text)?,
302 Some(Request::Paste) => {
303 let text = self.clipboard.get_text()?;
304 let response = self.view.commit_text(text)?;
305 self.respond(response)?;
306 }
307 Some(Request::CharacterPalette) => macos::show_character_palette(),
308 None => {}
309 }
310 if self.ime_allowed != self.view.accepts_text() {
311 self.ime_allowed = self.view.accepts_text();
312 self.window.set_ime_allowed(self.ime_allowed);
313 }
314 self.window.set_cursor(cursor_icon(self.view.cursor()));
315 if response.changed {
316 let [x0, y0, x1, y1] = self.view.caret_area()?;
317 self.window.set_ime_cursor_area(
318 PhysicalPosition::new(x0, y0),
319 PhysicalSize::new(x1 - x0, y1 - y0),
320 );
321 self.update_accessibility()?;
322 self.persist()?;
323 self.title_from_page();
367324 }
368 let point = self.viewport.document_point(self.pointer);
369 let position = [point[0] - grab[0], point[1] - grab[1]];
370 Some((
371 *id,
372 if self.modifiers.alt_key() {
373 position
374 } else {
375 snap_to_grid(position, self.editor.margin_origin())
376 },
377 ))
325 if response.redraw {
326 self.window.request_redraw();
327 }
328 Ok(())
378329 }
379330
380 fn image_preview(&self) -> Option<(onestore::ExGuid, [f32; 2], [f32; 2])> {
381 let Drag::Image {
382 id,
383 handle,
384 press,
385 pending_press: None,
386 } = self.drag.as_ref()?
387 else {
388 return None;
389 };
390 let (origin, size) = self.editor.image_placement(*id)?;
391 let point = self.viewport.document_point(self.pointer);
392 let delta = [point[0] - press[0], point[1] - press[1]];
393 if *handle != [0, 0] {
394 let (origin, size) = resize_image(origin, size, *handle, delta);
395 return Some((*id, origin, size));
396 }
397 // A picture in an outline's flow keeps its place.
398 if self.editor.image_in_outline(*id) {
399 return Some((*id, origin, size));
331 /// A temporary page's window takes its title's first line.
332 fn title_from_page(&self) {
333 let editor = &self.view.editor;
334 if self.session.is_some() || !editor.active_outline().title {
335 return;
400336 }
401 let origin = [origin[0] + delta[0], origin[1] + delta[1]];
402 let origin = if self.modifiers.alt_key() {
403 origin
337 let title = editor
338 .active_outline()
339 .document()
340 .paragraphs()
341 .next()
342 .unwrap()
343 .text()
344 .split(['\u{000b}', '\n', '\r'])
345 .next()
346 .unwrap();
347 let title = if title.trim().is_empty() {
348 "Untitled"
404349 } else {
405 snap_to_grid(origin, self.editor.margin_origin())
350 title
406351 };
407 Some((*id, origin, size))
408 }
409
410 /// The selected picture's handles lie above the page; its body keeps its paint order.
411 fn hit_test(&self, point: [f32; 2]) -> Option<Hit> {
412 let pixel = self.display_scale / self.viewport.scale;
413 if let Some(ObjectFocus::Image(id)) = self.object_focus
414 && let Some((origin, size)) = self.editor.image_placement(id)
415 && let Some(handle) = image_handle_at(image_rect(origin, size), pixel, point)
416 {
417 return Some(Hit::Image { id, handle });
352 let title = format!("{title} · Temporary page");
353 if self.window.title() != title {
354 self.window.set_title(&title);
418355 }
419 page_hit_test(&self.editor, self.scene.as_ref(), point, pixel)
420356 }
421357
422 fn set_object_focus(&mut self, focus: Option<ObjectFocus>) {
423 if self.object_focus != focus {
424 self.editor.finish_composition();
425 self.drag = None;
426 self.object_focus = focus;
427 self.window.set_ime_allowed(focus.is_none());
428 }
429 }
430
431 /// Page coordinates of a focused object.
432 fn object_rect(&self, focus: ObjectFocus) -> [f32; 4] {
433 let (scene, offset) = self.scene.as_ref().unwrap();
434 let [x0, y0, x1, y1] = match focus {
435 ObjectFocus::ReadOnly(index) => scene
436 .read_only(Some(&self.editor))
437 .nth(index)
438 .unwrap()
439 .rect(),
440 ObjectFocus::Image(id) => {
441 let (origin, size) = self.editor.image_placement(id).unwrap();
442 image_rect(origin, size)
443 }
358 fn edit_date(&mut self, field: DateField) -> Result<(), Box<dyn Error>> {
359 let Some(date) = self.view.editor.date() else {
360 return Ok(());
444361 };
445 [
446 x0 + offset[0],
447 y0 + offset[1],
448 x1 + offset[0],
449 y1 + offset[1],
450 ]
451 }
452
453 fn changed(&mut self) -> Result<(), Box<dyn Error>> {
454 self.scroll().clamp(&mut self.viewport);
455 self.caret = true;
456 self.blink_at = Instant::now() + Duration::from_millis(500);
457 let mut rect = self.editor.caret(1.0)?;
458 let origin = self
459 .preview()
460 .map(|(_, origin)| origin)
461 .unwrap_or_else(|| self.editor.active_outline().origin());
462 rect.x0 += f64::from(origin[0]);
463 rect.y0 += f64::from(origin[1]);
464 rect.x1 += f64::from(origin[0]);
465 rect.y1 += f64::from(origin[1]);
466 let v = self.viewport;
467 self.window.set_ime_cursor_area(
468 PhysicalPosition::new(
469 rect.x0 * f64::from(v.scale) + f64::from(v.origin[0]),
470 rect.y0 * f64::from(v.scale) + f64::from(v.origin[1]),
471 ),
472 PhysicalSize::new(
473 (rect.width() * f64::from(v.scale)).max(1.0),
474 (rect.height() * f64::from(v.scale)).max(1.0),
475 ),
476 );
477 self.update_accessibility()?;
478 self.persist()?;
479 if self.session.is_none() && self.editor.active_outline().title {
480 let title = self
481 .editor
482 .active_outline()
483 .document()
484 .paragraphs()
485 .next()
486 .unwrap()
487 .text()
488 .split(['\u{000b}', '\n', '\r'])
489 .next()
490 .unwrap();
491 let title = if title.trim().is_empty() {
492 "Untitled"
493 } else {
494 title
495 };
496 let title = format!("{title} · Temporary page");
497 if self.window.title() != title {
498 self.window.set_title(&title);
499 }
362 let timestamp = date.timestamp();
363 self.view.editor.finish_composition();
364 macos::clear_marked_text(&self.window);
365 if let Some((timestamp, text)) = macos::edit_date(timestamp, field)? {
366 let response = self.view.change_date(timestamp, text)?;
367 self.respond(response)?;
500368 }
501 self.window.request_redraw();
502369 Ok(())
503370 }
504371
......@@ -508,7 +375,7 @@ impl State {
508375 let Some(session) = &mut self.session else {
509376 return Ok(());
510377 };
511 let after = self.editor.page()?;
378 let after = self.view.editor.page()?;
512379 match session
513380 .section
514381 .save(session.space, &session.before, &after, "snowbound")?
......@@ -530,12 +397,9 @@ impl State {
530397 return Ok(());
531398 };
532399 let page = session.section.page(session.space)?;
533 let (scene, editor) = PageScene::from_page(page.clone(), &mut self.engine)?;
400 let (scene, editor) = PageScene::from_page(page.clone(), &mut self.view.engine)?;
534401 session.before = page;
535 self.editor = editor;
536 self.scene = Some((scene, [0.0; 2]));
537 self.drag = None;
538 self.object_focus = None;
402 self.view.replace(editor, Some((scene, [0.0; 2])));
539403 self.window.set_title(&session.window_title());
540404 self.update_accessibility()?;
541405 self.window.request_redraw();
......@@ -557,7 +421,7 @@ impl State {
557421 return Ok(());
558422 };
559423 let reviewed = if keep_mine {
560 self.editor.page()?
424 self.view.editor.page()?
561425 } else {
562426 session.section.remote_page(session.space)?
563427 };
......@@ -610,21 +474,21 @@ impl State {
610474
611475 fn update_accessibility(&mut self) -> Result<(), Box<dyn Error>> {
612476 let mut error = None;
613 let preview = self.preview();
477 let view = &self.view;
614478 self.access_adapter.update_if_active(|| {
615479 match self.accessibility.update(
616 &self.editor,
617 self.viewport,
480 &view.editor,
481 view.viewport,
618482 &self.window.title(),
619 preview,
483 view.outline_preview(),
620484 ) {
621485 Ok(mut update) => {
622486 self.accessibility.append_page_fields(
623487 &mut update,
624 self.scene.as_ref(),
625 &self.editor,
626 self.viewport,
627 self.object_focus.and_then(ObjectFocus::read_only),
488 view.scene.as_ref(),
489 &view.editor,
490 view.viewport,
491 view.object_focus().and_then(|focus| focus.read_only()),
628492 );
629493 update
630494 }
......@@ -662,138 +526,46 @@ impl State {
662526 }
663527 if let Some(index) = self.accessibility.read_only_for_node(request.target_node) {
664528 if request.action == Action::Focus {
665 self.set_object_focus(Some(ObjectFocus::ReadOnly(index)));
666529 self.window.focus_window();
667 self.reveal_focus()?;
668 self.changed()?;
530 let response = self.view.focus_read_only(index)?;
531 self.respond(response)?;
669532 }
670533 return Ok(());
671534 }
672535 let Some(outline) = self.accessibility.outline_for_node(request.target_node) else {
673536 return Ok(());
674537 };
538 let view = &mut self.view;
675539 match (request.action, request.data) {
676540 (Action::Focus, _) => {
677 self.editor.focus_outline(outline)?;
541 view.editor.focus_outline(outline)?;
678542 self.window.focus_window();
679543 }
680544 (Action::SetTextSelection, Some(ActionData::SetTextSelection(selection))) => {
681545 let selection = self.accessibility.selection(outline, selection)?;
682 self.editor.focus_outline(outline)?;
683 self.editor.select(selection)?;
684 trace_input(&self.editor.selection());
546 view.editor.focus_outline(outline)?;
547 view.editor.select(selection)?;
548 trace_input(&view.editor.selection());
685549 }
686550 (Action::SetValue, Some(ActionData::Value(text))) => {
687 self.editor.focus_outline(outline)?;
688 self.editor.select_all()?;
689 self.editor.commit_text(&mut self.engine, text.into())?;
551 view.editor.focus_outline(outline)?;
552 view.editor.select_all()?;
553 view.editor.commit_text(&mut view.engine, text.into())?;
690554 }
691555 (Action::ReplaceSelectedText, Some(ActionData::Value(text))) => {
692 self.editor.focus_outline(outline)?;
693 self.editor.commit_text(&mut self.engine, text.into())?;
556 view.editor.focus_outline(outline)?;
557 view.editor.commit_text(&mut view.engine, text.into())?;
694558 }
695559 _ => return Ok(()),
696560 }
697 self.set_object_focus(None);
698 self.reveal_focus()?;
699 self.changed()
700 }
701
702 fn reveal_focus(&mut self) -> Result<(), Box<dyn Error>> {
703 if self.viewport.size.contains(&0) {
704 return Ok(());
705 }
706 let rect = if let Some(focus) = self.object_focus {
707 let [x0, y0, x1, y1] = self.object_rect(focus).map(f64::from);
708 parley::BoundingBox { x0, y0, x1, y1 }
709 } else {
710 let mut rect = self.editor.caret(1.0)?;
711 let origin = self.editor.active_outline().origin();
712 rect.x0 += f64::from(origin[0]);
713 rect.x1 += f64::from(origin[0]);
714 rect.y0 += f64::from(origin[1]);
715 rect.y1 += f64::from(origin[1]);
716 rect
717 };
718 let outline = self.editor.active_outline().bounds();
719 for (axis, (mut start, mut end)) in [(rect.x0, rect.x1), (rect.y0, rect.y1)]
720 .into_iter()
721 .enumerate()
722 {
723 let size = f64::from(self.viewport.size[axis]);
724 let margin = f64::from(16.0 * self.display_scale).min(size * 0.25);
725 let (outline_start, outline_end) =
726 [(outline.x0, outline.x1), (outline.y0, outline.y1)][axis];
727 let outline_start = outline_start.min(start);
728 let outline_end = outline_end.max(end);
729 if self.object_focus.is_none()
730 && (outline_end - outline_start) * f64::from(self.viewport.scale)
731 <= size - margin * 2.0
732 {
733 start = outline_start;
734 end = outline_end;
735 }
736 let start =
737 start * f64::from(self.viewport.scale) + f64::from(self.viewport.origin[axis]);
738 let end = end * f64::from(self.viewport.scale) + f64::from(self.viewport.origin[axis]);
739 let shift = if start < margin {
740 margin - start
741 } else if end > size - margin {
742 size - margin - end
743 } else {
744 0.0
745 };
746 self.viewport.origin[axis] += shift as f32;
747 }
748 Ok(())
749 }
750
751 fn zoom(&mut self, factor: f32) {
752 let point = self.viewport.document_point(self.pointer);
753 let dpr = self.window.scale_factor() as f32;
754 self.viewport.scale = (self.viewport.scale * factor).clamp(dpr / 3.0, dpr * 16.0 / 3.0);
755 self.viewport.origin = [
756 self.pointer[0] - point[0] * self.viewport.scale,
757 self.pointer[1] - point[1] * self.viewport.scale,
758 ];
759 }
760
761 fn scroll(&self) -> scroll::Scroll {
762 let editable = self
763 .editor
764 .visible_outlines()
765 .chain(self.editor.caret_outline())
766 .map(|outline| {
767 let rect = outline.bounds();
768 let offset = self
769 .scene
770 .as_ref()
771 .filter(|_| self.editor.has_page_outline(outline.id))
772 .map(|(_, offset)| *offset)
773 .unwrap_or([0.0; 2]);
774 [
775 rect.x0 as f32 + offset[0],
776 rect.y0 as f32 + offset[1],
777 rect.x1 as f32 + offset[0],
778 rect.y1 as f32 + offset[1],
779 ]
780 });
781 let fixed = self.scene.iter().flat_map(|(scene, offset)| {
782 scene.content_bounds(&self.editor).map(|rect| {
783 [
784 rect[0] + offset[0],
785 rect[1] + offset[1],
786 rect[2] + offset[0],
787 rect[3] + offset[1],
788 ]
789 })
790 });
791 scroll::Scroll::new(self.viewport, editable.chain(fixed))
561 let response = self.view.focus_text()?;
562 self.respond(response)
792563 }
793564
794565 fn draw(&mut self) -> Result<(), Box<dyn Error>> {
795 trace_input(&("Draw", self.viewport.origin, self.viewport.scale));
796 if self.occluded || self.viewport.size.contains(&0) {
566 let viewport = self.view.viewport;
567 trace_input(&("Draw", viewport.origin, viewport.scale));
568 if self.occluded || viewport.size.contains(&0) {
797569 return Ok(());
798570 }
799571 let mut reconfigure = false;
......@@ -827,56 +599,12 @@ impl State {
827599 return Err("Canvas surface validation failed".into());
828600 }
829601 };
830 let mut primitives = page_primitives(
831 &self.editor,
832 self.scene.as_ref(),
833 match &self.drag {
834 Some(Drag::Resize {
835 outline: Some(outline),
836 ..
837 }) => Some(PointerFeedback::Resize(outline)),
838 _ => self
839 .preview()
840 .map(|(id, origin)| PointerFeedback::Move(id, origin))
841 .or_else(|| {
842 self.image_preview()
843 .map(|(id, origin, size)| PointerFeedback::Image(id, origin, size))
844 })
845 .or_else(|| {
846 if !self.pointer_inside {
847 return None;
848 }
849 match page_hit_test(
850 &self.editor,
851 self.scene.as_ref(),
852 self.viewport.document_point(self.pointer),
853 self.display_scale / self.viewport.scale,
854 ) {
855 Some(
856 Hit::Text { id, .. }
857 | Hit::Handle { id, .. }
858 | Hit::Resize { id, .. },
859 ) => Some(PointerFeedback::Hover(id)),
860 _ => None,
861 }
862 }),
863 },
864 self.object_focus,
865 self.caret
866 && self.focused
867 && !matches!(
868 self.drag,
869 Some(Drag::Outline { .. } | Drag::Resize { .. } | Drag::Image { .. })
870 ),
871 self.viewport.scale,
872 self.display_scale,
873 )?;
874 self.scroll()
875 .append(self.viewport, self.display_scale, &mut primitives);
602 let [caret, selection] = macos::text_colors();
603 let primitives = self.view.primitives(TextColors { caret, selection })?;
876604 self.renderer
877605 .draw(
878606 &frame.texture.create_view(&Default::default()),
879 self.viewport,
607 viewport,
880608 &primitives,
881609 )
882610 .map_err(|error| format!("Canvas drawing failed: {error:?}"))?;
......@@ -890,353 +618,42 @@ impl State {
890618 }
891619
892620 fn key(&mut self, key: &Key, text: Option<&str>) -> Result<(), Box<dyn Error>> {
893 let shift = self.modifiers.shift_key();
894 let command = self.modifiers.super_key();
895 let option = self.modifiers.alt_key();
896 if let Some(ObjectFocus::Image(id)) = self.object_focus
897 && matches!(key, Key::Named(NamedKey::Backspace | NamedKey::Delete))
898 {
899 self.editor.remove_image(&mut self.engine, id)?;
900 self.set_object_focus(None);
901 return self.changed();
902 }
903 if let Some(ObjectFocus::Image(id)) = self.object_focus
904 && !(shift || command || option || self.modifiers.control_key())
905 && let Key::Named(NamedKey::ArrowLeft | NamedKey::ArrowRight) = key
906 && self.editor.step_from_image(
907 &mut self.engine,
908 id,
909 key == &Key::Named(NamedKey::ArrowRight),
910 )?
621 let modifiers = self.view.modifiers();
622 if modifiers.command
623 && modifiers.shift
624 && self.session.is_some()
625 && self.view.accepts_text()
626 && let Key::Character(character) = key
911627 {
912 self.set_object_focus(None);
913 self.reveal_focus()?;
914 return self.changed();
915 }
916 if let Some(focus) = self.object_focus {
917 let undo = matches!(focus, ObjectFocus::Image(_))
918 && command
919 && matches!(key, Key::Character(value) if value.eq_ignore_ascii_case("z"));
920 if !undo && !read_only_shortcut(key, self.modifiers) {
921 return Ok(());
922 }
923 if key == &Key::Named(NamedKey::Escape) {
924 self.set_object_focus(None);
925 self.reveal_focus()?;
926 return self.changed();
927 }
928 }
929 if command && self.modifiers.control_key() && key == &Key::Named(NamedKey::Space) {
930 macos::show_character_palette();
931 return Ok(());
932 }
933 if command && shift && self.session.is_some() {
934 match key {
935 Key::Character(character) if character.eq_ignore_ascii_case("k") => {
936 return self.resolve_conflict(true);
937 }
938 Key::Character(character) if character.eq_ignore_ascii_case("t") => {
939 return self.resolve_conflict(false);
940 }
941 _ => {}
942 }
943 }
944 if matches!(
945 self.drag,
946 Some(Drag::Outline { .. } | Drag::Resize { .. } | Drag::Image { .. })
947 ) {
948 if matches!(
949 key,
950 Key::Named(
951 NamedKey::Alt
952 | NamedKey::AltGraph
953 | NamedKey::Control
954 | NamedKey::Shift
955 | NamedKey::Super
956 | NamedKey::Meta
957 )
958 ) {
959 return Ok(());
960 }
961 self.drag = None;
962 if key == &Key::Named(NamedKey::Escape) {
963 return self.changed();
964 }
965 }
966 if command && option {
967 let delta = match key {
968 Key::Named(NamedKey::ArrowLeft) => Some([-1.0, 0.0]),
969 Key::Named(NamedKey::ArrowRight) => Some([1.0, 0.0]),
970 Key::Named(NamedKey::ArrowUp) => Some([0.0, -1.0]),
971 Key::Named(NamedKey::ArrowDown) => Some([0.0, 1.0]),
972 _ => None,
973 };
974 if let Some(delta) = delta {
975 let outline = self.editor.active_outline();
976 let origin = outline.origin();
977 let step = if shift { 10.0 } else { 1.0 };
978 self.editor.move_outline(
979 outline.id,
980 [origin[0] + delta[0] * step, origin[1] + delta[1] * step],
981 )?;
982 self.reveal_focus()?;
983 return self.changed();
984 }
985 }
986 if self.modifiers.control_key() && key == &Key::Named(NamedKey::Tab) {
987 let outlines = self.editor.outlines();
988 let count = outlines.len()
989 + self
990 .scene
991 .as_ref()
992 .map_or(0, |(scene, _)| scene.read_only(Some(&self.editor)).count());
993 if count == 0 {
994 return self.changed();
995 }
996 let index = self
997 .object_focus
998 .and_then(ObjectFocus::read_only)
999 .map_or_else(
1000 || {
1001 outlines
1002 .iter()
1003 .position(|outline| outline.id == self.editor.active_outline().id)
1004 },
1005 |index| Some(outlines.len() + index),
1006 );
1007 let next = index.map_or(if shift { count - 1 } else { 0 }, |index| {
1008 if shift {
1009 (index + count - 1) % count
1010 } else {
1011 (index + 1) % count
1012 }
1013 });
1014 if next < outlines.len() {
1015 self.editor.focus_outline(outlines[next].id)?;
1016 self.set_object_focus(None);
1017 } else {
1018 self.set_object_focus(Some(ObjectFocus::ReadOnly(next - outlines.len())));
1019 }
1020 self.reveal_focus()?;
1021 return self.changed();
1022 }
1023 if command && let Key::Character(key) = key {
1024 match key.to_lowercase().as_str() {
1025 "n" if shift => {
1026 let position = if let Some(focus) = self.object_focus {
1027 let rect = self.object_rect(focus);
1028 [rect[2] + 24.0, rect[1]]
1029 } else {
1030 let bounds = self.editor.active_outline().bounds();
1031 [bounds.x1 as f32 + 24.0, bounds.y0 as f32]
1032 };
1033 self.editor.place_caret(
1034 &mut self.engine,
1035 snap_to_grid(position, self.editor.margin_origin()),
1036 DEFAULT_OUTLINE_WIDTH,
1037 )?;
1038 self.set_object_focus(None);
1039 }
1040 "a" => self.editor.select_all()?,
1041 "z" => {
1042 if shift {
1043 self.editor.redo(&mut self.engine)?;
1044 } else {
1045 self.editor.undo(&mut self.engine)?;
1046 }
1047 if let Some(ObjectFocus::Image(id)) = self.object_focus
1048 && self.editor.image_placement(id).is_none()
1049 {
1050 self.set_object_focus(None);
1051 }
1052 }
1053 "c" | "x" => {
1054 let [anchor, focus] = self.editor.selection().positions;
1055 let selected = self
1056 .editor
1057 .active_outline()
1058 .document()
1059 .slice(anchor.min(focus)..anchor.max(focus))?;
1060 let text = selected
1061 .iter()
1062 .map(|paragraph| {
1063 paragraph
1064 .project()
1065 .map(|projection| projection.text().text().to_owned())
1066 })
1067 .collect::<Result<Vec<_>, _>>()?
1068 .join("\n");
1069 if !text.is_empty() {
1070 self.clipboard.set_text(text)?;
1071 if key.eq_ignore_ascii_case("x") {
1072 self.editor.insert(&mut self.engine, "")?;
1073 }
1074 }
1075 }
1076 "v" => {
1077 let text = self.clipboard.get_text()?;
1078 self.editor.commit_text(&mut self.engine, text)?;
1079 }
1080 "+" | "=" => {
1081 self.zoom(1.1);
1082 return self.changed();
1083 }
1084 "-" => {
1085 self.zoom(1.0 / 1.1);
1086 return self.changed();
1087 }
1088 "0" => {
1089 self.zoom((self.display_scale * 96.0 / 72.0) / self.viewport.scale);
1090 return self.changed();
1091 }
1092 _ => return Ok(()),
628 if character.eq_ignore_ascii_case("k") {
629 return self.resolve_conflict(true);
1093630 }
1094 } else {
1095 let movement = match key {
1096 Key::Named(NamedKey::ArrowLeft) => Some(if command {
1097 Movement::LineStart
1098 } else if option {
1099 Movement::WordLeft
1100 } else {
1101 Movement::Left
1102 }),
1103 Key::Named(NamedKey::ArrowRight) => Some(if command {
1104 Movement::LineEnd
1105 } else if option {
1106 Movement::WordRight
1107 } else {
1108 Movement::Right
1109 }),
1110 Key::Named(NamedKey::ArrowUp) => Some(if command {
1111 Movement::DocumentStart
1112 } else if option {
1113 Movement::ParagraphStart
1114 } else {
1115 Movement::Up
1116 }),
1117 Key::Named(NamedKey::ArrowDown) => Some(if command {
1118 Movement::DocumentEnd
1119 } else if option {
1120 Movement::ParagraphEnd
1121 } else {
1122 Movement::Down
1123 }),
1124 Key::Named(NamedKey::Home) if shift => Some(Movement::DocumentStart),
1125 Key::Named(NamedKey::End) if shift => Some(Movement::DocumentEnd),
1126 Key::Character(key) if self.modifiers.control_key() && !option => {
1127 match key.as_str() {
1128 "a" => Some(Movement::LineStart),
1129 "e" => Some(Movement::LineEnd),
1130 "b" => Some(Movement::Left),
1131 "f" => Some(Movement::Right),
1132 "p" => Some(Movement::Up),
1133 "n" => Some(Movement::Down),
1134 _ => None,
1135 }
1136 }
1137 _ => None,
1138 };
1139 if let Some(movement) = movement {
1140 self.editor
1141 .move_selection(&mut self.engine, movement, shift)?;
1142 } else if self.editor.marked_range().is_none() {
1143 match key {
1144 Key::Named(NamedKey::Backspace) if command || option => {
1145 self.editor.delete_to(
1146 &mut self.engine,
1147 if command {
1148 Movement::LineStart
1149 } else {
1150 Movement::WordLeft
1151 },
1152 )?;
1153 }
1154 Key::Named(NamedKey::Delete) if command || option => {
1155 self.editor.delete_to(
1156 &mut self.engine,
1157 if command {
1158 Movement::LineEnd
1159 } else {
1160 Movement::WordRight
1161 },
1162 )?;
1163 }
1164 Key::Named(NamedKey::Backspace) => {
1165 self.editor.delete(&mut self.engine, true)?;
1166 }
1167 Key::Named(NamedKey::Delete) => {
1168 self.editor.delete(&mut self.engine, false)?;
1169 }
1170 Key::Named(NamedKey::Home | NamedKey::End) => {
1171 let limits = self.scroll();
1172 self.viewport.origin[1] = -if key == &Key::Named(NamedKey::Home) {
1173 limits.min[1]
1174 } else {
1175 limits.max[1]
1176 };
1177 return self.changed();
1178 }
1179 Key::Character(key) if self.modifiers.control_key() && !option => {
1180 match key.as_str() {
1181 "h" => {
1182 self.editor.delete(&mut self.engine, true)?;
1183 }
1184 "d" => {
1185 self.editor.delete(&mut self.engine, false)?;
1186 }
1187 "k" if !self
1188 .editor
1189 .delete_to(&mut self.engine, Movement::LineEnd)? =>
1190 {
1191 self.editor.delete(&mut self.engine, false)?;
1192 }
1193 _ => {}
1194 }
1195 }
1196 Key::Named(NamedKey::Enter) => {
1197 self.editor.enter(&mut self.engine, shift)?;
1198 }
1199 Key::Named(NamedKey::Tab) => {
1200 self.editor.tab(&mut self.engine, shift)?;
1201 }
1202 _ if !command && !self.modifiers.control_key() => {
1203 if let Some(text) = text
1204 .filter(|text| !text.is_empty() && !text.chars().any(char::is_control))
1205 {
1206 self.editor.insert(&mut self.engine, text)?;
1207 }
1208 }
1209 _ => {}
1210 }
1211 } else if key == &Key::Named(NamedKey::Escape) {
1212 self.editor.cancel_composition(&mut self.engine)?;
631 if character.eq_ignore_ascii_case("t") {
632 return self.resolve_conflict(false);
1213633 }
1214634 }
1215 self.reveal_focus()?;
1216 self.changed()
635 let response = self.view.key(&page_key(key), text)?;
636 self.respond(response)
1217637 }
1218638}
1219639
1220640impl App {
1221641 fn close(&self, event_loop: &ActiveEventLoop) {
1222642 if self.state.as_ref().is_none_or(|state| {
643 let editor = &state.view.editor;
1223644 state.session.is_some()
1224 || state
1225 .editor
1226 .caret_outline()
1227 .is_none_or(TextOutline::is_empty)
1228 && state.initial_date == state.editor.date().map(|date| date.timestamp())
645 || editor.caret_outline().is_none_or(TextOutline::is_empty)
646 && state.initial_date == editor.date().map(|date| date.timestamp())
1229647 && state
1230648 .initial_layouts
1231649 .iter()
1232650 .map(|(id, layout)| (*id, layout))
1233 .eq(state.editor.object_layouts())
651 .eq(editor.object_layouts())
1234652 && state
1235653 .initial
1236654 .iter()
1237655 .map(|(id, document)| (id, document))
1238 .eq(state
1239 .editor
656 .eq(editor
1240657 .outlines()
1241658 .iter()
1242659 .map(|outline| (&outline.id, outline.document())))
......@@ -1251,16 +668,11 @@ impl ApplicationHandler<UserEvent> for App {
1251668 fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) {
1252669 let event = match event {
1253670 UserEvent::InsertText(text) => {
1254 if let Some(state) = &mut self.state
1255 && state.object_focus.is_none()
1256 && !text.is_empty()
1257 && !text.chars().any(char::is_control)
1258 {
1259 let result = (|| -> Result<(), Box<dyn Error>> {
1260 state.editor.commit_text(&mut state.engine, text)?;
1261 state.reveal_focus()?;
1262 state.changed()
1263 })();
671 if let Some(state) = &mut self.state {
672 let result = state
673 .view
674 .insert_text(text)
675 .and_then(|response| state.respond(response));
1264676 if let Err(error) = result {
1265677 eprintln!("{error}");
1266678 }
......@@ -1287,7 +699,7 @@ impl ApplicationHandler<UserEvent> for App {
1287699 if event.window_id != state.window.id() {
1288700 return;
1289701 }
1290 let was_marked = state.editor.marked_range().is_some();
702 let was_marked = state.view.editor.marked_range().is_some();
1291703 let result = match event.window_event {
1292704 accesskit_winit::WindowEvent::InitialTreeRequested => state.update_accessibility(),
1293705 accesskit_winit::WindowEvent::ActionRequested(request) => state.access_action(request),
......@@ -1296,7 +708,7 @@ impl ApplicationHandler<UserEvent> for App {
1296708 Ok(())
1297709 }
1298710 };
1299 if was_marked && state.editor.marked_range().is_none() {
711 if was_marked && state.view.editor.marked_range().is_none() {
1300712 macos::clear_marked_text(&state.window);
1301713 }
1302714 if let Err(error) = result {
......@@ -1345,333 +757,82 @@ impl ApplicationHandler<UserEvent> for App {
1345757 return;
1346758 };
1347759 let was_marked =
1348 !matches!(event, WindowEvent::Ime(_)) && state.editor.marked_range().is_some();
760 !matches!(event, WindowEvent::Ime(_)) && state.view.editor.marked_range().is_some();
1349761 let result = (|| -> Result<(), Box<dyn Error>> {
1350 match event {
1351 WindowEvent::RedrawRequested => state.draw()?,
762 let response = match event {
763 WindowEvent::RedrawRequested => return state.draw(),
1352764 WindowEvent::Resized(size) => {
1353 state.viewport.size = [size.width, size.height];
1354765 if size.width > 0 && size.height > 0 {
1355766 state.config.width = size.width;
1356767 state.config.height = size.height;
1357768 state
1358769 .surface
1359770 .configure(&state.renderer.device, &state.config);
1360 state.changed()?;
1361 // Present inside AppKit's resize transaction; a redraw on the next turn
1362 // lets the window show the previous frame at the new size.
771 }
772 let response = state.view.resized([size.width, size.height])?;
773 let changed = response.changed;
774 state.respond(response)?;
775 // Present inside AppKit's resize transaction; a redraw on the next turn
776 // lets the window show the previous frame at the new size.
777 if changed {
1363778 state.draw()?;
1364779 }
780 return Ok(());
1365781 }
1366782 WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
1367 let scale = scale_factor as f32;
1368 let ratio = scale / state.display_scale;
1369 state.viewport.scale *= ratio;
1370 state.viewport.origin[0] *= ratio;
1371 state.viewport.origin[1] *= ratio;
1372 state.display_scale = scale;
1373783 state.renderer.clear_glyph_cache();
1374 state.changed()?;
1375 }
1376 WindowEvent::Focused(focused) => {
1377 state.focused = focused;
1378 if !focused {
1379 state.drag = None;
1380 }
1381 state.changed()?;
784 state.view.scale_factor_changed(scale_factor as f32)?
1382785 }
786 WindowEvent::Focused(focused) => state.view.focus_changed(focused)?,
1383787 WindowEvent::Occluded(occluded) => {
1384788 trace_input(&("Window occluded", occluded));
1385789 state.occluded = occluded;
1386 if !occluded {
1387 state.changed()?;
790 if occluded {
791 return Ok(());
1388792 }
1389 }
1390 WindowEvent::ModifiersChanged(modifiers) => {
1391 state.modifiers = modifiers.state();
1392 if matches!(state.drag, Some(Drag::Outline { .. })) {
1393 state.changed()?;
1394 }
1395 }
1396 WindowEvent::CursorLeft { .. } => {
1397 state.pointer_inside = false;
1398 state.window.request_redraw();
1399 }
1400 WindowEvent::CursorMoved { position, .. } => {
1401 state.pointer_inside = true;
1402 state.pointer = [position.x as f32, position.y as f32];
1403 let hit = state.hit_test(state.viewport.document_point(state.pointer));
1404 let scrollbar = state
1405 .scroll()
1406 .hit_test(state.viewport, state.display_scale, state.pointer)
1407 .is_some();
1408 state.window.set_cursor(match (&state.drag, hit) {
1409 (Some(Drag::Scrollbar { .. }), _) => winit::window::CursorIcon::Default,
1410 (None, _) if scrollbar => winit::window::CursorIcon::Default,
1411 (Some(Drag::Image { handle, .. }), _) => handle_cursor(*handle),
1412 (None, Some(Hit::Image { handle, .. })) => handle_cursor(handle),
1413 (Some(Drag::Resize { .. }), _) | (None, Some(Hit::Resize { .. })) => {
1414 winit::window::CursorIcon::EwResize
1415 }
1416 (Some(Drag::Outline { .. }), _) | (None, Some(Hit::Handle { .. })) => {
1417 winit::window::CursorIcon::Move
1418 }
1419 (None, Some(Hit::Date(_))) => winit::window::CursorIcon::Pointer,
1420 (None, Some(Hit::ReadOnly(_))) => winit::window::CursorIcon::Default,
1421 _ => winit::window::CursorIcon::Text,
1422 });
1423 match &mut state.drag {
1424 Some(Drag::Scrollbar { axis, grab }) => {
1425 let (axis, grab) = (*axis, *grab);
1426 state.scroll().drag(
1427 &mut state.viewport,
1428 state.display_scale,
1429 axis,
1430 state.pointer[axis],
1431 grab,
1432 );
1433 state.changed()?;
1434 }
1435 Some(Drag::Text { anchor, unit }) => {
1436 let point = state.viewport.document_point(state.pointer);
1437 let origin = state.editor.active_outline().origin();
1438 let target = state.editor.selection_at(
1439 point[0] - origin[0],
1440 point[1] - origin[1],
1441 *unit,
1442 )?;
1443 state
1444 .editor
1445 .select(drag_selection(*anchor, target, *unit))?;
1446 state.changed()?;
1447 }
1448 Some(
1449 Drag::Outline { pending_press, .. } | Drag::Image { pending_press, .. },
1450 ) => {
1451 if pending_press.is_some_and(|press| {
1452 (0..2).any(|axis| {
1453 (state.pointer[axis] - press[axis]).abs()
1454 > 2.0 * state.display_scale
1455 })
1456 }) {
1457 *pending_press = None;
1458 }
1459 state.changed()?;
1460 }
1461 Some(Drag::Resize { outline, grab }) => {
1462 let point = state.viewport.document_point(state.pointer);
1463 let width =
1464 (point[0] - state.editor.active_outline().origin()[0] - *grab)
1465 .max(36.0);
1466 if outline.is_some()
1467 || (width - state.editor.active_outline().bounds().width() as f32)
1468 .abs()
1469 * state.viewport.scale
1470 > 2.0 * state.display_scale
1471 {
1472 *outline = Some(Box::new(
1473 state.editor.preview_resize(&mut state.engine, width)?,
1474 ));
1475 state.changed()?;
1476 }
1477 }
1478 None => state.window.request_redraw(),
793 Response {
794 changed: true,
795 redraw: true,
796 request: None,
1479797 }
1480798 }
799 WindowEvent::ModifiersChanged(modifiers) => state
800 .view
801 .modifiers_changed(page_modifiers(modifiers.state()))?,
802 WindowEvent::CursorLeft { .. } => state.view.pointer_left(),
803 WindowEvent::CursorMoved { position, .. } => state
804 .view
805 .pointer_moved([position.x as f32, position.y as f32])?,
1481806 WindowEvent::MouseInput {
1482 state: button_state,
807 state: ElementState::Pressed,
1483808 button: MouseButton::Left,
1484809 ..
1485 } => {
1486 let point = state.viewport.document_point(state.pointer);
1487 if button_state == ElementState::Pressed {
1488 let now = Instant::now();
1489 let count = state
1490 .last_click
1491 .filter(|(time, point, _)| {
1492 now.duration_since(*time) <= macos::double_click_interval()
1493 && (0..2).all(|axis| {
1494 (point[axis] - state.pointer[axis]).abs()
1495 <= 4.0 * state.display_scale
1496 })
1497 })
1498 .map_or(1, |(_, _, count)| (count % 3) + 1);
1499 state.last_click = Some((now, state.pointer, count));
1500 let scrollbar = state
1501 .scroll()
1502 .hit_test(state.viewport, state.display_scale, state.pointer)
1503 .map(|(axis, grab)| Drag::Scrollbar { axis, grab });
1504 if scrollbar.is_some() {
1505 state.drag = scrollbar;
1506 } else {
1507 match state.hit_test(point) {
1508 Some(Hit::Date(field)) => state.edit_date(field)?,
1509 Some(Hit::ReadOnly(index)) => {
1510 state.set_object_focus(Some(ObjectFocus::ReadOnly(index)))
1511 }
1512 Some(Hit::Image { id, handle }) => {
1513 state.set_object_focus(Some(ObjectFocus::Image(id)));
1514 state.drag = Some(Drag::Image {
1515 id,
1516 handle,
1517 press: point,
1518 pending_press: Some(state.pointer),
1519 });
1520 }
1521 Some(Hit::Handle { id, grab }) => {
1522 state.set_object_focus(None);
1523 state.editor.focus_outline(id)?;
1524 state.drag = Some(Drag::Outline {
1525 id,
1526 grab,
1527 pending_press: Some(state.pointer),
1528 });
1529 }
1530 Some(Hit::Resize { id, grab }) => {
1531 state.set_object_focus(None);
1532 state.editor.focus_outline(id)?;
1533 state.drag = Some(Drag::Resize {
1534 outline: None,
1535 grab,
1536 });
1537 }
1538 Some(Hit::Text { id, point }) => {
1539 let extend = state.modifiers.shift_key()
1540 && state.object_focus.is_none()
1541 && id == state.editor.active_outline().id;
1542 state.set_object_focus(None);
1543 state.editor.focus_outline(id)?;
1544 let previous = state.editor.selection();
1545 state.editor.select_below(&mut state.engine, id, point)?;
1546 let unit = match count {
1547 2 => SelectionUnit::Word,
1548 3 => SelectionUnit::Paragraph,
1549 _ => SelectionUnit::Grapheme,
1550 };
1551 let selection =
1552 state.editor.selection_at(point[0], point[1], unit)?;
1553 let selection = if extend {
1554 Selection {
1555 positions: [
1556 previous.positions[0],
1557 selection.positions[1],
1558 ],
1559 affinities: [
1560 previous.affinities[0],
1561 selection.affinities[1],
1562 ],
1563 }
1564 } else {
1565 selection
1566 };
1567 state.editor.select(selection)?;
1568 state.drag = Some(Drag::Text {
1569 anchor: selection,
1570 unit,
1571 });
1572 }
1573 None => {
1574 let position = [
1575 point[0],
1576 point[1] - 7.0 * state.display_scale / state.viewport.scale,
1577 ];
1578 let position = if state.modifiers.alt_key() {
1579 position
1580 } else {
1581 snap_to_grid(position, state.editor.margin_origin())
1582 };
1583 state.editor.place_caret(
1584 &mut state.engine,
1585 position,
1586 DEFAULT_OUTLINE_WIDTH,
1587 )?;
1588 state.set_object_focus(None);
1589 state.drag = Some(Drag::Text {
1590 anchor: state.editor.selection(),
1591 unit: SelectionUnit::Grapheme,
1592 });
1593 }
1594 }
1595 }
1596 } else {
1597 let preview = state.preview();
1598 if let Some((id, origin, size)) = state.image_preview() {
1599 state
1600 .editor
1601 .place_image(&mut state.engine, id, origin, size)?;
1602 }
1603 if let Some(Drag::Resize {
1604 outline: Some(outline),
1605 ..
1606 }) = state.drag.take()
1607 {
1608 state
1609 .editor
1610 .resize(&mut state.engine, outline.bounds().width() as f32)?;
1611 }
1612 if let Some((id, origin)) = preview {
1613 state.editor.move_outline(id, origin)?;
1614 }
1615 }
1616 state.changed()?;
1617 }
810 } => state.view.pointer_pressed(Instant::now())?,
811 WindowEvent::MouseInput {
812 state: ElementState::Released,
813 button: MouseButton::Left,
814 ..
815 } => state.view.pointer_released()?,
1618816 WindowEvent::MouseWheel { delta, .. } => {
1619817 let dpr = state.window.scale_factor() as f32;
1620 let delta = match delta {
818 state.view.wheel(match delta {
1621819 MouseScrollDelta::LineDelta(x, y) => [x * 32.0 * dpr, y * 32.0 * dpr],
1622820 MouseScrollDelta::PixelDelta(p) => [p.x as f32, p.y as f32],
1623 };
1624 if state.modifiers.super_key() {
1625 state.zoom((delta[1] * 0.005).exp());
1626 } else {
1627 state.viewport.origin[0] += delta[0];
1628 state.viewport.origin[1] += delta[1];
1629 }
1630 state.changed()?;
821 })?
1631822 }
1632823 WindowEvent::KeyboardInput { event, .. }
1633824 if event.state == ElementState::Pressed =>
1634825 {
1635 state.key(&event.logical_key, event.text.as_deref())?;
1636 }
1637 WindowEvent::Ime(_) if state.object_focus.is_some() => {}
1638 WindowEvent::Ime(Ime::Preedit(text, cursor)) => {
1639 if text.is_empty() {
1640 state.editor.cancel_composition(&mut state.engine)?;
1641 } else {
1642 let (start, end) = cursor.unwrap_or((text.len(), text.len()));
1643 let start: u32 = text
1644 .get(..start)
1645 .ok_or("IME range splits UTF-8")?
1646 .encode_utf16()
1647 .count()
1648 .try_into()?;
1649 let end: u32 = text
1650 .get(..end)
1651 .ok_or("IME range splits UTF-8")?
1652 .encode_utf16()
1653 .count()
1654 .try_into()?;
1655 state.editor.compose(&mut state.engine, text, start..end)?;
1656 }
1657 state.reveal_focus()?;
1658 state.changed()?;
1659 }
1660 WindowEvent::Ime(Ime::Commit(text)) => {
1661 state.editor.commit_text(&mut state.engine, text)?;
1662 state.reveal_focus()?;
1663 state.changed()?;
1664 }
1665 WindowEvent::Ime(Ime::Disabled) => {
1666 state.editor.cancel_composition(&mut state.engine)?;
1667 state.reveal_focus()?;
1668 state.changed()?;
826 return state.key(&event.logical_key, event.text.as_deref());
1669827 }
1670 _ => {}
1671 }
1672 Ok(())
828 WindowEvent::Ime(Ime::Preedit(text, cursor)) => state.view.compose(text, cursor)?,
829 WindowEvent::Ime(Ime::Commit(text)) => state.view.commit_text(text)?,
830 WindowEvent::Ime(Ime::Disabled) => state.view.cancel_composition()?,
831 _ => return Ok(()),
832 };
833 state.respond(response)
1673834 })();
1674 if was_marked && state.editor.marked_range().is_none() {
835 if was_marked && state.view.editor.marked_range().is_none() {
1675836 macos::clear_marked_text(&state.window);
1676837 }
1677838 if let Err(error) = result {
......@@ -1683,617 +844,18 @@ impl ApplicationHandler<UserEvent> for App {
1683844 let Some(state) = &mut self.state else {
1684845 return;
1685846 };
1686 let [anchor, focus] = state.editor.selection().positions;
1687 if state.focused && !state.occluded && state.object_focus.is_none() && anchor == focus {
1688 let now = Instant::now();
1689 if now >= state.blink_at {
1690 state.caret = !state.caret;
1691 state.blink_at = now + Duration::from_millis(500);
1692 state.window.request_redraw();
1693 }
1694 event_loop.set_control_flow(ControlFlow::WaitUntil(state.blink_at));
847 let (repaint, next) = if state.occluded {
848 (false, None)
1695849 } else {
1696 event_loop.set_control_flow(ControlFlow::Wait);
1697 }
1698 }
1699}
1700
1701#[derive(Debug, PartialEq)]
1702enum Hit {
1703 Date(DateField),
1704 Resize {
1705 id: onestore::ExGuid,
1706 grab: f32,
1707 },
1708 Text {
1709 id: onestore::ExGuid,
1710 point: [f32; 2],
1711 },
1712 Handle {
1713 id: onestore::ExGuid,
1714 grab: [f32; 2],
1715 },
1716 ReadOnly(usize),
1717 /// `handle` is [0, 0] on the picture and a direction on the selected picture's handles.
1718 Image {
1719 id: onestore::ExGuid,
1720 handle: [i8; 2],
1721 },
1722}
1723
1724fn page_hit_test(
1725 editor: &CanvasEditor,
1726 scene: Option<&(PageScene, [f32; 2])>,
1727 point: [f32; 2],
1728 pixel: f32,
1729) -> Option<Hit> {
1730 /// Grips and text resolve front to back before any outline's padding, as a native
1731 /// width handle stays reachable under the next outline's left padding; the typing room
1732 /// below an outline comes last.
1733 #[derive(Clone, Copy, PartialEq)]
1734 enum Layer {
1735 Grips,
1736 Body,
1737 Below,
1738 }
1739 let hit = |outline: &TextOutline, offset: [f32; 2], layer: Layer| {
1740 if layer == Layer::Below {
1741 let outline = editor
1742 .outlines()
1743 .iter()
1744 .find(|source| source.id == outline.id)?;
1745 let local = [
1746 point[0] - offset[0] - outline.origin()[0],
1747 point[1] - offset[1] - outline.origin()[1],
1748 ];
1749 return outline.contains_extension(local).then_some(Hit::Text {
1750 id: outline.id,
1751 point: local,
1752 });
1753 }
1754 let (bounds, body_top) = outline_chrome(outline, pixel);
1755 let local = [point[0] - offset[0], point[1] - offset[1]];
1756 let [x, y] = local;
1757 if layer == Layer::Grips {
1758 // Width handles: 15 px inside to 6 px outside the header's right end, and 6 px
1759 // either side of the right border below it.
1760 let inside = if y < body_top { 15.0 } else { 6.0 };
1761 if !outline.title
1762 && (bounds[2] - inside * pixel..=bounds[2] + 6.0 * pixel).contains(&x)
1763 && (bounds[1]..=bounds[3]).contains(&y)
1764 {
1765 return Some(Hit::Resize {
1766 id: outline.id,
1767 grab: point[0] - outline.bounds().x1 as f32,
1768 });
1769 }
1770 if x >= bounds[0] && x <= bounds[2] && y >= bounds[1] && y < body_top {
1771 return Some(Hit::Handle {
1772 id: outline.id,
1773 grab: [
1774 point[0] - outline.origin()[0],
1775 point[1] - outline.origin()[1],
1776 ],
1777 });
1778 }
1779 let text = outline.bounds();
1780 if !(text.x0..=text.x1).contains(&f64::from(x))
1781 || !(text.y0..=text.y1).contains(&f64::from(y))
1782 {
1783 return None;
1784 }
1785 }
1786 let inner = [x - outline.origin()[0], y - outline.origin()[1]];
1787 if let Some(picture) = outline.shaped().objects.iter().find(|object| {
1788 matches!(object.kind, canvas::outline::ObjectKind::Picture)
1789 && (object.rect[0]..=object.rect[2]).contains(&inner[0])
1790 && (object.rect[1]..=object.rect[3]).contains(&inner[1])
1791 }) {
1792 return Some(Hit::Image {
1793 id: picture.id,
1794 handle: [0, 0],
1795 });
1796 }
1797 if (x >= bounds[0] && x <= bounds[2] && y >= body_top && y <= bounds[3])
1798 || outline.layouts().any(|(_, paragraph)| {
1799 paragraph.tags.iter().any(|tag| {
1800 let origin = outline.origin();
1801 let x = origin[0] + outline.shaped().tag_column_offset() + tag.origin[0];
1802 let y = origin[1] + paragraph.origin[1] + tag.origin[1];
1803 let size = canvas::outline::ParagraphTag::SIZE;
1804 (x..=x + size).contains(&local[0]) && (y..=y + size).contains(&local[1])
1805 })
1806 })
1807 {
1808 return Some(Hit::Text {
1809 id: outline.id,
1810 point: [
1811 local[0] - outline.origin()[0],
1812 local[1] - outline.origin()[1],
1813 ],
1814 });
1815 }
1816 None
1817 };
1818 let pass = |layer| {
1819 if let Some(hit) = editor
1820 .visible_outlines()
1821 .rev()
1822 .filter(|outline| scene.is_none() || !editor.has_page_outline(outline.id))
1823 .find_map(|outline| hit(outline, [0.0; 2], layer))
1824 {
1825 return Some(hit);
1826 }
1827 let (scene, offset) = scene?;
1828 match scene.hit_test(
1829 [point[0] - offset[0], point[1] - offset[1]],
1830 Some(editor),
1831 |id| {
1832 editor
1833 .visible_outlines()
1834 .find(|outline| outline.id == id)
1835 .and_then(|outline| hit(outline, *offset, layer))
1836 },
1837 )? {
1838 canvas::gpu::page::SceneHit::Outline(hit) => Some(hit),
1839 canvas::gpu::page::SceneHit::Date(field) => Some(Hit::Date(field)),
1840 canvas::gpu::page::SceneHit::ReadOnly(index) => Some(Hit::ReadOnly(index)),
1841 canvas::gpu::page::SceneHit::Image(id) => Some(Hit::Image { id, handle: [0, 0] }),
1842 }
1843 };
1844 [Layer::Grips, Layer::Body, Layer::Below]
1845 .into_iter()
1846 .find_map(pass)
1847}
1848
1849fn page_primitives<'a>(
1850 editor: &'a CanvasEditor,
1851 scene: Option<&'a (PageScene, [f32; 2])>,
1852 preview: Option<PointerFeedback<'a>>,
1853 object_focus: Option<ObjectFocus>,
1854 show_caret: bool,
1855 scale: f32,
1856 display_scale: f32,
1857) -> Result<Vec<Primitive<'a>>, Box<dyn Error>> {
1858 let mut primitives = Vec::new();
1859 let paint = |id, offset: [f32; 2], primitives: &mut Vec<_>| {
1860 let outline = editor
1861 .visible_outlines()
1862 .find(|outline| outline.id == id)
1863 .ok_or(canvas::gpu::page::SceneError::MissingOutline)?;
1864 let outline = match preview {
1865 Some(PointerFeedback::Resize(resized)) if resized.id == outline.id => resized,
1866 _ => outline,
1867 };
1868 let origin = match preview {
1869 Some(PointerFeedback::Move(id, origin)) if id == outline.id => origin,
1870 _ => outline.origin(),
850 state.view.blink(Instant::now())
1871851 };
1872 if (object_focus.is_none() && outline.id == editor.active_outline().id)
1873 || matches!(preview, Some(PointerFeedback::Hover(id) | PointerFeedback::Move(id, _)) if id == outline.id)
1874 || matches!(preview, Some(PointerFeedback::Resize(resized)) if resized.id == outline.id)
1875 {
1876 append_outline_chrome(
1877 outline,
1878 [origin[0] + offset[0], origin[1] + offset[1]],
1879 display_scale / scale,
1880 primitives,
1881 );
1882 }
1883 append_outline(
1884 (object_focus.is_none()
1885 && outline.id == editor.active_outline().id
1886 && !matches!(preview, Some(PointerFeedback::Resize(_))))
1887 .then_some(editor),
1888 outline,
1889 [origin[0] + offset[0], origin[1] + offset[1]],
1890 show_caret,
1891 scale,
1892 display_scale / scale,
1893 primitives,
1894 )?;
1895 if let Some((scene, _)) = scene {
1896 let moving = match preview {
1897 Some(PointerFeedback::Image(id, origin, size)) => {
1898 let [x0, y0, x1, y1] = image_rect(origin, size);
1899 Some((
1900 id,
1901 [
1902 x0 + offset[0],
1903 y0 + offset[1],
1904 x1 + offset[0],
1905 y1 + offset[1],
1906 ],
1907 ))
1908 }
1909 _ => None,
1910 };
1911 scene.append_outline_objects(
1912 outline.shaped(),
1913 [origin[0] + offset[0], origin[1] + offset[1]],
1914 moving,
1915 primitives,
1916 );
1917 }
1918 Ok::<_, Box<dyn Error>>(())
1919 };
1920 if let Some((scene, origin)) = scene {
1921 let moving = match preview {
1922 Some(PointerFeedback::Image(id, origin, size)) => Some((id, image_rect(origin, size))),
1923 _ => None,
1924 };
1925 scene.append_primitives_with(&mut primitives, *origin, Some(editor), moving, &paint)?;
1926 }
1927 for outline in editor
1928 .visible_outlines()
1929 .filter(|outline| scene.is_none() || !editor.has_page_outline(outline.id))
1930 {
1931 paint(outline.id, [0.0; 2], &mut primitives)?;
1932 }
1933 if object_focus.is_none()
1934 && let Some(outline) = editor.caret_outline()
1935 {
1936 append_outline(
1937 Some(editor),
1938 outline,
1939 outline.origin(),
1940 show_caret,
1941 scale,
1942 display_scale / scale,
1943 &mut primitives,
1944 )?;
1945 }
1946 if let Some(ObjectFocus::Image(id)) = object_focus {
1947 let (origin, size) = match preview {
1948 Some(PointerFeedback::Image(moving, origin, size)) if moving == id => (origin, size),
1949 _ => editor
1950 .image_placement(id)
1951 .ok_or("The selected picture is missing.")?,
1952 };
1953 append_image_chrome(
1954 image_rect(origin, size),
1955 display_scale / scale,
1956 &mut primitives,
1957 );
1958 }
1959 if let Some(ObjectFocus::ReadOnly(index)) = object_focus {
1960 let (scene, offset) = scene.unwrap();
1961 let [x0, y0, x1, y1] = scene.read_only(Some(editor)).nth(index).unwrap().rect();
1962 let [x0, y0, x1, y1] = [
1963 x0 + offset[0],
1964 y0 + offset[1],
1965 x1 + offset[0],
1966 y1 + offset[1],
1967 ];
1968 let border = 2.0 / scale;
1969 for rect in [
1970 [x0, y0, x1, y0 + border],
1971 [x0, y1 - border, x1, y1],
1972 [x0, y0, x0 + border, y1],
1973 [x1 - border, y0, x1, y1],
1974 ] {
1975 primitives.push(Primitive::Rect {
1976 rect,
1977 color: [0.25, 0.45, 0.7, 1.0],
1978 });
1979 }
1980 }
1981 Ok(primitives)
1982}
1983
1984/// Native picture handles sit on a selection border drawn 5 px outside the picture, named
1985/// by their direction from its center.
1986fn image_handles(rect: [f32; 4], pixel: f32) -> impl Iterator<Item = ([i8; 2], [f32; 2])> {
1987 let border = [
1988 rect[0] - 5.0 * pixel,
1989 rect[1] - 5.0 * pixel,
1990 rect[2] + 5.0 * pixel,
1991 rect[3] + 5.0 * pixel,
1992 ];
1993 [-1, 0, 1]
1994 .into_iter()
1995 .flat_map(|y| [-1, 0, 1].map(|x| [x, y]))
1996 .filter(|handle| *handle != [0, 0])
1997 .map(move |handle| {
1998 (
1999 handle,
2000 std::array::from_fn(|axis| {
2001 let [start, end] = [border[axis], border[axis + 2]];
2002 start + (end - start) * f32::from(handle[axis] + 1) * 0.5
2003 }),
2004 )
2005 })
2006}
2007
2008fn image_handle_at(rect: [f32; 4], pixel: f32, point: [f32; 2]) -> Option<[i8; 2]> {
2009 image_handles(rect, pixel)
2010 .find(|(_, center)| (0..2).all(|axis| (point[axis] - center[axis]).abs() <= 5.0 * pixel))
2011 .map(|(handle, _)| handle)
2012}
2013
2014fn handle_cursor(handle: [i8; 2]) -> winit::window::CursorIcon {
2015 use winit::window::CursorIcon;
2016 match handle {
2017 [0, 0] => CursorIcon::Move,
2018 [_, 0] => CursorIcon::EwResize,
2019 [0, _] => CursorIcon::NsResize,
2020 [x, y] if x == y => CursorIcon::NwseResize,
2021 _ => CursorIcon::NeswResize,
2022 }
2023}
2024
2025fn image_rect(origin: [f32; 2], size: [f32; 2]) -> [f32; 4] {
2026 [
2027 origin[0],
2028 origin[1],
2029 origin[0] + size[0],
2030 origin[1] + size[1],
2031 ]
2032}
2033
2034/// Drags a picture's `handle` by `delta`: edges stretch one axis, corners keep the aspect
2035/// ratio at the larger of the two scales, and the opposite side stays fixed.
2036fn resize_image(
2037 origin: [f32; 2],
2038 size: [f32; 2],
2039 handle: [i8; 2],
2040 delta: [f32; 2],
2041) -> ([f32; 2], [f32; 2]) {
2042 let mut scale: [f32; 2] = std::array::from_fn(|axis| {
2043 (size[axis] + f32::from(handle[axis]) * delta[axis]) / size[axis]
2044 });
2045 if !handle.contains(&0) {
2046 scale = [scale[0].max(scale[1]); 2];
2047 }
2048 let resized: [f32; 2] = std::array::from_fn(|axis| (size[axis] * scale[axis]).max(1.0));
2049 let origin = std::array::from_fn(|axis| {
2050 if handle[axis] < 0 {
2051 origin[axis] + size[axis] - resized[axis]
2052 } else {
2053 origin[axis]
2054 }
2055 });
2056 (origin, resized)
2057}
2058
2059fn append_image_chrome(rect: [f32; 4], pixel: f32, primitives: &mut Vec<Primitive<'_>>) {
2060 let mut tint = canvas::gpu::colorref(0x00e0d2e6);
2061 // Native pictures take this tint at 25% in sRGB; 10% in linear light matches it.
2062 tint[3] = 0.1;
2063 primitives.push(Primitive::Rect { rect, color: tint });
2064 primitives.push(Primitive::RoundedRect {
2065 rect: [
2066 rect[0] - 5.0 * pixel,
2067 rect[1] - 5.0 * pixel,
2068 rect[2] + 5.0 * pixel,
2069 rect[3] + 5.0 * pixel,
2070 ],
2071 radius: [0.0; 2],
2072 stroke: Some(Stroke::Dashed(pixel)),
2073 color: canvas::gpu::colorref(0x00ff9a31),
2074 });
2075 for (handle, [x, y]) in image_handles(rect, pixel) {
2076 let (half, radius) = if handle.contains(&0) {
2077 (3.5, 0.0)
2078 } else {
2079 (4.0, 4.0)
2080 };
2081 let rect = [
2082 x - half * pixel,
2083 y - half * pixel,
2084 x + half * pixel,
2085 y + half * pixel,
2086 ];
2087 primitives.push(Primitive::RoundedRect {
2088 rect,
2089 radius: [radius * pixel; 2],
2090 stroke: None,
2091 color: canvas::gpu::colorref(0x00ffefe7),
2092 });
2093 primitives.push(Primitive::RoundedRect {
2094 rect,
2095 radius: [radius * pixel; 2],
2096 stroke: Some(Stroke::Solid(pixel)),
2097 color: canvas::gpu::colorref(0x00dea67b),
2098 });
2099 }
2100}
2101
2102fn drag_selection(anchor: Selection, target: Selection, unit: SelectionUnit) -> Selection {
2103 if unit == SelectionUnit::Grapheme {
2104 return Selection {
2105 positions: [anchor.positions[0], target.positions[1]],
2106 affinities: [anchor.affinities[0], target.affinities[1]],
2107 };
2108 }
2109 let backwards = target.positions[0] < anchor.positions[0].min(anchor.positions[1]);
2110 let start = usize::from((anchor.positions[0] > anchor.positions[1]) != backwards);
2111 let end = usize::from((target.positions[0] > target.positions[1]) == backwards);
2112 Selection {
2113 positions: [anchor.positions[start], target.positions[end]],
2114 affinities: [anchor.affinities[start], target.affinities[end]],
2115 }
2116}
2117
2118fn snap_to_grid(point: [f32; 2], margin: [f32; 2]) -> [f32; 2] {
2119 std::array::from_fn(|axis| {
2120 let offset = margin[axis];
2121 let cell = (point[axis] - offset) / 18.0;
2122 let nearest = cell.round();
2123 // Native midpoints remain free; account for the source coordinate's float precision.
2124 if ((cell - nearest).abs() - 0.5).abs() <= f32::EPSILON * cell.abs().max(1.0) {
2125 point[axis]
2126 } else {
2127 nearest * 18.0 + offset
2128 }
2129 })
2130}
2131
2132fn outline_chrome(outline: &TextOutline, pixel: f32) -> ([f32; 4], f32) {
2133 let bounds = outline.bounds();
2134 // Native chrome combines page-scaled gutters with a fixed screen inset.
2135 let inset = 5.0 * pixel;
2136 let body_top = bounds.y0 as f32 - inset;
2137 if outline.title {
2138 let (_, paragraph) = outline.layouts().last().unwrap();
2139 let bottom = outline.origin()[1] + paragraph.origin[1] + paragraph.text.height();
2140 return (
2141 [
2142 bounds.x0 as f32 - inset - 6.0 * pixel,
2143 body_top,
2144 bounds.x1 as f32 + inset - 2.0 * pixel,
2145 bottom + inset,
2146 ],
2147 body_top,
2148 );
2149 }
2150 (
2151 [
2152 bounds.x0 as f32 - 7.5 - inset,
2153 body_top - HANDLE_HEIGHT,
2154 bounds.x1 as f32 + inset,
2155 bounds.y1 as f32 + HANDLE_HEIGHT + inset,
2156 ],
2157 body_top,
2158 )
2159}
2160
2161fn append_outline_chrome(
2162 outline: &TextOutline,
2163 origin: [f32; 2],
2164 pixel: f32,
2165 primitives: &mut Vec<Primitive<'_>>,
2166) {
2167 let [x, y] = origin;
2168 let (bounds, body_top) = outline_chrome(outline, pixel);
2169 let [dx, dy] = [x - outline.origin()[0], y - outline.origin()[1]];
2170 let [left, top, right, bottom] = [
2171 bounds[0] + dx,
2172 bounds[1] + dy,
2173 bounds[2] + dx,
2174 bounds[3] + dy,
2175 ];
2176 let body_top = body_top + dy;
2177 if outline.title {
2178 primitives.push(Primitive::RoundedRect {
2179 rect: [left, top, right, bottom],
2180 radius: [6.0 * pixel, (bottom - top) * 0.5],
2181 stroke: Some(Stroke::Dashed(pixel)),
2182 color: canvas::gpu::colorref(0x007f7f7f),
2183 });
2184 return;
2185 }
2186 primitives.push(Primitive::RoundedRect {
2187 rect: [left, top, right, body_top],
2188 radius: [3.0 * pixel; 2],
2189 stroke: None,
2190 color: canvas::gpu::colorref(0x00e8ebed),
2191 });
2192 primitives.push(Primitive::RoundedRect {
2193 rect: [right - 9.0 * pixel, top, right, body_top],
2194 radius: [3.0 * pixel; 2],
2195 stroke: None,
2196 color: canvas::gpu::colorref(0x00e5dee7),
2197 });
2198 primitives.push(Primitive::RoundedRect {
2199 rect: [left, top, right, bottom],
2200 radius: [3.0 * pixel; 2],
2201 stroke: Some(Stroke::Solid(pixel)),
2202 color: canvas::gpu::colorref(0x00d9cfd8),
2203 });
2204 let middle = (top + body_top) * 0.5;
2205 for offset in [-3.0, 0.0, 3.0] {
2206 let center = (left + right) * 0.5 + offset * pixel;
2207 primitives.push(Primitive::RoundedRect {
2208 rect: [
2209 center - pixel * 0.5,
2210 middle - pixel * 0.5,
2211 center + pixel * 0.5,
2212 middle + pixel * 0.5,
2213 ],
2214 radius: [pixel * 0.5; 2],
2215 stroke: None,
2216 color: canvas::gpu::colorref(0x00b4a5b4),
2217 });
2218 }
2219 for column in [0.0, 1.0, 2.0] {
2220 let half = (column + 0.5) * pixel;
2221 for x in [
2222 right - (8.0 - column) * pixel,
2223 right - (2.0 + column) * pixel,
2224 ] {
2225 primitives.push(Primitive::Rect {
2226 rect: [x, middle - half, x + pixel, middle + half],
2227 color: canvas::gpu::colorref(0x00b4a5b4),
2228 });
852 if repaint {
853 state.window.request_redraw();
2229854 }
855 event_loop.set_control_flow(next.map_or(ControlFlow::Wait, ControlFlow::WaitUntil));
2230856 }
2231857}
2232858
2233fn append_outline<'a>(
2234 editor: Option<&'a CanvasEditor>,
2235 outline: &'a TextOutline,
2236 origin: [f32; 2],
2237 show_caret: bool,
2238 scale: f32,
2239 pixel: f32,
2240 primitives: &mut Vec<Primitive<'a>>,
2241) -> Result<(), Box<dyn Error>> {
2242 let [x, y] = origin;
2243 outline.shaped().append_table_primitives(primitives, origin);
2244 outline
2245 .shaped()
2246 .append_background_primitives(primitives, origin);
2247 if let Some(editor) = editor {
2248 let [_, selection_color] = macos::text_colors();
2249 for rect in editor.selection_rects()? {
2250 primitives.push(Primitive::Rect {
2251 rect: [
2252 rect.x0 as f32 + x,
2253 rect.y0 as f32 + y,
2254 rect.x1 as f32 + x,
2255 rect.y1 as f32 + y,
2256 ],
2257 color: selection_color,
2258 });
2259 }
2260 }
2261 for (index, (_, paragraph)) in outline.layouts().enumerate() {
2262 outline
2263 .shaped()
2264 .append_paragraph_primitives(index, paragraph, origin, primitives);
2265 }
2266 if let Some(editor) = editor {
2267 for rect in editor.marked_rects()? {
2268 primitives.push(Primitive::Rect {
2269 rect: [
2270 rect.x0 as f32 + x,
2271 rect.y1 as f32 + y - 1.0 / scale,
2272 rect.x1 as f32 + x,
2273 rect.y1 as f32 + y,
2274 ],
2275 color: [0.0, 0.0, 0.0, 1.0],
2276 });
2277 }
2278 let [anchor, focus] = editor.selection().positions;
2279 if show_caret && anchor == focus {
2280 let rect = editor.caret(2.0 * pixel)?;
2281 primitives.push(Primitive::RoundedRect {
2282 rect: [
2283 rect.x0 as f32 + x,
2284 rect.y0 as f32 + y,
2285 rect.x1 as f32 + x,
2286 rect.y1 as f32 + y,
2287 ],
2288 radius: [pixel; 2],
2289 stroke: None,
2290 color: macos::text_colors()[0],
2291 });
2292 }
2293 }
2294 Ok(())
2295}
2296
2297859fn main() -> Result<(), Box<dyn Error>> {
2298860 let mut args = std::env::args_os().skip(1);
2299861 let mut positional = Vec::new();
......@@ -2407,1410 +969,3 @@ fn main() -> Result<(), Box<dyn Error>> {
2407969 event_loop.run_app(&mut app)?;
2408970 app.startup_error.map_or(Ok(()), Err)
2409971}
2410
2411#[cfg(test)]
2412mod tests {
2413 use super::*;
2414
2415 #[test]
2416 fn date_buttons_keep_accessibility_identity_and_match_mouse_hits_after_reflow() {
2417 let mut engine = TextEngine::default();
2418 let mut fields = [vec!["Header"], vec!["Monday", "6:14 AM"]].map(|text| {
2419 TextOutline::new(
2420 &mut engine,
2421 TextDocument::new(
2422 text.into_iter()
2423 .map(|text| Paragraph::new(text.into(), Default::default()))
2424 .collect(),
2425 )
2426 .unwrap(),
2427 468.0,
2428 [0.0; 2],
2429 )
2430 .unwrap()
2431 .snapshot()
2432 });
2433 fields[0].title = true;
2434 let page = Page {
2435 identity: None,
2436 title: "Header".into(),
2437 created: Some(1),
2438 margin_origin: [36.0, 14.4],
2439 definitions: Default::default(),
2440 objects: vec![onestore::page::PageObject::Title(onestore::page::Title {
2441 id: onestore::ExGuid::default(),
2442 date: Some(fields[1].id),
2443 layout: Default::default(),
2444 outlines: fields.into(),
2445 })],
2446 };
2447 let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap();
2448 let scene = (scene, [20.0, 40.0]);
2449 let viewport = Viewport {
2450 size: [1000, 700],
2451 scale: 2.0,
2452 origin: [-30.0, -50.0],
2453 };
2454 let mut access = accessibility::Accessibility::default();
2455 let mut identities = Vec::new();
2456 for phase in 0..3 {
2457 if phase == 1 {
2458 editor
2459 .insert(&mut engine, &"A wrapped title ".repeat(25))
2460 .unwrap();
2461 }
2462 if phase == 2 {
2463 editor
2464 .change_date(&mut engine, 2, ["Wednesday".into(), "8:40 AM".into()])
2465 .unwrap();
2466 }
2467 let mut update = access.update(&editor, viewport, "Header", None).unwrap();
2468 access.append_page_fields(&mut update, Some(&scene), &editor, viewport, None);
2469 let buttons: Vec<_> = update
2470 .nodes
2471 .iter()
2472 .filter(|(_, node)| node.role() == accesskit::Role::Button)
2473 .collect();
2474 assert_eq!(buttons.len(), 2);
2475 for (index, (id, node)) in buttons.iter().enumerate() {
2476 let field = [DateField::Date, DateField::Time][index];
2477 assert_eq!(access.date_for_node(*id), Some(field));
2478 assert!(node.supports_action(accesskit::Action::Click));
2479 let rect = node.bounds().unwrap();
2480 let point = viewport.document_point([
2481 ((rect.x0 + rect.x1) * 0.5) as f32,
2482 ((rect.y0 + rect.y1) * 0.5) as f32,
2483 ]);
2484 assert_eq!(
2485 page_hit_test(&editor, Some(&scene), point, 0.5),
2486 Some(Hit::Date(field))
2487 );
2488 if phase == 0 {
2489 identities.push(*id);
2490 } else {
2491 assert_eq!(*id, identities[index]);
2492 }
2493 }
2494 if phase == 2 {
2495 assert_eq!(buttons[1].1.value(), Some("8:40 AM"));
2496 }
2497 accesskit_consumer::Tree::new(update, true);
2498 }
2499 }
2500
2501 #[test]
2502 fn extension_hits_yield_to_objects_and_keep_their_source_coordinate_frame() {
2503 let mut engine = TextEngine::default();
2504 let upper = TextOutline::new(
2505 &mut engine,
2506 TextDocument::new(vec![Paragraph::new("Upper".into(), Default::default())]).unwrap(),
2507 240.0,
2508 [36.0, 90.0],
2509 )
2510 .unwrap();
2511 let bottom = upper.bounds().y1 as f32;
2512 let id = upper.id;
2513 let lower = TextOutline::new(
2514 &mut engine,
2515 TextDocument::new(vec![Paragraph::new("Lower".into(), Default::default())]).unwrap(),
2516 240.0,
2517 [36.0, bottom + 20.0],
2518 )
2519 .unwrap();
2520 let lower_id = lower.id;
2521 let source = upper.snapshot();
2522 let mut editor =
2523 CanvasEditor::from_text_outlines(vec![lower, upper], Default::default(), None).unwrap();
2524 assert!(
2525 matches!(page_hit_test(&editor, None, [60.0, bottom + 22.0], 0.75), Some(Hit::Text { id, .. }) if id == lower_id)
2526 );
2527 editor.move_outline(lower_id, [600.0, 500.0]).unwrap();
2528 for pixel in [0.375, 0.75, 1.5] {
2529 let hit = page_hit_test(&editor, None, [60.0, bottom + 26.0], pixel).unwrap();
2530 assert!(
2531 matches!(hit, Hit::Text { id: hit_id, point } if hit_id == id && (point[1] - (bottom + 26.0 - 90.0)).abs() < 0.001)
2532 );
2533 assert_eq!(
2534 page_hit_test(&editor, None, [60.0, bottom + 30.0], pixel),
2535 None
2536 );
2537 }
2538 let page = onestore::page::Page {
2539 identity: None,
2540 title: String::new(),
2541 created: None,
2542 margin_origin: [0.0; 2],
2543 definitions: Default::default(),
2544 objects: vec![onestore::page::PageObject::Outline(source)],
2545 };
2546 let (scene, editor) = PageScene::from_page(page, &mut engine).unwrap();
2547 let offset = [50.0, 300.0];
2548 assert!(
2549 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)
2550 );
2551 }
2552
2553 #[test]
2554 fn title_chrome_selects_text_and_exposes_a_named_editable_field() {
2555 let mut engine = TextEngine::default();
2556 let mut source = TextOutline::new(
2557 &mut engine,
2558 TextDocument::new(vec![Paragraph::new(
2559 "Header".into(),
2560 Format {
2561 font_size: Some(8.0),
2562 line_spacing: Some(20.751953),
2563 ..Default::default()
2564 },
2565 )])
2566 .unwrap(),
2567 468.0,
2568 [36.0, 14.4],
2569 )
2570 .unwrap()
2571 .snapshot();
2572 source.title = true;
2573 source.min_width = Some(162.0);
2574 source.layout.max_width = None;
2575 source.layout.width_set_by_user = None;
2576 source.layout.max_height = Some(21.6);
2577 let outline = TextOutline::from_outline(&mut engine, &source, &Default::default()).unwrap();
2578 let editor =
2579 CanvasEditor::from_text_outlines(vec![outline], Default::default(), None).unwrap();
2580 assert_eq!(editor.active_outline().bounds().width(), 162.0);
2581 assert_eq!(editor.active_outline().snapshot().min_width, Some(162.0));
2582 let (native_rect, _) = outline_chrome(editor.active_outline(), 0.75);
2583 for (axis, (actual, expected)) in native_rect
2584 .iter()
2585 .zip([85.0, 98.0, 315.0, 134.0])
2586 .enumerate()
2587 {
2588 let origin = if axis % 2 == 0 { 48.0 } else { 83.0 };
2589 assert!((actual / 0.75 + origin - expected).abs() < 1.5);
2590 }
2591 let id = editor.active_outline().id;
2592 let (rect, _) = outline_chrome(editor.active_outline(), 1.0);
2593 for point in [
2594 [rect[0] + 1.0, rect[1] + 1.0],
2595 [rect[2] - 1.0, rect[1] + 1.0],
2596 [40.0, 20.0],
2597 ] {
2598 assert!(
2599 matches!(page_hit_test(&editor, None, point, 1.0), Some(Hit::Text { id: hit, .. }) if hit == id)
2600 );
2601 }
2602 let primitives = page_primitives(&editor, None, None, None, false, 1.0, 1.0).unwrap();
2603 let borders = primitives
2604 .iter()
2605 .filter_map(|p| match p {
2606 Primitive::RoundedRect { stroke, .. } => Some(*stroke),
2607 _ => None,
2608 })
2609 .collect::<Vec<_>>();
2610 assert_eq!(borders, [Some(Stroke::Dashed(1.0))]);
2611 assert!(primitives.iter().any(|primitive| matches!(primitive, Primitive::RoundedRect { rect, radius, stroke: Some(Stroke::Dashed(_)), .. } if radius[0] == 6.0 && radius[1] * 2.0 == rect[3] - rect[1])));
2612 let mut access = accessibility::Accessibility::default();
2613 let update = access
2614 .update(
2615 &editor,
2616 Viewport {
2617 size: [800, 600],
2618 scale: 1.0,
2619 origin: [0.0; 2],
2620 },
2621 "Header",
2622 None,
2623 )
2624 .unwrap();
2625 assert!(update.nodes.iter().any(|(_, node)| node.role()
2626 == accesskit::Role::MultilineTextInput
2627 && node.label() == Some("Page title")));
2628 }
2629
2630 #[test]
2631 fn provisional_lines_share_one_drawn_hit_tested_and_accessible_outline() {
2632 let mut engine = TextEngine::default();
2633 let mut editor = CanvasEditor::new(
2634 &mut engine,
2635 TextDocument::new(vec![Paragraph::new("body".into(), Format::default())]).unwrap(),
2636 240.0,
2637 )
2638 .unwrap();
2639 let id = editor.active_outline().id;
2640 for _ in 0..2 {
2641 editor
2642 .move_selection(&mut engine, Movement::Down, false)
2643 .unwrap();
2644 }
2645 let primitives = page_primitives(&editor, None, None, None, true, 1.0, 1.0).unwrap();
2646 assert_eq!(
2647 primitives
2648 .iter()
2649 .filter(|p| matches!(p, Primitive::Text { .. }))
2650 .count(),
2651 3
2652 );
2653 let caret = editor.caret(1.0).unwrap();
2654 assert!(
2655 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)
2656 );
2657 let mut access = accessibility::Accessibility::default();
2658 let update = access
2659 .update(
2660 &editor,
2661 Viewport {
2662 size: [800, 600],
2663 scale: 1.0,
2664 origin: [0.0; 2],
2665 },
2666 "Test",
2667 None,
2668 )
2669 .unwrap();
2670 let fields = update
2671 .nodes
2672 .iter()
2673 .filter(|(_, node)| node.role() == accesskit::Role::MultilineTextInput)
2674 .collect::<Vec<_>>();
2675 assert_eq!(fields.len(), 1);
2676 assert_eq!(fields[0].1.value(), Some("body\n\n"));
2677 assert_eq!(editor.outlines()[0].document().nodes().len(), 1);
2678 editor
2679 .place_caret(&mut engine, [300.0, 200.0], 240.0)
2680 .unwrap();
2681 assert_eq!(
2682 editor
2683 .visible_outlines()
2684 .next()
2685 .unwrap()
2686 .document()
2687 .nodes()
2688 .len(),
2689 1
2690 );
2691 }
2692
2693 #[test]
2694 fn chrome_follows_focus_hover_and_drag_without_changing_hit_geometry() {
2695 let mut engine = TextEngine::default();
2696 let outlines = [[0.0, 0.0], [220.0, 0.0]]
2697 .into_iter()
2698 .map(|origin| {
2699 TextOutline::new(
2700 &mut engine,
2701 TextDocument::new(vec![Paragraph::new("text".into(), Format::default())])
2702 .unwrap(),
2703 100.0,
2704 origin,
2705 )
2706 .unwrap()
2707 })
2708 .collect();
2709 let editor = CanvasEditor::from_text_outlines(outlines, Default::default(), None).unwrap();
2710 let second = editor.outlines()[1].id;
2711 let borders = |feedback| {
2712 page_primitives(&editor, None, feedback, None, false, 1.0, 1.0)
2713 .unwrap()
2714 .into_iter()
2715 .filter_map(|p| match p {
2716 Primitive::RoundedRect {
2717 rect,
2718 stroke: Some(Stroke::Solid(1.0)),
2719 ..
2720 } => Some(rect),
2721 _ => None,
2722 })
2723 .collect::<Vec<_>>()
2724 };
2725 assert_eq!(borders(None).len(), 1);
2726 let hovered = borders(Some(PointerFeedback::Hover(second)));
2727 assert_eq!(hovered.len(), 2);
2728 let moved = borders(Some(PointerFeedback::Move(second, [240.0, 50.0])));
2729 assert_eq!(moved[1][0] - hovered[1][0], 20.0);
2730 assert_eq!(moved[1][1] - hovered[1][1], 50.0);
2731 assert!(
2732 matches!(page_hit_test(&editor, None, [225.0, 5.0], 1.0), Some(Hit::Text { id, .. }) if id == second)
2733 );
2734 }
2735
2736 #[test]
2737 fn a_covered_width_handle_stays_reachable_beside_the_outline_above_it() {
2738 let mut engine = TextEngine::default();
2739 let pixel = 0.75;
2740 let mut first = TextOutline::new(
2741 &mut engine,
2742 TextDocument::new(vec![Paragraph::new("text".into(), Format::default())]).unwrap(),
2743 100.0,
2744 [36.0, 68.4],
2745 )
2746 .unwrap();
2747 let editor =
2748 CanvasEditor::from_text_outlines(vec![first.clone()], Default::default(), None)
2749 .unwrap();
2750 first = editor.preview_resize(&mut engine, 226.5).unwrap();
2751 let (frame, body_top) = outline_chrome(&first, pixel);
2752 // The casual night page: the next outline starts 7.5 pt right of the first one.
2753 let second = TextOutline::new(
2754 &mut engine,
2755 TextDocument::new(vec![Paragraph::new("text".into(), Format::default())]).unwrap(),
2756 100.0,
2757 [first.bounds().x1 as f32 + 7.5, 68.4],
2758 )
2759 .unwrap();
2760 let (first_id, second_id) = (first.id, second.id);
2761 let editor =
2762 CanvasEditor::from_text_outlines(vec![first, second], Default::default(), None)
2763 .unwrap();
2764 let header = (frame[1] + body_top) / 2.0;
2765 let hit = |x| page_hit_test(&editor, None, [x, header], pixel);
2766 assert!(
2767 matches!(hit(frame[2] - 12.0 * pixel), Some(Hit::Resize { id, .. }) if id == first_id)
2768 );
2769 assert!(
2770 matches!(hit(frame[2] - 2.0 * pixel), Some(Hit::Handle { id, .. }) if id == second_id)
2771 );
2772 let body = |x| page_hit_test(&editor, None, [x, body_top + 20.0], pixel);
2773 assert!(
2774 matches!(body(frame[2] - 4.0 * pixel), Some(Hit::Resize { id, .. }) if id == first_id)
2775 );
2776 assert!(
2777 matches!(body(frame[2] - 8.0 * pixel), Some(Hit::Text { id, .. }) if id == second_id)
2778 );
2779 assert!(
2780 matches!(body(frame[2] - 12.0 * pixel), Some(Hit::Text { id, .. }) if id == first_id)
2781 );
2782 }
2783
2784 #[test]
2785 fn a_picture_in_an_outline_takes_the_click_over_its_text() {
2786 use onestore::page::{Image, Page, PageObject, ParagraphContent};
2787 let mut engine = TextEngine::default();
2788 let mut source = TextOutline::new(
2789 &mut engine,
2790 TextDocument::new(vec![
2791 Paragraph::new("Before".into(), Default::default()),
2792 Paragraph::new("After".into(), Default::default()),
2793 ])
2794 .unwrap(),
2795 240.0,
2796 [36.0, 36.0],
2797 )
2798 .unwrap()
2799 .snapshot();
2800 let mut picture = source.paragraphs[0].clone();
2801 picture.id = onestore::page::text::new_id().unwrap();
2802 let id = onestore::page::text::new_id().unwrap();
2803 picture.content = ParagraphContent::Image(Image {
2804 size: Some([40.0, 30.0]),
2805 id,
2806 layout: Default::default(),
2807 bytes: None,
2808 alt: None,
2809 background: false,
2810 });
2811 source.paragraphs.insert(1, picture);
2812 let editor = CanvasEditor::from_page(
2813 Page {
2814 title: String::new(),
2815 identity: None,
2816 created: None,
2817 margin_origin: [36.0, 14.4],
2818 definitions: Default::default(),
2819 objects: vec![PageObject::Outline(source)],
2820 },
2821 &mut engine,
2822 )
2823 .unwrap();
2824 let (origin, size) = editor.image_placement(id).unwrap();
2825 let center = [origin[0] + size[0] / 2.0, origin[1] + size[1] / 2.0];
2826 assert_eq!(
2827 page_hit_test(&editor, None, center, 1.0),
2828 Some(Hit::Image { id, handle: [0, 0] })
2829 );
2830 assert!(matches!(
2831 page_hit_test(&editor, None, [origin[0] + 2.0, origin[1] - 4.0], 1.0),
2832 Some(Hit::Text { .. })
2833 ));
2834 }
2835
2836 #[test]
2837 fn picture_handles_resize_as_onenote_does() {
2838 // Native drags of the 333 x 200.1 pt mockup on "av: casual night in the trees".
2839 let (origin, size) = ([468.0, 86.4], [333.0, 200.1]);
2840 let (corner_origin, corner) = resize_image(origin, size, [1, -1], [-75.0, 30.75]);
2841 assert!((corner[0] - 281.827).abs() < 0.01 && (corner[1] - 169.351).abs() < 0.01);
2842 assert!((corner_origin[1] - 117.15).abs() < 0.01 && corner_origin[0] == 468.0);
2843 assert_eq!(
2844 resize_image(origin, size, [1, 0], [46.5, 10.0]),
2845 (origin, [379.5, 200.1])
2846 );
2847 let (left_origin, left) = resize_image(origin, size, [-1, 0], [400.0, 0.0]);
2848 assert_eq!(left, [1.0, 200.1]);
2849 assert_eq!(left_origin[0], 468.0 + 333.0 - 1.0);
2850 let pixel = 0.75;
2851 let rect = image_rect(origin, size);
2852 assert_eq!(
2853 image_handle_at(rect, pixel, [468.0 - 3.75, 86.4 - 3.75]),
2854 Some([-1, -1])
2855 );
2856 assert_eq!(
2857 image_handle_at(rect, pixel, [468.0 + 166.5, 286.5 + 3.75]),
2858 Some([0, 1])
2859 );
2860 assert_eq!(image_handle_at(rect, pixel, [600.0, 150.0]), None);
2861 }
2862
2863 #[test]
2864 fn grouped_drag_keeps_the_initial_word_when_reversing_direction() {
2865 let selection = |start, end| {
2866 Selection::from([start, end].map(|offset| canvas::document::TextPosition {
2867 paragraph: 0,
2868 offset,
2869 }))
2870 };
2871 let anchor = selection(6, 10);
2872 assert_eq!(
2873 drag_selection(anchor, selection(11, 16), SelectionUnit::Word).positions,
2874 selection(6, 16).positions
2875 );
2876 assert_eq!(
2877 drag_selection(anchor, selection(0, 5), SelectionUnit::Word).positions,
2878 selection(10, 0).positions
2879 );
2880 assert_eq!(
2881 drag_selection(anchor, anchor, SelectionUnit::Word).positions,
2882 anchor.positions
2883 );
2884 let anchor = selection(10, 3);
2885 assert_eq!(
2886 drag_selection(anchor, selection(7, 7), SelectionUnit::Grapheme).positions,
2887 selection(10, 7).positions
2888 );
2889 }
2890
2891 const DEFAULT_MARGIN: [f32; 2] = [36.0, 14.4];
2892
2893 #[test]
2894 fn native_grid_follows_the_page_margin_origin() {
2895 // "av: casual night in the trees": margin 36.75; a 22.5 x 15 pt drag lands one cell on.
2896 let margin = [36.75, 14.4];
2897 let result = snap_to_grid([468.75 + 22.5, 86.4 + 15.0], margin);
2898 assert!((result[0] - 486.75).abs() < 0.0001);
2899 assert!((result[1] - 104.4).abs() < 0.0001);
2900 }
2901
2902 #[test]
2903 fn native_grid_matches_drag_offsets_midpoints_and_zoom() {
2904 for (pixels, points) in [
2905 (5.0, 0.0),
2906 (6.0, 0.0),
2907 (7.0, 0.0),
2908 (8.0, 0.0),
2909 (11.0, 0.0),
2910 (12.0, 9.0),
2911 (13.0, 18.0),
2912 (17.0, 18.0),
2913 (18.0, 18.0),
2914 (19.0, 18.0),
2915 (25.0, 18.0),
2916 (37.0, 36.0),
2917 (-6.0, 0.0),
2918 (-7.0, 0.0),
2919 (-12.0, -9.0),
2920 (-13.0, -18.0),
2921 (-19.0, -18.0),
2922 ] {
2923 let origin = [486.0, 230.40001];
2924 let proposed = origin.map(|v| v + pixels * 72.0 / 96.0);
2925 let result = snap_to_grid(proposed, DEFAULT_MARGIN);
2926 for axis in 0..2 {
2927 assert!((result[axis] - origin[axis] - points).abs() < 0.00004);
2928 }
2929 }
2930 for pixels in [6.0, 7.0, 12.0, 13.0, 19.0] {
2931 let result = snap_to_grid(
2932 [491.25 + pixels * 0.75, 235.65 + pixels * 0.75],
2933 DEFAULT_MARGIN,
2934 );
2935 assert!((result[0] - 504.0).abs() < 0.00004);
2936 assert!((result[1] - 248.4).abs() < 0.00004);
2937 }
2938 for (scale, delta, expected) in [
2939 (96.0 / 72.0, [27.0, 31.0], [54.0, 104.4]),
2940 (96.0 / 72.0, [13.0, 17.0], [54.0, 104.4]),
2941 (192.0 / 72.0, [13.0, 17.0], [36.0, 104.4]),
2942 ] {
2943 for dpr in [1.0, 2.0] {
2944 let origin = [36.0, 90.0];
2945 let proposed =
2946 std::array::from_fn(|axis| origin[axis] + delta[axis] * dpr / (scale * dpr));
2947 let result = snap_to_grid(proposed, DEFAULT_MARGIN);
2948 for axis in 0..2 {
2949 assert!((result[axis] - expected[axis]).abs() < 0.00004);
2950 }
2951 }
2952 }
2953 for point in [[9.0, 5.4], [-9.0, -12.6], [423.0, 239.4]] {
2954 assert_eq!(snap_to_grid(point, DEFAULT_MARGIN), point);
2955 }
2956 }
2957
2958 #[test]
2959 fn native_fresh_click_grid_uses_screen_offset_at_both_zoom_levels() {
2960 for (x, y, scale, expected) in [
2961 (900.0, 500.0, 96.0 / 72.0, [639.0, 302.4]),
2962 (901.0, 500.0, 96.0 / 72.0, [648.0, 302.4]),
2963 (906.0, 500.0, 96.0 / 72.0, [648.0, 302.4]),
2964 (913.0, 500.0, 96.0 / 72.0, [648.0, 302.4]),
2965 (900.0, 501.0, 96.0 / 72.0, [639.0, 302.4]),
2966 (900.0, 506.0, 96.0 / 72.0, [639.0, 320.4]),
2967 (900.0, 520.0, 96.0 / 72.0, [639.0, 320.4]),
2968 (900.0, 500.0, 192.0 / 72.0, [324.0, 158.4]),
2969 ] {
2970 for dpr in [1.0, 2.0] {
2971 let viewport = Viewport {
2972 size: [(1600.0 * dpr) as u32, (900.0 * dpr) as u32],
2973 origin: [48.0 * dpr, 83.0 * dpr],
2974 scale: scale * dpr,
2975 };
2976 let point = viewport.document_point([x * dpr, y * dpr]);
2977 let result = snap_to_grid(
2978 [point[0], point[1] - 7.0 * dpr / viewport.scale],
2979 DEFAULT_MARGIN,
2980 );
2981 for axis in 0..2 {
2982 assert!((result[axis] - expected[axis]).abs() < 0.00004);
2983 }
2984 }
2985 }
2986 }
2987
2988 #[test]
2989 fn blank_caret_and_composition_have_accessible_text_without_outline_chrome() {
2990 let mut engine = TextEngine::default();
2991 let mut editor = CanvasEditor::new(
2992 &mut engine,
2993 TextDocument::new(vec![Paragraph::new(String::new(), Default::default())]).unwrap(),
2994 240.0,
2995 )
2996 .unwrap();
2997 editor
2998 .place_caret(&mut engine, [120.0, 100.0], 240.0)
2999 .unwrap();
3000 let mut access = accessibility::Accessibility::default();
3001 let viewport = Viewport {
3002 size: [1000, 720],
3003 scale: 96.0 / 72.0,
3004 origin: [48.0; 2],
3005 };
3006 let initial = access.update(&editor, viewport, "Page", None).unwrap();
3007 let id = editor.active_outline().id;
3008 assert_eq!(access.outline_for_node(initial.focus), Some(id));
3009 let rectangles = |editor: &CanvasEditor| {
3010 page_primitives(editor, None, None, None, true, viewport.scale, 1.0)
3011 .unwrap()
3012 .into_iter()
3013 .filter_map(|p| match p {
3014 Primitive::Rect { rect, color }
3015 | Primitive::RoundedRect { rect, color, .. } => Some((rect, color)),
3016 _ => None,
3017 })
3018 .collect::<Vec<_>>()
3019 };
3020 assert_eq!(rectangles(&editor).len(), 1);
3021 assert_eq!(
3022 page_hit_test(&editor, None, [120.0, 100.0], 1.0 / viewport.scale),
3023 None
3024 );
3025 editor.compose(&mut engine, "に".into(), 1..1).unwrap();
3026 assert!(editor.outlines().is_empty());
3027 assert!(
3028 rectangles(&editor)
3029 .iter()
3030 .all(|(_, color)| *color == [0.0, 0.0, 0.0, 1.0]
3031 || *color == macos::text_colors()[0])
3032 );
3033 let preedit = access.update(&editor, viewport, "Page", None).unwrap();
3034 assert_eq!(preedit.focus, initial.focus);
3035 editor.commit_text(&mut engine, "日本".into()).unwrap();
3036 let committed = access.update(&editor, viewport, "Page", None).unwrap();
3037 assert_eq!(committed.focus, initial.focus);
3038 assert!(rectangles(&editor).len() > 1);
3039 editor.undo(&mut engine).unwrap();
3040 assert!(editor.outlines().is_empty());
3041 assert_eq!(rectangles(&editor).len(), 1);
3042 let empty = access.update(&editor, viewport, "Page", None).unwrap();
3043 assert_eq!(empty.focus, committed.focus);
3044 assert_eq!(access.outline_for_node(committed.focus), Some(id));
3045 editor.redo(&mut engine).unwrap();
3046 editor.select_all().unwrap();
3047 editor.delete(&mut engine, true).unwrap();
3048 assert!(editor.outlines().is_empty());
3049 assert_eq!(rectangles(&editor).len(), 1);
3050 let retired = access.update(&editor, viewport, "Page", None).unwrap();
3051 assert_eq!(retired.focus, committed.focus);
3052 assert_eq!(access.outline_for_node(retired.focus), Some(id));
3053 editor.undo(&mut engine).unwrap();
3054 assert!(rectangles(&editor).len() > 1);
3055 let restored = access.update(&editor, viewport, "Page", None).unwrap();
3056 assert_eq!(restored.focus, committed.focus);
3057 editor.redo(&mut engine).unwrap();
3058 assert_eq!(rectangles(&editor).len(), 1);
3059 assert!(editor.caret_outline().unwrap().is_empty());
3060 editor.insert(&mut engine, " ").unwrap();
3061 assert!(editor.caret_outline().unwrap().is_empty());
3062 editor.compose(&mut engine, "に".into(), 1..1).unwrap();
3063 assert!(!editor.caret_outline().unwrap().is_empty());
3064 }
3065
3066 #[test]
3067 fn table_glyphs_highlights_and_selection_share_cell_paint_bounds() {
3068 let mut engine = TextEngine::default();
3069 let mut editor = CanvasEditor::new(
3070 &mut engine,
3071 TextDocument::new(vec![Paragraph::new(
3072 "M".into(),
3073 Format {
3074 font_size: Some(130.0),
3075 highlight: Some(0xffff),
3076 ..Default::default()
3077 },
3078 )])
3079 .unwrap(),
3080 180.0,
3081 )
3082 .unwrap();
3083 editor
3084 .move_selection(&mut engine, Movement::LineEnd, false)
3085 .unwrap();
3086 editor.tab(&mut engine, false).unwrap();
3087 editor.insert(&mut engine, "R").unwrap();
3088 editor.tab(&mut engine, true).unwrap();
3089 let mut primitives = Vec::new();
3090 append_outline(
3091 Some(&editor),
3092 editor.active_outline(),
3093 [24.0, 48.0],
3094 false,
3095 1.0,
3096 1.0,
3097 &mut primitives,
3098 )
3099 .unwrap();
3100 let bounds = editor.active_outline().shaped().tables[0]
3101 .cells
3102 .iter()
3103 .map(|cell| {
3104 let [left, top, right, bottom] = cell.text_bounds();
3105 [left + 24.0, top + 48.0, right + 24.0, bottom + 48.0]
3106 })
3107 .collect::<Vec<_>>();
3108 let text = primitives
3109 .iter()
3110 .filter_map(|primitive| match primitive {
3111 Primitive::Text { clip, .. } => Some(clip.unwrap()),
3112 _ => None,
3113 })
3114 .collect::<Vec<_>>();
3115 assert_eq!(text, bounds);
3116 let highlights = primitives
3117 .iter()
3118 .filter_map(|primitive| match primitive {
3119 Primitive::Rect { rect, color } if *color == canvas::gpu::colorref(0xffff) => {
3120 Some(*rect)
3121 }
3122 _ => None,
3123 })
3124 .collect::<Vec<_>>();
3125 assert_eq!(highlights.len(), 2);
3126 for (rect, bounds) in highlights.iter().zip(&bounds) {
3127 assert_eq!(rect[2], bounds[2]);
3128 assert!(rect[0] >= bounds[0] && rect[1] >= bounds[1] && rect[3] <= bounds[3]);
3129 }
3130 let selection = primitives
3131 .iter()
3132 .find_map(|primitive| match primitive {
3133 Primitive::Rect { rect, color } if *color == macos::text_colors()[1] => Some(*rect),
3134 _ => None,
3135 })
3136 .unwrap();
3137 assert_eq!(selection[2], bounds[0][2]);
3138 assert!(editor.caret(1.0).unwrap().x0 + 24.0 > f64::from(bounds[0][2]));
3139 }
3140
3141 #[test]
3142 fn editable_tables_paint_borders_before_selection_and_cell_text() {
3143 let mut engine = TextEngine::default();
3144 let mut editor = CanvasEditor::new(
3145 &mut engine,
3146 TextDocument::new(vec![Paragraph::new("Left".into(), Format::default())]).unwrap(),
3147 180.0,
3148 )
3149 .unwrap();
3150 editor
3151 .move_selection(&mut engine, Movement::LineEnd, false)
3152 .unwrap();
3153 editor.tab(&mut engine, false).unwrap();
3154 editor.insert(&mut engine, "Right").unwrap();
3155 editor.tab(&mut engine, true).unwrap();
3156 let mut primitives = Vec::new();
3157 append_outline(
3158 Some(&editor),
3159 editor.active_outline(),
3160 [24.0, 48.0],
3161 false,
3162 1.0,
3163 1.0,
3164 &mut primitives,
3165 )
3166 .unwrap();
3167 let color = canvas::gpu::colorref(0x00a3a3a3);
3168 assert!(matches!(primitives[0], Primitive::RoundedRect {
3169 stroke: Some(canvas::gpu::Stroke::Solid(0.75)), color: actual, ..
3170 } if actual == color));
3171 assert!(matches!(primitives[1], Primitive::Rect { color: actual, .. } if actual == color));
3172 assert!(
3173 matches!(primitives[2], Primitive::Rect { color, .. } if color == macos::text_colors()[1])
3174 );
3175 let painted = primitives
3176 .iter()
3177 .filter_map(|primitive| match primitive {
3178 Primitive::Text { layout, origin, .. } => Some((layout.id(), *origin)),
3179 _ => None,
3180 })
3181 .collect::<Vec<_>>();
3182 let expected = editor
3183 .active_outline()
3184 .layouts()
3185 .map(|(_, paragraph)| {
3186 (
3187 paragraph.text.id(),
3188 [paragraph.origin[0] + 24.0, paragraph.origin[1] + 48.0],
3189 )
3190 })
3191 .collect::<Vec<_>>();
3192 assert_eq!(painted, expected);
3193 assert_eq!(painted.len(), 2);
3194 }
3195
3196 #[test]
3197 fn resize_preview_paints_reflowed_text_without_changing_the_editor() {
3198 let mut engine = TextEngine::default();
3199 let editor = CanvasEditor::new(
3200 &mut engine,
3201 TextDocument::new(vec![Paragraph::new(
3202 "A paragraph with enough words to wrap when the resize handle moves inward.".into(),
3203 Format::default(),
3204 )])
3205 .unwrap(),
3206 240.0,
3207 )
3208 .unwrap();
3209 let original = editor.active_outline().snapshot();
3210 let original_layout = editor
3211 .active_outline()
3212 .paragraph_layout(0)
3213 .unwrap()
3214 .text
3215 .id();
3216 let resized = editor.preview_resize(&mut engine, 72.0).unwrap();
3217 let primitives = page_primitives(
3218 &editor,
3219 None,
3220 Some(PointerFeedback::Resize(&resized)),
3221 None,
3222 false,
3223 96.0 / 72.0,
3224 1.0,
3225 )
3226 .unwrap();
3227 let painted = primitives
3228 .iter()
3229 .find_map(|primitive| match primitive {
3230 Primitive::Text { layout, .. } => Some(*layout),
3231 _ => None,
3232 })
3233 .unwrap();
3234 assert_eq!(painted.id(), resized.paragraph_layout(0).unwrap().text.id());
3235 assert!(
3236 painted.lines().count()
3237 > editor
3238 .active_outline()
3239 .paragraph_layout(0)
3240 .unwrap()
3241 .text
3242 .lines()
3243 .count()
3244 );
3245 assert_eq!(editor.active_outline().layout(), &original.layout);
3246 assert_eq!(
3247 editor
3248 .active_outline()
3249 .paragraph_layout(0)
3250 .unwrap()
3251 .text
3252 .id(),
3253 original_layout
3254 );
3255 assert!(primitives.iter().any(
3256 |primitive| matches!(primitive, Primitive::RoundedRect { stroke, .. } if stroke.is_some())
3257 ));
3258 }
3259
3260 #[test]
3261 fn native_chrome_geometry_preserves_text_and_matches_hit_regions_across_zoom_and_dpi() {
3262 let mut engine = TextEngine::default();
3263 let format = Format {
3264 font: Some("Arial".into()),
3265 font_size: Some(11.0),
3266 ..Default::default()
3267 };
3268 let mut editor = CanvasEditor::new(&mut engine, TextDocument::new(vec![
3269 Paragraph::new("HAMBURGEFONTS abcdefghijklmnopqrstuvwxyz 0123456789 HAMBURGEFONTS abcdefghijklmnopqrstuvwxyz 0123456789 HAMBURGEFONTS abcdefghijklmnopqrstuvwxyz 0123456789".into(), format.clone()),
3270 Paragraph::new("Second paragraph abcdefghijklmnopqrstuvwxyz.".into(), format),
3271 ]).unwrap(), 220.0).unwrap();
3272 let id = editor.active_outline().id;
3273 editor.move_outline(id, [36.0, 90.0]).unwrap();
3274 let document = editor.active_outline().document().clone();
3275 let layout_ids: Vec<_> = editor
3276 .active_outline()
3277 .layouts()
3278 .map(|(_, p)| p.text.id())
3279 .collect();
3280 for (zoom, expected) in [
3281 (0.5, [62.0, 134.0, 223.0, 219.0]),
3282 (1.0, [81.0, 189.0, 394.0, 348.0]),
3283 (1.5, [101.0, 245.0, 565.0, 479.0]),
3284 (2.0, [120.0, 300.0, 736.0, 608.0]),
3285 ] {
3286 for dpr in [1.0, 2.0] {
3287 let scale = zoom * (96.0 / 72.0) * dpr;
3288 let pixel = dpr / scale;
3289 let (frame, body_top) = outline_chrome(editor.active_outline(), pixel);
3290 for (index, coordinate) in frame.iter().enumerate() {
3291 let origin = if index % 2 == 0 { 48.0 } else { 83.0 };
3292 let screen = coordinate * scale / dpr + origin;
3293 assert!(
3294 (screen - expected[index]).abs() <= 1.5,
3295 "zoom={zoom} dpr={dpr} edge={index}: {screen} vs {}",
3296 expected[index]
3297 );
3298 }
3299 let header = [36.0, (frame[1] + body_top) / 2.0];
3300 assert!(
3301 matches!(page_hit_test(&editor, None, header, pixel), Some(Hit::Handle {id: hit, ..}) if hit == id)
3302 );
3303 assert!(matches!(
3304 page_hit_test(&editor, None, [frame[2] - 4.0 * pixel, header[1]], pixel),
3305 Some(Hit::Resize { id: hit, .. }) if hit == id
3306 ));
3307 let padding = [frame[0] + pixel, 90.0];
3308 assert!(
3309 matches!(page_hit_test(&editor, None, padding, pixel), Some(Hit::Text {id: hit, ..}) if hit == id)
3310 );
3311 assert_eq!(
3312 page_hit_test(&editor, None, [frame[0] - pixel, 90.0], pixel),
3313 None
3314 );
3315 let primitives =
3316 page_primitives(&editor, None, None, None, false, scale, dpr).unwrap();
3317 assert!(
3318 matches!(&primitives[0], Primitive::RoundedRect {rect, ..} if *rect == [frame[0], frame[1], frame[2], body_top])
3319 );
3320 assert!(
3321 primitives.iter().any(
3322 |p| matches!(p, Primitive::Text {origin, ..} if *origin == [36.0, 90.0])
3323 )
3324 );
3325 }
3326 }
3327 assert_eq!(editor.active_outline().document(), &document);
3328 assert_eq!(
3329 editor
3330 .active_outline()
3331 .layouts()
3332 .map(|(_, p)| p.text.id())
3333 .collect::<Vec<_>>(),
3334 layout_ids
3335 );
3336 }
3337
3338 #[test]
3339 fn a_later_text_body_takes_priority_over_an_earlier_drag_handle() {
3340 let mut engine = TextEngine::default();
3341 let mut editor = CanvasEditor::new(
3342 &mut engine,
3343 TextDocument::new(vec![Paragraph::new("first".into(), Default::default())]).unwrap(),
3344 240.0,
3345 )
3346 .unwrap();
3347 let first = editor.active_outline().id;
3348 editor.move_outline(first, [0.0, 20.0]).unwrap();
3349 assert_eq!(
3350 page_hit_test(&editor, None, [5.0, 12.0], 1.0),
3351 Some(Hit::Handle {
3352 id: first,
3353 grab: [5.0, -8.0]
3354 })
3355 );
3356 let second = editor
3357 .create_outline(&mut engine, [0.0, 10.0], 240.0)
3358 .unwrap();
3359 assert_eq!(
3360 page_hit_test(&editor, None, [5.0, 12.0], 1.0),
3361 Some(Hit::Text {
3362 id: second,
3363 point: [5.0, 2.0]
3364 })
3365 );
3366 editor.undo(&mut engine).unwrap();
3367 assert_eq!(
3368 page_hit_test(&editor, None, [5.0, 12.0], 1.0),
3369 Some(Hit::Handle {
3370 id: first,
3371 grab: [5.0, -8.0]
3372 })
3373 );
3374 }
3375
3376 #[test]
3377 fn overlapping_objects_follow_paint_order_through_creation_movement_and_undo() {
3378 use onestore::page::{Outline, PageObject, Unsupported};
3379 for readonly_on_top in [false, true] {
3380 let mut engine = TextEngine::default();
3381 let document = TextDocument::new(vec![Paragraph::new(
3382 "Imported text".into(),
3383 Default::default(),
3384 )])
3385 .unwrap();
3386 let id = onestore::ExGuid {
3387 guid: [5; 16],
3388 n: 1,
3389 };
3390 let mut objects = vec![
3391 PageObject::Outline(Outline {
3392 id,
3393 title: false,
3394 min_width: None,
3395 layout: onestore::document::Layout {
3396 x: Some(30.0),
3397 y: Some(40.0),
3398 max_width: Some(180.0),
3399 ..Default::default()
3400 },
3401 indents: vec![18.0, 0.0],
3402 paragraphs: document.nodes().to_vec(),
3403 unsupported: Vec::new(),
3404 }),
3405 PageObject::Unsupported(Unsupported {
3406 id: Default::default(),
3407 jcid: 0xdead,
3408 layout: onestore::document::Layout {
3409 x: Some(20.0),
3410 y: Some(20.0),
3411 max_width: Some(200.0),
3412 max_height: Some(100.0),
3413 ..Default::default()
3414 },
3415 }),
3416 ];
3417 if !readonly_on_top {
3418 objects.reverse();
3419 }
3420 let page = Page {
3421 identity: None,
3422 created: None,
3423 title: String::new(),
3424 margin_origin: [0.0; 2],
3425 definitions: Default::default(),
3426 objects,
3427 };
3428 let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap();
3429 let scene = (scene, [0.0; 2]);
3430 let imported_layout = editor
3431 .active_outline()
3432 .layouts()
3433 .next()
3434 .unwrap()
3435 .1
3436 .text
3437 .id();
3438 let texts: Vec<_> = page_primitives(&editor, Some(&scene), None, None, false, 1.0, 1.0)
3439 .unwrap()
3440 .into_iter()
3441 .filter_map(|p| match p {
3442 Primitive::Text { layout, .. } => Some(layout.id()),
3443 _ => None,
3444 })
3445 .collect();
3446 assert_eq!(texts.len(), 2);
3447 assert_eq!(texts[usize::from(!readonly_on_top)], imported_layout);
3448 let expected = if readonly_on_top {
3449 Hit::ReadOnly(0)
3450 } else {
3451 Hit::Text {
3452 id,
3453 point: [10.0, 5.0],
3454 }
3455 };
3456 assert_eq!(
3457 page_hit_test(&editor, Some(&scene), [40.0, 45.0], 1.0),
3458 Some(expected)
3459 );
3460 let header = page_hit_test(&editor, Some(&scene), [40.0, 32.0], 1.0);
3461 if readonly_on_top {
3462 assert_eq!(header, Some(Hit::ReadOnly(0)));
3463 } else {
3464 assert_eq!(
3465 header,
3466 Some(Hit::Handle {
3467 id,
3468 grab: [10.0, -8.0]
3469 })
3470 );
3471 }
3472 let annotation = editor
3473 .create_outline(&mut engine, [30.0, 40.0], 180.0)
3474 .unwrap();
3475 assert_eq!(
3476 page_hit_test(&editor, Some(&scene), [40.0, 45.0], 1.0),
3477 Some(Hit::Text {
3478 id: annotation,
3479 point: [10.0, 5.0]
3480 })
3481 );
3482 assert_eq!(
3483 page_hit_test(&editor, Some(&scene), [40.0, 32.0], 1.0),
3484 Some(Hit::Handle {
3485 id: annotation,
3486 grab: [10.0, -8.0]
3487 })
3488 );
3489 let texts: Vec<_> = page_primitives(&editor, Some(&scene), None, None, false, 1.0, 1.0)
3490 .unwrap()
3491 .into_iter()
3492 .filter_map(|p| match p {
3493 Primitive::Text { layout, .. } => Some(layout.id()),
3494 _ => None,
3495 })
3496 .collect();
3497 assert_eq!(
3498 *texts.last().unwrap(),
3499 editor
3500 .active_outline()
3501 .layouts()
3502 .next()
3503 .unwrap()
3504 .1
3505 .text
3506 .id()
3507 );
3508 editor.move_outline(annotation, [500.0, 500.0]).unwrap();
3509 assert!(
3510 !matches!(page_hit_test(&editor, Some(&scene), [40.0,45.0], 1.0), Some(Hit::Text { id, .. }) if id == annotation)
3511 );
3512 editor.undo(&mut engine).unwrap();
3513 assert_eq!(
3514 page_hit_test(&editor, Some(&scene), [40.0, 45.0], 1.0),
3515 Some(Hit::Text {
3516 id: annotation,
3517 point: [10.0, 5.0]
3518 })
3519 );
3520 editor.undo(&mut engine).unwrap();
3521 assert_eq!(editor.outlines().len(), 1);
3522 assert_eq!(editor.active_outline().document(), &document);
3523 assert_eq!(
3524 page_hit_test(&editor, Some(&scene), [10.0, 10.0], 1.0),
3525 None
3526 );
3527 }
3528 }
3529
3530 #[test]
3531 fn read_only_focus_accepts_navigation_without_edit_shortcuts() {
3532 for modifiers in [
3533 ModifiersState::empty(),
3534 ModifiersState::SHIFT,
3535 ModifiersState::ALT,
3536 ModifiersState::SUPER,
3537 ModifiersState::SUPER | ModifiersState::SHIFT,
3538 ModifiersState::SUPER | ModifiersState::ALT,
3539 ] {
3540 for key in [
3541 Key::Character("a".into()),
3542 Key::Character("v".into()),
3543 Key::Character("c".into()),
3544 Key::Character("x".into()),
3545 Key::Character("z".into()),
3546 Key::Character("🌳".into()),
3547 Key::Named(NamedKey::Backspace),
3548 Key::Named(NamedKey::Delete),
3549 Key::Named(NamedKey::Enter),
3550 Key::Named(NamedKey::ArrowLeft),
3551 Key::Named(NamedKey::ArrowRight),
3552 Key::Named(NamedKey::ArrowUp),
3553 Key::Named(NamedKey::ArrowDown),
3554 ] {
3555 assert!(
3556 !read_only_shortcut(&key, modifiers),
3557 "{key:?} {modifiers:?}"
3558 );
3559 }
3560 }
3561 for (key, modifiers) in [
3562 (Key::Named(NamedKey::Escape), ModifiersState::empty()),
3563 (Key::Named(NamedKey::Tab), ModifiersState::CONTROL),
3564 (
3565 Key::Named(NamedKey::Tab),
3566 ModifiersState::CONTROL | ModifiersState::SHIFT,
3567 ),
3568 (
3569 Key::Character("N".into()),
3570 ModifiersState::SUPER | ModifiersState::SHIFT,
3571 ),
3572 (Key::Character("+".into()), ModifiersState::SUPER),
3573 (Key::Character("0".into()), ModifiersState::SUPER),
3574 (Key::Character("-".into()), ModifiersState::SUPER),
3575 ] {
3576 assert!(read_only_shortcut(&key, modifiers));
3577 }
3578 assert!(!read_only_shortcut(
3579 &Key::Character("n".into()),
3580 ModifiersState::SUPER
3581 ));
3582 assert!(!read_only_shortcut(
3583 &Key::Named(NamedKey::Tab),
3584 ModifiersState::empty()
3585 ));
3586 }
3587
3588 #[test]
3589 fn read_only_focus_retires_text_overlays_and_draws_a_scaled_focus_border() {
3590 use onestore::page::{PageObject, Unsupported};
3591 let mut engine = TextEngine::default();
3592 let mut editor = CanvasEditor::new(
3593 &mut engine,
3594 TextDocument::new(vec![Paragraph::new("Draft".into(), Default::default())]).unwrap(),
3595 240.0,
3596 )
3597 .unwrap();
3598 let page = Page {
3599 identity: None,
3600 created: None,
3601 title: String::new(),
3602 margin_origin: [0.0; 2],
3603 definitions: Default::default(),
3604 objects: vec![PageObject::Unsupported(Unsupported {
3605 id: Default::default(),
3606 jcid: 0xdead,
3607 layout: onestore::document::Layout {
3608 x: Some(100.0),
3609 y: Some(80.0),
3610 max_width: Some(180.0),
3611 max_height: Some(80.0),
3612 ..Default::default()
3613 },
3614 })],
3615 };
3616 let scene = (PageScene::new(page, &mut engine).unwrap(), [20.0, 30.0]);
3617 let original = editor.active_outline().document().clone();
3618 let rectangles = |primitives: Vec<Primitive<'_>>| {
3619 primitives
3620 .into_iter()
3621 .filter_map(|p| match p {
3622 Primitive::Rect { rect, color }
3623 | Primitive::RoundedRect { rect, color, .. } => Some((rect, color)),
3624 _ => None,
3625 })
3626 .collect::<Vec<_>>()
3627 };
3628 for scale in [1.0, 2.0] {
3629 let text = rectangles(
3630 page_primitives(&editor, Some(&scene), None, None, true, scale, 1.0).unwrap(),
3631 );
3632 assert!(
3633 text.iter()
3634 .any(|(_, color)| *color == macos::text_colors()[0])
3635 );
3636 let selected = rectangles(
3637 page_primitives(
3638 &editor,
3639 Some(&scene),
3640 None,
3641 Some(ObjectFocus::ReadOnly(0)),
3642 true,
3643 scale,
3644 1.0,
3645 )
3646 .unwrap(),
3647 );
3648 assert!(
3649 !selected
3650 .iter()
3651 .any(|(_, color)| *color == macos::text_colors()[0])
3652 );
3653 assert_eq!(
3654 selected,
3655 rectangles(
3656 page_primitives(
3657 &editor,
3658 Some(&scene),
3659 None,
3660 Some(ObjectFocus::ReadOnly(0)),
3661 false,
3662 scale,
3663 1.0
3664 )
3665 .unwrap()
3666 )
3667 );
3668 assert_eq!(
3669 selected.last().unwrap().0,
3670 [300.0 - 2.0 / scale, 110.0, 300.0, 190.0]
3671 );
3672 }
3673 editor.select_all().unwrap();
3674 let text = rectangles(
3675 page_primitives(&editor, Some(&scene), None, None, false, 1.0, 1.0).unwrap(),
3676 );
3677 assert!(
3678 text.iter()
3679 .any(|(_, color)| *color == macos::text_colors()[1])
3680 );
3681 let selected = rectangles(
3682 page_primitives(
3683 &editor,
3684 Some(&scene),
3685 None,
3686 Some(ObjectFocus::ReadOnly(0)),
3687 true,
3688 1.0,
3689 1.0,
3690 )
3691 .unwrap(),
3692 );
3693 assert!(
3694 !selected
3695 .iter()
3696 .any(|(_, color)| *color == macos::text_colors()[1])
3697 );
3698 assert_eq!(editor.active_outline().document(), &original);
3699 }
3700
3701 #[test]
3702 #[ignore = "requires CANVAS_TEST_SECTION and CANVAS_TEST_PAGE private fixture inputs"]
3703 fn imported_page_widgets_and_accessibility_follow_edits() {
3704 let bytes = std::fs::read(std::env::var_os("CANVAS_TEST_SECTION").unwrap()).unwrap();
3705 let page = {
3706 let store = onestore::Store::parse(&bytes).unwrap();
3707 let index = onestore::RevisionIndex::parse(&store).unwrap();
3708 Page::from_document(
3709 &onestore::document::Document::parse(&index).unwrap(),
3710 &std::env::var("CANVAS_TEST_PAGE").unwrap(),
3711 )
3712 .unwrap()
3713 };
3714 drop(bytes);
3715 let mut engine = TextEngine::default();
3716 if let Some(font) = std::env::var_os("CANVAS_TEST_SUBSTITUTE") {
3717 engine
3718 .register_substitute(parley::fontique::Blob::new(Arc::new(
3719 std::fs::read(font).unwrap(),
3720 )))
3721 .unwrap();
3722 }
3723 let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap();
3724 let scene = (scene, [0.0; 2]);
3725 let viewport = Viewport {
3726 size: [1000, 720],
3727 scale: 96.0 / 72.0,
3728 origin: [48.0; 2],
3729 };
3730 let mut tag_hits = 0;
3731 for outline in editor.outlines() {
3732 for (_, paragraph) in outline.layouts() {
3733 for tag in &paragraph.tags {
3734 let point = [
3735 outline.origin()[0]
3736 + tag.origin[0]
3737 + canvas::outline::ParagraphTag::SIZE / 2.0,
3738 outline.origin()[1]
3739 + paragraph.origin[1]
3740 + tag.origin[1]
3741 + canvas::outline::ParagraphTag::SIZE / 2.0,
3742 ];
3743 assert!(
3744 matches!(page_hit_test(&editor, Some(&scene), point, 1.0), Some(Hit::Text { id, .. }) if id == outline.id),
3745 "Tag center {point:?} did not target outline {:?}",
3746 outline.id
3747 );
3748 tag_hits += 1;
3749 }
3750 }
3751 }
3752 eprintln!("Verified {tag_hits} imported tag-gutter targets");
3753 let mut access = accessibility::Accessibility::default();
3754 let tags = |editor: &CanvasEditor| {
3755 page_primitives(editor, Some(&scene), None, None, true, viewport.scale, 1.0)
3756 .unwrap()
3757 .into_iter()
3758 .filter_map(|primitive| match primitive {
3759 Primitive::Tag { tag, origin } => Some((tag.clone(), origin)),
3760 _ => None,
3761 })
3762 .collect::<Vec<_>>()
3763 };
3764 let original_tags = tags(&editor);
3765 let ids: Vec<_> = editor.outlines().iter().map(|outline| outline.id).collect();
3766 for id in ids {
3767 editor.focus_outline(id).unwrap();
3768 let original = editor.active_outline().document().clone();
3769 editor
3770 .select(
3771 [canvas::document::TextPosition {
3772 paragraph: 0,
3773 offset: 0,
3774 }; 2]
3775 .into(),
3776 )
3777 .unwrap();
3778 let before = access.update(&editor, viewport, "Test", None).unwrap();
3779 let before = accesskit_consumer::Tree::new(before, true);
3780 let before = before.state().focus().unwrap().document_range().text();
3781 editor.insert(&mut engine, "CANVAS CHECK ").unwrap();
3782 {
3783 let update = access.update(&editor, viewport, "Test", None).unwrap();
3784 let tree = accesskit_consumer::Tree::new(update, true);
3785 assert_eq!(
3786 tree.state().focus().unwrap().document_range().text(),
3787 format!("CANVAS CHECK {before}")
3788 );
3789 let primitives =
3790 page_primitives(&editor, Some(&scene), None, None, true, viewport.scale, 1.0)
3791 .unwrap();
3792 assert!(!primitives.is_empty());
3793 let edited_tags = tags(&editor);
3794 assert_eq!(edited_tags.len(), original_tags.len());
3795 for ((edited, _), (original, _)) in edited_tags.iter().zip(&original_tags) {
3796 assert_eq!(edited.icon, original.icon);
3797 assert_eq!(edited.label, original.label);
3798 assert_eq!(edited.disabled, original.disabled);
3799 }
3800 }
3801 editor.undo(&mut engine).unwrap();
3802 assert_eq!(editor.active_outline().document(), &original);
3803 assert_eq!(tags(&editor), original_tags);
3804 let update = access.update(&editor, viewport, "Test", None).unwrap();
3805 let tree = accesskit_consumer::Tree::new(update, true);
3806 assert_eq!(
3807 tree.state().focus().unwrap().document_range().text(),
3808 before
3809 );
3810 }
3811 eprintln!(
3812 "Rendered and restored {} imported outline widgets and accessibility fields",
3813 editor.outlines().len()
3814 );
3815 }
3816}
crates/snowbound/src/profile.rs deleted-192
......@@ -1,192 +0,0 @@
1use super::*;
2use canvas::document::TextPosition;
3use std::{hint::black_box, process::Command};
4
5fn timed<T>(case: &str, phase: &str, sample: usize, operation: impl FnOnce() -> T) -> T {
6 let start = Instant::now();
7 let result = operation();
8 eprintln!(
9 "canvas_profile\t{case}\t{phase}\t{sample}\t{}",
10 start.elapsed().as_nanos()
11 );
12 result
13}
14
15fn resident(case: &str, phase: &str, sample: usize) {
16 let output = Command::new("/bin/ps")
17 .args(["-o", "rss=", "-p", &std::process::id().to_string()])
18 .output()
19 .unwrap();
20 assert!(output.status.success());
21 let kib: u64 = std::str::from_utf8(&output.stdout)
22 .unwrap()
23 .trim()
24 .parse()
25 .unwrap();
26 eprintln!("canvas_rss_kib\t{case}\t{phase}\t{sample}\t{kib}");
27}
28
29fn layout_ids(editor: &CanvasEditor) -> Vec<u64> {
30 editor
31 .outlines()
32 .iter()
33 .flat_map(|outline| outline.layouts().map(|(_, paragraph)| paragraph.text.id()))
34 .collect()
35}
36
37fn editing_cost(
38 case: &str,
39 engine: &mut TextEngine,
40 editor: &mut CanvasEditor,
41 scene: Option<&(PageScene, [f32; 2])>,
42) {
43 let viewport = Viewport {
44 size: [1000, 720],
45 scale: 96.0 / 72.0,
46 origin: [48.0; 2],
47 };
48 let initial: Vec<_> = timed(case, "opening_snapshot", 0, || {
49 editor
50 .outlines()
51 .iter()
52 .map(|outline| outline.document().clone())
53 .collect()
54 });
55 let mut access = accessibility::Accessibility::default();
56 let update = timed(case, "ax_first", 0, || {
57 access.update(editor, viewport, "Profile", None).unwrap()
58 });
59 eprintln!("canvas_count\t{case}\tax_nodes\t0\t{}", update.nodes.len());
60 drop(update);
61 let count: usize = editor
62 .outlines()
63 .iter()
64 .map(|outline| outline.document().nodes().len())
65 .sum();
66 eprintln!("canvas_count\t{case}\tparagraphs\t0\t{count}");
67 for sample in 0..32 {
68 let index = sample % editor.outlines().len();
69 let id = editor.outlines()[index].id;
70 editor.focus_outline(id).unwrap();
71 let paragraph = if sample % 2 == 0 {
72 0
73 } else {
74 editor.active_outline().layouts().last().unwrap().0
75 };
76 editor
77 .select(
78 [TextPosition {
79 paragraph,
80 offset: 0,
81 }; 2]
82 .into(),
83 )
84 .unwrap();
85 let original_layouts = layout_ids(editor);
86 timed(case, "insert", sample, || {
87 editor.insert(engine, "x").unwrap()
88 });
89 let edited_layouts = layout_ids(editor);
90 let changed = original_layouts
91 .iter()
92 .zip(&edited_layouts)
93 .filter(|(a, b)| a != b)
94 .count();
95 assert_eq!(changed, 1);
96 eprintln!("canvas_count\t{case}\tchanged_layouts\t{sample}\t{changed}");
97 let primitives = timed(case, "frame_primitives", sample, || {
98 let primitives =
99 page_primitives(editor, scene, None, None, true, viewport.scale, 1.0).unwrap();
100 black_box(&primitives);
101 primitives.len()
102 });
103 eprintln!("canvas_count\t{case}\tprimitives\t{sample}\t{primitives}");
104 timed(case, "ax_insert", sample, || {
105 black_box(access.update(editor, viewport, "Profile", None).unwrap());
106 });
107 assert_eq!(edited_layouts, layout_ids(editor));
108 timed(case, "undo", sample, || {
109 assert!(editor.undo(engine).unwrap())
110 });
111 timed(case, "ax_undo", sample, || {
112 black_box(access.update(editor, viewport, "Profile", None).unwrap());
113 });
114 assert_eq!(editor.active_outline().document(), &initial[index]);
115 }
116 resident(case, "after_edits", 0);
117}
118
119#[test]
120#[ignore = "manual release canvas pipeline timing probe"]
121fn canvas_pipeline_cost() {
122 if let Some(path) = std::env::var_os("CANVAS_TEST_SECTION") {
123 let bytes = timed("native", "read", 0, || std::fs::read(path).unwrap());
124 let title = std::env::var("CANVAS_TEST_PAGE").unwrap();
125 let mut engine = timed("native", "engine", 0, TextEngine::default);
126 if let Some(font) = std::env::var_os("CANVAS_TEST_SUBSTITUTE") {
127 timed("native", "font", 0, || {
128 engine
129 .register_substitute(parley::fontique::Blob::new(Arc::new(
130 std::fs::read(font).unwrap(),
131 )))
132 .unwrap()
133 });
134 }
135 let cycles = std::env::var("CANVAS_PROFILE_CYCLES")
136 .map(|value| value.parse::<usize>().unwrap())
137 .unwrap_or(16);
138 assert!(cycles > 0);
139 for cycle in 0..cycles {
140 let page = timed("native", "parse", cycle, || {
141 let store = onestore::Store::parse(&bytes).unwrap();
142 let index = onestore::RevisionIndex::parse(&store).unwrap();
143 Page::from_document(
144 &onestore::document::Document::parse(&index).unwrap(),
145 &title,
146 )
147 .unwrap()
148 });
149 let (scene, mut editor) = timed("native", "scene", cycle, || {
150 PageScene::from_page(page, &mut engine).unwrap()
151 });
152 let scene = (scene, [0.0; 2]);
153 if cycle == 0 {
154 editing_cost("native", &mut engine, &mut editor, Some(&scene));
155 } else {
156 black_box(
157 page_primitives(&editor, Some(&scene), None, None, false, 96.0 / 72.0, 1.0)
158 .unwrap(),
159 );
160 }
161 resident("native", "open", cycle);
162 drop(editor);
163 drop(scene);
164 resident("native", "closed", cycle);
165 }
166 } else {
167 for count in [100, 1_000, 5_000] {
168 let case = format!("synthetic_{count}");
169 let mut engine = timed(&case, "engine", 0, TextEngine::default);
170 let document = timed(&case, "document", 0, || {
171 TextDocument::new(
172 (0..count)
173 .map(|_| {
174 Paragraph::new(
175 "A paragraph of plain text with several words.".into(),
176 Format::default(),
177 )
178 })
179 .collect(),
180 )
181 .unwrap()
182 });
183 let mut editor = timed(&case, "scene", 0, || {
184 CanvasEditor::new(&mut engine, document, 480.0).unwrap()
185 });
186 editing_cost(&case, &mut engine, &mut editor, None);
187 drop(editor);
188 drop(engine);
189 resident(&case, "closed", 0);
190 }
191 }
192}
crates/snowbound/src/scroll.rs deleted-134
......@@ -1,134 +0,0 @@
1use canvas::gpu::{Primitive, Viewport};
2
3pub struct Scroll {
4 pub min: [f32; 2],
5 pub max: [f32; 2],
6}
7
8impl Scroll {
9 pub fn new(viewport: Viewport, bounds: impl Iterator<Item = [f32; 4]>) -> Self {
10 let mut min = [0.0_f32; 2];
11 let mut max = [0.0_f32; 2];
12 for rect in bounds {
13 for axis in 0..2 {
14 min[axis] = min[axis].min(rect[axis]);
15 max[axis] = max[axis].max(rect[axis + 2]);
16 }
17 }
18 // OneNote stops at the page origin or the object furthest above or left of it.
19 for axis in 0..2 {
20 min[axis] *= viewport.scale;
21 max[axis] =
22 ((max[axis] + 36.0) * viewport.scale - viewport.size[axis] as f32).max(min[axis]);
23 }
24 Self { min, max }
25 }
26
27 pub fn clamp(&self, viewport: &mut Viewport) {
28 for axis in 0..2 {
29 viewport.origin[axis] = -(-viewport.origin[axis]).clamp(self.min[axis], self.max[axis]);
30 }
31 }
32
33 pub fn thumb(&self, viewport: Viewport, dpr: f32, axis: usize) -> Option<[f32; 4]> {
34 let range = self.max[axis] - self.min[axis];
35 let length = viewport.size[axis] as f32;
36 let track = length - 20.0 * dpr;
37 if range <= 0.0 || track <= 0.0 {
38 return None;
39 }
40 let size = (track * length / (length + range))
41 .max(28.0 * dpr)
42 .min(track);
43 let position = (-viewport.origin[axis] - self.min[axis]) / range;
44 let start = 4.0 * dpr + position.clamp(0.0, 1.0) * (track - size);
45 let cross = viewport.size[1 - axis] as f32 - 4.0 * dpr;
46 Some(if axis == 0 {
47 [start, cross - 6.0 * dpr, start + size, cross]
48 } else {
49 [cross - 6.0 * dpr, start, cross, start + size]
50 })
51 }
52
53 pub fn drag(&self, viewport: &mut Viewport, dpr: f32, axis: usize, pointer: f32, grab: f32) {
54 let Some(rect) = self.thumb(*viewport, dpr, axis) else {
55 return;
56 };
57 let travel = viewport.size[axis] as f32 - 20.0 * dpr - (rect[axis + 2] - rect[axis]);
58 if travel > 0.0 {
59 let fraction = ((pointer - grab - 4.0 * dpr) / travel).clamp(0.0, 1.0);
60 viewport.origin[axis] =
61 -(self.min[axis] + fraction * (self.max[axis] - self.min[axis]));
62 }
63 }
64
65 pub fn hit_test(&self, viewport: Viewport, dpr: f32, point: [f32; 2]) -> Option<(usize, f32)> {
66 (0..2).find_map(|axis| {
67 let rect = self.thumb(viewport, dpr, axis)?;
68 let inset = 3.0 * dpr;
69 ((rect[0] - inset..=rect[2] + inset).contains(&point[0])
70 && (rect[1] - inset..=rect[3] + inset).contains(&point[1]))
71 .then_some((axis, point[axis] - rect[axis]))
72 })
73 }
74
75 pub fn append(&self, viewport: Viewport, dpr: f32, primitives: &mut Vec<Primitive<'_>>) {
76 for axis in 0..2 {
77 if let Some(rect) = self.thumb(viewport, dpr, axis) {
78 let start = viewport.document_point([rect[0], rect[1]]);
79 let end = viewport.document_point([rect[2], rect[3]]);
80 primitives.push(Primitive::RoundedRect {
81 rect: [start[0], start[1], end[0], end[1]],
82 radius: [3.0 * dpr / viewport.scale; 2],
83 stroke: None,
84 color: [0.18, 0.18, 0.18, 0.45],
85 });
86 }
87 }
88 }
89}
90
91#[cfg(test)]
92mod tests {
93 use super::*;
94
95 #[test]
96 fn bounds_include_negative_objects_and_clamp_both_ends() {
97 let mut viewport = Viewport {
98 size: [800, 600],
99 scale: 2.0,
100 origin: [9999.0; 2],
101 };
102 let scroll = Scroll::new(viewport, [[-80.0, -20.0, 1000.0, 1200.0]].into_iter());
103 scroll.clamp(&mut viewport);
104 assert_eq!(viewport.origin, [160.0, 40.0]);
105 viewport.origin = [-9999.0; 2];
106 scroll.clamp(&mut viewport);
107 assert_eq!(viewport.origin, [-1272.0, -1872.0]);
108 }
109
110 #[test]
111 fn small_page_has_no_scrollbars_and_thumb_drag_reaches_endpoints() {
112 for dpr in [1.0, 2.0] {
113 let mut viewport = Viewport {
114 size: [(800.0 * dpr) as u32, (600.0 * dpr) as u32],
115 scale: dpr * 4.0 / 3.0,
116 origin: [48.0 * dpr; 2],
117 };
118 let scroll = Scroll::new(viewport, [[0.0, 0.0, 72.0, 14.0]].into_iter());
119 assert!(scroll.thumb(viewport, dpr, 0).is_none());
120 assert!(scroll.thumb(viewport, dpr, 1).is_none());
121 let scroll = Scroll::new(viewport, [[0.0, 0.0, 2000.0, 2000.0]].into_iter());
122 for axis in 0..2 {
123 scroll.drag(&mut viewport, dpr, axis, -1000.0, 3.0 * dpr);
124 assert_eq!(-viewport.origin[axis], scroll.min[axis]);
125 let rect = scroll.thumb(viewport, dpr, axis).unwrap();
126 assert!((rect[axis] - 4.0 * dpr).abs() < 0.001);
127 scroll.drag(&mut viewport, dpr, axis, 10000.0, 3.0 * dpr);
128 assert_eq!(-viewport.origin[axis], scroll.max[axis]);
129 let rect = scroll.thumb(viewport, dpr, axis).unwrap();
130 assert!((rect[axis + 2] - (viewport.size[axis] as f32 - 16.0 * dpr)).abs() < 0.001);
131 }
132 }
133 }
134}
tools/canvas/README.md+9-6
......@@ -9,7 +9,7 @@ The host opens nonempty UTF-8 text as paragraphs in one temporary text outline.
99```sh
1010python3 tools/canvas/build_macos.py
1111"target/Snowbound.app/Contents/MacOS/Snowbound"
12cargo test -p canvas
12cargo test -p canvas --features interaction
1313cargo test -p canvas --features gpu gpu:: -- --ignored
1414```
1515
......@@ -32,7 +32,10 @@ canvas text edits · inverse history · composition · paragraph layout ·
3232 ↓
3333canvas::gpu Swash glyph rasterization · custom wgpu quads · bounded glyph/image caches
3434 ↓
35snowbound Winit window/input · AppKit quit guard · clipboard · AccessKit
35canvas::interaction hit layers · drags · placement grid · picture handles · chrome · key routing
36 · scrolling and zoom · caret blink · AccessKit tree
37 ↓
38snowbound Winit window and events · AppKit pickers and quit guard · clipboard · saving
3639```
3740
3841The renderer allocates a 2048² RGBA glyph atlas and a 3.5 MiB vertex buffer, limits combined text-glyph and tag-icon entries to 8192, and rebuilds a frame after atlas eviction. Font scaling uses 32 cache entries. These limits bound those caches; they are not a process-memory or worst-case rasterization budget. Drawing and hit-testing share Windows-metric line boxes. First-baseline fidelity and substitute-font metric selection remain experimental; the `windows_*` probe values expose that policy independently from Parley's original metrics.
......@@ -99,7 +102,7 @@ CANVAS_TEST_SECTION=PRIVATE_SECTION_COPY CANVAS_TEST_PAGE=EXACT_PAGE_TITLE CANVA
99102AccessKit exposes each text outline as an independent editable macOS text area. Logical text runs carry the canvas line boxes, cluster advances and source positions, including blank paragraphs and hidden-field projection. They use the editor's retained projections and explicit source indices; separators connect the next visible paragraph across collapsed children. Native selection, whole-field replacement and keyboard edits share the editor history. Focus transfers retire native AppKit preedit when the canvas commits or cancels composition, preventing dead-key state from transferring to another outline. Selection/navigation reveals the caret; pan and zoom keep their own viewport behavior. Accessibility updates run only when the adapter is active and the host changes, not for caret-blink redraws. Cached paragraph runs reuse text and character geometry when the shaped-layout identity, source index, next visible paragraph and local origin match; reflow or changed source mappings invalidate them. The cache retains only the current page’s paragraph ranges and clears on adapter deactivation. IDs survive geometry/selection updates; changed run text or source mappings receive fresh IDs so obsolete action positions are rejected.
100103
101104```sh
102cargo test -p snowbound
105cargo test -p canvas --features interaction accessibility
103106SNOWBOUND_TRACE_INPUT=1 "target/Snowbound.app/Contents/MacOS/Snowbound" SYNTHETIC_TEXT_FILE
104107```
105108
......@@ -130,7 +133,7 @@ The host sleeps its caret timer while macOS reports the window occluded and requ
130133The app's private integration test exercises the actual outline painter and accessibility consumer for each imported outline, then verifies text restoration through undo:
131134
132135```sh
133CANVAS_TEST_SECTION=PRIVATE_SECTION_COPY CANVAS_TEST_PAGE=EXACT_PAGE_TITLE CANVAS_TEST_SUBSTITUTE=CARLITO_REGULAR_FILE cargo test -p snowbound imported_page_widgets_and_accessibility_follow_edits -- --ignored --nocapture
136CANVAS_TEST_SECTION=PRIVATE_SECTION_COPY CANVAS_TEST_PAGE=EXACT_PAGE_TITLE CANVAS_TEST_SUBSTITUTE=CARLITO_REGULAR_FILE cargo test -p canvas --features interaction imported_page_widgets_and_accessibility_follow_edits -- --ignored --nocapture
134137```
135138
136139## Unsupported page content
......@@ -222,8 +225,8 @@ Full native captures, candidate font binaries/licenses and probe outputs remain
222225The opt-in release probe measures engine construction, document baseline cloning, editing, undo, the host’s production page primitive collection and accessibility construction. Synthetic cases contain 100, 1,000 and 5,000 paragraphs. Each runs 32 insertion/undo samples alternating between the first and last paragraph; assertions require one changed layout per insertion, retained layouts through drawing/accessibility, and exact document restoration after undo. The accessibility regression compares cached output with full reconstruction through 160 mixed editing operations and complete undo/redo, including composition, source index changes, resize and viewport transforms.
223226
224227```sh
225cargo test --release -p snowbound canvas_pipeline_cost -- --ignored --nocapture --test-threads=1
226CANVAS_TEST_SECTION=PRIVATE_SECTION_COPY CANVAS_TEST_PAGE=EXACT_PAGE_TITLE CANVAS_TEST_SUBSTITUTE=CARLITO_REGULAR_FILE cargo test --release -p snowbound canvas_pipeline_cost -- --ignored --nocapture --test-threads=1
228cargo test --release -p canvas --features interaction canvas_pipeline_cost -- --ignored --nocapture --test-threads=1
229CANVAS_TEST_SECTION=PRIVATE_SECTION_COPY CANVAS_TEST_PAGE=EXACT_PAGE_TITLE CANVAS_TEST_SUBSTITUTE=CARLITO_REGULAR_FILE cargo test --release -p canvas --features interaction canvas_pipeline_cost -- --ignored --nocapture --test-threads=1
227230```
228231
229232Private-page mode reads the section once and measures 16 parse/open/close cycles with one retained text engine, recording first construction separately from repeated cycles. Set `CANVAS_PROFILE_CYCLES` to a positive count for longer retention runs. It samples this process’s resident memory through macOS `ps` with each page open and closed. Tab-separated `canvas_profile` records report nanoseconds; `canvas_count` records report object counts; `canvas_rss_kib` records report KiB. The test harness can prefix the first record on a line. Times exclude log output, layout identity scans and memory sampling. Primitive collection includes its temporary allocation and destruction but excludes GPU preparation, submission and presentation. File reads can hit the OS cache. Resident memory includes allocator retention and is neither live allocation accounting nor GPU occupancy; these measurements do not establish input-to-present latency or a process memory limit.