authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-06 16:24:32-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-06 16:24:32-07:00
log13f6c1c7a897937e5eb4c3d9260d9071200ddb54
tree5dd83b74ced2e91b66e18b547f3e0baacdc097f6
parent200c25e12ad5083feb3e7594ccf16959042c6c8f
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: merge the canvas crates into snowbound and extend the editor

Assisted-by: gpt-6-astra Assisted-by: claude-fable-5.1

47 files changed, 17497 insertions(+), 7260 deletions(-)

Cargo.lock+38-47
...@@ -389,6 +389,25 @@ dependencies = [...@@ -389,6 +389,25 @@ dependencies = [
389 "wayland-client",389 "wayland-client",
390]390]
391391
392[[package]]
393name = "canvas"
394version = "0.1.0"
395dependencies = [
396 "bytemuck",
397 "getrandom 0.4.3",
398 "image",
399 "onestore",
400 "parley",
401 "png",
402 "pollster",
403 "roxmltree",
404 "serde",
405 "serde_json",
406 "skrifa",
407 "swash",
408 "wgpu",
409]
410
392[[package]]411[[package]]
393name = "cbc"412name = "cbc"
394version = "0.2.1"413version = "0.2.1"
...@@ -1887,53 +1906,6 @@ version = "1.21.4"...@@ -1887,53 +1906,6 @@ version = "1.21.4"
1887source = "registry+https://github.com/rust-lang/crates.io-index"1906source = "registry+https://github.com/rust-lang/crates.io-index"
1888checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"1907checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
18891908
1890[[package]]
1891name = "one-canvas"
1892version = "0.1.0"
1893dependencies = [
1894 "getrandom 0.4.3",
1895 "onestore",
1896 "parley",
1897 "serde",
1898 "serde_json",
1899 "skrifa",
1900]
1901
1902[[package]]
1903name = "one-canvas-app"
1904version = "0.1.0"
1905dependencies = [
1906 "accesskit",
1907 "accesskit_consumer",
1908 "accesskit_winit",
1909 "arboard",
1910 "objc2 0.5.2",
1911 "objc2-app-kit 0.2.2",
1912 "objc2-foundation 0.2.2",
1913 "one-canvas",
1914 "one-canvas-gpu",
1915 "onestore",
1916 "parley",
1917 "pollster",
1918 "wgpu",
1919 "winit",
1920]
1921
1922[[package]]
1923name = "one-canvas-gpu"
1924version = "0.1.0"
1925dependencies = [
1926 "bytemuck",
1927 "image",
1928 "one-canvas",
1929 "onestore",
1930 "parley",
1931 "png",
1932 "pollster",
1933 "swash",
1934 "wgpu",
1935]
1936
1937[[package]]1909[[package]]
1938name = "onestore"1910name = "onestore"
1939version = "0.1.0"1911version = "0.1.0"
...@@ -2665,6 +2637,25 @@ dependencies = [...@@ -2665,6 +2637,25 @@ dependencies = [
2665 "serde",2637 "serde",
2666]2638]
26672639
2640[[package]]
2641name = "snowbound"
2642version = "0.1.0"
2643dependencies = [
2644 "accesskit",
2645 "accesskit_consumer",
2646 "accesskit_winit",
2647 "arboard",
2648 "canvas",
2649 "objc2 0.5.2",
2650 "objc2-app-kit 0.2.2",
2651 "objc2-foundation 0.2.2",
2652 "onestore",
2653 "parley",
2654 "pollster",
2655 "wgpu",
2656 "winit",
2657]
2658
2668[[package]]2659[[package]]
2669name = "socket2"2660name = "socket2"
2670version = "0.6.5"2661version = "0.6.5"
crates/canvas-app/Cargo.toml deleted-23
...@@ -1,23 +0,0 @@
1[package]
2name = "one-canvas-app"
3version = "0.1.0"
4edition = "2024"
5publish = false
6
7[dependencies]
8one-canvas = { path = "../canvas" }
9one-canvas-gpu = { path = "../canvas-gpu" }
10onestore = { path = "../onestore" }
11pollster = "0.4"
12wgpu.workspace = true
13parley.workspace = true
14winit = "0.30.13"
15arboard = { version = "3.6.1", default-features = false }
16accesskit = "0.25.0"
17accesskit_winit = { version = "0.34.0", default-features = false, features = ["rwh_06"] }
18objc2 = "0.5.2"
19objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSAlert", "NSButton", "NSControl", "NSView", "NSResponder", "NSWindow", "NSApplication", "NSEvent"] }
20objc2-foundation = { version = "0.2.2", default-features = false, features = ["NSString", "NSThread"] }
21
22[dev-dependencies]
23accesskit_consumer = "0.39.0"
crates/canvas-app/src/accessibility.rs deleted-1312
...@@ -1,1312 +0,0 @@
1use accesskit::{
2 Action, Affine, Node, NodeId, Rect, Role, TextDirection, TextSelection, TreeId, TreeInfo,
3 TreeUpdate,
4};
5use one_canvas::{
6 document::TextPosition,
7 editor::{CanvasEditor, Selection, TextOutline},
8 text::EditError,
9};
10use one_canvas_gpu::Viewport;
11use parley::Affinity;
12use std::{collections::HashMap, ops::Range};
13
14pub const ROOT: NodeId = NodeId(0);
15
16struct Run {
17 outline: onestore::ExGuid,
18 id: NodeId,
19 node: Node,
20 positions: Vec<TextPosition>,
21}
22
23#[derive(Hash, PartialEq, Eq)]
24struct ParagraphKey {
25 layout: u64,
26 source: usize,
27 next: Option<usize>,
28 origin: [u32; 2],
29}
30
31pub struct Accessibility {
32 outlines: Vec<(onestore::ExGuid, NodeId)>,
33 runs: Vec<Run>,
34 paragraphs: HashMap<ParagraphKey, Range<usize>>,
35 read_only: Vec<NodeId>,
36 next_id: u64,
37}
38
39impl Default for Accessibility {
40 fn default() -> Self {
41 Self {
42 outlines: Vec::new(),
43 runs: Vec::new(),
44 paragraphs: HashMap::new(),
45 read_only: Vec::new(),
46 next_id: 1,
47 }
48 }
49}
50
51impl Accessibility {
52 /// Retire published runs without recycling their IDs.
53 pub fn deactivate(&mut self) {
54 self.runs.clear();
55 self.paragraphs.clear();
56 self.outlines.clear();
57 self.read_only.clear();
58 }
59
60 pub fn append_read_only(
61 &mut self,
62 update: &mut TreeUpdate,
63 scene: Option<&(one_canvas_gpu::page::PageScene, [f32; 2])>,
64 viewport: Viewport,
65 focus: Option<usize>,
66 ) {
67 let mut count = 0;
68 if let Some((scene, offset)) = scene {
69 for (index, object) in scene.read_only().enumerate() {
70 if index == self.read_only.len() {
71 let id = self.allocate();
72 self.read_only.push(id);
73 }
74 let id = self.read_only[index];
75 let mut node = Node::new(Role::Label);
76 node.set_value(object.message);
77 node.set_read_only();
78 node.add_action(Action::Focus);
79 if focus == Some(index) {
80 update.focus = id;
81 }
82 let [x0, y0, x1, y1] = object.rect;
83 let x = |v| f64::from((v + offset[0]) * viewport.scale + viewport.origin[0]);
84 let y = |v| f64::from((v + offset[1]) * viewport.scale + viewport.origin[1]);
85 node.set_bounds(Rect::new(x(x0), y(y0), x(x1), y(y1)));
86 update
87 .nodes
88 .iter_mut()
89 .find(|(id, _)| *id == ROOT)
90 .unwrap()
91 .1
92 .push_child(id);
93 update.nodes.push((id, node));
94 count += 1;
95 }
96 }
97 self.read_only.truncate(count);
98 }
99
100 fn allocate(&mut self) -> NodeId {
101 let id = NodeId(self.next_id);
102 self.next_id = self
103 .next_id
104 .checked_add(1)
105 .expect("Accessibility node IDs exhausted");
106 id
107 }
108
109 pub fn read_only_for_node(&self, node: NodeId) -> Option<usize> {
110 self.read_only.iter().position(|id| *id == node)
111 }
112
113 pub fn outline_for_node(&self, node: NodeId) -> Option<onestore::ExGuid> {
114 self.outlines
115 .iter()
116 .find(|(_, id)| *id == node)
117 .map(|(outline, _)| *outline)
118 }
119
120 pub fn update(
121 &mut self,
122 editor: &CanvasEditor,
123 viewport: Viewport,
124 title: &str,
125 preview: Option<(onestore::ExGuid, [f32; 2])>,
126 ) -> Result<TreeUpdate, EditError> {
127 let mut runs = Vec::new();
128 let mut outlines = Vec::new();
129 let mut paragraph_ranges = HashMap::new();
130 for outline in editor.outlines().iter().chain(editor.caret_outline()) {
131 let field = self
132 .outlines
133 .iter()
134 .find(|(id, _)| *id == outline.id)
135 .map(|(_, node)| *node)
136 .unwrap_or_else(|| self.allocate());
137 outlines.push((outline.id, field));
138 let mut paragraphs = outline.layouts().peekable();
139 while let Some((paragraph, shaped)) = paragraphs.next() {
140 let layout = &shaped.text;
141 let origin = shaped.origin;
142 let key = ParagraphKey {
143 layout: layout.id(),
144 source: paragraph,
145 next: paragraphs.peek().map(|(next, _)| *next),
146 origin: origin.map(f32::to_bits),
147 };
148 let start = runs.len();
149 if let Some(range) = self.paragraphs.get(&key) {
150 runs.extend(
151 self.runs[range.clone()]
152 .iter()
153 .map(|run| (run.outline, run.node.clone(), run.positions.clone())),
154 );
155 paragraph_ranges.insert(key, start..runs.len());
156 continue;
157 }
158 let projection = &shaped.projection;
159 let text = projection.text().text();
160 let boundaries: Vec<_> = projection.source_boundaries().collect();
161 let source_position = |byte| -> Result<TextPosition, EditError> {
162 let index = boundaries
163 .binary_search_by_key(&byte, |(byte, _)| *byte)
164 .map_err(|_| EditError::InvalidRange)?;
165 Ok(TextPosition {
166 paragraph,
167 offset: boundaries[index].1,
168 })
169 };
170 let line_count = layout.lines().count();
171 for (line_index, (line, bounds)) in layout.lines().enumerate() {
172 let line_start = runs.len();
173 let mut x = line.metrics().offset;
174 let mut logical_runs = Vec::new();
175 for run in line.runs() {
176 let advance = run.advance();
177 logical_runs.push((run, x));
178 x += advance;
179 }
180 logical_runs.sort_by_key(|(run, _)| run.text_range().start);
181 for (run, x) in logical_runs {
182 // AccessKit stores each character's byte length in a u8.
183 let mut characters = Vec::new();
184 for cluster in run.clusters() {
185 let range = cluster.text_range();
186 if range.is_empty() || range.start == text.len() {
187 continue;
188 }
189 let cluster_text =
190 text.get(range.clone()).ok_or(EditError::InvalidRange)?;
191 if range.len() <= u8::MAX as usize {
192 characters.push((
193 range,
194 cluster.advance(),
195 cluster.is_word_boundary(),
196 ));
197 } else {
198 for (index, (offset, ch)) in cluster_text.char_indices().enumerate()
199 {
200 characters.push((
201 range.start + offset..range.start + offset + ch.len_utf8(),
202 if index == 0 { cluster.advance() } else { 0.0 },
203 index == 0 && cluster.is_word_boundary(),
204 ));
205 }
206 }
207 }
208 let mut advance = 0.0;
209 for chunk in characters.chunks(256) {
210 let width: f32 = chunk.iter().map(|(_, width, _)| width).sum();
211 let left = x + if run.is_rtl() {
212 run.advance() - advance - width
213 } else {
214 advance
215 };
216 let mut node = Node::new(Role::TextRun);
217 node.set_bounds(Rect::new(
218 f64::from(left + origin[0]),
219 f64::from(bounds.top + origin[1]),
220 f64::from(left + width + origin[0]),
221 f64::from(bounds.top + bounds.height + origin[1]),
222 ));
223 node.set_text_direction(if run.is_rtl() {
224 TextDirection::RightToLeft
225 } else {
226 TextDirection::LeftToRight
227 });
228 node.set_font_size(run.font_size());
229 let start = chunk[0].0.start;
230 let end = chunk.last().unwrap().0.end;
231 node.set_value(text.get(start..end).ok_or(EditError::InvalidRange)?);
232 node.set_character_lengths(
233 chunk
234 .iter()
235 .map(|(range, _, _)| u8::try_from(range.len()).unwrap())
236 .collect::<Vec<_>>(),
237 );
238 let mut offset = 0.0;
239 node.set_character_positions(
240 chunk
241 .iter()
242 .map(|(_, width, _)| {
243 let position = offset;
244 offset += width;
245 position
246 })
247 .collect::<Vec<_>>(),
248 );
249 node.set_character_widths(
250 chunk.iter().map(|(_, width, _)| *width).collect::<Vec<_>>(),
251 );
252 node.set_word_starts(
253 chunk
254 .iter()
255 .enumerate()
256 .filter_map(|(index, (_, _, word))| word.then_some(index as u8))
257 .collect::<Vec<_>>(),
258 );
259 let positions = chunk
260 .iter()
261 .map(|(range, _, _)| source_position(range.start))
262 .chain(std::iter::once(source_position(end)))
263 .collect::<Result<Vec<_>, _>>()?;
264 runs.push((outline.id, node, positions));
265 advance += width;
266 }
267 }
268 // Empty lines still need a text position for selection and insertion.
269 if runs.len() == line_start {
270 let mut node = Node::new(Role::TextRun);
271 node.set_value("");
272 node.set_character_lengths(Vec::<u8>::new());
273 node.set_character_positions(Vec::<f32>::new());
274 node.set_character_widths(Vec::<f32>::new());
275 node.set_text_direction(TextDirection::LeftToRight);
276 node.set_bounds(Rect::new(
277 f64::from(origin[0]),
278 f64::from(bounds.top + origin[1]),
279 f64::from(origin[0]),
280 f64::from(bounds.top + bounds.height + origin[1]),
281 ));
282 runs.push((
283 outline.id,
284 node,
285 vec![source_position(bounds.source.start)?],
286 ));
287 }
288 if line_index + 1 == line_count
289 && let Some((next, _)) = paragraphs.peek()
290 {
291 let (_, node, source_positions) = runs.last_mut().unwrap();
292 let mut value = node.value().unwrap().to_owned();
293 value.push('\n');
294 node.set_value(value);
295 let mut lengths = node.character_lengths().to_vec();
296 lengths.push(1);
297 node.set_character_lengths(lengths);
298 if let Some(rect) = node.bounds() {
299 let mut positions =
300 node.character_positions().unwrap_or_default().to_vec();
301 positions.push(rect.width() as f32);
302 node.set_character_positions(positions);
303 let mut widths = node.character_widths().unwrap_or_default().to_vec();
304 widths.push(4.0);
305 node.set_character_widths(widths);
306 }
307 source_positions.push(TextPosition {
308 paragraph: *next,
309 offset: 0,
310 });
311 }
312 }
313 if !shaped.tags.is_empty() {
314 let descriptions = shaped
315 .tags
316 .iter()
317 .map(|tag| {
318 use one_canvas::outline::TagIcon;
319 let fallback = match tag.icon {
320 TagIcon::CheckBox { .. } => "To do",
321 TagIcon::Question => "Question",
322 TagIcon::Music => "Music",
323 };
324 let label = if tag.label.is_empty() {
325 fallback
326 } else {
327 &tag.label
328 };
329 let state = match tag.icon {
330 TagIcon::CheckBox { checked: true } => ", completed",
331 TagIcon::CheckBox { checked: false } => ", incomplete",
332 _ => "",
333 };
334 format!(
335 "{label}{state}{}",
336 if tag.disabled { ", disabled" } else { "" }
337 )
338 })
339 .collect::<Vec<_>>()
340 .join("; ");
341 runs[start].1.set_description(descriptions);
342 }
343 paragraph_ranges.insert(key, start..runs.len());
344 }
345 }
346 let mut runs: Vec<_> = runs
347 .into_iter()
348 .enumerate()
349 .map(|(index, (outline, mut node, positions))| {
350 node.clear_next_on_line();
351 node.clear_previous_on_line();
352 let id = match self.runs.get(index) {
353 Some(old)
354 if old.outline == outline
355 && old.positions == positions
356 && old.node.value() == node.value() =>
357 {
358 old.id
359 }
360 _ => self.allocate(),
361 };
362 Run {
363 outline,
364 id,
365 node,
366 positions,
367 }
368 })
369 .collect();
370 for index in 1..runs.len() {
371 if runs[index - 1].outline == runs[index].outline
372 && runs[index - 1].node.bounds().map(|r| r.y0)
373 == runs[index].node.bounds().map(|r| r.y0)
374 {
375 let (before, after) = runs.split_at_mut(index);
376 before[index - 1].node.set_next_on_line(after[0].id);
377 after[0].node.set_previous_on_line(before[index - 1].id);
378 }
379 }
380 self.runs = runs;
381 self.paragraphs = paragraph_ranges;
382 self.outlines = outlines;
383 let mut root = Node::new(Role::Window);
384 root.set_label(title);
385 root.set_children(self.outlines.iter().map(|(_, id)| *id).collect::<Vec<_>>());
386 root.set_bounds(Rect::new(
387 0.0,
388 0.0,
389 viewport.size[0] as f64,
390 viewport.size[1] as f64,
391 ));
392 let mut nodes = vec![(ROOT, root)];
393 let mut focus = ROOT;
394 for (index, (outline, (_, id))) in editor
395 .outlines()
396 .iter()
397 .chain(editor.caret_outline())
398 .zip(&self.outlines)
399 .enumerate()
400 {
401 let mut field = Node::new(Role::MultilineTextInput);
402 field.set_label(
403 if editor
404 .caret_outline()
405 .is_some_and(|caret| caret.id == outline.id)
406 {
407 "Text input".into()
408 } else {
409 format!("Text outline {}", index + 1)
410 },
411 );
412 field.add_action(Action::Focus);
413 field.add_action(Action::SetTextSelection);
414 field.add_action(Action::ReplaceSelectedText);
415 field.add_action(Action::SetValue);
416 let runs = self.runs.iter().filter(|run| run.outline == outline.id);
417 field.set_value(
418 runs.clone()
419 .filter_map(|run| run.node.value())
420 .collect::<String>(),
421 );
422 field.set_children(runs.map(|run| run.id).collect::<Vec<_>>());
423 let origin = preview
424 .filter(|(id, _)| *id == outline.id)
425 .map(|(_, origin)| origin)
426 .unwrap_or_else(|| outline.origin());
427 field.set_transform(Affine::new([
428 f64::from(viewport.scale),
429 0.0,
430 0.0,
431 f64::from(viewport.scale),
432 f64::from(viewport.origin[0]) + f64::from(origin[0]) * f64::from(viewport.scale),
433 f64::from(viewport.origin[1]) + f64::from(origin[1]) * f64::from(viewport.scale),
434 ]));
435 let bounds = outline.bounds();
436 field.set_bounds(Rect::new(0.0, 0.0, bounds.width(), bounds.height()));
437 if outline.id == editor.active_outline().id {
438 let Selection {
439 positions: [anchor, caret],
440 affinities,
441 } = editor.selection();
442 field.set_text_selection(TextSelection {
443 anchor: self.position(outline, anchor, affinities[0])?,
444 focus: self.position(outline, caret, affinities[1])?,
445 });
446 focus = *id;
447 }
448 nodes.push((*id, field));
449 }
450 nodes.extend(self.runs.iter().map(|run| (run.id, run.node.clone())));
451 Ok(TreeUpdate {
452 nodes,
453 tree: Some(TreeInfo::new(ROOT)),
454 tree_id: TreeId::ROOT,
455 focus,
456 })
457 }
458
459 fn position(
460 &self,
461 outline: &TextOutline,
462 mut position: TextPosition,
463 affinity: Affinity,
464 ) -> Result<accesskit::TextPosition, EditError> {
465 let shaped = outline.paragraph_layout(position.paragraph)?;
466 let projection = &shaped.projection;
467 let layout = &shaped.text;
468 let origin = shaped.origin;
469 let cursor = layout.cursor(
470 projection
471 .text()
472 .byte_offset(projection.visible_offset(position.offset)?)?,
473 affinity,
474 );
475 position.offset = projection.source_offset(
476 projection.text().utf16_offset(cursor.index())?,
477 Affinity::Downstream,
478 )?;
479 let caret = layout.caret(cursor, 0.0);
480 let mut positions = self
481 .runs
482 .iter()
483 .filter(|run| {
484 run.outline == outline.id
485 && run
486 .node
487 .bounds()
488 .is_some_and(|rect| rect.y0 == f64::from(caret.y0 as f32 + origin[1]))
489 })
490 .filter_map(|run| {
491 run.positions
492 .iter()
493 .position(|p| *p == position)
494 .map(|character_index| accesskit::TextPosition {
495 node: run.id,
496 character_index,
497 })
498 });
499 match affinity {
500 Affinity::Upstream => positions.next(),
501 Affinity::Downstream => positions.next_back(),
502 }
503 .ok_or(EditError::InvalidRange)
504 }
505
506 pub fn selection(
507 &self,
508 outline: onestore::ExGuid,
509 selection: TextSelection,
510 ) -> Result<Selection, EditError> {
511 let position = |position: accesskit::TextPosition| {
512 let run = self
513 .runs
514 .iter()
515 .find(|run| run.outline == outline && run.id == position.node)
516 .ok_or(EditError::InvalidRange)?;
517 let source = *run
518 .positions
519 .get(position.character_index)
520 .ok_or(EditError::InvalidRange)?;
521 let affinity = if position.character_index > 0
522 && position.character_index + 1 == run.positions.len()
523 {
524 Affinity::Upstream
525 } else {
526 Affinity::Downstream
527 };
528 Ok::<_, EditError>((source, affinity))
529 };
530 let (anchor, anchor_affinity) = position(selection.anchor)?;
531 let (focus, focus_affinity) = position(selection.focus)?;
532 Ok(Selection {
533 positions: [anchor, focus],
534 affinities: [anchor_affinity, focus_affinity],
535 })
536 }
537}
538
539#[cfg(test)]
540mod tests {
541 use super::*;
542 use one_canvas::{document::TextDocument, layout::TextEngine, text::Paragraph};
543 use onestore::document::Format;
544
545 #[test]
546 fn read_only_objects_keep_accessibility_identity_through_edits_and_view_changes() {
547 use one_canvas::page::{Page, PageObject, Unsupported};
548 use one_canvas_gpu::page::PageScene;
549 let page = Page {
550 title: String::new(),
551 margin_origin: [0.0; 2],
552 definitions: Default::default(),
553 objects: vec![PageObject::Unsupported(Unsupported {
554 id: Default::default(),
555 jcid: 0xdead,
556 layout: onestore::document::Layout {
557 x: Some(10.0),
558 y: Some(20.0),
559 max_width: Some(200.0),
560 max_height: Some(60.0),
561 ..Default::default()
562 },
563 })],
564 };
565 let mut engine = TextEngine::default();
566 let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap();
567 let scene = (scene, [30.0, 40.0]);
568 let mut access = Accessibility::default();
569 let mut identity = None;
570 for scale in [1.0, 2.0, 0.5] {
571 editor.insert(&mut engine, "annotation ").unwrap();
572 let viewport = Viewport {
573 size: [800, 600],
574 origin: [-200.0, 10.0],
575 scale,
576 };
577 let mut update = access.update(&editor, viewport, "Test", None).unwrap();
578 access.append_read_only(&mut update, Some(&scene), viewport, None);
579 let (id, node) = update
580 .nodes
581 .iter()
582 .find(|(_, node)| node.role() == Role::Label)
583 .unwrap();
584 if let Some(identity) = identity {
585 assert_eq!(*id, identity);
586 }
587 identity = Some(*id);
588 assert_eq!(node.value(), Some("Unsupported content\nRead-only"));
589 assert!(node.is_read_only());
590 assert!(node.supports_action(Action::Focus));
591 for action in [
592 Action::SetValue,
593 Action::ReplaceSelectedText,
594 Action::SetTextSelection,
595 ] {
596 assert!(!node.supports_action(action));
597 }
598 assert_eq!(
599 node.bounds(),
600 Some(Rect::new(
601 f64::from(40.0 * scale - 200.0),
602 f64::from(60.0 * scale + 10.0),
603 f64::from(240.0 * scale - 200.0),
604 f64::from(120.0 * scale + 10.0)
605 ))
606 );
607 assert_eq!(access.outline_for_node(*id), None);
608 let tree = accesskit_consumer::Tree::new(update, true);
609 assert_eq!(
610 tree.state().focus().unwrap().document_range().text(),
611 "annotation "
612 );
613 let mut focused = access.update(&editor, viewport, "Test", None).unwrap();
614 access.append_read_only(&mut focused, Some(&scene), viewport, Some(0));
615 assert_eq!(focused.focus, identity.unwrap());
616 assert_eq!(access.read_only_for_node(focused.focus), Some(0));
617 let tree = accesskit_consumer::Tree::new(focused, true);
618 assert_eq!(
619 tree.state().focus().unwrap().value().as_deref(),
620 Some("Unsupported content\nRead-only")
621 );
622 editor.undo(&mut engine).unwrap();
623 }
624 access.deactivate();
625 assert!(access.read_only.is_empty());
626 }
627
628 #[test]
629 fn tag_descriptions_preserve_plain_text_and_survive_edit_undo_and_cache_reuse() {
630 use one_canvas::page::{Definition, Outline};
631 use onestore::{
632 ExGuid,
633 document::{Kind, Layout, Tag},
634 };
635 use std::collections::BTreeMap;
636 let text = TextDocument::new(
637 ["task one", "task two", "question", "music"]
638 .into_iter()
639 .map(|text| Paragraph::new(text.into(), Format::default()))
640 .collect(),
641 )
642 .unwrap();
643 let mut nodes = text.nodes().to_vec();
644 let mut definitions = BTreeMap::new();
645 for (index, (shape, label, status)) in [
646 (3, Some("Rehearsal"), 0),
647 (3, Some("Rehearsal"), 1),
648 (15, None, 1),
649 (121, None, 3),
650 ]
651 .into_iter()
652 .enumerate()
653 {
654 let id = ExGuid {
655 n: index as u32 + 500,
656 ..ExGuid::default()
657 };
658 definitions.insert(
659 id,
660 Definition {
661 kind: Kind::TagDefinition {
662 label: label.map(str::to_owned),
663 action_type: None,
664 shape: Some(shape),
665 color: None,
666 highlight: None,
667 },
668 format: Format::default(),
669 },
670 );
671 nodes[index].text[0].tags.push(Tag {
672 definition: Some(id),
673 action_type: None,
674 status,
675 created: None,
676 completed: None,
677 start: None,
678 due: None,
679 task_id: None,
680 extra_set: 0,
681 });
682 }
683 let mut engine = TextEngine::default();
684 let mut editor = CanvasEditor::from_outlines(
685 &mut engine,
686 vec![Outline {
687 id: ExGuid {
688 n: 900,
689 ..ExGuid::default()
690 },
691 layout: Layout {
692 max_width: Some(120.0),
693 ..Layout::default()
694 },
695 indents: vec![18.0, 0.0],
696 paragraphs: nodes,
697 unsupported: Vec::new(),
698 }],
699 definitions,
700 )
701 .unwrap();
702 let mut access = Accessibility::default();
703 let viewport = Viewport {
704 size: [800, 600],
705 origin: [48.0; 2],
706 scale: 2.0,
707 };
708 for step in 0..4 {
709 if step == 2 {
710 editor.insert(&mut engine, "new ").unwrap();
711 }
712 if step == 3 {
713 editor.undo(&mut engine).unwrap();
714 }
715 let update = access.update(&editor, viewport, "Test", None).unwrap();
716 assert_eq!(
717 update
718 .nodes
719 .iter()
720 .filter_map(|(_, n)| n.description())
721 .collect::<Vec<_>>(),
722 [
723 "Rehearsal, incomplete",
724 "Rehearsal, completed",
725 "Question",
726 "Music, disabled"
727 ]
728 );
729 let tree = accesskit_consumer::Tree::new(update, true);
730 let text = tree.state().focus().unwrap().document_range().text();
731 assert_eq!(
732 text,
733 if step == 2 {
734 "new task one\ntask two\nquestion\nmusic"
735 } else {
736 "task one\ntask two\nquestion\nmusic"
737 }
738 );
739 }
740 }
741
742 #[test]
743 fn collapsed_paragraphs_keep_visible_accessibility_ranges_in_source_coordinates() {
744 let document = TextDocument::new(
745 [
746 "root",
747 "folded",
748 "hidden",
749 "last",
750 "folded tail",
751 "hidden tail",
752 ]
753 .into_iter()
754 .map(|text| Paragraph::new(text.into(), Format::default()))
755 .collect(),
756 )
757 .unwrap();
758 let mut nodes = document.nodes().to_vec();
759 for (child, parent, level) in [(1, 0, 2), (2, 1, 3), (4, 3, 2), (5, 4, 3)] {
760 nodes[child].parent = Some(nodes[parent].id);
761 nodes[child].level = level;
762 }
763 nodes[1].collapsed = true;
764 nodes[4].collapsed = true;
765 let mut engine = TextEngine::default();
766 let mut editor =
767 CanvasEditor::new(&mut engine, TextDocument::from_nodes(nodes).unwrap(), 180.0)
768 .unwrap();
769 let mut access = Accessibility::default();
770 let viewport = Viewport {
771 size: [900, 700],
772 scale: 2.0,
773 origin: [13.0, 19.0],
774 };
775 let update = access.update(&editor, viewport, "Test", None).unwrap();
776 let tree = accesskit_consumer::Tree::new(update, true);
777 let field = tree.state().focus().unwrap();
778 assert_eq!(
779 field.document_range().text(),
780 "root\nfolded\nlast\nfolded tail"
781 );
782 assert!(
783 access
784 .runs
785 .iter()
786 .flat_map(|run| &run.positions)
787 .all(|position| ![2, 5].contains(&position.paragraph))
788 );
789 let source = access
790 .selection(
791 editor.active_outline().id,
792 field.document_range().to_text_selection(),
793 )
794 .unwrap();
795 assert_eq!(
796 source.positions[0],
797 TextPosition {
798 paragraph: 0,
799 offset: 0
800 }
801 );
802 assert_eq!(
803 source.positions[1],
804 TextPosition {
805 paragraph: 4,
806 offset: 11
807 }
808 );
809 editor.select(source).unwrap();
810 let update = access.update(&editor, viewport, "Test", None).unwrap();
811 let tree = accesskit_consumer::Tree::new(update, true);
812 assert_eq!(
813 tree.state()
814 .focus()
815 .unwrap()
816 .text_selection()
817 .unwrap()
818 .text(),
819 "root\nfolded\nlast\nfolded tail"
820 );
821 editor
822 .select(
823 [TextPosition {
824 paragraph: 4,
825 offset: 0,
826 }; 2]
827 .into(),
828 )
829 .unwrap();
830 access.update(&editor, viewport, "Test", None).unwrap();
831 let caret = editor.caret(1.0).unwrap();
832 assert_eq!(caret.x0, 27.0);
833 let position = access
834 .position(
835 editor.active_outline(),
836 editor.selection().positions[1],
837 Affinity::Downstream,
838 )
839 .unwrap();
840 let run = access
841 .runs
842 .iter()
843 .find(|run| run.id == position.node)
844 .unwrap();
845 assert_eq!(run.node.bounds().unwrap().x0, 27.0);
846 assert_eq!(run.node.bounds().unwrap().y0, caret.y0);
847 }
848
849 #[test]
850 fn consumer_reads_unicode_blank_paragraphs_and_hidden_fields() {
851 let cases = [
852 "",
853 "a\n\nשלום\nend",
854 "café e\u{301} 🌳 👩‍👩‍👧‍👦 words wrap here",
855 "\n\n",
856 "a\nb\n",
857 ];
858 let mut engine = TextEngine::default();
859 for text in cases
860 .into_iter()
861 .map(str::to_owned)
862 .chain([format!("a{}", "\u{301}".repeat(300)), "x".repeat(600)])
863 {
864 for width in [24.0, 480.0, 10000.0] {
865 let document = TextDocument::new(
866 text.split('\n')
867 .map(|line| {
868 Paragraph::from_runs([
869 (
870 String::from("secret"),
871 Format {
872 hidden: Some(true),
873 ..Format::default()
874 },
875 ),
876 (line.to_owned(), Format::default()),
877 ])
878 })
879 .collect(),
880 )
881 .unwrap();
882 let mut editor = CanvasEditor::new(&mut engine, document, width).unwrap();
883 let mut access = Accessibility::default();
884 let viewport = Viewport {
885 size: [800, 600],
886 origin: [48.0; 2],
887 scale: 2.0,
888 };
889 let update = access.update(&editor, viewport, "Test", None).unwrap();
890 for (_, node) in &update.nodes {
891 if node.role() == Role::TextRun {
892 assert_eq!(
893 node.character_lengths()
894 .iter()
895 .map(|n| *n as usize)
896 .sum::<usize>(),
897 node.value().unwrap().len()
898 );
899 if let Some(positions) = node.character_positions() {
900 assert_eq!(positions.len(), node.character_lengths().len());
901 }
902 if let Some(widths) = node.character_widths() {
903 assert_eq!(widths.len(), node.character_lengths().len());
904 }
905 }
906 }
907 let tree = accesskit_consumer::Tree::new(update, true);
908 let field = tree.state().focus().unwrap();
909 assert_eq!(field.document_range().text(), text, "width {width}");
910 let selection = field.document_range().to_text_selection();
911 let source_selection = access
912 .selection(editor.active_outline().id, selection)
913 .unwrap();
914 editor.select(source_selection).unwrap();
915 let selected = access.update(&editor, viewport, "Test", None).unwrap();
916 let tree = accesskit_consumer::Tree::new(selected, true);
917 assert_eq!(
918 tree.state()
919 .focus()
920 .unwrap()
921 .text_selection()
922 .unwrap()
923 .text(),
924 text
925 );
926 let stable_ids: Vec<_> = access.runs.iter().map(|run| run.id).collect();
927 access
928 .update(
929 &editor,
930 Viewport {
931 scale: 3.0,
932 ..viewport
933 },
934 "Test",
935 None,
936 )
937 .unwrap();
938 assert_eq!(
939 stable_ids,
940 access.runs.iter().map(|run| run.id).collect::<Vec<_>>()
941 );
942 access.deactivate();
943 access.update(&editor, viewport, "Test", None).unwrap();
944 assert!(
945 access
946 .selection(editor.active_outline().id, selection)
947 .is_err()
948 );
949 let selection = access
950 .update(&editor, viewport, "Test", None)
951 .unwrap()
952 .nodes[1]
953 .1
954 .text_selection()
955 .copied()
956 .unwrap();
957 editor.insert(&mut engine, "replacement").unwrap();
958 access.update(&editor, viewport, "Test", None).unwrap();
959 assert!(
960 access
961 .selection(editor.active_outline().id, selection)
962 .is_err()
963 );
964 editor.undo(&mut engine).unwrap();
965 let update = access.update(&editor, viewport, "Test", None).unwrap();
966 let tree = accesskit_consumer::Tree::new(update, true);
967 assert_eq!(tree.state().focus().unwrap().document_range().text(), text);
968 }
969 }
970 }
971
972 #[test]
973 fn caret_geometry_uses_visible_offsets_and_canvas_line_metrics() {
974 let mut engine = TextEngine::default();
975 let document = TextDocument::new(vec![
976 Paragraph::from_runs([
977 ("abc ".into(), Format::default()),
978 (
979 "hidden field".into(),
980 Format {
981 hidden: Some(true),
982 ..Format::default()
983 },
984 ),
985 ("e\u{301} 🌳 words wrap here".into(), Format::default()),
986 ]),
987 Paragraph::new("".into(), Format::default()),
988 Paragraph::new("abc שלום def عالم end".into(), Format::default()),
989 Paragraph::new("last\nline\n".into(), Format::default()),
990 ])
991 .unwrap();
992 let positions: Vec<_> = document
993 .paragraphs()
994 .enumerate()
995 .flat_map(|(paragraph, text)| {
996 text.text()
997 .char_indices()
998 .map(|(byte, _)| text.utf16_offset(byte).unwrap())
999 .chain(std::iter::once(
1000 text.utf16_offset(text.text().len()).unwrap(),
1001 ))
1002 .map(move |offset| TextPosition { paragraph, offset })
1003 })
1004 .collect();
1005 let mut editor = CanvasEditor::new(&mut engine, document, 48.0).unwrap();
1006 let mut access = Accessibility::default();
1007 let viewport = Viewport {
1008 size: [800, 600],
1009 origin: [31.0, -57.0],
1010 scale: 2.5,
1011 };
1012 for (position, affinity) in positions.into_iter().flat_map(|position| {
1013 [Affinity::Upstream, Affinity::Downstream].map(|affinity| (position, affinity))
1014 }) {
1015 editor
1016 .select(Selection {
1017 positions: [position; 2],
1018 affinities: [affinity; 2],
1019 })
1020 .unwrap();
1021 let caret = editor.caret(0.0).unwrap();
1022 let update = access.update(&editor, viewport, "Test", None).unwrap();
1023 let tree = accesskit_consumer::Tree::new(update, true);
1024 let field = tree.state().focus().unwrap();
1025 assert_eq!(
1026 field.document_range().text(),
1027 "abc e\u{301} 🌳 words wrap here\n\nabc שלום def عالم end\nlast\nline\n"
1028 );
1029 let rects = field.text_selection().unwrap().bounding_boxes();
1030 assert_eq!(rects.len(), 1, "{position:?}");
1031 let rect = rects[0];
1032 for (actual, expected) in [
1033 (rect.x0, caret.x0 * 2.5 + 31.0),
1034 (rect.y0, caret.y0 * 2.5 - 57.0),
1035 (rect.y1, caret.y1 * 2.5 - 57.0),
1036 ] {
1037 assert!(
1038 (actual - expected).abs() < 0.0001,
1039 "{position:?} {affinity:?}: {rect:?} vs {caret:?}"
1040 );
1041 }
1042 let selection = access
1043 .selection(
1044 editor.active_outline().id,
1045 field.text_selection().unwrap().to_text_selection(),
1046 )
1047 .unwrap();
1048 editor.select(selection).unwrap();
1049 assert_eq!(
1050 editor.caret(0.0).unwrap(),
1051 caret,
1052 "{position:?} {affinity:?}: AX selection moved the caret"
1053 );
1054 }
1055 }
1056 #[test]
1057 fn keyboard_and_accessibility_preserve_visual_caret_at_wraps() {
1058 use one_canvas::editor::Movement;
1059 let mut engine = TextEngine::default();
1060 let document = TextDocument::new(vec![
1061 Paragraph::new(
1062 "first words wrap here and continue".into(),
1063 Format::default(),
1064 ),
1065 Paragraph::new("abc שלום def عالم end".into(), Format::default()),
1066 Paragraph::new("last\nline".into(), Format::default()),
1067 ])
1068 .unwrap();
1069 let mut editor = CanvasEditor::new(&mut engine, document, 72.0).unwrap();
1070 let mut access = Accessibility::default();
1071 let viewport = Viewport {
1072 size: [800, 600],
1073 origin: [0.0; 2],
1074 scale: 1.0,
1075 };
1076 for movement in [
1077 Movement::LineEnd,
1078 Movement::Right,
1079 Movement::Left,
1080 Movement::Down,
1081 Movement::LineStart,
1082 Movement::LineEnd,
1083 Movement::Down,
1084 Movement::Down,
1085 Movement::Down,
1086 Movement::LineEnd,
1087 Movement::Left,
1088 Movement::Left,
1089 Movement::Right,
1090 Movement::LineStart,
1091 Movement::Up,
1092 Movement::Right,
1093 Movement::LineEnd,
1094 ] {
1095 editor.move_selection(movement, false).unwrap();
1096 let caret = editor.caret(0.0).unwrap();
1097 let tree = accesskit_consumer::Tree::new(
1098 access.update(&editor, viewport, "Test", None).unwrap(),
1099 true,
1100 );
1101 let field = tree.state().focus().unwrap();
1102 let selection = field.text_selection().unwrap();
1103 let rects = selection.bounding_boxes();
1104 assert_eq!(rects.len(), 1);
1105 let rect = rects[0];
1106 for (actual, expected) in [
1107 (rect.x0, caret.x0),
1108 (rect.y0, caret.y0),
1109 (rect.y1, caret.y1),
1110 ] {
1111 assert!(
1112 (actual - expected).abs() < 0.0001,
1113 "{movement:?}: {rect:?} vs {caret:?}"
1114 );
1115 }
1116 let source_selection = access
1117 .selection(editor.active_outline().id, selection.to_text_selection())
1118 .unwrap();
1119 editor.select(source_selection).unwrap();
1120 let restored = editor.caret(0.0).unwrap();
1121 assert_eq!(
1122 restored, caret,
1123 "{movement:?}: AX selection moved the caret"
1124 );
1125 }
1126 }
1127 #[test]
1128 fn cached_runs_match_reconstruction_through_editing_and_view_changes() {
1129 let mut engine = TextEngine::default();
1130 let document = TextDocument::new(
1131 [
1132 "one two three",
1133 "",
1134 "אבג 日本 👩🏽‍💻 e\u{301}",
1135 "last paragraph",
1136 ]
1137 .into_iter()
1138 .map(|text| Paragraph::new(text.into(), Format::default()))
1139 .collect(),
1140 )
1141 .unwrap();
1142 let mut editor = CanvasEditor::new(&mut engine, document, 72.0).unwrap();
1143 let mut cached = Accessibility::default();
1144 let mut rebuilt = Accessibility::default();
1145 let mut compare = |editor: &CanvasEditor, step: usize| {
1146 let viewport = Viewport {
1147 size: [800, 600],
1148 origin: [step as f32 * 0.125, -17.25],
1149 scale: 0.75 + (step % 5) as f32 * 0.5,
1150 };
1151 let preview = step
1152 .is_multiple_of(2)
1153 .then_some((editor.active_outline().id, [13.5, -7.25]));
1154 for repeat in 0..2 {
1155 rebuilt.paragraphs.clear();
1156 let actual = cached.update(editor, viewport, "Test", preview).unwrap();
1157 let expected = rebuilt.update(editor, viewport, "Test", preview).unwrap();
1158 assert_eq!(actual.nodes, expected.nodes, "step {step}, repeat {repeat}");
1159 assert_eq!(actual.focus, expected.focus);
1160 let tree = accesskit_consumer::Tree::new(actual, true);
1161 let field = tree.state().focus().unwrap();
1162 let selection = field.document_range().to_text_selection();
1163 assert_eq!(
1164 cached
1165 .selection(editor.active_outline().id, selection)
1166 .unwrap(),
1167 rebuilt
1168 .selection(editor.active_outline().id, selection)
1169 .unwrap()
1170 );
1171 let count = editor.outlines().iter().map(|o| o.layouts().count()).sum();
1172 assert_eq!(cached.paragraphs.len(), count);
1173 }
1174 };
1175 compare(&editor, 0);
1176 let mut seed = 0x923e_u64;
1177 let mut next = || {
1178 seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
1179 (seed >> 32) as usize
1180 };
1181 for step in 1..=160 {
1182 let id = editor.outlines()[next() % editor.outlines().len()].id;
1183 editor.focus_outline(id).unwrap();
1184 let positions: Vec<_> = editor
1185 .active_outline()
1186 .document()
1187 .paragraphs()
1188 .enumerate()
1189 .flat_map(|(paragraph, text)| {
1190 (0..=text.text().len()).filter_map(move |byte| {
1191 text.utf16_offset(byte)
1192 .ok()
1193 .map(|offset| TextPosition { paragraph, offset })
1194 })
1195 })
1196 .collect();
1197 let anchor = positions[next() % positions.len()];
1198 let focus = if step % 3 == 0 {
1199 positions[next() % positions.len()]
1200 } else {
1201 anchor
1202 };
1203 editor.select([anchor, focus].into()).unwrap();
1204 match step % 10 {
1205 0 => editor.insert(&mut engine, "\n").unwrap(),
1206 1 => editor.insert(&mut engine, "abc 👩🏽‍💻 שלום e\u{301}").unwrap(),
1207 2 => {
1208 editor.delete(&mut engine, true).unwrap();
1209 }
1210 3 => editor
1211 .resize(&mut engine, 48.0 + (step % 7) as f32 * 31.25)
1212 .unwrap(),
1213 4 => {
1214 editor
1215 .create_outline(&mut engine, [step as f32, -5.0], 97.0)
1216 .unwrap();
1217 editor.insert(&mut engine, "annotation").unwrap();
1218 }
1219 5 => {
1220 editor.undo(&mut engine).unwrap();
1221 }
1222 6 => {
1223 editor.redo(&mut engine).unwrap();
1224 }
1225 7 => editor.move_outline(id, [17.5, step as f32]).unwrap(),
1226 8 => {
1227 editor.compose(&mut engine, "仮\n名".into(), 1..1).unwrap();
1228 compare(&editor, step);
1229 editor.cancel_composition(&mut engine).unwrap();
1230 }
1231 _ => {}
1232 }
1233 compare(&editor, step);
1234 }
1235 while editor.undo(&mut engine).unwrap() {
1236 compare(&editor, 161);
1237 }
1238 while editor.redo(&mut engine).unwrap() {
1239 compare(&editor, 162);
1240 }
1241 }
1242 #[test]
1243 fn outlines_have_independent_accessibility_identity_and_selection() {
1244 let mut engine = TextEngine::default();
1245 let document =
1246 TextDocument::new(vec![Paragraph::new("lyrics".into(), Format::default())]).unwrap();
1247 let mut editor = CanvasEditor::new(&mut engine, document, 240.0).unwrap();
1248 let first = editor.active_outline().id;
1249 let second = editor
1250 .create_outline(&mut engine, [300.0, 40.0], 120.0)
1251 .unwrap();
1252 editor.insert(&mut engine, "annotation 🌳").unwrap();
1253 let mut access = Accessibility::default();
1254 let viewport = Viewport {
1255 size: [1000, 720],
1256 scale: 2.0,
1257 origin: [48.0; 2],
1258 };
1259 let update = access.update(&editor, viewport, "Test", None).unwrap();
1260 let fields: Vec<_> = update
1261 .nodes
1262 .iter()
1263 .filter(|(_, node)| node.role() == Role::MultilineTextInput)
1264 .map(|(id, node)| (*id, node.value().unwrap().to_owned()))
1265 .collect();
1266 assert_eq!(
1267 fields
1268 .iter()
1269 .map(|(_, text)| text.as_str())
1270 .collect::<Vec<_>>(),
1271 ["lyrics", "annotation 🌳"]
1272 );
1273 assert_ne!(fields[0].0, fields[1].0);
1274 assert_eq!(access.outline_for_node(fields[0].0), Some(first));
1275 assert_eq!(access.outline_for_node(fields[1].0), Some(second));
1276 let tree = accesskit_consumer::Tree::new(update, true);
1277 let field = tree.state().focus().unwrap();
1278 assert_eq!(field.document_range().text(), "annotation 🌳");
1279 let selection = field.document_range().to_text_selection();
1280 assert!(access.selection(first, selection).is_err());
1281 let source_selection = access.selection(second, selection).unwrap();
1282 editor.select(source_selection).unwrap();
1283 let preview = access
1284 .update(&editor, viewport, "Test", Some((second, [400.0, 50.0])))
1285 .unwrap();
1286 let preview_tree = accesskit_consumer::Tree::new(preview, true);
1287 let preview_field = preview_tree.state().focus().unwrap();
1288 let rect = preview_field.document_range().bounding_boxes()[0];
1289 assert_eq!(rect.x0, 848.0);
1290 assert_eq!(rect.y0, 148.0);
1291 assert_eq!(editor.active_outline().origin(), [300.0, 40.0]);
1292 editor.move_outline(second, [400.0, 50.0]).unwrap();
1293 access.update(&editor, viewport, "Test", None).unwrap();
1294 assert_eq!(access.outline_for_node(fields[1].0), Some(second));
1295 assert!(access.selection(second, selection).is_ok());
1296 editor.undo(&mut engine).unwrap();
1297 editor.undo(&mut engine).unwrap();
1298 editor.undo(&mut engine).unwrap();
1299 let update = access.update(&editor, viewport, "Test", None).unwrap();
1300 assert_eq!(
1301 update
1302 .nodes
1303 .iter()
1304 .filter(|(_, node)| node.role() == Role::MultilineTextInput)
1305 .count(),
1306 1
1307 );
1308 assert_eq!(access.outline_for_node(fields[1].0), None);
1309 assert!(access.selection(second, selection).is_err());
1310 assert_eq!(access.outline_for_node(fields[0].0), Some(first));
1311 }
1312}
crates/canvas-app/src/macos.rs deleted-102
...@@ -1,102 +0,0 @@
1use objc2::{
2 ClassType, DeclaredClass, declare_class, msg_send, msg_send_id, mutability,
3 rc::{Allocated, Retained},
4 runtime::AnyObject,
5};
6use objc2_app_kit::{NSAlert, NSAlertSecondButtonReturn, NSApplication, NSEvent, NSEventType};
7use objc2_foundation::{MainThreadMarker, NSString};
8use std::sync::OnceLock;
9use winit::{
10 error::EventLoopError,
11 event_loop::{EventLoop, EventLoopProxy},
12 raw_window_handle::{HasWindowHandle, RawWindowHandle},
13 window::Window,
14};
15
16static QUIT: OnceLock<EventLoopProxy<crate::UserEvent>> = OnceLock::new();
17
18declare_class!(
19 struct CanvasApplication;
20
21 unsafe impl ClassType for CanvasApplication {
22 type Super = NSApplication;
23 type Mutability = mutability::MainThreadOnly;
24 const NAME: &'static str = "OneCanvasApplication";
25 }
26
27 impl DeclaredClass for CanvasApplication {
28 type Ivars = ();
29 }
30
31 unsafe impl CanvasApplication {
32 #[method_id(init)]
33 fn init(this: Allocated<Self>) -> Retained<Self> {
34 let this = this.set_ivars(());
35 unsafe { msg_send_id![super(this), init] }
36 }
37
38 #[method(terminate:)]
39 fn terminate(&self, _sender: Option<&AnyObject>) {
40 if let Some(proxy) = QUIT.get() { let _ = proxy.send_event(crate::UserEvent::Quit); }
41 }
42
43 // NSAlert consumes Escape before it reaches cancelOperation:.
44 #[method(sendEvent:)]
45 fn send_event(&self, event: &NSEvent) {
46 unsafe {
47 if event.r#type() == NSEventType::KeyDown
48 && self.modalWindow().is_some()
49 && event.charactersIgnoringModifiers().is_some_and(|text| text.to_string() == "\u{1b}")
50 {
51 self.stopModal();
52 } else {
53 let _: () = msg_send![super(self), sendEvent: event];
54 }
55 }
56 }
57 }
58);
59
60pub fn event_loop() -> Result<EventLoop<crate::UserEvent>, EventLoopError> {
61 MainThreadMarker::new().expect("AppKit must start on the main thread");
62 // Create the application's own subclass before Winit obtains the singleton.
63 let _: Retained<CanvasApplication> =
64 unsafe { msg_send_id![CanvasApplication::class(), sharedApplication] };
65 let event_loop = EventLoop::with_user_event().build()?;
66 QUIT.set(event_loop.create_proxy())
67 .expect("Only one application event loop is created");
68 Ok(event_loop)
69}
70
71pub fn discard_changes() -> bool {
72 let mtm = MainThreadMarker::new().expect("Window events run on the main thread");
73 // The alert and its strings stay on AppKit's main thread for the modal call.
74 unsafe {
75 let alert = NSAlert::new(mtm);
76 alert.setMessageText(&NSString::from_str("Discard this page?"));
77 alert.setInformativeText(&NSString::from_str(
78 "This temporary page has no saved copy. Closing it will discard your edits.",
79 ));
80 alert.addButtonWithTitle(&NSString::from_str("Keep Editing"));
81 alert.addButtonWithTitle(&NSString::from_str("Discard Changes"));
82 alert.runModal() == NSAlertSecondButtonReturn
83 }
84}
85
86/// End AppKit preedit after a canvas action commits or cancels the composition.
87pub fn clear_marked_text(window: &Window) {
88 MainThreadMarker::new().expect("Text input belongs to the main thread");
89 let RawWindowHandle::AppKit(handle) =
90 window.window_handle().expect("Live AppKit window").as_raw()
91 else {
92 unreachable!()
93 };
94 // Winit owns this view and implements the NSTextInputClient selectors.
95 unsafe {
96 let view = &*handle.ns_view.as_ptr().cast::<AnyObject>();
97 let marked: bool = msg_send![view, hasMarkedText];
98 if marked {
99 let _: () = msg_send![view, unmarkText];
100 }
101 }
102}
crates/canvas-app/src/main.rs deleted-2077
...@@ -1,2077 +0,0 @@
1mod accessibility;
2mod macos;
3#[cfg(test)]
4mod profile;
5
6use one_canvas::{
7 document::TextDocument,
8 editor::{CanvasEditor, Movement, TextOutline},
9 layout::TextEngine,
10 page::Page,
11 text::Paragraph,
12};
13use one_canvas_gpu::{Primitive, Renderer, Viewport, page::PageScene};
14use onestore::document::Format;
15use std::{
16 error::Error,
17 path::PathBuf,
18 sync::Arc,
19 time::{Duration, Instant},
20};
21use winit::{
22 application::ApplicationHandler,
23 dpi::{LogicalSize, PhysicalPosition, PhysicalSize},
24 event::{ElementState, Ime, MouseButton, MouseScrollDelta, WindowEvent},
25 event_loop::{ActiveEventLoop, ControlFlow, EventLoopProxy},
26 keyboard::{Key, ModifiersState, NamedKey},
27 window::{Window, WindowId},
28};
29
30const HANDLE_HEIGHT: f32 = 6.75;
31const NEW_OUTLINE_WIDTH: f32 = 240.0;
32
33#[derive(Debug)]
34enum UserEvent {
35 Quit,
36 Accessibility(accesskit_winit::Event),
37}
38
39impl From<accesskit_winit::Event> for UserEvent {
40 fn from(event: accesskit_winit::Event) -> Self {
41 Self::Accessibility(event)
42 }
43}
44
45fn trace_input(event: &impl std::fmt::Debug) {
46 if std::env::var_os("ONE_CANVAS_TRACE_INPUT").is_some() {
47 eprintln!("Input {:?}: {event:?}", std::time::SystemTime::now());
48 }
49}
50
51fn read_only_shortcut(key: &Key, modifiers: ModifiersState) -> bool {
52 key == &Key::Named(NamedKey::Escape)
53 || (modifiers.control_key() && key == &Key::Named(NamedKey::Tab))
54 || (modifiers.super_key()
55 && matches!(key, Key::Character(value) if matches!(value.as_str(), "+" | "=" | "-" | "0") || (modifiers.shift_key() && value.eq_ignore_ascii_case("n"))))
56}
57
58struct App {
59 proxy: EventLoopProxy<UserEvent>,
60 input: Option<Input>,
61 substitutes: Vec<PathBuf>,
62 state: Option<State>,
63 startup_error: Option<Box<dyn Error>>,
64}
65
66enum Input {
67 Notes {
68 document: TextDocument,
69 width: f32,
70 reference: Option<Page>,
71 },
72 Page(Page),
73}
74
75enum Drag {
76 Text,
77 Outline {
78 id: onestore::ExGuid,
79 grab: [f32; 2],
80 pending_press: Option<[f32; 2]>,
81 },
82}
83
84struct State {
85 window: Arc<Window>,
86 instance: wgpu::Instance,
87 surface: wgpu::Surface<'static>,
88 config: wgpu::SurfaceConfiguration,
89 renderer: Renderer,
90 engine: TextEngine,
91 editor: CanvasEditor,
92 initial: Vec<(onestore::ExGuid, onestore::document::Layout, TextDocument)>,
93 scene: Option<(PageScene, [f32; 2])>,
94 viewport: Viewport,
95 display_scale: f32,
96 pointer: [f32; 2],
97 drag: Option<Drag>,
98 read_only_focus: Option<usize>,
99 modifiers: ModifiersState,
100 focused: bool,
101 occluded: bool,
102 caret: bool,
103 blink_at: Instant,
104 clipboard: arboard::Clipboard,
105 access_adapter: accesskit_winit::Adapter,
106 accessibility: accessibility::Accessibility,
107}
108
109impl State {
110 async fn new(
111 event_loop: &ActiveEventLoop,
112 proxy: EventLoopProxy<UserEvent>,
113 input: Input,
114 substitutes: &[PathBuf],
115 ) -> Result<Self, Box<dyn Error>> {
116 let window = Arc::new(
117 event_loop.create_window(
118 Window::default_attributes()
119 .with_visible(false)
120 .with_title(match &input {
121 Input::Notes {
122 reference: Some(page),
123 ..
124 } => format!("{} · Reference and temporary notes", page.title),
125 Input::Page(page) => format!("{} · Temporary page", page.title),
126 Input::Notes {
127 reference: None, ..
128 } => "Untitled · Temporary page".into(),
129 })
130 .with_inner_size(LogicalSize::new(1000.0, 720.0)),
131 )?,
132 );
133 let access_adapter =
134 accesskit_winit::Adapter::with_event_loop_proxy(event_loop, &window, proxy);
135 window.set_visible(true);
136 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_with_display_handle(
137 Box::new(window.clone()),
138 ));
139 let surface = instance.create_surface(window.clone())?;
140 let adapter = instance
141 .request_adapter(&wgpu::RequestAdapterOptions {
142 compatible_surface: Some(&surface),
143 ..Default::default()
144 })
145 .await?;
146 let (device, queue) = adapter.request_device(&Default::default()).await?;
147 let size = window.inner_size();
148 let config = surface
149 .get_default_config(&adapter, size.width, size.height)
150 .ok_or("No supported canvas surface")?;
151 surface.configure(&device, &config);
152 let renderer = Renderer::new(device, queue, config.format);
153 let mut engine = TextEngine::default();
154 for path in substitutes {
155 let target = engine
156 .register_substitute(parley::fontique::Blob::new(Arc::new(std::fs::read(path)?)))?;
157 eprintln!("Using {} for {target}", path.display());
158 }
159 let (editor, scene) = match input {
160 Input::Notes {
161 document,
162 width,
163 reference,
164 } => {
165 let editor = CanvasEditor::new(&mut engine, document, width)?;
166 let scene = reference
167 .map(|page| {
168 PageScene::new(page, &mut engine).map(|scene| (scene, [width + 24.0, 0.0]))
169 })
170 .transpose()?;
171 (editor, scene)
172 }
173 Input::Page(page) => {
174 let (scene, editor) = PageScene::from_page(page, &mut engine)?;
175 (editor, Some((scene, [0.0; 2])))
176 }
177 };
178 let initial = editor
179 .outlines()
180 .iter()
181 .map(|outline| {
182 (
183 outline.id,
184 outline.layout().clone(),
185 outline.document().clone(),
186 )
187 })
188 .collect();
189 let dpr = window.scale_factor() as f32;
190 window.set_ime_allowed(true);
191 window.set_cursor(winit::window::CursorIcon::Text);
192 window.request_redraw();
193 eprintln!("Canvas GPU: {:?}; scale factor {dpr}", adapter.get_info());
194 Ok(Self {
195 window,
196 instance,
197 surface,
198 config,
199 renderer,
200 engine,
201 editor,
202 initial,
203 scene,
204 viewport: Viewport {
205 size: [size.width, size.height],
206 scale: dpr * 96.0 / 72.0,
207 origin: [48.0 * dpr; 2],
208 },
209 display_scale: dpr,
210 pointer: [0.0; 2],
211 drag: None,
212 read_only_focus: None,
213 modifiers: ModifiersState::empty(),
214 focused: true,
215 occluded: false,
216 caret: true,
217 blink_at: Instant::now() + Duration::from_millis(500),
218 clipboard: arboard::Clipboard::new()?,
219 access_adapter,
220 accessibility: accessibility::Accessibility::default(),
221 })
222 }
223
224 fn preview(&self) -> Option<(onestore::ExGuid, [f32; 2])> {
225 let Drag::Outline {
226 id,
227 grab,
228 pending_press,
229 } = self.drag.as_ref()?
230 else {
231 return None;
232 };
233 let origin = self
234 .editor
235 .outlines()
236 .iter()
237 .find(|outline| outline.id == *id)?
238 .origin();
239 if pending_press.is_some() {
240 return Some((*id, origin));
241 }
242 let point = self.viewport.document_point(self.pointer);
243 let position = [point[0] - grab[0], point[1] - grab[1]];
244 Some((
245 *id,
246 if self.modifiers.alt_key() {
247 position
248 } else {
249 snap_to_grid(position)
250 },
251 ))
252 }
253
254 fn set_read_only_focus(&mut self, index: Option<usize>) {
255 if self.read_only_focus != index {
256 self.editor.finish_composition();
257 self.drag = None;
258 self.read_only_focus = index;
259 self.window.set_ime_allowed(index.is_none());
260 }
261 }
262
263 fn changed(&mut self) -> Result<(), Box<dyn Error>> {
264 self.caret = true;
265 self.blink_at = Instant::now() + Duration::from_millis(500);
266 let mut rect = self.editor.caret(1.0)?;
267 let origin = self
268 .preview()
269 .map(|(_, origin)| origin)
270 .unwrap_or_else(|| self.editor.active_outline().origin());
271 rect.x0 += f64::from(origin[0]);
272 rect.y0 += f64::from(origin[1]);
273 rect.x1 += f64::from(origin[0]);
274 rect.y1 += f64::from(origin[1]);
275 let v = self.viewport;
276 self.window.set_ime_cursor_area(
277 PhysicalPosition::new(
278 rect.x0 * f64::from(v.scale) + f64::from(v.origin[0]),
279 rect.y0 * f64::from(v.scale) + f64::from(v.origin[1]),
280 ),
281 PhysicalSize::new(
282 (rect.width() * f64::from(v.scale)).max(1.0),
283 (rect.height() * f64::from(v.scale)).max(1.0),
284 ),
285 );
286 self.update_accessibility()?;
287 self.window.request_redraw();
288 Ok(())
289 }
290
291 fn update_accessibility(&mut self) -> Result<(), Box<dyn Error>> {
292 let mut error = None;
293 let preview = self.preview();
294 self.access_adapter.update_if_active(|| {
295 match self.accessibility.update(
296 &self.editor,
297 self.viewport,
298 &self.window.title(),
299 preview,
300 ) {
301 Ok(mut update) => {
302 self.accessibility.append_read_only(
303 &mut update,
304 self.scene.as_ref(),
305 self.viewport,
306 self.read_only_focus,
307 );
308 update
309 }
310 Err(failure) => {
311 error = Some(failure);
312 self.accessibility.deactivate();
313 let mut root = accesskit::Node::new(accesskit::Role::Window);
314 root.set_label(self.window.title());
315 accesskit::TreeUpdate {
316 nodes: vec![(accessibility::ROOT, root)],
317 tree: Some(accesskit::TreeInfo::new(accessibility::ROOT)),
318 tree_id: accesskit::TreeId::ROOT,
319 focus: accessibility::ROOT,
320 }
321 }
322 }
323 });
324 if let Some(error) = error {
325 return Err(error.into());
326 }
327 Ok(())
328 }
329
330 fn access_action(&mut self, request: accesskit::ActionRequest) -> Result<(), Box<dyn Error>> {
331 trace_input(&request);
332 use accesskit::{Action, ActionData};
333 if request.target_tree != accesskit::TreeId::ROOT {
334 return Ok(());
335 }
336 if let Some(index) = self.accessibility.read_only_for_node(request.target_node) {
337 if request.action == Action::Focus {
338 self.set_read_only_focus(Some(index));
339 self.window.focus_window();
340 self.reveal_focus()?;
341 self.changed()?;
342 }
343 return Ok(());
344 }
345 let Some(outline) = self.accessibility.outline_for_node(request.target_node) else {
346 return Ok(());
347 };
348 match (request.action, request.data) {
349 (Action::Focus, _) => {
350 self.editor.focus_outline(outline)?;
351 self.window.focus_window();
352 }
353 (Action::SetTextSelection, Some(ActionData::SetTextSelection(selection))) => {
354 let selection = self.accessibility.selection(outline, selection)?;
355 self.editor.focus_outline(outline)?;
356 self.editor.select(selection)?;
357 trace_input(&self.editor.selection());
358 }
359 (Action::SetValue, Some(ActionData::Value(text))) => {
360 self.editor.focus_outline(outline)?;
361 self.editor.select_all()?;
362 self.editor.commit_text(&mut self.engine, text.into())?;
363 }
364 (Action::ReplaceSelectedText, Some(ActionData::Value(text))) => {
365 self.editor.focus_outline(outline)?;
366 self.editor.commit_text(&mut self.engine, text.into())?;
367 }
368 _ => return Ok(()),
369 }
370 self.set_read_only_focus(None);
371 self.reveal_focus()?;
372 self.changed()
373 }
374
375 fn reveal_focus(&mut self) -> Result<(), Box<dyn Error>> {
376 if self.viewport.size.contains(&0) {
377 return Ok(());
378 }
379 let rect = if let Some(index) = self.read_only_focus {
380 let (scene, offset) = self.scene.as_ref().unwrap();
381 let [x0, y0, x1, y1] = scene.read_only().nth(index).unwrap().rect;
382 parley::BoundingBox {
383 x0: f64::from(x0 + offset[0]),
384 y0: f64::from(y0 + offset[1]),
385 x1: f64::from(x1 + offset[0]),
386 y1: f64::from(y1 + offset[1]),
387 }
388 } else {
389 let mut rect = self.editor.caret(1.0)?;
390 let origin = self.editor.active_outline().origin();
391 rect.x0 += f64::from(origin[0]);
392 rect.x1 += f64::from(origin[0]);
393 rect.y0 += f64::from(origin[1]);
394 rect.y1 += f64::from(origin[1]);
395 rect
396 };
397 let outline = self.editor.active_outline().bounds();
398 for (axis, (mut start, mut end)) in [(rect.x0, rect.x1), (rect.y0, rect.y1)]
399 .into_iter()
400 .enumerate()
401 {
402 let size = f64::from(self.viewport.size[axis]);
403 let margin = f64::from(16.0 * self.display_scale).min(size * 0.25);
404 let (outline_start, outline_end) =
405 [(outline.x0, outline.x1), (outline.y0, outline.y1)][axis];
406 let outline_start = outline_start.min(start);
407 let outline_end = outline_end.max(end);
408 if self.read_only_focus.is_none()
409 && (outline_end - outline_start) * f64::from(self.viewport.scale)
410 <= size - margin * 2.0
411 {
412 start = outline_start;
413 end = outline_end;
414 }
415 let start =
416 start * f64::from(self.viewport.scale) + f64::from(self.viewport.origin[axis]);
417 let end = end * f64::from(self.viewport.scale) + f64::from(self.viewport.origin[axis]);
418 let shift = if start < margin {
419 margin - start
420 } else if end > size - margin {
421 size - margin - end
422 } else {
423 0.0
424 };
425 self.viewport.origin[axis] += shift as f32;
426 }
427 Ok(())
428 }
429
430 fn zoom(&mut self, factor: f32) {
431 let point = self.viewport.document_point(self.pointer);
432 let dpr = self.window.scale_factor() as f32;
433 self.viewport.scale = (self.viewport.scale * factor).clamp(dpr / 3.0, dpr * 16.0 / 3.0);
434 self.viewport.origin = [
435 self.pointer[0] - point[0] * self.viewport.scale,
436 self.pointer[1] - point[1] * self.viewport.scale,
437 ];
438 }
439
440 fn draw(&mut self) -> Result<(), Box<dyn Error>> {
441 trace_input(&("Draw", self.viewport.origin, self.viewport.scale));
442 if self.occluded || self.viewport.size.contains(&0) {
443 return Ok(());
444 }
445 let mut reconfigure = false;
446 let frame = match self.surface.get_current_texture() {
447 wgpu::CurrentSurfaceTexture::Success(frame) => frame,
448 wgpu::CurrentSurfaceTexture::Suboptimal(frame) => {
449 reconfigure = true;
450 frame
451 }
452 wgpu::CurrentSurfaceTexture::Outdated => {
453 self.surface.configure(&self.renderer.device, &self.config);
454 self.window.request_redraw();
455 return Ok(());
456 }
457 wgpu::CurrentSurfaceTexture::Lost => {
458 self.surface = self.instance.create_surface(self.window.clone())?;
459 self.surface.configure(&self.renderer.device, &self.config);
460 self.window.request_redraw();
461 return Ok(());
462 }
463 wgpu::CurrentSurfaceTexture::Timeout => {
464 trace_input(&"Surface timeout");
465 return Ok(());
466 }
467 wgpu::CurrentSurfaceTexture::Occluded => {
468 trace_input(&"Surface occluded");
469 return Ok(());
470 }
471 wgpu::CurrentSurfaceTexture::Validation => {
472 return Err("Canvas surface validation failed".into());
473 }
474 };
475 let primitives = page_primitives(
476 &self.editor,
477 self.scene.as_ref(),
478 self.preview(),
479 self.read_only_focus,
480 self.caret && self.focused && !matches!(self.drag, Some(Drag::Outline { .. })),
481 self.viewport.scale,
482 self.display_scale,
483 )?;
484 self.renderer
485 .draw(
486 &frame.texture.create_view(&Default::default()),
487 self.viewport,
488 &primitives,
489 )
490 .map_err(|error| format!("Canvas drawing failed: {error:?}"))?;
491 self.window.pre_present_notify();
492 self.renderer.queue.present(frame);
493 trace_input(&"Present submitted");
494 if reconfigure {
495 self.surface.configure(&self.renderer.device, &self.config);
496 }
497 Ok(())
498 }
499
500 fn key(&mut self, key: &Key, text: Option<&str>) -> Result<(), Box<dyn Error>> {
501 let shift = self.modifiers.shift_key();
502 let command = self.modifiers.super_key();
503 let option = self.modifiers.alt_key();
504 if self.read_only_focus.is_some() {
505 if !read_only_shortcut(key, self.modifiers) {
506 return Ok(());
507 }
508 if key == &Key::Named(NamedKey::Escape) {
509 self.set_read_only_focus(None);
510 self.reveal_focus()?;
511 return self.changed();
512 }
513 }
514 if matches!(self.drag, Some(Drag::Outline { .. })) {
515 if matches!(
516 key,
517 Key::Named(
518 NamedKey::Alt
519 | NamedKey::AltGraph
520 | NamedKey::Control
521 | NamedKey::Shift
522 | NamedKey::Super
523 | NamedKey::Meta
524 )
525 ) {
526 return Ok(());
527 }
528 self.drag = None;
529 if key == &Key::Named(NamedKey::Escape) {
530 return self.changed();
531 }
532 }
533 if command && option {
534 let delta = match key {
535 Key::Named(NamedKey::ArrowLeft) => Some([-1.0, 0.0]),
536 Key::Named(NamedKey::ArrowRight) => Some([1.0, 0.0]),
537 Key::Named(NamedKey::ArrowUp) => Some([0.0, -1.0]),
538 Key::Named(NamedKey::ArrowDown) => Some([0.0, 1.0]),
539 _ => None,
540 };
541 if let Some(delta) = delta {
542 let outline = self.editor.active_outline();
543 let origin = outline.origin();
544 let step = if shift { 10.0 } else { 1.0 };
545 self.editor.move_outline(
546 outline.id,
547 [origin[0] + delta[0] * step, origin[1] + delta[1] * step],
548 )?;
549 self.reveal_focus()?;
550 return self.changed();
551 }
552 }
553 if self.modifiers.control_key() && key == &Key::Named(NamedKey::Tab) {
554 let outlines = self.editor.outlines();
555 let count = outlines.len()
556 + self
557 .scene
558 .as_ref()
559 .map_or(0, |(scene, _)| scene.read_only().count());
560 if count == 0 {
561 return self.changed();
562 }
563 let index = self.read_only_focus.map_or_else(
564 || {
565 outlines
566 .iter()
567 .position(|outline| outline.id == self.editor.active_outline().id)
568 },
569 |index| Some(outlines.len() + index),
570 );
571 let next = index.map_or(if shift { count - 1 } else { 0 }, |index| {
572 if shift {
573 (index + count - 1) % count
574 } else {
575 (index + 1) % count
576 }
577 });
578 if next < outlines.len() {
579 self.editor.focus_outline(outlines[next].id)?;
580 self.set_read_only_focus(None);
581 } else {
582 self.set_read_only_focus(Some(next - outlines.len()));
583 }
584 self.reveal_focus()?;
585 return self.changed();
586 }
587 if command && let Key::Character(key) = key {
588 match key.to_lowercase().as_str() {
589 "n" if shift => {
590 let position = if let Some(index) = self.read_only_focus {
591 let (scene, offset) = self.scene.as_ref().unwrap();
592 let rect = scene.read_only().nth(index).unwrap().rect;
593 [rect[2] + offset[0] + 24.0, rect[1] + offset[1]]
594 } else {
595 let bounds = self.editor.active_outline().bounds();
596 [bounds.x1 as f32 + 24.0, bounds.y0 as f32]
597 };
598 self.editor.place_caret(
599 &mut self.engine,
600 snap_to_grid(position),
601 NEW_OUTLINE_WIDTH,
602 )?;
603 self.set_read_only_focus(None);
604 }
605 "a" => self.editor.select_all()?,
606 "z" => {
607 if shift {
608 self.editor.redo(&mut self.engine)?;
609 } else {
610 self.editor.undo(&mut self.engine)?;
611 }
612 }
613 "c" | "x" => {
614 let [anchor, focus] = self.editor.selection().positions;
615 let selected = self
616 .editor
617 .active_outline()
618 .document()
619 .slice(anchor.min(focus)..anchor.max(focus))?;
620 let text = selected
621 .iter()
622 .map(|paragraph| {
623 paragraph
624 .project()
625 .map(|projection| projection.text().text().to_owned())
626 })
627 .collect::<Result<Vec<_>, _>>()?
628 .join("\n");
629 if !text.is_empty() {
630 self.clipboard.set_text(text)?;
631 if key.eq_ignore_ascii_case("x") {
632 self.editor.insert(&mut self.engine, "")?;
633 }
634 }
635 }
636 "v" => {
637 let text = self.clipboard.get_text()?;
638 self.editor.commit_text(&mut self.engine, text)?;
639 }
640 "+" | "=" => {
641 self.zoom(1.1);
642 return self.changed();
643 }
644 "-" => {
645 self.zoom(1.0 / 1.1);
646 return self.changed();
647 }
648 "0" => {
649 self.zoom((self.display_scale * 96.0 / 72.0) / self.viewport.scale);
650 return self.changed();
651 }
652 _ => return Ok(()),
653 }
654 } else {
655 let movement = match key {
656 Key::Named(NamedKey::ArrowLeft) => Some(if command {
657 Movement::LineStart
658 } else if option {
659 Movement::WordLeft
660 } else {
661 Movement::Left
662 }),
663 Key::Named(NamedKey::ArrowRight) => Some(if command {
664 Movement::LineEnd
665 } else if option {
666 Movement::WordRight
667 } else {
668 Movement::Right
669 }),
670 Key::Named(NamedKey::ArrowUp) => Some(Movement::Up),
671 Key::Named(NamedKey::ArrowDown) => Some(Movement::Down),
672 Key::Named(NamedKey::Home) => Some(Movement::LineStart),
673 Key::Named(NamedKey::End) => Some(Movement::LineEnd),
674 _ => None,
675 };
676 if let Some(movement) = movement {
677 self.editor.move_selection(movement, shift)?;
678 } else if self.editor.marked_range().is_none() {
679 match key {
680 Key::Named(NamedKey::Backspace) => {
681 self.editor.delete(&mut self.engine, true)?;
682 }
683 Key::Named(NamedKey::Delete) => {
684 self.editor.delete(&mut self.engine, false)?;
685 }
686 Key::Named(NamedKey::Enter) => self.editor.insert(&mut self.engine, "\n")?,
687 Key::Named(NamedKey::Tab) => self.editor.insert(&mut self.engine, "\t")?,
688 _ if !command && !self.modifiers.control_key() => {
689 if let Some(text) = text
690 .filter(|text| !text.is_empty() && !text.chars().any(char::is_control))
691 {
692 self.editor.insert(&mut self.engine, text)?;
693 }
694 }
695 _ => {}
696 }
697 } else if key == &Key::Named(NamedKey::Escape) {
698 self.editor.cancel_composition(&mut self.engine)?;
699 }
700 }
701 self.reveal_focus()?;
702 self.changed()
703 }
704}
705
706impl App {
707 fn close(&self, event_loop: &ActiveEventLoop) {
708 if self.state.as_ref().is_none_or(|state| {
709 state
710 .editor
711 .caret_outline()
712 .is_none_or(TextOutline::is_empty)
713 && state
714 .initial
715 .iter()
716 .map(|(id, layout, document)| (id, layout, document))
717 .eq(state
718 .editor
719 .outlines()
720 .iter()
721 .map(|outline| (&outline.id, outline.layout(), outline.document())))
722 }) || macos::discard_changes()
723 {
724 event_loop.exit();
725 }
726 }
727}
728
729impl ApplicationHandler<UserEvent> for App {
730 fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) {
731 let event = match event {
732 UserEvent::Quit => {
733 self.close(event_loop);
734 return;
735 }
736 UserEvent::Accessibility(event) => event,
737 };
738 let Some(state) = &mut self.state else {
739 return;
740 };
741 if event.window_id != state.window.id() {
742 return;
743 }
744 let was_marked = state.editor.marked_range().is_some();
745 let result = match event.window_event {
746 accesskit_winit::WindowEvent::InitialTreeRequested => state.update_accessibility(),
747 accesskit_winit::WindowEvent::ActionRequested(request) => state.access_action(request),
748 accesskit_winit::WindowEvent::AccessibilityDeactivated => {
749 state.accessibility.deactivate();
750 Ok(())
751 }
752 };
753 if was_marked && state.editor.marked_range().is_none() {
754 macos::clear_marked_text(&state.window);
755 }
756 if let Err(error) = result {
757 eprintln!("{error}");
758 }
759 }
760
761 fn resumed(&mut self, event_loop: &ActiveEventLoop) {
762 if self.state.is_some() {
763 return;
764 }
765 match pollster::block_on(State::new(
766 event_loop,
767 self.proxy.clone(),
768 self.input.take().unwrap(),
769 &self.substitutes,
770 )) {
771 Ok(state) => self.state = Some(state),
772 Err(error) => {
773 self.startup_error = Some(error);
774 event_loop.exit();
775 }
776 }
777 }
778
779 fn window_event(&mut self, event_loop: &ActiveEventLoop, _: WindowId, event: WindowEvent) {
780 if matches!(
781 event,
782 WindowEvent::KeyboardInput { .. }
783 | WindowEvent::Ime(_)
784 | WindowEvent::MouseInput { .. }
785 | WindowEvent::CursorMoved { .. }
786 | WindowEvent::ModifiersChanged(_)
787 | WindowEvent::Focused(_)
788 ) {
789 trace_input(&event);
790 }
791 if let Some(state) = &mut self.state {
792 state.access_adapter.process_event(&state.window, &event);
793 }
794 if matches!(event, WindowEvent::CloseRequested) {
795 self.close(event_loop);
796 return;
797 }
798 let Some(state) = &mut self.state else {
799 return;
800 };
801 let was_marked =
802 !matches!(event, WindowEvent::Ime(_)) && state.editor.marked_range().is_some();
803 let result = (|| -> Result<(), Box<dyn Error>> {
804 match event {
805 WindowEvent::RedrawRequested => state.draw()?,
806 WindowEvent::Resized(size) => {
807 state.viewport.size = [size.width, size.height];
808 if size.width > 0 && size.height > 0 {
809 state.config.width = size.width;
810 state.config.height = size.height;
811 state
812 .surface
813 .configure(&state.renderer.device, &state.config);
814 state.changed()?;
815 }
816 }
817 WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
818 let scale = scale_factor as f32;
819 let ratio = scale / state.display_scale;
820 state.viewport.scale *= ratio;
821 state.viewport.origin[0] *= ratio;
822 state.viewport.origin[1] *= ratio;
823 state.display_scale = scale;
824 state.renderer.clear_glyph_cache();
825 state.changed()?;
826 }
827 WindowEvent::Focused(focused) => {
828 state.focused = focused;
829 if !focused {
830 state.drag = None;
831 }
832 state.changed()?;
833 }
834 WindowEvent::Occluded(occluded) => {
835 trace_input(&("Window occluded", occluded));
836 state.occluded = occluded;
837 if !occluded {
838 state.changed()?;
839 }
840 }
841 WindowEvent::ModifiersChanged(modifiers) => {
842 state.modifiers = modifiers.state();
843 if matches!(state.drag, Some(Drag::Outline { .. })) {
844 state.changed()?;
845 }
846 }
847 WindowEvent::CursorMoved { position, .. } => {
848 state.pointer = [position.x as f32, position.y as f32];
849 match &mut state.drag {
850 Some(Drag::Text) => {
851 let point = state.viewport.document_point(state.pointer);
852 let origin = state.editor.active_outline().origin();
853 state.editor.select_at(
854 point[0] - origin[0],
855 point[1] - origin[1],
856 true,
857 )?;
858 state.changed()?;
859 }
860 Some(Drag::Outline { pending_press, .. }) => {
861 if pending_press.is_some_and(|press| {
862 (0..2).any(|axis| {
863 (state.pointer[axis] - press[axis]).abs()
864 > 2.0 * state.display_scale
865 })
866 }) {
867 *pending_press = None;
868 }
869 state.changed()?;
870 }
871 None => {}
872 }
873 }
874 WindowEvent::MouseInput {
875 state: button_state,
876 button: MouseButton::Left,
877 ..
878 } => {
879 let point = state.viewport.document_point(state.pointer);
880 if button_state == ElementState::Pressed {
881 match page_hit_test(
882 &state.editor,
883 state.scene.as_ref(),
884 point,
885 state.display_scale / state.viewport.scale,
886 ) {
887 Some(Hit::ReadOnly(index)) => state.set_read_only_focus(Some(index)),
888 Some(Hit::Handle { id, grab }) => {
889 state.set_read_only_focus(None);
890 state.editor.focus_outline(id)?;
891 state.drag = Some(Drag::Outline {
892 id,
893 grab,
894 pending_press: Some(state.pointer),
895 });
896 }
897 Some(Hit::Text { id, point }) => {
898 let extend = state.modifiers.shift_key()
899 && state.read_only_focus.is_none()
900 && id == state.editor.active_outline().id;
901 state.set_read_only_focus(None);
902 state.editor.focus_outline(id)?;
903 state.editor.select_at(point[0], point[1], extend)?;
904 state.drag = Some(Drag::Text);
905 }
906 None => {
907 let position = [
908 point[0],
909 point[1] - 7.0 * state.display_scale / state.viewport.scale,
910 ];
911 let position = if state.modifiers.alt_key() {
912 position
913 } else {
914 snap_to_grid(position)
915 };
916 state.editor.place_caret(
917 &mut state.engine,
918 position,
919 NEW_OUTLINE_WIDTH,
920 )?;
921 state.set_read_only_focus(None);
922 state.drag = Some(Drag::Text);
923 }
924 }
925 } else {
926 let preview = state.preview();
927 state.drag = None;
928 if let Some((id, origin)) = preview {
929 state.editor.move_outline(id, origin)?;
930 }
931 }
932 state.changed()?;
933 }
934 WindowEvent::MouseWheel { delta, .. } => {
935 let dpr = state.window.scale_factor() as f32;
936 let delta = match delta {
937 MouseScrollDelta::LineDelta(x, y) => [x * 32.0 * dpr, y * 32.0 * dpr],
938 MouseScrollDelta::PixelDelta(p) => [p.x as f32, p.y as f32],
939 };
940 if state.modifiers.super_key() {
941 state.zoom((delta[1] * 0.005).exp());
942 } else {
943 state.viewport.origin[0] += delta[0];
944 state.viewport.origin[1] += delta[1];
945 }
946 state.changed()?;
947 }
948 WindowEvent::KeyboardInput { event, .. }
949 if event.state == ElementState::Pressed =>
950 {
951 state.key(&event.logical_key, event.text.as_deref())?;
952 }
953 WindowEvent::Ime(_) if state.read_only_focus.is_some() => {}
954 WindowEvent::Ime(Ime::Preedit(text, cursor)) => {
955 if text.is_empty() {
956 state.editor.cancel_composition(&mut state.engine)?;
957 } else {
958 let (start, end) = cursor.unwrap_or((text.len(), text.len()));
959 let start: u32 = text
960 .get(..start)
961 .ok_or("IME range splits UTF-8")?
962 .encode_utf16()
963 .count()
964 .try_into()?;
965 let end: u32 = text
966 .get(..end)
967 .ok_or("IME range splits UTF-8")?
968 .encode_utf16()
969 .count()
970 .try_into()?;
971 state.editor.compose(&mut state.engine, text, start..end)?;
972 }
973 state.reveal_focus()?;
974 state.changed()?;
975 }
976 WindowEvent::Ime(Ime::Commit(text)) => {
977 state.editor.commit_text(&mut state.engine, text)?;
978 state.reveal_focus()?;
979 state.changed()?;
980 }
981 WindowEvent::Ime(Ime::Disabled) => {
982 state.editor.cancel_composition(&mut state.engine)?;
983 state.reveal_focus()?;
984 state.changed()?;
985 }
986 _ => {}
987 }
988 Ok(())
989 })();
990 if was_marked && state.editor.marked_range().is_none() {
991 macos::clear_marked_text(&state.window);
992 }
993 if let Err(error) = result {
994 eprintln!("{error}");
995 }
996 }
997
998 fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
999 let Some(state) = &mut self.state else {
1000 return;
1001 };
1002 let [anchor, focus] = state.editor.selection().positions;
1003 if state.focused && !state.occluded && state.read_only_focus.is_none() && anchor == focus {
1004 let now = Instant::now();
1005 if now >= state.blink_at {
1006 state.caret = !state.caret;
1007 state.blink_at = now + Duration::from_millis(500);
1008 state.window.request_redraw();
1009 }
1010 event_loop.set_control_flow(ControlFlow::WaitUntil(state.blink_at));
1011 } else {
1012 event_loop.set_control_flow(ControlFlow::Wait);
1013 }
1014 }
1015}
1016
1017#[derive(Debug, PartialEq)]
1018enum Hit {
1019 Text {
1020 id: onestore::ExGuid,
1021 point: [f32; 2],
1022 },
1023 Handle {
1024 id: onestore::ExGuid,
1025 grab: [f32; 2],
1026 },
1027 ReadOnly(usize),
1028}
1029
1030fn page_hit_test(
1031 editor: &CanvasEditor,
1032 scene: Option<&(PageScene, [f32; 2])>,
1033 point: [f32; 2],
1034 pixel: f32,
1035) -> Option<Hit> {
1036 let hit = |outline: &TextOutline, offset: [f32; 2]| {
1037 let (bounds, body_top) = outline_chrome(outline, pixel);
1038 let local = [point[0] - offset[0], point[1] - offset[1]];
1039 let [x, y] = local;
1040 if x >= bounds[0] && x <= bounds[2] && y >= bounds[1] && y < body_top {
1041 return Some(Hit::Handle {
1042 id: outline.id,
1043 grab: [
1044 point[0] - outline.origin()[0],
1045 point[1] - outline.origin()[1],
1046 ],
1047 });
1048 }
1049 if (x >= bounds[0] && x <= bounds[2] && y >= body_top && y <= bounds[3])
1050 || outline.layouts().any(|(_, paragraph)| {
1051 paragraph.tags.iter().any(|tag| {
1052 let origin = outline.origin();
1053 let x = origin[0] + tag.origin[0];
1054 let y = origin[1] + paragraph.origin[1] + tag.origin[1];
1055 let size = one_canvas::outline::ParagraphTag::SIZE;
1056 (x..=x + size).contains(&local[0]) && (y..=y + size).contains(&local[1])
1057 })
1058 })
1059 {
1060 return Some(Hit::Text {
1061 id: outline.id,
1062 point: [
1063 local[0] - outline.origin()[0],
1064 local[1] - outline.origin()[1],
1065 ],
1066 });
1067 }
1068 None
1069 };
1070 if let Some(hit) = editor
1071 .outlines()
1072 .iter()
1073 .rev()
1074 .filter(|outline| scene.is_none_or(|(scene, _)| !scene.contains_outline(outline.id)))
1075 .find_map(|outline| hit(outline, [0.0; 2]))
1076 {
1077 return Some(hit);
1078 }
1079 let (scene, offset) = scene?;
1080 match scene.hit_test([point[0] - offset[0], point[1] - offset[1]], |id| {
1081 editor
1082 .outlines()
1083 .iter()
1084 .find(|outline| outline.id == id)
1085 .and_then(|outline| hit(outline, *offset))
1086 })? {
1087 one_canvas_gpu::page::SceneHit::Outline(hit) => Some(hit),
1088 one_canvas_gpu::page::SceneHit::ReadOnly(index) => Some(Hit::ReadOnly(index)),
1089 }
1090}
1091
1092fn page_primitives<'a>(
1093 editor: &'a CanvasEditor,
1094 scene: Option<&'a (PageScene, [f32; 2])>,
1095 preview: Option<(onestore::ExGuid, [f32; 2])>,
1096 read_only_focus: Option<usize>,
1097 show_caret: bool,
1098 scale: f32,
1099 display_scale: f32,
1100) -> Result<Vec<Primitive<'a>>, Box<dyn Error>> {
1101 let mut primitives = Vec::new();
1102 let paint = |id, offset: [f32; 2], primitives: &mut Vec<_>| {
1103 let outline = editor
1104 .outlines()
1105 .iter()
1106 .find(|outline| outline.id == id)
1107 .ok_or(one_canvas_gpu::page::SceneError::MissingOutline)?;
1108 let origin = preview
1109 .filter(|(id, _)| *id == outline.id)
1110 .map(|(_, origin)| origin)
1111 .unwrap_or_else(|| outline.origin());
1112 append_outline(
1113 (read_only_focus.is_none() && outline.id == editor.active_outline().id)
1114 .then_some(editor),
1115 outline,
1116 [origin[0] + offset[0], origin[1] + offset[1]],
1117 show_caret,
1118 scale,
1119 display_scale / scale,
1120 primitives,
1121 )
1122 };
1123 if let Some((scene, origin)) = scene {
1124 scene.append_primitives_with(&mut primitives, *origin, &paint)?;
1125 }
1126 for outline in editor
1127 .outlines()
1128 .iter()
1129 .filter(|outline| scene.is_none_or(|(scene, _)| !scene.contains_outline(outline.id)))
1130 {
1131 paint(outline.id, [0.0; 2], &mut primitives)?;
1132 }
1133 if read_only_focus.is_none()
1134 && let Some(outline) = editor.caret_outline()
1135 {
1136 append_outline(
1137 Some(editor),
1138 outline,
1139 outline.origin(),
1140 show_caret,
1141 scale,
1142 display_scale / scale,
1143 &mut primitives,
1144 )?;
1145 }
1146 if let Some(index) = read_only_focus {
1147 let (scene, offset) = scene.unwrap();
1148 let [x0, y0, x1, y1] = scene.read_only().nth(index).unwrap().rect;
1149 let [x0, y0, x1, y1] = [
1150 x0 + offset[0],
1151 y0 + offset[1],
1152 x1 + offset[0],
1153 y1 + offset[1],
1154 ];
1155 let border = 2.0 / scale;
1156 for rect in [
1157 [x0, y0, x1, y0 + border],
1158 [x0, y1 - border, x1, y1],
1159 [x0, y0, x0 + border, y1],
1160 [x1 - border, y0, x1, y1],
1161 ] {
1162 primitives.push(Primitive::Rect {
1163 rect,
1164 color: [0.25, 0.45, 0.7, 1.0],
1165 });
1166 }
1167 }
1168 Ok(primitives)
1169}
1170
1171fn snap_to_grid(point: [f32; 2]) -> [f32; 2] {
1172 std::array::from_fn(|axis| {
1173 let offset = [0.0, 14.4][axis];
1174 let cell = (point[axis] - offset) / 18.0;
1175 let nearest = cell.round();
1176 // Native midpoints remain free; account for the source coordinate's float precision.
1177 if ((cell - nearest).abs() - 0.5).abs() <= f32::EPSILON * cell.abs().max(1.0) {
1178 point[axis]
1179 } else {
1180 nearest * 18.0 + offset
1181 }
1182 })
1183}
1184
1185fn outline_chrome(outline: &TextOutline, pixel: f32) -> ([f32; 4], f32) {
1186 let bounds = outline.bounds();
1187 // Native chrome combines page-scaled gutters with a fixed screen inset.
1188 let inset = 5.0 * pixel;
1189 let body_top = bounds.y0 as f32 - inset;
1190 (
1191 [
1192 bounds.x0 as f32 - 7.5 - inset,
1193 body_top - HANDLE_HEIGHT,
1194 bounds.x1 as f32 + inset,
1195 bounds.y1 as f32 + HANDLE_HEIGHT + inset,
1196 ],
1197 body_top,
1198 )
1199}
1200
1201fn append_outline<'a>(
1202 editor: Option<&'a CanvasEditor>,
1203 outline: &'a TextOutline,
1204 origin: [f32; 2],
1205 show_caret: bool,
1206 scale: f32,
1207 pixel: f32,
1208 primitives: &mut Vec<Primitive<'a>>,
1209) -> Result<(), Box<dyn Error>> {
1210 let [x, y] = origin;
1211 let (bounds, body_top) = outline_chrome(outline, pixel);
1212 let [dx, dy] = [x - outline.origin()[0], y - outline.origin()[1]];
1213 let [left, top, right, bottom] = [
1214 bounds[0] + dx,
1215 bounds[1] + dy,
1216 bounds[2] + dx,
1217 bounds[3] + dy,
1218 ];
1219 let body_top = body_top + dy;
1220 let border = if editor.is_some() {
1221 [0.25, 0.45, 0.7, 0.65]
1222 } else {
1223 [0.5, 0.5, 0.5, 0.2]
1224 };
1225 if editor.is_none_or(|editor| editor.caret_outline().is_none()) {
1226 for rect in [
1227 [left, top, right, body_top],
1228 [left, body_top, left + pixel, bottom],
1229 [right - pixel, body_top, right, bottom],
1230 [left + pixel, bottom - pixel, right - pixel, bottom],
1231 ] {
1232 primitives.push(Primitive::Rect {
1233 rect,
1234 color: border,
1235 });
1236 }
1237 }
1238 for (_, paragraph) in outline.layouts() {
1239 let layout = &paragraph.text;
1240 let paragraph_origin = paragraph.origin;
1241 for (rect, color) in layout.backgrounds() {
1242 primitives.push(Primitive::Rect {
1243 rect: [
1244 rect.x0 as f32 + x + paragraph_origin[0],
1245 rect.y0 as f32 + y + paragraph_origin[1],
1246 rect.x1 as f32 + x + paragraph_origin[0],
1247 rect.y1 as f32 + y + paragraph_origin[1],
1248 ],
1249 color: one_canvas_gpu::colorref(color),
1250 });
1251 }
1252 }
1253 if let Some(editor) = editor {
1254 for rect in editor.selection_rects()? {
1255 primitives.push(Primitive::Rect {
1256 rect: [
1257 rect.x0 as f32 + x,
1258 rect.y0 as f32 + y,
1259 rect.x1 as f32 + x,
1260 rect.y1 as f32 + y,
1261 ],
1262 color: [0.55, 0.73, 1.0, 0.5],
1263 });
1264 }
1265 }
1266 for (_, paragraph) in outline.layouts() {
1267 let layout = &paragraph.text;
1268 let paragraph_origin = paragraph.origin;
1269 primitives.push(Primitive::Text {
1270 layout,
1271 origin: [x + paragraph_origin[0], y + paragraph_origin[1]],
1272 });
1273 for (layout, origin) in &paragraph.markers {
1274 primitives.push(Primitive::Text {
1275 layout,
1276 origin: [x + origin[0], y + (origin[1] + paragraph_origin[1])],
1277 });
1278 }
1279 for tag in &paragraph.tags {
1280 primitives.push(Primitive::Tag {
1281 tag,
1282 origin: [x, y + paragraph_origin[1]],
1283 });
1284 }
1285 }
1286 if let Some(editor) = editor {
1287 for rect in editor.marked_rects()? {
1288 primitives.push(Primitive::Rect {
1289 rect: [
1290 rect.x0 as f32 + x,
1291 rect.y1 as f32 + y - 1.0 / scale,
1292 rect.x1 as f32 + x,
1293 rect.y1 as f32 + y,
1294 ],
1295 color: [0.0, 0.0, 0.0, 1.0],
1296 });
1297 }
1298 let [anchor, focus] = editor.selection().positions;
1299 if show_caret && anchor == focus {
1300 let rect = editor.caret(1.0 / scale)?;
1301 primitives.push(Primitive::Rect {
1302 rect: [
1303 rect.x0 as f32 + x,
1304 rect.y0 as f32 + y,
1305 rect.x1 as f32 + x,
1306 rect.y1 as f32 + y,
1307 ],
1308 color: [0.0, 0.0, 0.0, 1.0],
1309 });
1310 }
1311 }
1312 Ok(())
1313}
1314
1315fn main() -> Result<(), Box<dyn Error>> {
1316 let mut args = std::env::args_os().skip(1);
1317 let mut positional = Vec::new();
1318 let mut substitutes = Vec::new();
1319 let mut reference = None;
1320 let mut editable = false;
1321 while let Some(arg) = args.next() {
1322 if arg == "--substitute-font" {
1323 substitutes.push(PathBuf::from(
1324 args.next()
1325 .ok_or("Provide a font file after --substitute-font.")?,
1326 ));
1327 } else if arg == "--reference" || arg == "--page" {
1328 if reference.is_some() {
1329 return Err("Only one page can be opened.".into());
1330 }
1331 editable = arg == "--page";
1332 let path = args
1333 .next()
1334 .ok_or("Provide a section file and page title after the page option.")?;
1335 let title = args
1336 .next()
1337 .ok_or("Provide a page title after the section file.")?;
1338 let bytes = std::fs::read(path)?;
1339 let store = onestore::Store::parse(&bytes)?;
1340 let index = onestore::RevisionIndex::parse(&store)?;
1341 reference = Some(Page::from_document(
1342 &onestore::document::Document::parse(&index)?,
1343 title
1344 .to_str()
1345 .ok_or("The page title must be valid Unicode.")?,
1346 )?);
1347 } else {
1348 positional.push(arg);
1349 }
1350 }
1351 if editable && !positional.is_empty() {
1352 return Err(
1353 "Use --page with a section file and page title, without a text file or width.".into(),
1354 );
1355 }
1356 if positional.len() > 2 {
1357 return Err(
1358 "Usage: one-canvas-app [TEXT_FILE] [WIDTH_POINTS] [--reference SECTION PAGE_TITLE | --page SECTION PAGE_TITLE] [--substitute-font FONT_FILE]..."
1359 .into(),
1360 );
1361 }
1362 let text = positional
1363 .first()
1364 .map(std::fs::read_to_string)
1365 .transpose()?
1366 .unwrap_or_default();
1367 let width = positional
1368 .get(1)
1369 .map(|value| value.to_string_lossy().parse::<f32>())
1370 .transpose()?
1371 .unwrap_or(if reference.is_some() {
1372 NEW_OUTLINE_WIDTH
1373 } else {
1374 480.0
1375 });
1376 let input = if editable {
1377 Input::Page(reference.unwrap())
1378 } else {
1379 Input::Notes {
1380 document: TextDocument::new(
1381 text.split('\n')
1382 .map(|line| Paragraph::new(line.to_owned(), Format::default()))
1383 .collect(),
1384 )?,
1385 width,
1386 reference,
1387 }
1388 };
1389 let event_loop = macos::event_loop()?;
1390 let mut app = App {
1391 proxy: event_loop.create_proxy(),
1392 input: Some(input),
1393 substitutes,
1394 state: None,
1395 startup_error: None,
1396 };
1397 event_loop.run_app(&mut app)?;
1398 app.startup_error.map_or(Ok(()), Err)
1399}
1400
1401#[cfg(test)]
1402mod tests {
1403 use super::*;
1404
1405 #[test]
1406 fn native_grid_matches_drag_offsets_midpoints_and_zoom() {
1407 for (pixels, points) in [
1408 (5.0, 0.0),
1409 (6.0, 0.0),
1410 (7.0, 0.0),
1411 (8.0, 0.0),
1412 (11.0, 0.0),
1413 (12.0, 9.0),
1414 (13.0, 18.0),
1415 (17.0, 18.0),
1416 (18.0, 18.0),
1417 (19.0, 18.0),
1418 (25.0, 18.0),
1419 (37.0, 36.0),
1420 (-6.0, 0.0),
1421 (-7.0, 0.0),
1422 (-12.0, -9.0),
1423 (-13.0, -18.0),
1424 (-19.0, -18.0),
1425 ] {
1426 let origin = [486.0, 230.40001];
1427 let proposed = origin.map(|v| v + pixels * 72.0 / 96.0);
1428 let result = snap_to_grid(proposed);
1429 for axis in 0..2 {
1430 assert!((result[axis] - origin[axis] - points).abs() < 0.00004);
1431 }
1432 }
1433 for pixels in [6.0, 7.0, 12.0, 13.0, 19.0] {
1434 let result = snap_to_grid([491.25 + pixels * 0.75, 235.65 + pixels * 0.75]);
1435 assert!((result[0] - 504.0).abs() < 0.00004);
1436 assert!((result[1] - 248.4).abs() < 0.00004);
1437 }
1438 for (scale, delta, expected) in [
1439 (96.0 / 72.0, [27.0, 31.0], [54.0, 104.4]),
1440 (96.0 / 72.0, [13.0, 17.0], [54.0, 104.4]),
1441 (192.0 / 72.0, [13.0, 17.0], [36.0, 104.4]),
1442 ] {
1443 for dpr in [1.0, 2.0] {
1444 let origin = [36.0, 90.0];
1445 let proposed =
1446 std::array::from_fn(|axis| origin[axis] + delta[axis] * dpr / (scale * dpr));
1447 let result = snap_to_grid(proposed);
1448 for axis in 0..2 {
1449 assert!((result[axis] - expected[axis]).abs() < 0.00004);
1450 }
1451 }
1452 }
1453 for point in [[9.0, 5.4], [-9.0, -12.6], [423.0, 239.4]] {
1454 assert_eq!(snap_to_grid(point), point);
1455 }
1456 }
1457
1458 #[test]
1459 fn native_fresh_click_grid_uses_screen_offset_at_both_zoom_levels() {
1460 for (x, y, scale, expected) in [
1461 (900.0, 500.0, 96.0 / 72.0, [639.0, 302.4]),
1462 (901.0, 500.0, 96.0 / 72.0, [648.0, 302.4]),
1463 (906.0, 500.0, 96.0 / 72.0, [648.0, 302.4]),
1464 (913.0, 500.0, 96.0 / 72.0, [648.0, 302.4]),
1465 (900.0, 501.0, 96.0 / 72.0, [639.0, 302.4]),
1466 (900.0, 506.0, 96.0 / 72.0, [639.0, 320.4]),
1467 (900.0, 520.0, 96.0 / 72.0, [639.0, 320.4]),
1468 (900.0, 500.0, 192.0 / 72.0, [324.0, 158.4]),
1469 ] {
1470 for dpr in [1.0, 2.0] {
1471 let viewport = Viewport {
1472 size: [(1600.0 * dpr) as u32, (900.0 * dpr) as u32],
1473 origin: [48.0 * dpr, 83.0 * dpr],
1474 scale: scale * dpr,
1475 };
1476 let point = viewport.document_point([x * dpr, y * dpr]);
1477 let result = snap_to_grid([point[0], point[1] - 7.0 * dpr / viewport.scale]);
1478 for axis in 0..2 {
1479 assert!((result[axis] - expected[axis]).abs() < 0.00004);
1480 }
1481 }
1482 }
1483 }
1484
1485 #[test]
1486 fn blank_caret_and_composition_have_accessible_text_without_outline_chrome() {
1487 let mut engine = TextEngine::default();
1488 let mut editor = CanvasEditor::new(
1489 &mut engine,
1490 TextDocument::new(vec![Paragraph::new(String::new(), Default::default())]).unwrap(),
1491 240.0,
1492 )
1493 .unwrap();
1494 editor
1495 .place_caret(&mut engine, [120.0, 100.0], 240.0)
1496 .unwrap();
1497 let mut access = accessibility::Accessibility::default();
1498 let viewport = Viewport {
1499 size: [1000, 720],
1500 scale: 96.0 / 72.0,
1501 origin: [48.0; 2],
1502 };
1503 let initial = access.update(&editor, viewport, "Page", None).unwrap();
1504 let id = editor.active_outline().id;
1505 assert_eq!(access.outline_for_node(initial.focus), Some(id));
1506 let rectangles = |editor: &CanvasEditor| {
1507 page_primitives(editor, None, None, None, true, viewport.scale, 1.0)
1508 .unwrap()
1509 .into_iter()
1510 .filter_map(|p| match p {
1511 Primitive::Rect { rect, color } => Some((rect, color)),
1512 _ => None,
1513 })
1514 .collect::<Vec<_>>()
1515 };
1516 assert_eq!(rectangles(&editor).len(), 1);
1517 assert_eq!(
1518 page_hit_test(&editor, None, [120.0, 100.0], 1.0 / viewport.scale),
1519 None
1520 );
1521 editor.compose(&mut engine, "に".into(), 1..1).unwrap();
1522 assert!(editor.outlines().is_empty());
1523 assert!(
1524 rectangles(&editor)
1525 .iter()
1526 .all(|(_, color)| *color == [0.0, 0.0, 0.0, 1.0])
1527 );
1528 let preedit = access.update(&editor, viewport, "Page", None).unwrap();
1529 assert_eq!(preedit.focus, initial.focus);
1530 editor.commit_text(&mut engine, "日本".into()).unwrap();
1531 let committed = access.update(&editor, viewport, "Page", None).unwrap();
1532 assert_eq!(committed.focus, initial.focus);
1533 assert_eq!(rectangles(&editor).len(), 5);
1534 editor.undo(&mut engine).unwrap();
1535 assert!(editor.outlines().is_empty());
1536 assert_eq!(rectangles(&editor).len(), 1);
1537 let empty = access.update(&editor, viewport, "Page", None).unwrap();
1538 assert_eq!(empty.focus, committed.focus);
1539 assert_eq!(access.outline_for_node(committed.focus), Some(id));
1540 editor.redo(&mut engine).unwrap();
1541 editor.select_all().unwrap();
1542 editor.delete(&mut engine, true).unwrap();
1543 assert!(editor.outlines().is_empty());
1544 assert_eq!(rectangles(&editor).len(), 1);
1545 let retired = access.update(&editor, viewport, "Page", None).unwrap();
1546 assert_eq!(retired.focus, committed.focus);
1547 assert_eq!(access.outline_for_node(retired.focus), Some(id));
1548 editor.undo(&mut engine).unwrap();
1549 assert_eq!(rectangles(&editor).len(), 5);
1550 let restored = access.update(&editor, viewport, "Page", None).unwrap();
1551 assert_eq!(restored.focus, committed.focus);
1552 editor.redo(&mut engine).unwrap();
1553 assert_eq!(rectangles(&editor).len(), 1);
1554 assert!(editor.caret_outline().unwrap().is_empty());
1555 editor.insert(&mut engine, " ").unwrap();
1556 assert!(editor.caret_outline().unwrap().is_empty());
1557 editor.compose(&mut engine, "に".into(), 1..1).unwrap();
1558 assert!(!editor.caret_outline().unwrap().is_empty());
1559 }
1560
1561 #[test]
1562 fn native_chrome_geometry_preserves_text_and_matches_hit_regions_across_zoom_and_dpi() {
1563 let mut engine = TextEngine::default();
1564 let format = Format {
1565 font: Some("Arial".into()),
1566 font_size: Some(11.0),
1567 ..Default::default()
1568 };
1569 let mut editor = CanvasEditor::new(&mut engine, TextDocument::new(vec![
1570 Paragraph::new("HAMBURGEFONTS abcdefghijklmnopqrstuvwxyz 0123456789 HAMBURGEFONTS abcdefghijklmnopqrstuvwxyz 0123456789 HAMBURGEFONTS abcdefghijklmnopqrstuvwxyz 0123456789".into(), format.clone()),
1571 Paragraph::new("Second paragraph abcdefghijklmnopqrstuvwxyz.".into(), format),
1572 ]).unwrap(), 220.0).unwrap();
1573 let id = editor.active_outline().id;
1574 editor.move_outline(id, [36.0, 90.0]).unwrap();
1575 let document = editor.active_outline().document().clone();
1576 let layout_ids: Vec<_> = editor
1577 .active_outline()
1578 .layouts()
1579 .map(|(_, p)| p.text.id())
1580 .collect();
1581 for (zoom, expected) in [
1582 (0.5, [62.0, 134.0, 223.0, 219.0]),
1583 (1.0, [81.0, 189.0, 394.0, 348.0]),
1584 (1.5, [101.0, 245.0, 565.0, 479.0]),
1585 (2.0, [120.0, 300.0, 736.0, 608.0]),
1586 ] {
1587 for dpr in [1.0, 2.0] {
1588 let scale = zoom * (96.0 / 72.0) * dpr;
1589 let pixel = dpr / scale;
1590 let (frame, body_top) = outline_chrome(editor.active_outline(), pixel);
1591 for (index, coordinate) in frame.iter().enumerate() {
1592 let origin = if index % 2 == 0 { 48.0 } else { 83.0 };
1593 let screen = coordinate * scale / dpr + origin;
1594 assert!(
1595 (screen - expected[index]).abs() <= 1.5,
1596 "zoom={zoom} dpr={dpr} edge={index}: {screen} vs {}",
1597 expected[index]
1598 );
1599 }
1600 let header = [36.0, (frame[1] + body_top) / 2.0];
1601 assert!(
1602 matches!(page_hit_test(&editor, None, header, pixel), Some(Hit::Handle {id: hit, ..}) if hit == id)
1603 );
1604 let padding = [frame[0] + pixel, 90.0];
1605 assert!(
1606 matches!(page_hit_test(&editor, None, padding, pixel), Some(Hit::Text {id: hit, ..}) if hit == id)
1607 );
1608 assert_eq!(
1609 page_hit_test(&editor, None, [frame[0] - pixel, 90.0], pixel),
1610 None
1611 );
1612 let primitives =
1613 page_primitives(&editor, None, None, None, false, scale, dpr).unwrap();
1614 assert!(
1615 matches!(&primitives[0], Primitive::Rect {rect, ..} if *rect == [frame[0], frame[1], frame[2], body_top])
1616 );
1617 assert!(
1618 primitives.iter().any(
1619 |p| matches!(p, Primitive::Text {origin, ..} if *origin == [36.0, 90.0])
1620 )
1621 );
1622 }
1623 }
1624 assert_eq!(editor.active_outline().document(), &document);
1625 assert_eq!(
1626 editor
1627 .active_outline()
1628 .layouts()
1629 .map(|(_, p)| p.text.id())
1630 .collect::<Vec<_>>(),
1631 layout_ids
1632 );
1633 }
1634
1635 #[test]
1636 fn a_later_text_body_takes_priority_over_an_earlier_drag_handle() {
1637 let mut engine = TextEngine::default();
1638 let mut editor = CanvasEditor::new(
1639 &mut engine,
1640 TextDocument::new(vec![Paragraph::new("first".into(), Default::default())]).unwrap(),
1641 240.0,
1642 )
1643 .unwrap();
1644 let first = editor.active_outline().id;
1645 editor.move_outline(first, [0.0, 20.0]).unwrap();
1646 assert_eq!(
1647 page_hit_test(&editor, None, [5.0, 12.0], 1.0),
1648 Some(Hit::Handle {
1649 id: first,
1650 grab: [5.0, -8.0]
1651 })
1652 );
1653 let second = editor
1654 .create_outline(&mut engine, [0.0, 10.0], 240.0)
1655 .unwrap();
1656 assert_eq!(
1657 page_hit_test(&editor, None, [5.0, 12.0], 1.0),
1658 Some(Hit::Text {
1659 id: second,
1660 point: [5.0, 2.0]
1661 })
1662 );
1663 editor.undo(&mut engine).unwrap();
1664 assert_eq!(
1665 page_hit_test(&editor, None, [5.0, 12.0], 1.0),
1666 Some(Hit::Handle {
1667 id: first,
1668 grab: [5.0, -8.0]
1669 })
1670 );
1671 }
1672
1673 #[test]
1674 fn overlapping_objects_follow_paint_order_through_creation_movement_and_undo() {
1675 use one_canvas::page::{Outline, PageObject, Unsupported};
1676 for readonly_on_top in [false, true] {
1677 let mut engine = TextEngine::default();
1678 let document = TextDocument::new(vec![Paragraph::new(
1679 "Imported text".into(),
1680 Default::default(),
1681 )])
1682 .unwrap();
1683 let id = onestore::ExGuid {
1684 guid: [5; 16],
1685 n: 1,
1686 };
1687 let mut objects = vec![
1688 PageObject::Outline(Outline {
1689 id,
1690 layout: onestore::document::Layout {
1691 x: Some(30.0),
1692 y: Some(40.0),
1693 max_width: Some(180.0),
1694 ..Default::default()
1695 },
1696 indents: vec![18.0, 0.0],
1697 paragraphs: document.nodes().to_vec(),
1698 unsupported: Vec::new(),
1699 }),
1700 PageObject::Unsupported(Unsupported {
1701 id: Default::default(),
1702 jcid: 0xdead,
1703 layout: onestore::document::Layout {
1704 x: Some(20.0),
1705 y: Some(20.0),
1706 max_width: Some(200.0),
1707 max_height: Some(100.0),
1708 ..Default::default()
1709 },
1710 }),
1711 ];
1712 if !readonly_on_top {
1713 objects.reverse();
1714 }
1715 let page = Page {
1716 title: String::new(),
1717 margin_origin: [0.0; 2],
1718 definitions: Default::default(),
1719 objects,
1720 };
1721 let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap();
1722 let scene = (scene, [0.0; 2]);
1723 let imported_layout = editor
1724 .active_outline()
1725 .layouts()
1726 .next()
1727 .unwrap()
1728 .1
1729 .text
1730 .id();
1731 let texts: Vec<_> = page_primitives(&editor, Some(&scene), None, None, false, 1.0, 1.0)
1732 .unwrap()
1733 .into_iter()
1734 .filter_map(|p| match p {
1735 Primitive::Text { layout, .. } => Some(layout.id()),
1736 _ => None,
1737 })
1738 .collect();
1739 assert_eq!(texts.len(), 2);
1740 assert_eq!(texts[usize::from(!readonly_on_top)], imported_layout);
1741 let expected = if readonly_on_top {
1742 Hit::ReadOnly(0)
1743 } else {
1744 Hit::Text {
1745 id,
1746 point: [10.0, 5.0],
1747 }
1748 };
1749 assert_eq!(
1750 page_hit_test(&editor, Some(&scene), [40.0, 45.0], 1.0),
1751 Some(expected)
1752 );
1753 let header = page_hit_test(&editor, Some(&scene), [40.0, 32.0], 1.0);
1754 if readonly_on_top {
1755 assert_eq!(header, Some(Hit::ReadOnly(0)));
1756 } else {
1757 assert_eq!(
1758 header,
1759 Some(Hit::Handle {
1760 id,
1761 grab: [10.0, -8.0]
1762 })
1763 );
1764 }
1765 let annotation = editor
1766 .create_outline(&mut engine, [30.0, 40.0], 180.0)
1767 .unwrap();
1768 assert_eq!(
1769 page_hit_test(&editor, Some(&scene), [40.0, 45.0], 1.0),
1770 Some(Hit::Text {
1771 id: annotation,
1772 point: [10.0, 5.0]
1773 })
1774 );
1775 assert_eq!(
1776 page_hit_test(&editor, Some(&scene), [40.0, 32.0], 1.0),
1777 Some(Hit::Handle {
1778 id: annotation,
1779 grab: [10.0, -8.0]
1780 })
1781 );
1782 let texts: Vec<_> = page_primitives(&editor, Some(&scene), None, None, false, 1.0, 1.0)
1783 .unwrap()
1784 .into_iter()
1785 .filter_map(|p| match p {
1786 Primitive::Text { layout, .. } => Some(layout.id()),
1787 _ => None,
1788 })
1789 .collect();
1790 assert_eq!(
1791 *texts.last().unwrap(),
1792 editor
1793 .active_outline()
1794 .layouts()
1795 .next()
1796 .unwrap()
1797 .1
1798 .text
1799 .id()
1800 );
1801 editor.move_outline(annotation, [500.0, 500.0]).unwrap();
1802 assert!(
1803 !matches!(page_hit_test(&editor, Some(&scene), [40.0,45.0], 1.0), Some(Hit::Text { id, .. }) if id == annotation)
1804 );
1805 editor.undo(&mut engine).unwrap();
1806 assert_eq!(
1807 page_hit_test(&editor, Some(&scene), [40.0, 45.0], 1.0),
1808 Some(Hit::Text {
1809 id: annotation,
1810 point: [10.0, 5.0]
1811 })
1812 );
1813 editor.undo(&mut engine).unwrap();
1814 assert_eq!(editor.outlines().len(), 1);
1815 assert_eq!(editor.active_outline().document(), &document);
1816 assert_eq!(
1817 page_hit_test(&editor, Some(&scene), [10.0, 10.0], 1.0),
1818 None
1819 );
1820 }
1821 }
1822
1823 #[test]
1824 fn read_only_focus_accepts_navigation_without_edit_shortcuts() {
1825 for modifiers in [
1826 ModifiersState::empty(),
1827 ModifiersState::SHIFT,
1828 ModifiersState::ALT,
1829 ModifiersState::SUPER,
1830 ModifiersState::SUPER | ModifiersState::SHIFT,
1831 ModifiersState::SUPER | ModifiersState::ALT,
1832 ] {
1833 for key in [
1834 Key::Character("a".into()),
1835 Key::Character("v".into()),
1836 Key::Character("c".into()),
1837 Key::Character("x".into()),
1838 Key::Character("z".into()),
1839 Key::Character("🌳".into()),
1840 Key::Named(NamedKey::Backspace),
1841 Key::Named(NamedKey::Delete),
1842 Key::Named(NamedKey::Enter),
1843 Key::Named(NamedKey::ArrowLeft),
1844 Key::Named(NamedKey::ArrowRight),
1845 Key::Named(NamedKey::ArrowUp),
1846 Key::Named(NamedKey::ArrowDown),
1847 ] {
1848 assert!(
1849 !read_only_shortcut(&key, modifiers),
1850 "{key:?} {modifiers:?}"
1851 );
1852 }
1853 }
1854 for (key, modifiers) in [
1855 (Key::Named(NamedKey::Escape), ModifiersState::empty()),
1856 (Key::Named(NamedKey::Tab), ModifiersState::CONTROL),
1857 (
1858 Key::Named(NamedKey::Tab),
1859 ModifiersState::CONTROL | ModifiersState::SHIFT,
1860 ),
1861 (
1862 Key::Character("N".into()),
1863 ModifiersState::SUPER | ModifiersState::SHIFT,
1864 ),
1865 (Key::Character("+".into()), ModifiersState::SUPER),
1866 (Key::Character("0".into()), ModifiersState::SUPER),
1867 (Key::Character("-".into()), ModifiersState::SUPER),
1868 ] {
1869 assert!(read_only_shortcut(&key, modifiers));
1870 }
1871 assert!(!read_only_shortcut(
1872 &Key::Character("n".into()),
1873 ModifiersState::SUPER
1874 ));
1875 assert!(!read_only_shortcut(
1876 &Key::Named(NamedKey::Tab),
1877 ModifiersState::empty()
1878 ));
1879 }
1880
1881 #[test]
1882 fn read_only_focus_retires_text_overlays_and_draws_a_scaled_focus_border() {
1883 use one_canvas::page::{PageObject, Unsupported};
1884 let mut engine = TextEngine::default();
1885 let mut editor = CanvasEditor::new(
1886 &mut engine,
1887 TextDocument::new(vec![Paragraph::new("Draft".into(), Default::default())]).unwrap(),
1888 240.0,
1889 )
1890 .unwrap();
1891 let page = Page {
1892 title: String::new(),
1893 margin_origin: [0.0; 2],
1894 definitions: Default::default(),
1895 objects: vec![PageObject::Unsupported(Unsupported {
1896 id: Default::default(),
1897 jcid: 0xdead,
1898 layout: onestore::document::Layout {
1899 x: Some(100.0),
1900 y: Some(80.0),
1901 max_width: Some(180.0),
1902 max_height: Some(80.0),
1903 ..Default::default()
1904 },
1905 })],
1906 };
1907 let scene = (PageScene::new(page, &mut engine).unwrap(), [20.0, 30.0]);
1908 let original = editor.active_outline().document().clone();
1909 let rectangles = |primitives: Vec<Primitive<'_>>| {
1910 primitives
1911 .into_iter()
1912 .filter_map(|p| match p {
1913 Primitive::Rect { rect, color } => Some((rect, color)),
1914 _ => None,
1915 })
1916 .collect::<Vec<_>>()
1917 };
1918 for scale in [1.0, 2.0] {
1919 let text = rectangles(
1920 page_primitives(&editor, Some(&scene), None, None, true, scale, 1.0).unwrap(),
1921 );
1922 assert!(text.iter().any(|(_, color)| *color == [0.0, 0.0, 0.0, 1.0]));
1923 let selected = rectangles(
1924 page_primitives(&editor, Some(&scene), None, Some(0), true, scale, 1.0).unwrap(),
1925 );
1926 assert!(
1927 !selected
1928 .iter()
1929 .any(|(_, color)| *color == [0.0, 0.0, 0.0, 1.0])
1930 );
1931 assert_eq!(
1932 selected,
1933 rectangles(
1934 page_primitives(&editor, Some(&scene), None, Some(0), false, scale, 1.0)
1935 .unwrap()
1936 )
1937 );
1938 assert_eq!(
1939 selected.last().unwrap().0,
1940 [300.0 - 2.0 / scale, 110.0, 300.0, 190.0]
1941 );
1942 }
1943 editor.select_all().unwrap();
1944 let text = rectangles(
1945 page_primitives(&editor, Some(&scene), None, None, false, 1.0, 1.0).unwrap(),
1946 );
1947 assert!(
1948 text.iter()
1949 .any(|(_, color)| *color == [0.55, 0.73, 1.0, 0.5])
1950 );
1951 let selected = rectangles(
1952 page_primitives(&editor, Some(&scene), None, Some(0), true, 1.0, 1.0).unwrap(),
1953 );
1954 assert!(
1955 !selected
1956 .iter()
1957 .any(|(_, color)| *color == [0.55, 0.73, 1.0, 0.5])
1958 );
1959 assert_eq!(editor.active_outline().document(), &original);
1960 }
1961
1962 #[test]
1963 #[ignore = "requires CANVAS_TEST_SECTION and CANVAS_TEST_PAGE private fixture inputs"]
1964 fn imported_page_widgets_and_accessibility_follow_edits() {
1965 let bytes = std::fs::read(std::env::var_os("CANVAS_TEST_SECTION").unwrap()).unwrap();
1966 let page = {
1967 let store = onestore::Store::parse(&bytes).unwrap();
1968 let index = onestore::RevisionIndex::parse(&store).unwrap();
1969 Page::from_document(
1970 &onestore::document::Document::parse(&index).unwrap(),
1971 &std::env::var("CANVAS_TEST_PAGE").unwrap(),
1972 )
1973 .unwrap()
1974 };
1975 drop(bytes);
1976 let mut engine = TextEngine::default();
1977 if let Some(font) = std::env::var_os("CANVAS_TEST_SUBSTITUTE") {
1978 engine
1979 .register_substitute(parley::fontique::Blob::new(Arc::new(
1980 std::fs::read(font).unwrap(),
1981 )))
1982 .unwrap();
1983 }
1984 let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap();
1985 let scene = (scene, [0.0; 2]);
1986 let viewport = Viewport {
1987 size: [1000, 720],
1988 scale: 96.0 / 72.0,
1989 origin: [48.0; 2],
1990 };
1991 let mut tag_hits = 0;
1992 for outline in editor.outlines() {
1993 for (_, paragraph) in outline.layouts() {
1994 for tag in &paragraph.tags {
1995 let point = [
1996 outline.origin()[0]
1997 + tag.origin[0]
1998 + one_canvas::outline::ParagraphTag::SIZE / 2.0,
1999 outline.origin()[1]
2000 + paragraph.origin[1]
2001 + tag.origin[1]
2002 + one_canvas::outline::ParagraphTag::SIZE / 2.0,
2003 ];
2004 assert!(
2005 matches!(page_hit_test(&editor, Some(&scene), point, 1.0), Some(Hit::Text { id, .. }) if id == outline.id),
2006 "Tag center {point:?} did not target outline {:?}",
2007 outline.id
2008 );
2009 tag_hits += 1;
2010 }
2011 }
2012 }
2013 eprintln!("Verified {tag_hits} imported tag-gutter targets");
2014 let mut access = accessibility::Accessibility::default();
2015 let tags = |editor: &CanvasEditor| {
2016 page_primitives(editor, Some(&scene), None, None, true, viewport.scale, 1.0)
2017 .unwrap()
2018 .into_iter()
2019 .filter_map(|primitive| match primitive {
2020 Primitive::Tag { tag, origin } => Some((tag.clone(), origin)),
2021 _ => None,
2022 })
2023 .collect::<Vec<_>>()
2024 };
2025 let original_tags = tags(&editor);
2026 let ids: Vec<_> = editor.outlines().iter().map(|outline| outline.id).collect();
2027 for id in ids {
2028 editor.focus_outline(id).unwrap();
2029 let original = editor.active_outline().document().clone();
2030 editor
2031 .select(
2032 [one_canvas::document::TextPosition {
2033 paragraph: 0,
2034 offset: 0,
2035 }; 2]
2036 .into(),
2037 )
2038 .unwrap();
2039 let before = access.update(&editor, viewport, "Test", None).unwrap();
2040 let before = accesskit_consumer::Tree::new(before, true);
2041 let before = before.state().focus().unwrap().document_range().text();
2042 editor.insert(&mut engine, "CANVAS CHECK ").unwrap();
2043 {
2044 let update = access.update(&editor, viewport, "Test", None).unwrap();
2045 let tree = accesskit_consumer::Tree::new(update, true);
2046 assert_eq!(
2047 tree.state().focus().unwrap().document_range().text(),
2048 format!("CANVAS CHECK {before}")
2049 );
2050 let primitives =
2051 page_primitives(&editor, Some(&scene), None, None, true, viewport.scale, 1.0)
2052 .unwrap();
2053 assert!(!primitives.is_empty());
2054 let edited_tags = tags(&editor);
2055 assert_eq!(edited_tags.len(), original_tags.len());
2056 for ((edited, _), (original, _)) in edited_tags.iter().zip(&original_tags) {
2057 assert_eq!(edited.icon, original.icon);
2058 assert_eq!(edited.label, original.label);
2059 assert_eq!(edited.disabled, original.disabled);
2060 }
2061 }
2062 editor.undo(&mut engine).unwrap();
2063 assert_eq!(editor.active_outline().document(), &original);
2064 assert_eq!(tags(&editor), original_tags);
2065 let update = access.update(&editor, viewport, "Test", None).unwrap();
2066 let tree = accesskit_consumer::Tree::new(update, true);
2067 assert_eq!(
2068 tree.state().focus().unwrap().document_range().text(),
2069 before
2070 );
2071 }
2072 eprintln!(
2073 "Rendered and restored {} imported outline widgets and accessibility fields",
2074 editor.outlines().len()
2075 );
2076 }
2077}
crates/canvas-app/src/profile.rs deleted-192
...@@ -1,192 +0,0 @@
1use super::*;
2use one_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/canvas-gpu/Cargo.toml deleted-18
...@@ -1,18 +0,0 @@
1[package]
2name = "one-canvas-gpu"
3version = "0.1.0"
4edition = "2024"
5publish = false
6
7[dependencies]
8onestore = { path = "../onestore" }
9one-canvas = { path = "../canvas" }
10bytemuck = { version = "1.25", features = ["derive"] }
11swash = "0.2.10"
12parley.workspace = true
13wgpu.workspace = true
14image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
15
16[dev-dependencies]
17pollster = "0.4"
18png = "0.18"
crates/canvas-gpu/examples/render_page.rs deleted-125
...@@ -1,125 +0,0 @@
1use one_canvas::{layout::TextEngine, page::Page};
2use one_canvas_gpu::{Renderer, Viewport, page::PageScene};
3use onestore::{RevisionIndex, Store, document::Document};
4use std::{env, fs, sync::Arc, time::Duration};
5
6fn main() -> Result<(), Box<dyn std::error::Error>> {
7 let mut args = env::args().skip(1).peekable();
8 let path = args
9 .next()
10 .ok_or("Expected a section snapshot, page title and new PNG path")?;
11 let title = args.next().ok_or("Expected a page title")?;
12 let destination = args.next().ok_or("Expected a new PNG path")?;
13 let origin_y: f32 = if args
14 .peek()
15 .is_some_and(|value| value != "--substitute-font")
16 {
17 args.next().unwrap().parse()?
18 } else {
19 0.0
20 };
21 let mut engine = TextEngine::default();
22 while let Some(option) = args.next() {
23 if option != "--substitute-font" {
24 return Err(format!("Unknown option: {option}").into());
25 }
26 let path = args
27 .next()
28 .ok_or("Provide a font file after --substitute-font.")?;
29 engine.register_substitute(parley::fontique::Blob::new(Arc::new(fs::read(path)?)))?;
30 }
31 let page = {
32 let bytes = fs::read(path)?;
33 let store = Store::parse(&bytes)?;
34 let index = RevisionIndex::parse(&store)?;
35 Page::from_document(&Document::parse(&index)?, &title)?
36 };
37 let margin_origin = page.margin_origin;
38 let object_count = page.objects.len();
39 let scene = PageScene::new(page, &mut engine)?;
40 let mut primitives = Vec::new();
41 scene.append_primitives(&mut primitives, [0.0; 2])?;
42 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
43 let adapter = pollster::block_on(instance.request_adapter(&Default::default()))?;
44 let (device, queue) = pollster::block_on(adapter.request_device(&Default::default()))?;
45 let size = [1386_u32, 759];
46 let format = wgpu::TextureFormat::Rgba8UnormSrgb;
47 let target = device.create_texture(&wgpu::TextureDescriptor {
48 label: Some("Native page comparison"),
49 size: wgpu::Extent3d {
50 width: size[0],
51 height: size[1],
52 depth_or_array_layers: 1,
53 },
54 mip_level_count: 1,
55 sample_count: 1,
56 dimension: wgpu::TextureDimension::D2,
57 format,
58 usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
59 view_formats: &[],
60 });
61 let row_bytes = (size[0] * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT);
62 let buffer = device.create_buffer(&wgpu::BufferDescriptor {
63 label: Some("Native page comparison readback"),
64 size: u64::from(row_bytes) * u64::from(size[1]),
65 usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
66 mapped_at_creation: false,
67 });
68 let mut renderer = Renderer::new(device, queue, format);
69 renderer
70 .draw(
71 &target.create_view(&Default::default()),
72 Viewport {
73 size,
74 scale: 96.0 / 72.0,
75 origin: [
76 (36.0 - margin_origin[0]) * (96.0 / 72.0),
77 (14.4 - margin_origin[1]) * (96.0 / 72.0) + origin_y,
78 ],
79 },
80 &primitives,
81 )
82 .map_err(|e| format!("Page comparison rendering failed: {e:?}"))?;
83 let mut encoder = renderer.device.create_command_encoder(&Default::default());
84 encoder.copy_texture_to_buffer(
85 target.as_image_copy(),
86 wgpu::TexelCopyBufferInfo {
87 buffer: &buffer,
88 layout: wgpu::TexelCopyBufferLayout {
89 offset: 0,
90 bytes_per_row: Some(row_bytes),
91 rows_per_image: Some(size[1]),
92 },
93 },
94 wgpu::Extent3d {
95 width: size[0],
96 height: size[1],
97 depth_or_array_layers: 1,
98 },
99 );
100 renderer.queue.submit([encoder.finish()]);
101 let (sender, receiver) = std::sync::mpsc::channel();
102 buffer.map_async(wgpu::MapMode::Read, .., move |result| {
103 let _ = sender.send(result);
104 });
105 renderer.device.poll(wgpu::PollType::Wait {
106 submission_index: None,
107 timeout: Some(Duration::from_secs(10)),
108 })?;
109 receiver.recv_timeout(Duration::from_secs(10))??;
110 let mapped = buffer.get_mapped_range(..)?;
111 let pixels: Vec<_> = mapped
112 .chunks_exact(row_bytes as usize)
113 .flat_map(|row| row[..size[0] as usize * 4].iter().copied())
114 .collect();
115 let mut encoder = png::Encoder::new(fs::File::create_new(destination)?, size[0], size[1]);
116 encoder.set_color(png::ColorType::Rgba);
117 encoder.set_depth(png::BitDepth::Eight);
118 encoder.write_header()?.write_image_data(&pixels)?;
119 eprintln!(
120 "Rendered {} page objects at 100% / 96 DPI, origin Y {origin_y} px using {:?}",
121 object_count,
122 adapter.get_info()
123 );
124 Ok(())
125}
crates/canvas-gpu/src/canvas.wgsl deleted-19
...@@ -1,19 +0,0 @@
1struct Vertex {
2 @builtin(position) position: vec4<f32>,
3 @location(0) uv: vec2<f32>,
4 @location(1) color: vec4<f32>,
5}
6
7@group(0) @binding(0) var atlas: texture_2d<f32>;
8@group(0) @binding(1) var atlas_sampler: sampler;
9
10@vertex
11fn vertex(@location(0) position: vec2<f32>, @location(1) uv: vec2<f32>,
12 @location(2) color: vec4<f32>) -> Vertex {
13 return Vertex(vec4<f32>(position, 0.0, 1.0), uv, color);
14}
15
16@fragment
17fn fragment(input: Vertex) -> @location(0) vec4<f32> {
18 return textureSample(atlas, atlas_sampler, input.uv) * input.color;
19}
crates/canvas-gpu/src/lib.rs deleted-1346
...@@ -1,1346 +0,0 @@
1pub mod page;
2#[cfg(test)]
3mod profile;
4mod tags;
5
6use bytemuck::{Pod, Zeroable};
7use image::ImageDecoder;
8use one_canvas::{
9 layout::TextLayout,
10 outline::{ParagraphTag, TagIcon},
11};
12use parley::{PositionedLayoutItem, fontique::Blob};
13use std::{
14 collections::{HashMap, HashSet},
15 ops::Range,
16};
17use swash::{
18 FontRef,
19 scale::{Render, ScaleContext, Source, StrikeWith, image::Content},
20 zeno::{Angle, Format, Transform, Vector},
21};
22
23const ATLAS_SIZE: u32 = 2048;
24const MAX_GLYPHS: usize = 8192;
25const MAX_VERTICES: usize = 65_536;
26const MAX_IMAGE_BYTES: u64 = 64 * 1024 * 1024;
27const MAX_IMAGES: usize = 256;
28
29#[derive(Clone)]
30pub struct RasterImage {
31 size: [u32; 2],
32 pixels: Blob<u8>,
33}
34
35impl RasterImage {
36 pub fn decode(encoded: &[u8]) -> Result<Self, RenderError> {
37 if encoded.len() as u64 > MAX_IMAGE_BYTES {
38 return Err(RenderError::ImageBudget);
39 }
40 let format = image::guess_format(encoded).map_err(RenderError::ImageDecode)?;
41 let mut reader = image::ImageReader::with_format(std::io::Cursor::new(encoded), format);
42 let mut limits = image::Limits::default();
43 limits.max_image_width = Some(16_384);
44 limits.max_image_height = Some(16_384);
45 limits.max_alloc = Some(MAX_IMAGE_BYTES);
46 reader.limits(limits.clone());
47 let mut decoder = reader.into_decoder().map_err(RenderError::ImageDecode)?;
48 let (width, height) = decoder.dimensions();
49 if u64::from(width) * u64::from(height) * 4 > MAX_IMAGE_BYTES
50 || decoder.total_bytes() > MAX_IMAGE_BYTES
51 {
52 return Err(RenderError::ImageBudget);
53 }
54 limits
55 .reserve(decoder.total_bytes())
56 .map_err(RenderError::ImageDecode)?;
57 decoder
58 .set_limits(limits)
59 .map_err(RenderError::ImageDecode)?;
60 let pixels = image::DynamicImage::from_decoder(decoder)
61 .map_err(RenderError::ImageDecode)?
62 .into_rgba8();
63 Self::new([width, height], pixels.into_raw())
64 }
65
66 /// Immutable, straight-alpha sRGB RGBA pixels retain one cache identity across clones.
67 pub fn new(size: [u32; 2], mut pixels: Vec<u8>) -> Result<Self, RenderError> {
68 let bytes = u64::from(size[0])
69 .checked_mul(u64::from(size[1]))
70 .and_then(|v| v.checked_mul(4))
71 .ok_or(RenderError::InvalidImage)?;
72 if size.contains(&0) || bytes != pixels.len() as u64 {
73 return Err(RenderError::InvalidImage);
74 }
75 if bytes > MAX_IMAGE_BYTES {
76 return Err(RenderError::ImageBudget);
77 }
78 // Premultiply in linear light before filtering; transparent RGB must not bleed at edges.
79 for pixel in pixels.chunks_exact_mut(4) {
80 let alpha = pixel[3];
81 if alpha == 255 {
82 continue;
83 }
84 let linear = colorref(u32::from_le_bytes(pixel.try_into().unwrap()));
85 for (byte, value) in pixel[..3].iter_mut().zip(linear) {
86 let value = value * f32::from(alpha) / 255.0;
87 *byte = srgb_byte(value);
88 }
89 }
90 Ok(Self {
91 size,
92 pixels: pixels.into(),
93 })
94 }
95}
96
97struct CachedImage {
98 binding: wgpu::BindGroup,
99 bytes: u64,
100}
101
102struct Batch {
103 vertices: Range<u32>,
104 image: Option<u64>,
105}
106
107#[repr(C)]
108#[derive(Clone, Copy, Pod, Zeroable)]
109struct Vertex {
110 position: [f32; 2],
111 uv: [f32; 2],
112 color: [f32; 4],
113}
114
115#[derive(Hash, PartialEq, Eq)]
116enum AtlasKey {
117 Text {
118 font: u64,
119 index: u32,
120 glyph: u16,
121 size: u32,
122 coords: Vec<i16>,
123 phase: [u8; 2],
124 embolden: bool,
125 skew: u32,
126 },
127 Tag {
128 icon: TagIcon,
129 size: u32,
130 phase: [u8; 2],
131 },
132}
133
134#[derive(Clone, Copy)]
135struct AtlasGlyph {
136 x: u32,
137 y: u32,
138 width: u32,
139 height: u32,
140 left: i32,
141 top: i32,
142 color: bool,
143}
144
145#[derive(Clone, Copy)]
146pub struct Viewport {
147 pub size: [u32; 2],
148 /// Physical pixels per document point.
149 pub scale: f32,
150 /// Physical pixel position of the document origin.
151 pub origin: [f32; 2],
152}
153
154impl Viewport {
155 pub fn document_point(&self, point: [f32; 2]) -> [f32; 2] {
156 [
157 (point[0] - self.origin[0]) / self.scale,
158 (point[1] - self.origin[1]) / self.scale,
159 ]
160 }
161
162 fn visible_image_rect(&self, rect: [f32; 4]) -> Result<Option<[f32; 4]>, RenderError> {
163 let rect = [
164 rect[0] * self.scale + self.origin[0],
165 rect[1] * self.scale + self.origin[1],
166 rect[2] * self.scale + self.origin[0],
167 rect[3] * self.scale + self.origin[1],
168 ];
169 if rect.iter().any(|v| !v.is_finite()) || rect[2] < rect[0] || rect[3] < rect[1] {
170 return Err(RenderError::InvalidPrimitive);
171 }
172 Ok((rect[0] < self.size[0] as f32
173 && rect[1] < self.size[1] as f32
174 && rect[2] > 0.0
175 && rect[3] > 0.0
176 && rect[0] < rect[2]
177 && rect[1] < rect[3])
178 .then_some(rect))
179 }
180}
181
182/// Coordinates are document points; colors are linear RGBA.
183pub enum Primitive<'a> {
184 Tag {
185 tag: &'a ParagraphTag,
186 origin: [f32; 2],
187 },
188 Text {
189 layout: &'a TextLayout,
190 origin: [f32; 2],
191 },
192 Rect {
193 rect: [f32; 4],
194 color: [f32; 4],
195 },
196 Image {
197 image: &'a RasterImage,
198 rect: [f32; 4],
199 },
200}
201
202#[derive(Debug)]
203pub enum RenderError {
204 AtlasFull,
205 FrameTooLarge,
206 UnreadableFont,
207 UnsupportedGlyph,
208 InvalidViewport,
209 InvalidPrimitive,
210 InvalidImage,
211 ImageBudget,
212 ImageTooLarge,
213 ImageDecode(image::ImageError),
214}
215
216pub struct Renderer {
217 pub device: wgpu::Device,
218 pub queue: wgpu::Queue,
219 pipeline: wgpu::RenderPipeline,
220 image_pipeline: wgpu::RenderPipeline,
221 atlas: wgpu::Texture,
222 bind_group: wgpu::BindGroup,
223 image_sampler: wgpu::Sampler,
224 vertex_buffer: wgpu::Buffer,
225 vertices: Vec<Vertex>,
226 glyphs: HashMap<AtlasKey, Option<AtlasGlyph>>,
227 images: HashMap<u64, CachedImage>,
228 batches: Vec<Batch>,
229 scaler: ScaleContext,
230 pen: [u32; 2],
231 row_height: u32,
232}
233
234impl Renderer {
235 pub fn new(device: wgpu::Device, queue: wgpu::Queue, format: wgpu::TextureFormat) -> Self {
236 let shader = device.create_shader_module(wgpu::include_wgsl!("canvas.wgsl"));
237 let binding_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
238 label: Some("Canvas texture"),
239 entries: &[
240 wgpu::BindGroupLayoutEntry {
241 binding: 0,
242 visibility: wgpu::ShaderStages::FRAGMENT,
243 ty: wgpu::BindingType::Texture {
244 sample_type: wgpu::TextureSampleType::Float { filterable: true },
245 view_dimension: wgpu::TextureViewDimension::D2,
246 multisampled: false,
247 },
248 count: None,
249 },
250 wgpu::BindGroupLayoutEntry {
251 binding: 1,
252 visibility: wgpu::ShaderStages::FRAGMENT,
253 ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
254 count: None,
255 },
256 ],
257 });
258 let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
259 label: Some("Canvas"),
260 bind_group_layouts: &[Some(&binding_layout)],
261 ..Default::default()
262 });
263 let make_pipeline = |blend| {
264 device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
265 label: Some("Canvas"),
266 layout: Some(&pipeline_layout),
267 vertex: wgpu::VertexState {
268 module: &shader,
269 entry_point: Some("vertex"),
270 compilation_options: Default::default(),
271 buffers: &[Some(wgpu::VertexBufferLayout {
272 array_stride: size_of::<Vertex>() as u64,
273 step_mode: wgpu::VertexStepMode::Vertex,
274 attributes: &wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Float32x4],
275 })],
276 },
277 fragment: Some(wgpu::FragmentState {
278 module: &shader,
279 entry_point: Some("fragment"),
280 compilation_options: Default::default(),
281 targets: &[Some(wgpu::ColorTargetState {
282 format,
283 blend: Some(blend),
284 write_mask: wgpu::ColorWrites::ALL,
285 })],
286 }),
287 primitive: Default::default(),
288 depth_stencil: None,
289 multisample: Default::default(),
290 multiview_mask: None,
291 cache: None,
292 })
293 };
294 let pipeline = make_pipeline(wgpu::BlendState::ALPHA_BLENDING);
295 let image_pipeline = make_pipeline(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING);
296 let atlas = device.create_texture(&wgpu::TextureDescriptor {
297 label: Some("Glyph atlas"),
298 size: wgpu::Extent3d {
299 width: ATLAS_SIZE,
300 height: ATLAS_SIZE,
301 depth_or_array_layers: 1,
302 },
303 mip_level_count: 1,
304 sample_count: 1,
305 dimension: wgpu::TextureDimension::D2,
306 format: wgpu::TextureFormat::Rgba8UnormSrgb,
307 usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
308 view_formats: &[],
309 });
310 let view = atlas.create_view(&Default::default());
311 let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
312 mag_filter: wgpu::FilterMode::Nearest,
313 min_filter: wgpu::FilterMode::Nearest,
314 ..Default::default()
315 });
316 let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
317 label: Some("Glyph atlas"),
318 layout: &pipeline.get_bind_group_layout(0),
319 entries: &[
320 wgpu::BindGroupEntry {
321 binding: 0,
322 resource: wgpu::BindingResource::TextureView(&view),
323 },
324 wgpu::BindGroupEntry {
325 binding: 1,
326 resource: wgpu::BindingResource::Sampler(&sampler),
327 },
328 ],
329 });
330 let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
331 label: Some("Canvas vertices"),
332 size: (MAX_VERTICES * size_of::<Vertex>()) as u64,
333 usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
334 mapped_at_creation: false,
335 });
336 let image_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
337 label: Some("Canvas image filtering"),
338 mag_filter: wgpu::FilterMode::Linear,
339 min_filter: wgpu::FilterMode::Linear,
340 ..Default::default()
341 });
342 queue.write_texture(
343 atlas.as_image_copy(),
344 &[255; 4],
345 wgpu::TexelCopyBufferLayout {
346 offset: 0,
347 bytes_per_row: Some(4),
348 rows_per_image: Some(1),
349 },
350 wgpu::Extent3d {
351 width: 1,
352 height: 1,
353 depth_or_array_layers: 1,
354 },
355 );
356 Self {
357 device,
358 queue,
359 pipeline,
360 image_pipeline,
361 atlas,
362 bind_group,
363 image_sampler,
364 vertex_buffer,
365 vertices: Vec::new(),
366 glyphs: HashMap::new(),
367 images: HashMap::new(),
368 batches: Vec::new(),
369 scaler: ScaleContext::with_max_entries(32),
370 pen: [1, 0],
371 row_height: 1,
372 }
373 }
374
375 pub fn clear_glyph_cache(&mut self) {
376 self.glyphs.clear();
377 self.pen = [1, 0];
378 self.row_height = 1;
379 }
380
381 pub fn draw(
382 &mut self,
383 target: &wgpu::TextureView,
384 viewport: Viewport,
385 primitives: &[Primitive<'_>],
386 ) -> Result<(), RenderError> {
387 if viewport.size.contains(&0)
388 || !viewport.scale.is_finite()
389 || viewport.scale <= 0.0
390 || viewport.origin.iter().any(|v| !v.is_finite())
391 {
392 return Err(RenderError::InvalidViewport);
393 }
394 let mut active_images = HashSet::new();
395 let mut image_bytes = 0;
396 for primitive in primitives {
397 if let Primitive::Image { image, rect } = primitive
398 && viewport.visible_image_rect(*rect)?.is_some()
399 && active_images.insert(image.pixels.id())
400 {
401 image_bytes += image.pixels.as_ref().len() as u64;
402 if image_bytes > MAX_IMAGE_BYTES || active_images.len() > MAX_IMAGES {
403 return Err(RenderError::ImageBudget);
404 }
405 }
406 }
407 for attempt in 0..2 {
408 self.vertices.clear();
409 self.batches.clear();
410 match self.prepare(viewport, primitives, &active_images) {
411 Err(RenderError::AtlasFull) if attempt == 0 => self.clear_glyph_cache(),
412 result => {
413 result?;
414 break;
415 }
416 }
417 }
418 self.queue
419 .write_buffer(&self.vertex_buffer, 0, bytemuck::cast_slice(&self.vertices));
420 let mut encoder = self.device.create_command_encoder(&Default::default());
421 {
422 let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
423 label: Some("Canvas"),
424 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
425 view: target,
426 depth_slice: None,
427 resolve_target: None,
428 ops: wgpu::Operations {
429 load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
430 store: wgpu::StoreOp::Store,
431 },
432 })],
433 ..Default::default()
434 });
435 pass.set_vertex_buffer(0, self.vertex_buffer.slice(..));
436 for batch in &self.batches {
437 pass.set_pipeline(if batch.image.is_some() {
438 &self.image_pipeline
439 } else {
440 &self.pipeline
441 });
442 let binding = batch
443 .image
444 .map(|id| &self.images[&id].binding)
445 .unwrap_or(&self.bind_group);
446 pass.set_bind_group(0, binding, &[]);
447 pass.draw(batch.vertices.clone(), 0..1);
448 }
449 }
450 self.queue.submit([encoder.finish()]);
451 Ok(())
452 }
453
454 fn prepare(
455 &mut self,
456 viewport: Viewport,
457 primitives: &[Primitive<'_>],
458 active_images: &HashSet<u64>,
459 ) -> Result<(), RenderError> {
460 for primitive in primitives {
461 let start = self.vertices.len() as u32;
462 let mut image_id = None;
463 match primitive {
464 Primitive::Tag { tag, origin } => {
465 let origin = [
466 viewport.origin[0] + (origin[0] + tag.origin[0]) * viewport.scale,
467 viewport.origin[1] + (origin[1] + tag.origin[1]) * viewport.scale,
468 ];
469 self.tag(Viewport { origin, ..viewport }, tag)?;
470 }
471 Primitive::Text { layout, origin } => {
472 let origin = [
473 viewport.origin[0] + origin[0] * viewport.scale,
474 viewport.origin[1] + origin[1] * viewport.scale,
475 ];
476 if origin.iter().any(|v| !v.is_finite()) {
477 return Err(RenderError::InvalidPrimitive);
478 }
479 self.text(Viewport { origin, ..viewport }, layout)?;
480 }
481 Primitive::Rect { rect, color } => {
482 self.quad(
483 viewport,
484 [
485 rect[0] * viewport.scale + viewport.origin[0],
486 rect[1] * viewport.scale + viewport.origin[1],
487 rect[2] * viewport.scale + viewport.origin[0],
488 rect[3] * viewport.scale + viewport.origin[1],
489 ],
490 [0.5 / ATLAS_SIZE as f32; 4],
491 *color,
492 )?;
493 }
494 Primitive::Image { image, rect } => {
495 if let Some(rect) = viewport.visible_image_rect(*rect)? {
496 self.image(image, active_images)?;
497 image_id = Some(image.pixels.id());
498 self.quad(viewport, rect, [0.0, 0.0, 1.0, 1.0], [1.0; 4])?;
499 }
500 }
501 }
502 let end = self.vertices.len() as u32;
503 if start != end {
504 if let Some(last) = self.batches.last_mut()
505 && last.image == image_id
506 {
507 last.vertices.end = end;
508 } else {
509 self.batches.push(Batch {
510 vertices: start..end,
511 image: image_id,
512 });
513 }
514 }
515 }
516 Ok(())
517 }
518
519 fn image(&mut self, image: &RasterImage, active: &HashSet<u64>) -> Result<(), RenderError> {
520 if self.images.contains_key(&image.pixels.id()) {
521 return Ok(());
522 }
523 if image
524 .size
525 .iter()
526 .any(|v| *v > self.device.limits().max_texture_dimension_2d)
527 {
528 return Err(RenderError::ImageTooLarge);
529 }
530 let bytes = image.pixels.as_ref().len() as u64;
531 while self.images.values().map(|i| i.bytes).sum::<u64>() + bytes > MAX_IMAGE_BYTES
532 || self.images.len() >= MAX_IMAGES
533 {
534 let id = self
535 .images
536 .keys()
537 .find(|id| !active.contains(id))
538 .copied()
539 .ok_or(RenderError::ImageBudget)?;
540 self.images.remove(&id);
541 }
542 let size = wgpu::Extent3d {
543 width: image.size[0],
544 height: image.size[1],
545 depth_or_array_layers: 1,
546 };
547 let texture = self.device.create_texture(&wgpu::TextureDescriptor {
548 label: Some("Canvas image"),
549 size,
550 mip_level_count: 1,
551 sample_count: 1,
552 dimension: wgpu::TextureDimension::D2,
553 format: wgpu::TextureFormat::Rgba8UnormSrgb,
554 usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
555 view_formats: &[],
556 });
557 self.queue.write_texture(
558 texture.as_image_copy(),
559 image.pixels.as_ref(),
560 wgpu::TexelCopyBufferLayout {
561 offset: 0,
562 bytes_per_row: Some(image.size[0] * 4),
563 rows_per_image: Some(image.size[1]),
564 },
565 size,
566 );
567 let binding = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
568 label: Some("Canvas image"),
569 layout: &self.pipeline.get_bind_group_layout(0),
570 entries: &[
571 wgpu::BindGroupEntry {
572 binding: 0,
573 resource: wgpu::BindingResource::TextureView(
574 &texture.create_view(&Default::default()),
575 ),
576 },
577 wgpu::BindGroupEntry {
578 binding: 1,
579 resource: wgpu::BindingResource::Sampler(&self.image_sampler),
580 },
581 ],
582 });
583 self.images
584 .insert(image.pixels.id(), CachedImage { binding, bytes });
585 Ok(())
586 }
587
588 fn text(&mut self, viewport: Viewport, layout: &TextLayout) -> Result<(), RenderError> {
589 let scale = viewport.scale;
590 let origin = viewport.origin;
591 for (line, line_box) in layout.lines() {
592 let top = line_box.top * scale + origin[1];
593 if top + line_box.height * scale < 0.0 || top > viewport.size[1] as f32 {
594 continue;
595 }
596 for item in line.items() {
597 let PositionedLayoutItem::GlyphRun(glyph_run) = item else {
598 continue;
599 };
600 let run = glyph_run.run();
601 let data = &run.font().font;
602 let size = run.font_size() * scale;
603 if !size.is_finite() || size > ATLAS_SIZE as f32 {
604 return Err(RenderError::AtlasFull);
605 }
606 let synthesis = run.synthesis();
607 let coords: Vec<i16> = run
608 .normalized_coords()
609 .iter()
610 .map(|c| c.to_bits())
611 .collect();
612 let color = glyph_run.style().brush.color;
613 for glyph in glyph_run.positioned_glyphs() {
614 let x = glyph.x * scale + origin[0];
615 let y =
616 (glyph.y + line_box.baseline - line.metrics().baseline) * scale + origin[1];
617 // Quantization affects raster coverage only, never advances or line breaks.
618 let x = (x * 4.0).round() * 0.25;
619 let y = (y * 4.0).round() * 0.25;
620 let glyph_id = glyph
621 .id
622 .try_into()
623 .map_err(|_| RenderError::UnsupportedGlyph)?;
624 let phase = [((x - x.floor()) * 4.0) as u8, ((y - y.floor()) * 4.0) as u8];
625 let key = AtlasKey::Text {
626 font: data.data.id(),
627 index: data.index,
628 glyph: glyph_id,
629 size: size.to_bits(),
630 coords: coords.clone(),
631 phase,
632 embolden: synthesis.embolden(),
633 skew: synthesis.skew().unwrap_or(0.0).to_bits(),
634 };
635 let cached = if let Some(cached) = self.glyphs.get(&key) {
636 *cached
637 } else {
638 if self.glyphs.len() >= MAX_GLYPHS {
639 return Err(RenderError::AtlasFull);
640 }
641 let font = FontRef::from_index(data.data.as_ref(), data.index as usize)
642 .ok_or(RenderError::UnreadableFont)?;
643 let mut scaler = self
644 .scaler
645 .builder_with_id(font, [data.data.id(), u64::from(data.index)])
646 .size(size)
647 .hint(false)
648 .normalized_coords(coords.iter().copied())
649 .build();
650 let mut render = Render::new(&[
651 Source::ColorOutline(0),
652 Source::ColorBitmap(StrikeWith::BestFit),
653 Source::Outline,
654 ]);
655 // Swash rasterizes outlines in Y-up coordinates.
656 render.format(Format::Alpha).offset(Vector::new(
657 f32::from(phase[0]) * 0.25,
658 -f32::from(phase[1]) * 0.25,
659 ));
660 if synthesis.embolden() {
661 render.embolden(size / 24.0);
662 }
663 if let Some(skew) = synthesis.skew() {
664 render.transform(Some(Transform::skew(
665 Angle::from_degrees(skew),
666 Angle::from_degrees(0.0),
667 )));
668 }
669 let image = render.render(&mut scaler, glyph_id);
670 let cached = if let Some(image) =
671 image.filter(|i| i.placement.width > 0 && i.placement.height > 0)
672 {
673 Some(self.upload(image)?)
674 } else {
675 None
676 };
677 self.glyphs.insert(key, cached);
678 cached
679 };
680 if let Some(glyph) = cached {
681 let left = x.floor() + glyph.left as f32;
682 let top = y.floor() - glyph.top as f32;
683 let atlas_size = ATLAS_SIZE as f32;
684 self.quad(
685 viewport,
686 [
687 left,
688 top,
689 left + glyph.width as f32,
690 top + glyph.height as f32,
691 ],
692 [
693 glyph.x as f32 / atlas_size,
694 glyph.y as f32 / atlas_size,
695 (glyph.x + glyph.width) as f32 / atlas_size,
696 (glyph.y + glyph.height) as f32 / atlas_size,
697 ],
698 if glyph.color {
699 [1.0; 4]
700 } else {
701 colorref(color)
702 },
703 )?;
704 }
705 }
706 let metrics = run.font_metrics();
707 for (decoration, offset, thickness) in [
708 (
709 &glyph_run.style().underline,
710 metrics.underline_offset,
711 metrics.underline_size,
712 ),
713 (
714 &glyph_run.style().strikethrough,
715 metrics.strikethrough_offset,
716 metrics.strikethrough_size,
717 ),
718 ] {
719 if let Some(decoration) = decoration {
720 let x = glyph_run.offset() * scale + origin[0];
721 let y = (line_box.baseline - decoration.offset.unwrap_or(offset)) * scale
722 + origin[1];
723 self.quad(
724 viewport,
725 [
726 x,
727 y,
728 x + glyph_run.advance() * scale,
729 y + (decoration.size.unwrap_or(thickness) * scale).max(1.0),
730 ],
731 [0.5 / ATLAS_SIZE as f32; 4],
732 colorref(decoration.brush.color),
733 )?;
734 }
735 }
736 }
737 }
738 Ok(())
739 }
740
741 fn upload(&mut self, image: swash::scale::image::Image) -> Result<AtlasGlyph, RenderError> {
742 let p = image.placement;
743 if p.width + 2 > ATLAS_SIZE || p.height + 2 > ATLAS_SIZE {
744 return Err(RenderError::AtlasFull);
745 }
746 if self.pen[0] + p.width + 1 > ATLAS_SIZE {
747 self.pen[0] = 0;
748 self.pen[1] += self.row_height + 1;
749 self.row_height = 0;
750 }
751 if self.pen[1] + p.height + 1 > ATLAS_SIZE {
752 return Err(RenderError::AtlasFull);
753 }
754 let cached = AtlasGlyph {
755 x: self.pen[0],
756 y: self.pen[1],
757 width: p.width,
758 height: p.height,
759 left: p.left,
760 top: p.top,
761 color: image.content == Content::Color,
762 };
763 let rgba = match image.content {
764 Content::Mask => image
765 .data
766 .iter()
767 .flat_map(|a| [255, 255, 255, *a])
768 .collect(),
769 _ => image.data,
770 };
771 self.queue.write_texture(
772 wgpu::TexelCopyTextureInfo {
773 origin: wgpu::Origin3d {
774 x: cached.x,
775 y: cached.y,
776 z: 0,
777 },
778 ..self.atlas.as_image_copy()
779 },
780 &rgba,
781 wgpu::TexelCopyBufferLayout {
782 offset: 0,
783 bytes_per_row: Some(p.width * 4),
784 rows_per_image: Some(p.height),
785 },
786 wgpu::Extent3d {
787 width: p.width,
788 height: p.height,
789 depth_or_array_layers: 1,
790 },
791 );
792 self.pen[0] += p.width + 1;
793 self.row_height = self.row_height.max(p.height);
794 Ok(cached)
795 }
796
797 fn tag(&mut self, viewport: Viewport, tag: &ParagraphTag) -> Result<(), RenderError> {
798 let size = ParagraphTag::SIZE * viewport.scale;
799 let [x, y] = viewport.origin.map(|v| (v * 4.0).round() * 0.25);
800 if !size.is_finite() || [x, y].iter().any(|v| !v.is_finite()) {
801 return Err(RenderError::InvalidPrimitive);
802 }
803 if x + size + 1.0 < 0.0
804 || y + size + 1.0 < 0.0
805 || x - 1.0 > viewport.size[0] as f32
806 || y - 1.0 > viewport.size[1] as f32
807 {
808 return Ok(());
809 }
810 if size + 3.0 > ATLAS_SIZE as f32 {
811 return Err(RenderError::AtlasFull);
812 }
813 let phase = [((x - x.floor()) * 4.0) as u8, ((y - y.floor()) * 4.0) as u8];
814 let key = AtlasKey::Tag {
815 icon: tag.icon,
816 size: size.to_bits(),
817 phase,
818 };
819 let glyph = if let Some(Some(glyph)) = self.glyphs.get(&key) {
820 *glyph
821 } else {
822 if self.glyphs.len() >= MAX_GLYPHS {
823 return Err(RenderError::AtlasFull);
824 }
825 let glyph = self.upload(tags::rasterize(tag.icon, size, phase))?;
826 self.glyphs.insert(key, Some(glyph));
827 glyph
828 };
829 let left = x.floor();
830 let top = y.floor();
831 let atlas = ATLAS_SIZE as f32;
832 self.quad(
833 viewport,
834 [
835 left,
836 top,
837 left + glyph.width as f32,
838 top + glyph.height as f32,
839 ],
840 [
841 glyph.x as f32 / atlas,
842 glyph.y as f32 / atlas,
843 (glyph.x + glyph.width) as f32 / atlas,
844 (glyph.y + glyph.height) as f32 / atlas,
845 ],
846 [1.0, 1.0, 1.0, if tag.disabled { 0.45 } else { 1.0 }],
847 )
848 }
849
850 fn quad(
851 &mut self,
852 viewport: Viewport,
853 rect: [f32; 4],
854 uv: [f32; 4],
855 color: [f32; 4],
856 ) -> Result<(), RenderError> {
857 if rect.iter().chain(&color).any(|v| !v.is_finite())
858 || rect[2] < rect[0]
859 || rect[3] < rect[1]
860 {
861 return Err(RenderError::InvalidPrimitive);
862 }
863 if rect[2] < 0.0
864 || rect[3] < 0.0
865 || rect[0] > viewport.size[0] as f32
866 || rect[1] > viewport.size[1] as f32
867 {
868 return Ok(());
869 }
870 if self.vertices.len() + 6 > MAX_VERTICES {
871 return Err(RenderError::FrameTooLarge);
872 }
873 let x0 = rect[0] * 2.0 / viewport.size[0] as f32 - 1.0;
874 let x1 = rect[2] * 2.0 / viewport.size[0] as f32 - 1.0;
875 let y0 = 1.0 - rect[1] * 2.0 / viewport.size[1] as f32;
876 let y1 = 1.0 - rect[3] * 2.0 / viewport.size[1] as f32;
877 if [x0, x1, y0, y1].iter().any(|v| !v.is_finite()) {
878 return Err(RenderError::InvalidPrimitive);
879 }
880 for (position, uv) in [
881 ([x0, y0], [uv[0], uv[1]]),
882 ([x0, y1], [uv[0], uv[3]]),
883 ([x1, y1], [uv[2], uv[3]]),
884 ([x0, y0], [uv[0], uv[1]]),
885 ([x1, y1], [uv[2], uv[3]]),
886 ([x1, y0], [uv[2], uv[1]]),
887 ] {
888 self.vertices.push(Vertex {
889 position,
890 uv,
891 color,
892 });
893 }
894 Ok(())
895 }
896}
897
898fn srgb_byte(value: f32) -> u8 {
899 let srgb = if value <= 0.0031308 {
900 value * 12.92
901 } else {
902 1.055 * value.powf(1.0 / 2.4) - 0.055
903 };
904 (srgb * 255.0).round() as u8
905}
906
907pub fn colorref(color: u32) -> [f32; 4] {
908 let linear = |byte: u32| {
909 let value = (byte & 255) as f32 / 255.0;
910 if value <= 0.04045 {
911 value / 12.92
912 } else {
913 ((value + 0.055) / 1.055).powf(2.4)
914 }
915 };
916 [linear(color), linear(color >> 8), linear(color >> 16), 1.0]
917}
918
919#[cfg(test)]
920mod tests {
921 use super::*;
922 use one_canvas::{layout::TextEngine, text::Paragraph};
923 use onestore::document::Format as TextFormat;
924 use std::time::Duration;
925
926 #[test]
927 fn decoding_preserves_pixels_and_rejects_large_or_truncated_images() {
928 let mut encoded = Vec::new();
929 let mut encoder = png::Encoder::new(&mut encoded, 2, 1);
930 encoder.set_color(png::ColorType::Rgba);
931 encoder.set_depth(png::BitDepth::Eight);
932 let rgba = [255, 64, 0, 255, 0, 128, 255, 128];
933 encoder
934 .write_header()
935 .unwrap()
936 .write_image_data(&rgba)
937 .unwrap();
938 let decoded = RasterImage::decode(&encoded).unwrap();
939 assert_eq!(decoded.size, [2, 1]);
940 assert_eq!(decoded.pixels.as_ref(), [255, 64, 0, 255, 0, 93, 188, 128]);
941 for end in 0..encoded.len() - 12 {
942 assert!(RasterImage::decode(&encoded[..end]).is_err(), "{end}");
943 }
944
945 let mut jpeg = Vec::new();
946 image::codecs::jpeg::JpegEncoder::new(&mut jpeg)
947 .encode(&[255; 4 * 4 * 3], 4, 4, image::ExtendedColorType::Rgb8)
948 .unwrap();
949 let decoded = RasterImage::decode(&jpeg).unwrap();
950 assert_eq!(decoded.size, [4, 4]);
951 assert_eq!(decoded.pixels.as_ref(), [255; 4 * 4 * 4]);
952
953 for size in [[8192, 8192], [16_385, 1]] {
954 let mut header = Vec::new();
955 let mut encoder = png::Encoder::new(&mut header, size[0], size[1]);
956 encoder.set_color(png::ColorType::Rgba);
957 encoder.set_depth(png::BitDepth::Eight);
958 let mut writer = encoder.write_header().unwrap();
959 writer.write_chunk(png::chunk::IDAT, &[]).unwrap();
960 drop(writer);
961 let error = RasterImage::decode(&header).err().unwrap();
962 if size[0] == 8192 {
963 assert!(matches!(error, RenderError::ImageBudget), "{error:?}");
964 } else {
965 assert!(
966 matches!(
967 error,
968 RenderError::ImageDecode(image::ImageError::Limits(_))
969 ),
970 "{error:?}"
971 );
972 }
973 }
974 }
975
976 #[test]
977 fn image_pixels_have_valid_dimensions_and_immutable_clone_identity() {
978 assert!(matches!(
979 RasterImage::new([0, 1], Vec::new()),
980 Err(RenderError::InvalidImage)
981 ));
982 assert!(matches!(
983 RasterImage::new([2, 2], vec![255; 15]),
984 Err(RenderError::InvalidImage)
985 ));
986 assert!(matches!(
987 RasterImage::new([u32::MAX; 2], Vec::new()),
988 Err(RenderError::InvalidImage)
989 ));
990 let image = RasterImage::new([1, 1], vec![1, 2, 3, 255]).unwrap();
991 assert_eq!(image.pixels.id(), image.clone().pixels.id());
992 }
993
994 #[test]
995 #[ignore = "requires a native GPU adapter"]
996 fn eviction_and_repaint_reproduce_pixels() {
997 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
998 let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
999 let (device, queue) =
1000 pollster::block_on(adapter.request_device(&Default::default())).unwrap();
1001 let format = wgpu::TextureFormat::Rgba8UnormSrgb;
1002 let target = device.create_texture(&wgpu::TextureDescriptor {
1003 label: Some("Canvas readback test"),
1004 size: wgpu::Extent3d {
1005 width: 512,
1006 height: 256,
1007 depth_or_array_layers: 1,
1008 },
1009 mip_level_count: 1,
1010 sample_count: 1,
1011 dimension: wgpu::TextureDimension::D2,
1012 format,
1013 usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
1014 view_formats: &[],
1015 });
1016 let readback = device.create_buffer(&wgpu::BufferDescriptor {
1017 label: Some("Canvas readback"),
1018 size: 512 * 256 * 4,
1019 usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
1020 mapped_at_creation: false,
1021 });
1022 let mut renderer = Renderer::new(device, queue, format);
1023 let mut engine = TextEngine::default();
1024 let layout = engine
1025 .layout(
1026 &Paragraph::new(
1027 "Hello 🌳\nCafé e\u{301} שלום".into(),
1028 TextFormat {
1029 underline: Some(true),
1030 ..TextFormat::default()
1031 },
1032 ),
1033 220.0,
1034 )
1035 .unwrap();
1036 let viewport = Viewport {
1037 size: [512, 256],
1038 scale: 2.0,
1039 origin: [24.0; 2],
1040 };
1041 let mut captures = Vec::new();
1042 let image = RasterImage::new(
1043 [2, 2],
1044 vec![255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 0, 0, 0, 0],
1045 )
1046 .unwrap();
1047 let transparent_edge =
1048 RasterImage::new([2, 1], vec![255, 0, 0, 255, 0, 0, 255, 0]).unwrap();
1049 let tags = [
1050 TagIcon::CheckBox { checked: false },
1051 TagIcon::CheckBox { checked: true },
1052 TagIcon::Question,
1053 TagIcon::Music,
1054 ]
1055 .map(|icon| ParagraphTag {
1056 icon,
1057 origin: [0.0; 2],
1058 label: String::new(),
1059 disabled: false,
1060 });
1061 let phase_layout = engine
1062 .layout(
1063 &Paragraph::new(
1064 "H".into(),
1065 TextFormat {
1066 font: Some("Arial".into()),
1067 font_size: Some(11.0),
1068 ..TextFormat::default()
1069 },
1070 ),
1071 20.0,
1072 )
1073 .unwrap();
1074 let mut primitives = vec![
1075 Primitive::Rect {
1076 rect: [0.0, 0.0, 70.0, 14.0],
1077 color: [0.55, 0.73, 1.0, 0.5],
1078 },
1079 Primitive::Text {
1080 layout: &layout,
1081 origin: [0.0; 2],
1082 },
1083 Primitive::Text {
1084 layout: &layout,
1085 origin: [8.0, 64.0],
1086 },
1087 Primitive::Image {
1088 image: &image,
1089 rect: [128.0, 8.0, 160.0, 40.0],
1090 },
1091 Primitive::Rect {
1092 rect: [128.0, 8.0, 132.0, 12.0],
1093 color: [0.0, 0.0, 1.0, 1.0],
1094 },
1095 Primitive::Image {
1096 image: &transparent_edge,
1097 rect: [176.0, 8.0, 208.0, 40.0],
1098 },
1099 Primitive::Tag {
1100 tag: &tags[0],
1101 origin: [128.125, 80.25],
1102 },
1103 Primitive::Tag {
1104 tag: &tags[1],
1105 origin: [148.125, 80.25],
1106 },
1107 Primitive::Tag {
1108 tag: &tags[2],
1109 origin: [168.125, 80.25],
1110 },
1111 Primitive::Tag {
1112 tag: &tags[3],
1113 origin: [188.125, 80.25],
1114 },
1115 ];
1116 primitives.extend((0..9).map(|step| Primitive::Text {
1117 layout: &phase_layout,
1118 origin: [8.0 + 24.0 * step as f32, 96.0 + 0.125 * step as f32],
1119 }));
1120 for pass in 0..4 {
1121 if pass == 2 {
1122 renderer.clear_glyph_cache();
1123 renderer.images.clear();
1124 } else if pass == 3 {
1125 renderer = Renderer::new(renderer.device.clone(), renderer.queue.clone(), format);
1126 }
1127 renderer
1128 .draw(
1129 &target.create_view(&Default::default()),
1130 viewport,
1131 &primitives,
1132 )
1133 .unwrap();
1134 let mut encoder = renderer.device.create_command_encoder(&Default::default());
1135 encoder.copy_texture_to_buffer(
1136 target.as_image_copy(),
1137 wgpu::TexelCopyBufferInfo {
1138 buffer: &readback,
1139 layout: wgpu::TexelCopyBufferLayout {
1140 offset: 0,
1141 bytes_per_row: Some(512 * 4),
1142 rows_per_image: Some(256),
1143 },
1144 },
1145 wgpu::Extent3d {
1146 width: 512,
1147 height: 256,
1148 depth_or_array_layers: 1,
1149 },
1150 );
1151 renderer.queue.submit([encoder.finish()]);
1152 let (sender, receiver) = std::sync::mpsc::channel();
1153 readback.map_async(wgpu::MapMode::Read, .., move |result| {
1154 sender.send(result).unwrap();
1155 });
1156 renderer
1157 .device
1158 .poll(wgpu::PollType::Wait {
1159 submission_index: None,
1160 timeout: Some(Duration::from_secs(5)),
1161 })
1162 .unwrap();
1163 receiver
1164 .recv_timeout(Duration::from_secs(5))
1165 .unwrap()
1166 .unwrap();
1167 captures.push(readback.get_mapped_range(..).unwrap().to_vec());
1168 readback.unmap();
1169 }
1170 let centroids: Vec<_> = (0..9)
1171 .map(|step| {
1172 let mut weight = 0.0_f64;
1173 let mut moment = 0.0_f64;
1174 for y in 212..252 {
1175 for x in (40 + 48 * step)..(72 + 48 * step) {
1176 let value = captures[0][(y * 512 + x) * 4];
1177 let coverage = 1.0 - f64::from(colorref(u32::from(value))[0]);
1178 weight += coverage;
1179 moment += coverage * y as f64;
1180 }
1181 }
1182 assert!(weight > 10.0, "phase sample {step} was not painted");
1183 moment / weight
1184 })
1185 .collect();
1186 for (step, centroid) in centroids.iter().enumerate() {
1187 let movement = centroid - centroids[0];
1188 assert!(
1189 (movement - step as f64 * 0.25).abs() < 0.06,
1190 "quarter-pixel step {step} moved the glyph by {movement} px"
1191 );
1192 }
1193 assert!(!renderer.glyphs.is_empty());
1194 assert!(renderer.glyphs.len() <= MAX_GLYPHS);
1195 assert_eq!(
1196 renderer
1197 .glyphs
1198 .keys()
1199 .filter(|key| matches!(key, AtlasKey::Tag { .. }))
1200 .count(),
1201 4
1202 );
1203 for x in [280, 320, 360, 400] {
1204 let colored = (184..210)
1205 .flat_map(|y| (x..x + 26).map(move |x| (y * 512 + x) * 4))
1206 .filter(|offset| captures[0][*offset..*offset + 3].iter().any(|v| *v < 180))
1207 .count();
1208 assert!(colored > 10, "tag at {x} was not painted");
1209 }
1210 let count = renderer.glyphs.len();
1211 renderer
1212 .draw(
1213 &target.create_view(&Default::default()),
1214 viewport,
1215 &[Primitive::Tag {
1216 tag: &tags[0],
1217 origin: [10000.0; 2],
1218 }],
1219 )
1220 .unwrap();
1221 assert_eq!(renderer.glyphs.len(), count);
1222 assert!(
1223 captures[0]
1224 .chunks_exact(4)
1225 .filter(|pixel| pixel[0] < 200)
1226 .count()
1227 > 100
1228 );
1229 assert_eq!(captures[0], captures[1]);
1230 assert_eq!(captures[0], captures[2]);
1231 assert_eq!(captures[0], captures[3]);
1232 for (x, y, color) in [
1233 (288, 48, [255, 0, 0, 255]),
1234 (336, 96, [255; 4]),
1235 (284, 44, [0, 0, 255, 255]),
1236 ] {
1237 let offset = (y * 512 + x) * 4;
1238 assert_eq!(captures[0][offset..offset + 4], color);
1239 }
1240 let edge = &captures[0][(48 * 512 + 408) * 4..(48 * 512 + 408) * 4 + 4];
1241 assert_eq!(edge[0], 255, "{edge:?}");
1242 assert_eq!(edge[1], edge[2], "{edge:?}");
1243 assert!((180..=200).contains(&edge[1]), "{edge:?}");
1244 assert_eq!(renderer.images.len(), 2);
1245 for primitive in [
1246 Primitive::Rect {
1247 rect: [0.0, 0.0, f32::NAN, 1.0],
1248 color: [1.0; 4],
1249 },
1250 Primitive::Rect {
1251 rect: [2.0, 0.0, 1.0, 1.0],
1252 color: [1.0; 4],
1253 },
1254 Primitive::Text {
1255 layout: &layout,
1256 origin: [f32::MAX; 2],
1257 },
1258 ] {
1259 assert!(matches!(
1260 renderer.draw(
1261 &target.create_view(&Default::default()),
1262 viewport,
1263 &[primitive]
1264 ),
1265 Err(RenderError::InvalidPrimitive)
1266 ));
1267 }
1268 let too_many: Vec<_> = (0..=MAX_IMAGES)
1269 .map(|_| RasterImage::new([1, 1], vec![255; 4]).unwrap())
1270 .collect();
1271 let primitives: Vec<_> = too_many
1272 .iter()
1273 .map(|image| Primitive::Image {
1274 image,
1275 rect: [0.0, 0.0, 1.0, 1.0],
1276 })
1277 .collect();
1278 assert!(matches!(
1279 renderer.draw(
1280 &target.create_view(&Default::default()),
1281 viewport,
1282 &primitives
1283 ),
1284 Err(RenderError::ImageBudget)
1285 ));
1286 let primitives: Vec<_> = too_many
1287 .iter()
1288 .map(|image| Primitive::Image {
1289 image,
1290 rect: [-20.0, -20.0, -19.0, -19.0],
1291 })
1292 .collect();
1293 renderer
1294 .draw(
1295 &target.create_view(&Default::default()),
1296 viewport,
1297 &primitives,
1298 )
1299 .unwrap();
1300 assert_eq!(renderer.images.len(), 2);
1301 let mut seen = HashSet::from([image.pixels.id(), transparent_edge.pixels.id()]);
1302 let mut previous = None;
1303 for _ in 0..5 {
1304 let large = RasterImage::new([2048; 2], vec![255; 2048 * 2048 * 4]).unwrap();
1305 seen.insert(large.pixels.id());
1306 let mut primitives = vec![Primitive::Image {
1307 image: &large,
1308 rect: [0.0, 0.0, 16.0, 16.0],
1309 }];
1310 if let Some(image) = &previous {
1311 primitives.push(Primitive::Image {
1312 image,
1313 rect: [16.0, 0.0, 32.0, 16.0],
1314 });
1315 }
1316 renderer
1317 .draw(
1318 &target.create_view(&Default::default()),
1319 viewport,
1320 &primitives,
1321 )
1322 .unwrap();
1323 assert!(renderer.images.contains_key(&large.pixels.id()));
1324 if let Some(image) = &previous {
1325 assert!(renderer.images.contains_key(&image.pixels.id()));
1326 }
1327 assert!(renderer.images.values().map(|i| i.bytes).sum::<u64>() <= MAX_IMAGE_BYTES);
1328 previous = Some(large);
1329 }
1330 assert!(renderer.images.len() <= MAX_IMAGES);
1331 assert!(seen.iter().any(|id| !renderer.images.contains_key(id)));
1332 renderer.images.clear();
1333 renderer
1334 .draw(
1335 &target.create_view(&Default::default()),
1336 viewport,
1337 &[Primitive::Image {
1338 image: &image,
1339 rect: [128.0, 8.0, 160.0, 40.0],
1340 }],
1341 )
1342 .unwrap();
1343 assert_eq!(renderer.images.len(), 1);
1344 assert!(renderer.images.contains_key(&image.pixels.id()));
1345 }
1346}
crates/canvas-gpu/src/page.rs deleted-923
...@@ -1,923 +0,0 @@
1use crate::{Primitive, RasterImage, RenderError, colorref};
2use one_canvas::{
3 editor::{CanvasEditor, EditorError, TextOutline},
4 layout::{LayoutError, TextEngine, TextLayout},
5 outline::OutlineLayout,
6 page::{Page, PageObject},
7 text::Paragraph,
8};
9use std::fmt;
10
11/// Retained drawing data in the source page's coordinate system.
12pub struct PageScene {
13 objects: Vec<Content>,
14}
15
16#[derive(Debug, PartialEq)]
17pub enum SceneHit<T> {
18 Outline(T),
19 ReadOnly(usize),
20}
21
22enum Content {
23 Outline {
24 origin: [f32; 2],
25 layout: OutlineLayout,
26 },
27 Image {
28 origin: [f32; 2],
29 image: RasterImage,
30 size: [f32; 2],
31 },
32 Editable(onestore::ExGuid),
33 ReadOnly(Box<ReadOnlyObject>),
34}
35
36pub struct ReadOnlyObject {
37 pub source: PageObject,
38 pub rect: [f32; 4],
39 pub message: &'static str,
40 label: TextLayout,
41}
42
43impl ReadOnlyObject {
44 fn new(
45 source: PageObject,
46 margin: [f32; 2],
47 message: &'static str,
48 engine: &mut TextEngine,
49 ) -> Result<Box<Self>, SceneError> {
50 let (layout, offset) = match &source {
51 PageObject::Outline(v) => (&v.layout, [0.0; 2]),
52 PageObject::Title(v) => (&v.layout, margin),
53 PageObject::Image(v) => (&v.layout, [0.0; 2]),
54 PageObject::Unsupported(v) => (&v.layout, [0.0; 2]),
55 };
56 let x = layout.x.unwrap_or(0.0) + offset[0];
57 let y = layout.y.unwrap_or(0.0) + offset[1];
58 let width = layout.max_width.unwrap_or(160.0);
59 let height = layout.max_height.unwrap_or(42.0);
60 if [x, y, width, height].iter().any(|v| !v.is_finite()) || width <= 0.0 || height <= 0.0 {
61 return Err(SceneError::InvalidGeometry);
62 }
63 let width = width.max(160.0);
64 let label = engine.layout(
65 &Paragraph::new(
66 message.into(),
67 onestore::document::Format {
68 font: Some("Arial".into()),
69 font_size: Some(11.0),
70 color: Some(0x005d554e),
71 ..Default::default()
72 },
73 ),
74 width - 16.0,
75 )?;
76 let height = height.max(label.height() + 16.0);
77 let rect = [x, y, x + width, y + height];
78 if rect.iter().any(|v| !v.is_finite()) {
79 return Err(SceneError::InvalidGeometry);
80 }
81 Ok(Box::new(Self {
82 source,
83 rect,
84 message,
85 label,
86 }))
87 }
88}
89
90#[derive(Debug)]
91pub enum SceneError {
92 Layout(LayoutError),
93 Editor(EditorError),
94 MissingOutline,
95 Image(RenderError),
96 MissingImage,
97 InvalidGeometry,
98}
99
100impl fmt::Display for SceneError {
101 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102 match self {
103 Self::Layout(error) => error.fmt(f),
104 Self::Editor(error) => error.fmt(f),
105 Self::MissingOutline => f.write_str("An editable outline is missing from this page."),
106 Self::Image(error) => write!(f, "Image rendering failed: {error:?}"),
107 Self::MissingImage => f.write_str("An image is missing its data or dimensions."),
108 Self::InvalidGeometry => {
109 f.write_str("The page contains invalid object dimensions or positions.")
110 }
111 }
112 }
113}
114impl std::error::Error for SceneError {}
115impl From<LayoutError> for SceneError {
116 fn from(error: LayoutError) -> Self {
117 Self::Layout(error)
118 }
119}
120
121impl PageScene {
122 pub fn new(mut page: Page, engine: &mut TextEngine) -> Result<Self, SceneError> {
123 Self::build(&mut page, engine, false).map(|(scene, _)| scene)
124 }
125
126 pub fn from_page(
127 mut page: Page,
128 engine: &mut TextEngine,
129 ) -> Result<(Self, CanvasEditor), SceneError> {
130 let (scene, mut outlines) = Self::build(&mut page, engine, true)?;
131 if outlines.is_empty() {
132 let x = scene
133 .read_only()
134 .map(|object| object.rect[2])
135 .fold(0.0_f32, f32::max);
136 outlines.push(
137 TextOutline::new(
138 engine,
139 one_canvas::document::TextDocument::new(vec![Paragraph::new(
140 String::new(),
141 Default::default(),
142 )])
143 .map_err(EditorError::from)
144 .map_err(SceneError::Editor)?,
145 240.0,
146 [if x > 0.0 { x + 24.0 } else { 0.0 }, 0.0],
147 )
148 .map_err(SceneError::Editor)?,
149 );
150 }
151 let editor = CanvasEditor::from_text_outlines(outlines, page.definitions)
152 .map_err(SceneError::Editor)?;
153 Ok((scene, editor))
154 }
155
156 fn build(
157 page: &mut Page,
158 engine: &mut TextEngine,
159 editable: bool,
160 ) -> Result<(Self, Vec<TextOutline>), SceneError> {
161 if page.margin_origin.iter().any(|v| !v.is_finite()) {
162 return Err(SceneError::InvalidGeometry);
163 }
164 let mut objects = Vec::new();
165 let mut outlines = Vec::new();
166 let mut image_bytes = 0_u64;
167 for object in std::mem::take(&mut page.objects) {
168 match &object {
169 PageObject::Outline(outline) => {
170 let origin = [
171 outline.layout.x.unwrap_or(0.0),
172 outline.layout.y.unwrap_or(0.0),
173 ];
174 if origin.iter().any(|v| !v.is_finite()) {
175 return Err(SceneError::InvalidGeometry);
176 }
177 if editable {
178 match TextOutline::from_outline(engine, outline, &page.definitions) {
179 Ok(outline) => {
180 objects.push(Content::Editable(outline.id));
181 outlines.push(outline);
182 }
183 Err(EditorError::Layout(LayoutError::UnsupportedContent)) => objects
184 .push(Content::ReadOnly(ReadOnlyObject::new(
185 object,
186 page.margin_origin,
187 "Unsupported content\nRead-only",
188 engine,
189 )?)),
190 Err(error) => return Err(SceneError::Editor(error)),
191 }
192 } else {
193 match outline.layout(engine, &page.definitions) {
194 Ok(layout) => objects.push(Content::Outline { origin, layout }),
195 Err(LayoutError::UnsupportedContent) => {
196 objects.push(Content::ReadOnly(ReadOnlyObject::new(
197 object,
198 page.margin_origin,
199 "Unsupported content\nRead-only",
200 engine,
201 )?))
202 }
203 Err(error) => return Err(error.into()),
204 }
205 }
206 }
207 PageObject::Title(title) => {
208 let layouts = match title.layout(engine, &page.definitions) {
209 Ok(layouts) => layouts,
210 Err(LayoutError::UnsupportedContent) => {
211 objects.push(Content::ReadOnly(ReadOnlyObject::new(
212 object,
213 page.margin_origin,
214 "Unsupported content\nRead-only",
215 engine,
216 )?));
217 continue;
218 }
219 Err(error) => return Err(error.into()),
220 };
221 for (origin, layout) in layouts {
222 let origin = [
223 title.layout.x.unwrap_or(0.0) + origin[0] + page.margin_origin[0],
224 title.layout.y.unwrap_or(0.0) + origin[1] + page.margin_origin[1],
225 ];
226 if origin.iter().any(|v| !v.is_finite()) {
227 return Err(SceneError::InvalidGeometry);
228 }
229 objects.push(Content::Outline { origin, layout });
230 }
231 }
232 PageObject::Image(source) => {
233 if source.bytes.is_none()
234 || source.layout.max_width.is_none()
235 || source.layout.max_height.is_none()
236 {
237 objects.push(Content::ReadOnly(ReadOnlyObject::new(
238 object,
239 page.margin_origin,
240 "Image unavailable\nRead-only",
241 engine,
242 )?));
243 continue;
244 }
245 let origin = [
246 source.layout.x.unwrap_or(0.0),
247 source.layout.y.unwrap_or(0.0),
248 ];
249 let size = [
250 source.layout.max_width.ok_or(SceneError::MissingImage)?,
251 source.layout.max_height.ok_or(SceneError::MissingImage)?,
252 ];
253 if origin.iter().any(|v| !v.is_finite())
254 || size.iter().any(|v| !v.is_finite() || *v <= 0.0)
255 {
256 return Err(SceneError::InvalidGeometry);
257 }
258 let image = RasterImage::decode(
259 source.bytes.as_deref().ok_or(SceneError::MissingImage)?,
260 )
261 .map_err(SceneError::Image)?;
262 image_bytes += image.pixels.as_ref().len() as u64;
263 if image_bytes > crate::MAX_IMAGE_BYTES {
264 return Err(SceneError::Image(RenderError::ImageBudget));
265 }
266 objects.push(Content::Image {
267 origin,
268 image,
269 size,
270 });
271 }
272 PageObject::Unsupported(_) => objects.push(Content::ReadOnly(ReadOnlyObject::new(
273 object,
274 page.margin_origin,
275 "Unsupported content\nRead-only",
276 engine,
277 )?)),
278 }
279 }
280 Ok((Self { objects }, outlines))
281 }
282
283 pub fn read_only(&self) -> impl Iterator<Item = &ReadOnlyObject> {
284 self.objects.iter().filter_map(|object| match object {
285 Content::ReadOnly(object) => Some(object.as_ref()),
286 _ => None,
287 })
288 }
289
290 pub fn contains_outline(&self, id: onestore::ExGuid) -> bool {
291 self.objects
292 .iter()
293 .any(|object| matches!(object, Content::Editable(candidate) if *candidate == id))
294 }
295
296 /// The point and outline hit callback use scene coordinates.
297 pub fn hit_test<T>(
298 &self,
299 point: [f32; 2],
300 mut outline: impl FnMut(onestore::ExGuid) -> Option<T>,
301 ) -> Option<SceneHit<T>> {
302 let mut readonly = self.read_only().count();
303 for object in self.objects.iter().rev() {
304 match object {
305 Content::Editable(id) => {
306 if let Some(hit) = outline(*id) {
307 return Some(SceneHit::Outline(hit));
308 }
309 }
310 Content::ReadOnly(object) => {
311 readonly -= 1;
312 let [x0, y0, x1, y1] = object.rect;
313 if (x0..=x1).contains(&point[0]) && (y0..=y1).contains(&point[1]) {
314 return Some(SceneHit::ReadOnly(readonly));
315 }
316 }
317 Content::Outline { .. } | Content::Image { .. } => {}
318 }
319 }
320 None
321 }
322
323 /// The offset is in document points.
324 pub fn append_primitives<'a>(
325 &'a self,
326 primitives: &mut Vec<Primitive<'a>>,
327 offset: [f32; 2],
328 ) -> Result<(), SceneError> {
329 self.append_primitives_with(primitives, offset, |_, _, _| {
330 Err(SceneError::MissingOutline)
331 })
332 }
333
334 pub fn append_primitives_with<'a, E>(
335 &'a self,
336 primitives: &mut Vec<Primitive<'a>>,
337 offset: [f32; 2],
338 mut outline: impl FnMut(onestore::ExGuid, [f32; 2], &mut Vec<Primitive<'a>>) -> Result<(), E>,
339 ) -> Result<(), E> {
340 for content in &self.objects {
341 let origin = match content {
342 Content::Outline { origin, .. } | Content::Image { origin, .. } => origin,
343 Content::ReadOnly(object) => {
344 let [x0, y0, x1, y1] = object.rect;
345 let rect = [
346 x0 + offset[0],
347 y0 + offset[1],
348 x1 + offset[0],
349 y1 + offset[1],
350 ];
351 primitives.push(Primitive::Rect {
352 rect,
353 color: colorref(0x00e4ddd6),
354 });
355 primitives.push(Primitive::Rect {
356 rect: [rect[0] + 1.0, rect[1] + 1.0, rect[2] - 1.0, rect[3] - 1.0],
357 color: colorref(0x00faf7f3),
358 });
359 primitives.push(Primitive::Text {
360 layout: &object.label,
361 origin: [rect[0] + 8.0, rect[1] + 8.0],
362 });
363 continue;
364 }
365 Content::Editable(id) => {
366 outline(*id, offset, primitives)?;
367 continue;
368 }
369 };
370 let object_origin = [origin[0] + offset[0], origin[1] + offset[1]];
371 match content {
372 Content::Image { image, size, .. } => primitives.push(Primitive::Image {
373 image,
374 rect: [
375 object_origin[0],
376 object_origin[1],
377 object_origin[0] + size[0],
378 object_origin[1] + size[1],
379 ],
380 }),
381 Content::Outline {
382 layout: outline, ..
383 } => {
384 for paragraph in &outline.paragraphs {
385 let origin = [
386 object_origin[0] + paragraph.origin[0],
387 object_origin[1] + paragraph.origin[1],
388 ];
389 for (rect, color) in paragraph.text.backgrounds() {
390 primitives.push(Primitive::Rect {
391 rect: [
392 rect.x0 as f32 + origin[0],
393 rect.y0 as f32 + origin[1],
394 rect.x1 as f32 + origin[0],
395 rect.y1 as f32 + origin[1],
396 ],
397 color: colorref(color),
398 });
399 }
400 primitives.push(Primitive::Text {
401 layout: &paragraph.text,
402 origin,
403 });
404 for (layout, origin) in &paragraph.markers {
405 primitives.push(Primitive::Text {
406 layout,
407 origin: [
408 object_origin[0] + origin[0],
409 object_origin[1] + (origin[1] + paragraph.origin[1]),
410 ],
411 });
412 }
413 for tag in &paragraph.tags {
414 primitives.push(Primitive::Tag {
415 tag,
416 origin: [object_origin[0], object_origin[1] + paragraph.origin[1]],
417 });
418 }
419 }
420 }
421 Content::Editable(_) | Content::ReadOnly(_) => unreachable!(),
422 }
423 }
424 Ok(())
425 }
426}
427
428#[cfg(test)]
429mod tests {
430 use super::*;
431 use one_canvas::page::Image;
432 use onestore::{ExGuid, document::Layout};
433 use std::{collections::BTreeMap, sync::Arc};
434
435 #[test]
436 fn reverse_hit_order_keeps_read_only_focus_indices() {
437 let objects = [0.0, 20.0]
438 .into_iter()
439 .map(|position| {
440 PageObject::Unsupported(one_canvas::page::Unsupported {
441 id: ExGuid {
442 guid: [position as u8; 16],
443 n: 1,
444 },
445 jcid: 0xdead,
446 layout: Layout {
447 x: Some(position),
448 y: Some(position),
449 max_width: Some(160.0),
450 max_height: Some(80.0),
451 ..Default::default()
452 },
453 })
454 })
455 .collect();
456 let page = Page {
457 title: String::new(),
458 margin_origin: [0.0; 2],
459 definitions: BTreeMap::new(),
460 objects,
461 };
462 let scene = PageScene::new(page, &mut TextEngine::default()).unwrap();
463 assert_eq!(
464 scene.hit_test::<()>([30.0, 30.0], |_| unreachable!()),
465 Some(SceneHit::ReadOnly(1))
466 );
467 assert_eq!(
468 scene.hit_test::<()>([5.0, 5.0], |_| unreachable!()),
469 Some(SceneHit::ReadOnly(0))
470 );
471 assert_eq!(
472 scene.hit_test::<()>([300.0, 300.0], |_| unreachable!()),
473 None
474 );
475 }
476
477 #[test]
478 fn unsupported_tags_and_titles_leave_supported_outlines_editable() {
479 use one_canvas::{
480 document::TextDocument,
481 page::{Definition, Outline, Title},
482 };
483 use onestore::document::{Kind, Tag};
484 for title in [false, true] {
485 let definition = ExGuid {
486 guid: [9; 16],
487 n: 1,
488 };
489 let make_outline = |text: &str| {
490 let document =
491 TextDocument::new(vec![Paragraph::new(text.into(), Default::default())])
492 .unwrap();
493 Outline {
494 id: ExGuid {
495 guid: [u8::from(text == "Editable") + 1; 16],
496 n: 1,
497 },
498 layout: Layout {
499 max_width: Some(200.0),
500 ..Default::default()
501 },
502 indents: vec![18.0, 0.0],
503 paragraphs: document.nodes().to_vec(),
504 unsupported: Vec::new(),
505 }
506 };
507 let mut tagged = make_outline("Tagged");
508 tagged.paragraphs[0].tags.push(Tag {
509 definition: Some(definition),
510 status: 1,
511 action_type: None,
512 created: None,
513 completed: None,
514 start: None,
515 due: None,
516 task_id: None,
517 extra_set: 0,
518 });
519 let unsupported = if title {
520 PageObject::Title(Title {
521 id: ExGuid::default(),
522 layout: Layout {
523 x: Some(10.0),
524 y: Some(20.0),
525 ..Default::default()
526 },
527 outlines: vec![tagged],
528 })
529 } else {
530 PageObject::Outline(tagged)
531 };
532 let page = Page {
533 title: String::new(),
534 margin_origin: [36.0, 14.0],
535 objects: vec![unsupported, PageObject::Outline(make_outline("Editable"))],
536 definitions: BTreeMap::from([(
537 definition,
538 Definition {
539 kind: Kind::TagDefinition {
540 shape: Some(999),
541 label: Some("Unknown icon".into()),
542 color: None,
543 highlight: None,
544 action_type: None,
545 },
546 format: Default::default(),
547 },
548 )]),
549 };
550 let mut engine = TextEngine::default();
551 let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap();
552 assert_eq!(editor.outlines().len(), 1);
553 let readonly = scene.read_only().next().unwrap();
554 assert_eq!(scene.read_only().count(), 1);
555 if title {
556 assert_eq!(&readonly.rect[..2], &[46.0, 34.0]);
557 }
558 editor.insert(&mut engine, "Still ").unwrap();
559 assert_eq!(
560 editor
561 .active_outline()
562 .document()
563 .paragraphs()
564 .next()
565 .unwrap()
566 .text(),
567 "Still Editable"
568 );
569 editor.undo(&mut engine).unwrap();
570 assert_eq!(
571 editor
572 .active_outline()
573 .document()
574 .paragraphs()
575 .next()
576 .unwrap()
577 .text(),
578 "Editable"
579 );
580 }
581 }
582
583 #[test]
584 fn unsupported_sources_stay_owned_and_read_only_across_edit_and_undo() {
585 use one_canvas::{
586 document::TextDocument,
587 page::{Outline, Unsupported},
588 };
589 let document = TextDocument::new(vec![Paragraph::new(
590 "preserved text".into(),
591 Default::default(),
592 )])
593 .unwrap();
594 let original = document.nodes().to_vec();
595 let unknown = Unsupported {
596 id: ExGuid {
597 guid: [8; 16],
598 n: 1,
599 },
600 jcid: 0xdead,
601 layout: Layout::default(),
602 };
603 let page = Page {
604 title: String::new(),
605 margin_origin: [0.0; 2],
606 definitions: BTreeMap::new(),
607 objects: vec![
608 PageObject::Outline(Outline {
609 id: ExGuid {
610 guid: [7; 16],
611 n: 1,
612 },
613 layout: Layout {
614 x: Some(50.0),
615 y: Some(70.0),
616 max_width: Some(200.0),
617 ..Default::default()
618 },
619 indents: vec![18.0, 0.0],
620 paragraphs: original.clone(),
621 unsupported: vec![unknown.clone()],
622 }),
623 PageObject::Image(Image {
624 id: ExGuid::default(),
625 layout: Layout {
626 y: Some(150.0),
627 ..Default::default()
628 },
629 bytes: None,
630 alt: Some("missing diagram".into()),
631 background: false,
632 }),
633 PageObject::Unsupported(unknown.clone()),
634 ],
635 };
636 let mut engine = TextEngine::default();
637 let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap();
638 assert_eq!(scene.read_only().count(), 3);
639 assert!(
640 editor.active_outline().origin()[0]
641 > scene
642 .read_only()
643 .map(|object| object.rect[2])
644 .fold(0.0_f32, f32::max)
645 );
646 let labels: Vec<_> = scene.read_only().map(|object| object.label.id()).collect();
647 for edit in [true, false] {
648 if edit {
649 editor.insert(&mut engine, "new annotation").unwrap();
650 } else {
651 editor.undo(&mut engine).unwrap();
652 }
653 let readonly: Vec<_> = scene.read_only().collect();
654 let PageObject::Outline(outline) = &readonly[0].source else {
655 panic!()
656 };
657 assert_eq!(outline.paragraphs, original);
658 assert_eq!(
659 outline.unsupported.as_slice(),
660 std::slice::from_ref(&unknown)
661 );
662 let PageObject::Image(image) = &readonly[1].source else {
663 panic!()
664 };
665 assert_eq!(image.alt.as_deref(), Some("missing diagram"));
666 assert!(image.bytes.is_none());
667 let PageObject::Unsupported(source) = &readonly[2].source else {
668 panic!()
669 };
670 assert_eq!(source, &unknown);
671 assert_eq!(
672 readonly
673 .iter()
674 .map(|object| object.label.id())
675 .collect::<Vec<_>>(),
676 labels
677 );
678 let primitives = paint_editor(&scene, &editor);
679 assert_eq!(primitives.len(), 9);
680 assert_eq!(
681 primitives
682 .iter()
683 .filter(|p| matches!(p, Primitive::Text { .. }))
684 .count(),
685 3
686 );
687 }
688 assert_eq!(
689 editor
690 .active_outline()
691 .document()
692 .paragraphs()
693 .next()
694 .unwrap()
695 .text(),
696 ""
697 );
698 }
699
700 fn paint_editor<'a>(scene: &'a PageScene, editor: &'a CanvasEditor) -> Vec<Primitive<'a>> {
701 let mut primitives = Vec::new();
702 scene
703 .append_primitives_with(&mut primitives, [0.0; 2], |id, offset, primitives| {
704 let outline = editor
705 .outlines()
706 .iter()
707 .find(|outline| outline.id == id)
708 .ok_or(SceneError::MissingOutline)?;
709 for (_, paragraph) in outline.layouts() {
710 primitives.push(Primitive::Text {
711 layout: &paragraph.text,
712 origin: [
713 outline.origin()[0] + paragraph.origin[0] + offset[0],
714 outline.origin()[1] + paragraph.origin[1] + offset[1],
715 ],
716 });
717 }
718 Ok::<_, SceneError>(())
719 })
720 .unwrap();
721 primitives
722 }
723
724 #[test]
725 fn editable_outlines_keep_paint_order_and_images_across_edit_and_undo() {
726 use one_canvas::{document::TextDocument, page::Outline, text::Paragraph};
727 let outline = |x, text: &str| {
728 let document =
729 TextDocument::new(vec![Paragraph::new(text.into(), Default::default())]).unwrap();
730 PageObject::Outline(Outline {
731 id: ExGuid {
732 guid: [x as u8; 16],
733 n: 1,
734 },
735 layout: Layout {
736 x: Some(x),
737 max_width: Some(180.0),
738 width_set_by_user: Some(true),
739 ..Default::default()
740 },
741 indents: vec![18.0, 0.0],
742 paragraphs: document.nodes().to_vec(),
743 unsupported: Vec::new(),
744 })
745 };
746 let mut bytes = Vec::new();
747 {
748 let mut encoder = png::Encoder::new(&mut bytes, 1, 1);
749 encoder.set_color(png::ColorType::Rgba);
750 encoder.set_depth(png::BitDepth::Eight);
751 encoder
752 .write_header()
753 .unwrap()
754 .write_image_data(&[17, 34, 51, 255])
755 .unwrap();
756 }
757 let page = || Page {
758 title: String::new(),
759 margin_origin: [0.0; 2],
760 definitions: BTreeMap::new(),
761 objects: vec![
762 outline(2.0, "first"),
763 PageObject::Image(Image {
764 id: ExGuid::default(),
765 layout: Layout {
766 x: Some(5.0),
767 y: Some(7.0),
768 max_width: Some(40.0),
769 max_height: Some(30.0),
770 ..Default::default()
771 },
772 bytes: Some(Arc::from(bytes.clone())),
773 alt: None,
774 background: false,
775 }),
776 outline(50.0, "last"),
777 ],
778 };
779 let mut engine = TextEngine::default();
780 let reference = PageScene::new(page(), &mut engine).unwrap();
781 let (scene, mut editor) = PageScene::from_page(page(), &mut engine).unwrap();
782 let mut reference_primitives = Vec::new();
783 reference
784 .append_primitives(&mut reference_primitives, [0.0; 2])
785 .unwrap();
786 let image_id;
787 {
788 let actual = paint_editor(&scene, &editor);
789 assert_eq!(actual.len(), 3);
790 for (actual, expected) in actual.iter().zip(&reference_primitives) {
791 match (actual, expected) {
792 (
793 Primitive::Text {
794 layout: a,
795 origin: x,
796 },
797 Primitive::Text {
798 layout: b,
799 origin: y,
800 },
801 ) => {
802 assert_eq!(x, y);
803 assert_eq!(
804 a.lines().next().unwrap().0.metrics().advance,
805 b.lines().next().unwrap().0.metrics().advance
806 );
807 }
808 (
809 Primitive::Image { image: a, rect: x },
810 Primitive::Image { image: b, rect: y },
811 ) => {
812 assert_eq!(x, y);
813 assert_eq!(a.pixels.as_ref(), b.pixels.as_ref());
814 }
815 _ => panic!("paint order changed"),
816 }
817 }
818 let Primitive::Image { image, .. } = &actual[1] else {
819 panic!()
820 };
821 image_id = image.pixels.id();
822 }
823 let original = editor.active_outline().document().clone();
824 editor.insert(&mut engine, "new words ").unwrap();
825 {
826 let actual = paint_editor(&scene, &editor);
827 let Primitive::Text { layout, .. } = &actual[0] else {
828 panic!()
829 };
830 let Primitive::Text { layout: before, .. } = &reference_primitives[0] else {
831 panic!()
832 };
833 assert!(
834 layout.lines().next().unwrap().0.metrics().advance
835 > before.lines().next().unwrap().0.metrics().advance
836 );
837 let Primitive::Image { image, .. } = &actual[1] else {
838 panic!()
839 };
840 assert_eq!(image.pixels.id(), image_id);
841 }
842 editor.undo(&mut engine).unwrap();
843 assert_eq!(editor.active_outline().document(), &original);
844 let actual = paint_editor(&scene, &editor);
845 let Primitive::Text { layout, .. } = &actual[0] else {
846 panic!()
847 };
848 let Primitive::Text { layout: before, .. } = &reference_primitives[0] else {
849 panic!()
850 };
851 assert_eq!(
852 layout.lines().next().unwrap().0.metrics().advance,
853 before.lines().next().unwrap().0.metrics().advance
854 );
855 assert!(matches!(
856 scene.append_primitives(&mut Vec::new(), [0.0; 2]),
857 Err(SceneError::MissingOutline)
858 ));
859 }
860
861 #[test]
862 fn scene_owns_decoded_images_and_reuses_identity_across_translated_frames() {
863 let mut encoded = Vec::new();
864 {
865 let mut encoder = png::Encoder::new(&mut encoded, 1, 1);
866 encoder.set_color(png::ColorType::Rgba);
867 encoder.set_depth(png::BitDepth::Eight);
868 encoder
869 .write_header()
870 .unwrap()
871 .write_image_data(&[255, 0, 0, 255])
872 .unwrap();
873 }
874 let page = |width| Page {
875 title: String::new(),
876 margin_origin: [0.0; 2],
877 definitions: BTreeMap::new(),
878 objects: vec![PageObject::Image(Image {
879 id: ExGuid::default(),
880 layout: Layout {
881 x: Some(2.0),
882 y: Some(5.0),
883 max_width: Some(width),
884 max_height: Some(4.0),
885 ..Default::default()
886 },
887 bytes: Some(Arc::from(encoded.clone())),
888 alt: None,
889 background: false,
890 })],
891 };
892 let mut engine = TextEngine::default();
893 let scene = PageScene::new(page(3.0), &mut engine).unwrap();
894 for invalid in [f32::NAN, f32::INFINITY, -1.0, 0.0] {
895 assert!(matches!(
896 PageScene::new(page(invalid), &mut engine),
897 Err(SceneError::InvalidGeometry)
898 ));
899 }
900 let mut primitives = Vec::new();
901 scene.append_primitives(&mut primitives, [0.0; 2]).unwrap();
902 scene
903 .append_primitives(&mut primitives, [10.0, -20.0])
904 .unwrap();
905 let [
906 Primitive::Image {
907 image: first,
908 rect: a,
909 },
910 Primitive::Image {
911 image: second,
912 rect: b,
913 },
914 ] = primitives.as_slice()
915 else {
916 panic!()
917 };
918 assert_eq!(*a, [2.0, 5.0, 5.0, 9.0]);
919 assert_eq!(*b, [12.0, -15.0, 15.0, -11.0]);
920 assert_eq!(first.pixels.id(), second.pixels.id());
921 assert_eq!(first.pixels.as_ref(), [255, 0, 0, 255]);
922 }
923}
crates/canvas-gpu/src/profile.rs deleted-150
...@@ -1,150 +0,0 @@
1use super::*;
2use one_canvas::{layout::TextEngine, page::Page, text::Paragraph};
3use std::{
4 sync::Arc,
5 time::{Duration, Instant},
6};
7
8#[test]
9#[ignore = "manual release GPU timing and cache occupancy probe"]
10fn renderer_cost() {
11 let mut engine = TextEngine::default();
12 if let Some(font) = std::env::var_os("CANVAS_TEST_SUBSTITUTE") {
13 engine
14 .register_substitute(Blob::new(Arc::new(std::fs::read(font).unwrap())))
15 .unwrap();
16 }
17 let source = std::env::var_os("CANVAS_TEST_SECTION").map(|path| std::fs::read(path).unwrap());
18 let title = std::env::var("CANVAS_TEST_PAGE").unwrap_or_default();
19 let mut scene = source
20 .as_ref()
21 .map(|bytes| load_scene(bytes, &title, &mut engine));
22 let layout = source.is_none().then(|| {
23 engine
24 .layout(
25 &Paragraph::new(
26 "A paragraph with café, trees 🌳 and a few words.\n".repeat(2000),
27 Default::default(),
28 ),
29 360.0,
30 )
31 .unwrap()
32 });
33 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
34 let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
35 eprintln!("canvas_gpu_adapter\t{:?}", adapter.get_info());
36 let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
37 let size = [1386, 759];
38 let format = wgpu::TextureFormat::Rgba8UnormSrgb;
39 let target = device.create_texture(&wgpu::TextureDescriptor {
40 label: Some("Canvas profile target"),
41 size: wgpu::Extent3d {
42 width: size[0],
43 height: size[1],
44 depth_or_array_layers: 1,
45 },
46 mip_level_count: 1,
47 sample_count: 1,
48 dimension: wgpu::TextureDimension::D2,
49 format,
50 usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
51 view_formats: &[],
52 });
53 let view = target.create_view(&Default::default());
54 let mut renderer = Renderer::new(device, queue, format);
55 eprintln!(
56 "canvas_gpu_allocated_bytes\tatlas\t{}\tvertex_buffer\t{}\ttarget\t{}",
57 u64::from(renderer.atlas.width()) * u64::from(renderer.atlas.height()) * 4,
58 renderer.vertex_buffer.size(),
59 u64::from(size[0]) * u64::from(size[1]) * 4
60 );
61 for phase in [
62 "warm",
63 "pan",
64 "zoom",
65 "evict",
66 "offscreen_x",
67 "offscreen_y",
68 "reopen",
69 ] {
70 if phase == "reopen" && source.is_none() {
71 continue;
72 }
73 if phase.starts_with("offscreen") {
74 renderer.clear_glyph_cache();
75 renderer.images.clear();
76 }
77 for sample in 0..32 {
78 if phase == "evict" {
79 renderer.clear_glyph_cache();
80 renderer.images.clear();
81 }
82 let mut viewport = Viewport {
83 size,
84 scale: 96.0 / 72.0,
85 origin: [0.0; 2],
86 };
87 match phase {
88 "pan" => viewport.origin[1] = -(sample as f32 * 63.25),
89 "zoom" => viewport.scale *= [0.5, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0][sample % 8],
90 "offscreen_x" => viewport.origin[0] = 1_000_000.0,
91 "offscreen_y" => viewport.origin[1] = 1_000_000.0,
92 "reopen" => {
93 scene = Some(load_scene(source.as_ref().unwrap(), &title, &mut engine));
94 }
95 _ => {}
96 }
97 let mut primitives = Vec::new();
98 if let Some(scene) = &scene {
99 scene.append_primitives(&mut primitives, [0.0; 2]).unwrap();
100 } else {
101 primitives.push(Primitive::Text {
102 layout: layout.as_ref().unwrap(),
103 origin: [36.0, 90.0],
104 });
105 }
106 let start = Instant::now();
107 renderer.draw(&view, viewport, &primitives).unwrap();
108 let submitted = start.elapsed().as_nanos();
109 renderer
110 .device
111 .poll(wgpu::PollType::Wait {
112 submission_index: None,
113 timeout: Some(Duration::from_secs(10)),
114 })
115 .unwrap();
116 let completed = start.elapsed().as_nanos();
117 let image_bytes: u64 = renderer.images.values().map(|image| image.bytes).sum();
118 let atlas_texels: u64 = renderer
119 .glyphs
120 .values()
121 .flatten()
122 .map(|glyph| u64::from(glyph.width) * u64::from(glyph.height))
123 .sum();
124 assert!(renderer.glyphs.len() <= MAX_GLYPHS);
125 assert!(renderer.images.len() <= MAX_IMAGES && image_bytes <= MAX_IMAGE_BYTES);
126 assert!(renderer.vertices.len() <= MAX_VERTICES);
127 eprintln!(
128 "canvas_gpu_sample\t{phase}\t{sample}\t{submitted}\t{completed}\t{}\t{atlas_texels}\t{}\t{image_bytes}\t{}\t{}\t{}\t{}\t{}\t{}",
129 renderer.glyphs.len(),
130 renderer.images.len(),
131 renderer.vertices.len(),
132 renderer.batches.len(),
133 renderer.vertices.capacity() * size_of::<Vertex>(),
134 renderer.batches.capacity() * size_of::<Batch>(),
135 renderer.glyphs.capacity(),
136 renderer.images.capacity()
137 );
138 }
139 }
140}
141
142fn load_scene(bytes: &[u8], title: &str, engine: &mut TextEngine) -> page::PageScene {
143 let store = onestore::Store::parse(bytes).unwrap();
144 let index = onestore::RevisionIndex::parse(&store).unwrap();
145 page::PageScene::new(
146 Page::from_document(&onestore::document::Document::parse(&index).unwrap(), title).unwrap(),
147 engine,
148 )
149 .unwrap()
150}
crates/canvas-gpu/src/tags.rs deleted-120
...@@ -1,120 +0,0 @@
1use one_canvas::outline::TagIcon;
2use swash::{
3 scale::image::{Content, Image},
4 zeno::{Mask, Placement, Transform, Vector},
5};
6
7pub(crate) fn rasterize(icon: TagIcon, size: f32, phase: [u8; 2]) -> Image {
8 let layers = match icon {
9 TagIcon::CheckBox { checked } => {
10 let mut layers = vec![
11 ("M0 0H16V16H0Z", 0xf7b691, 0xef9864),
12 ("M1 1H15V15H1Z", 0xffffff, 0xffffff),
13 ("M2 2H14V14H2Z", 0xf6af7c, 0xe98755),
14 ("M3 3H13V13H3Z", 0xffffff, 0xf5e6d6),
15 ];
16 if checked {
17 layers.push(("M2 8L6 11L13 3L14 5L6 14L1 10Z", 0x778cf4, 0x2544c8));
18 }
19 layers
20 }
21 TagIcon::Question => vec![(
22 "M8 5C8 1 15 1 15 5C15 7 11 8 11 10H9C9 6 13 6 13 5C13 3 10 3 10 5ZM9 12H11V14H9Z",
23 0xe83ace,
24 0x8f008f,
25 )],
26 TagIcon::Music => vec![
27 (
28 "M7 1C10 1 9 3 12 4C14 5 15 7 13 9C13 6 10 7 9 5V11C9 15 2 17 1 13C0 10 4 8 7 9Z",
29 0xf0bb9c,
30 0xa35546,
31 ),
32 ("M2 11C3 9 6 9 7 10C7 11 4 13 2 12Z", 0xffe8dc, 0xda9786),
33 ],
34 };
35 let width = (size + 1.0).ceil() as u32;
36 let mut pixels = vec![[0.0_f32; 4]; (width * width) as usize];
37 for (path, top, bottom) in layers {
38 let (mask, _) = Mask::new(path)
39 .transform(Some(Transform::scale(size / 16.0, size / 16.0)))
40 .render_offset(Vector::new(phase[0] as f32 * 0.25, phase[1] as f32 * 0.25))
41 .size(width, width)
42 .render();
43 let top = super::colorref(top);
44 let bottom = super::colorref(bottom);
45 for (index, (pixel, coverage)) in pixels.iter_mut().zip(mask).enumerate() {
46 let alpha = f32::from(coverage) / 255.0;
47 let y = ((index / width as usize) as f32 / size).min(1.0);
48 for channel in 0..3 {
49 let color = top[channel] + (bottom[channel] - top[channel]) * y;
50 pixel[channel] = color * alpha + pixel[channel] * (1.0 - alpha);
51 }
52 pixel[3] = alpha + pixel[3] * (1.0 - alpha);
53 }
54 }
55 let data = pixels
56 .into_iter()
57 .flat_map(|pixel| {
58 let alpha = pixel[3];
59 let channel = |value: f32| {
60 let value = if alpha > 0.0 { value / alpha } else { 0.0 };
61 super::srgb_byte(value)
62 };
63 [
64 channel(pixel[0]),
65 channel(pixel[1]),
66 channel(pixel[2]),
67 (alpha * 255.0).round() as u8,
68 ]
69 })
70 .collect();
71 Image {
72 content: Content::Color,
73 placement: Placement {
74 left: 0,
75 top: 0,
76 width,
77 height: width,
78 },
79 data,
80 ..Image::default()
81 }
82}
83
84#[cfg(test)]
85mod tests {
86 use super::*;
87
88 #[test]
89 fn tag_masks_keep_transparency_and_checked_state_across_scales() {
90 for size in [8.0, 16.0, 32.0, 57.5] {
91 for phase in [[0, 0], [1, 2], [3, 3]] {
92 let mut masks = Vec::new();
93 for icon in [
94 TagIcon::CheckBox { checked: false },
95 TagIcon::CheckBox { checked: true },
96 TagIcon::Question,
97 TagIcon::Music,
98 ] {
99 let image = rasterize(icon, size, phase);
100 assert_eq!(
101 image.data.len(),
102 (image.placement.width * image.placement.height * 4) as usize
103 );
104 assert!(image.data.chunks_exact(4).any(|p| p[3] > 0));
105 assert!(image.data.chunks_exact(4).any(|p| p[3] < 255));
106 assert!(
107 image
108 .data
109 .chunks_exact(4)
110 .filter(|p| p[3] == 0)
111 .all(|p| p[..3] == [0; 3])
112 );
113 masks.push(image.data);
114 }
115 assert_ne!(masks[0], masks[1]);
116 assert!(masks[1].chunks_exact(4).any(|p| p[0] > p[2] && p[3] > 0));
117 }
118 }
119 }
120}
crates/canvas/Cargo.toml+18-1
...@@ -1,9 +1,12 @@...@@ -1,9 +1,12 @@
1[package]1[package]
2name = "one-canvas"2name = "canvas"
3version = "0.1.0"3version = "0.1.0"
4edition = "2024"4edition = "2024"
5publish = false5publish = false
66
7[features]
8gpu = ["dep:bytemuck", "dep:swash", "dep:wgpu", "dep:image", "dep:roxmltree"]
9
7[dependencies]10[dependencies]
8onestore = { path = "../onestore" }11onestore = { path = "../onestore" }
9parley.workspace = true12parley.workspace = true
...@@ -12,6 +15,12 @@ serde_json = "1.0.151"...@@ -12,6 +15,12 @@ serde_json = "1.0.151"
12skrifa = "0.44.0"15skrifa = "0.44.0"
13getrandom = "0.4.3"16getrandom = "0.4.3"
1417
18bytemuck = { version = "1.25", features = ["derive"], optional = true }
19swash = { version = "0.2.10", optional = true }
20wgpu = { workspace = true, optional = true }
21image = { version = "0.25", default-features = false, features = ["png", "jpeg"], optional = true }
22roxmltree = { version = "0.21", optional = true }
23
15[[bin]]24[[bin]]
16name = "layout-probe"25name = "layout-probe"
17path = "src/bin/layout_probe.rs"26path = "src/bin/layout_probe.rs"
...@@ -19,3 +28,11 @@ path = "src/bin/layout_probe.rs"...@@ -19,3 +28,11 @@ path = "src/bin/layout_probe.rs"
19[[bin]]28[[bin]]
20name = "page-probe"29name = "page-probe"
21path = "src/bin/page_probe.rs"30path = "src/bin/page_probe.rs"
31
32[dev-dependencies]
33pollster = "0.4"
34png = "0.18"
35
36[[example]]
37name = "render_page"
38required-features = ["gpu"]
crates/canvas/assets/tags/checkbox.svg created+24
...@@ -0,0 +1,24 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
4 <stop offset="0" stop-color="#91b6f7"/>
5 <stop offset="1" stop-color="#6498ef"/>
6 </linearGradient>
7 <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
8 <stop offset="0" stop-color="#ffffff"/>
9 <stop offset="1" stop-color="#ffffff"/>
10 </linearGradient>
11 <linearGradient id="layer2" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
12 <stop offset="0" stop-color="#7caff6"/>
13 <stop offset="1" stop-color="#5587e9"/>
14 </linearGradient>
15 <linearGradient id="layer3" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
16 <stop offset="0" stop-color="#ffffff"/>
17 <stop offset="1" stop-color="#d6e6f5"/>
18 </linearGradient>
19 </defs>
20 <path d="M0 0H16V16H0Z" fill="url(#layer0)"/>
21 <path d="M1 1H15V15H1Z" fill="url(#layer1)"/>
22 <path d="M2 2H14V14H2Z" fill="url(#layer2)"/>
23 <path d="M3 3H13V13H3Z" fill="url(#layer3)"/>
24</svg>
crates/canvas/assets/tags/checkmark.svg created+9
...@@ -0,0 +1,9 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
4 <stop offset="0" stop-color="#f48c77"/>
5 <stop offset="1" stop-color="#c84425"/>
6 </linearGradient>
7 </defs>
8 <path d="M2 8L6 11L13 3L14 5L6 14L1 10Z" fill="url(#layer0)"/>
9</svg>
crates/canvas/assets/tags/music.svg created+14
...@@ -0,0 +1,14 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
4 <stop offset="0" stop-color="#9cbbf0"/>
5 <stop offset="1" stop-color="#4655a3"/>
6 </linearGradient>
7 <linearGradient id="layer1" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
8 <stop offset="0" stop-color="#dce8ff"/>
9 <stop offset="1" stop-color="#8697da"/>
10 </linearGradient>
11 </defs>
12 <path d="M7 1C10 1 9 3 12 4C14 5 15 7 13 9C13 6 10 7 9 5V11C9 15 2 17 1 13C0 10 4 8 7 9Z" fill="url(#layer0)"/>
13 <path d="M2 11C3 9 6 9 7 10C7 11 4 13 2 12Z" fill="url(#layer1)"/>
14</svg>
crates/canvas/assets/tags/question.svg created+9
...@@ -0,0 +1,9 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="layer0" x1="0" y1="0" x2="0" y2="16" gradientUnits="userSpaceOnUse">
4 <stop offset="0" stop-color="#ce3ae8"/>
5 <stop offset="1" stop-color="#8f008f"/>
6 </linearGradient>
7 </defs>
8 <path d="M8 5C8 1 15 1 15 5C15 7 11 8 11 10H9C9 6 13 6 13 5C13 3 10 3 10 5ZM9 12H11V14H9Z" fill="url(#layer0)"/>
9</svg>
crates/canvas/examples/render_page.rs created+125
...@@ -0,0 +1,125 @@
1use canvas::gpu::{Renderer, Viewport, page::PageScene};
2use canvas::{layout::TextEngine, page::Page};
3use onestore::{RevisionIndex, Store, document::Document};
4use std::{env, fs, sync::Arc, time::Duration};
5
6fn main() -> Result<(), Box<dyn std::error::Error>> {
7 let mut args = env::args().skip(1).peekable();
8 let path = args
9 .next()
10 .ok_or("Expected a section snapshot, page title and new PNG path")?;
11 let title = args.next().ok_or("Expected a page title")?;
12 let destination = args.next().ok_or("Expected a new PNG path")?;
13 let origin_y: f32 = if args
14 .peek()
15 .is_some_and(|value| value != "--substitute-font")
16 {
17 args.next().unwrap().parse()?
18 } else {
19 0.0
20 };
21 let mut engine = TextEngine::default();
22 while let Some(option) = args.next() {
23 if option != "--substitute-font" {
24 return Err(format!("Unknown option: {option}").into());
25 }
26 let path = args
27 .next()
28 .ok_or("Provide a font file after --substitute-font.")?;
29 engine.register_substitute(parley::fontique::Blob::new(Arc::new(fs::read(path)?)))?;
30 }
31 let page = {
32 let bytes = fs::read(path)?;
33 let store = Store::parse(&bytes)?;
34 let index = RevisionIndex::parse(&store)?;
35 Page::from_document(&Document::parse(&index)?, &title)?
36 };
37 let margin_origin = page.margin_origin;
38 let object_count = page.objects.len();
39 let scene = PageScene::new(page, &mut engine)?;
40 let mut primitives = Vec::new();
41 scene.append_primitives(&mut primitives, [0.0; 2])?;
42 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
43 let adapter = pollster::block_on(instance.request_adapter(&Default::default()))?;
44 let (device, queue) = pollster::block_on(adapter.request_device(&Default::default()))?;
45 let size = [1386_u32, 759];
46 let format = wgpu::TextureFormat::Rgba8UnormSrgb;
47 let target = device.create_texture(&wgpu::TextureDescriptor {
48 label: Some("Native page comparison"),
49 size: wgpu::Extent3d {
50 width: size[0],
51 height: size[1],
52 depth_or_array_layers: 1,
53 },
54 mip_level_count: 1,
55 sample_count: 1,
56 dimension: wgpu::TextureDimension::D2,
57 format,
58 usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
59 view_formats: &[],
60 });
61 let row_bytes = (size[0] * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT);
62 let buffer = device.create_buffer(&wgpu::BufferDescriptor {
63 label: Some("Native page comparison readback"),
64 size: u64::from(row_bytes) * u64::from(size[1]),
65 usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
66 mapped_at_creation: false,
67 });
68 let mut renderer = Renderer::new(device, queue, format);
69 renderer
70 .draw(
71 &target.create_view(&Default::default()),
72 Viewport {
73 size,
74 scale: 96.0 / 72.0,
75 origin: [
76 (36.0 - margin_origin[0]) * (96.0 / 72.0),
77 (14.4 - margin_origin[1]) * (96.0 / 72.0) + origin_y,
78 ],
79 },
80 &primitives,
81 )
82 .map_err(|e| format!("Page comparison rendering failed: {e:?}"))?;
83 let mut encoder = renderer.device.create_command_encoder(&Default::default());
84 encoder.copy_texture_to_buffer(
85 target.as_image_copy(),
86 wgpu::TexelCopyBufferInfo {
87 buffer: &buffer,
88 layout: wgpu::TexelCopyBufferLayout {
89 offset: 0,
90 bytes_per_row: Some(row_bytes),
91 rows_per_image: Some(size[1]),
92 },
93 },
94 wgpu::Extent3d {
95 width: size[0],
96 height: size[1],
97 depth_or_array_layers: 1,
98 },
99 );
100 renderer.queue.submit([encoder.finish()]);
101 let (sender, receiver) = std::sync::mpsc::channel();
102 buffer.map_async(wgpu::MapMode::Read, .., move |result| {
103 let _ = sender.send(result);
104 });
105 renderer.device.poll(wgpu::PollType::Wait {
106 submission_index: None,
107 timeout: Some(Duration::from_secs(10)),
108 })?;
109 receiver.recv_timeout(Duration::from_secs(10))??;
110 let mapped = buffer.get_mapped_range(..)?;
111 let pixels: Vec<_> = mapped
112 .chunks_exact(row_bytes as usize)
113 .flat_map(|row| row[..size[0] as usize * 4].iter().copied())
114 .collect();
115 let mut encoder = png::Encoder::new(fs::File::create_new(destination)?, size[0], size[1]);
116 encoder.set_color(png::ColorType::Rgba);
117 encoder.set_depth(png::BitDepth::Eight);
118 encoder.write_header()?.write_image_data(&pixels)?;
119 eprintln!(
120 "Rendered {} page objects at 100% / 96 DPI, origin Y {origin_y} px using {:?}",
121 object_count,
122 adapter.get_info()
123 );
124 Ok(())
125}
crates/canvas/src/bin/layout_probe.rs+1-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1use one_canvas::{layout::TextEngine, text::Paragraph};1use canvas::{layout::TextEngine, text::Paragraph};
2use onestore::document::Format;2use onestore::document::Format;
3use serde::{Deserialize, Serialize};3use serde::{Deserialize, Serialize};
4use serde_json::json;4use serde_json::json;
crates/canvas/src/bin/page_probe.rs+20-9
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1use one_canvas::{1use canvas::{
2 document::TextDocument,
2 layout::TextEngine,3 layout::TextEngine,
3 outline::OutlineLayout,4 outline::OutlineLayout,
4 page::{Outline, Page, PageObject},5 page::{Outline, Page, PageObject},
...@@ -49,8 +50,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {...@@ -49,8 +50,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
49 None,50 None,
50 )?),51 )?),
51 PageObject::Title(title) => {52 PageObject::Title(title) => {
52 title_areas.push(json!({"id": title.id, "layout": title.layout,53 title_areas.push(
53 "outlines": title.outlines.iter().map(|o| o.id).collect::<Vec<_>>()}));54 json!({"id": title.id, "layout": title.layout, "date_outline": title.date,
55 "outlines": title.outlines.iter().map(|o| o.id).collect::<Vec<_>>()}),
56 );
54 for (outline, (origin, layout)) in title57 for (outline, (origin, layout)) in title
55 .outlines58 .outlines
56 .iter()59 .iter()
...@@ -69,7 +72,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {...@@ -69,7 +72,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
69 serde_json::to_writer_pretty(72 serde_json::to_writer_pretty(
70 io::stdout().lock(),73 io::stdout().lock(),
71 &json!({74 &json!({
72 "title": page.title, "title_areas": title_areas, "margin_origin": page.margin_origin, "objects": objects,75 "title": page.title, "created_filetime": page.created, "title_areas": title_areas, "margin_origin": page.margin_origin, "objects": objects,
73 "definitions": page.definitions.iter().map(|(id, definition)| (id.to_string(), json!({76 "definitions": page.definitions.iter().map(|(id, definition)| (id.to_string(), json!({
74 "kind": definition.kind, "format": definition.format,77 "kind": definition.kind, "format": definition.format,
75 }))).collect::<serde_json::Map<_, _>>(), "import_ms": import_ms,78 }))).collect::<serde_json::Map<_, _>>(), "import_ms": import_ms,
...@@ -86,9 +89,13 @@ fn outline_json(...@@ -86,9 +89,13 @@ fn outline_json(
86 title_origin: Option<[f32; 2]>,89 title_origin: Option<[f32; 2]>,
87) -> Result<serde_json::Value, Box<dyn std::error::Error>> {90) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
88 let mut paragraphs = Vec::new();91 let mut paragraphs = Vec::new();
89 for paragraph in &outline.paragraphs {92 let document = TextDocument::from_nodes(outline.paragraphs.clone())?;
93 for paragraph in document.text_nodes() {
90 let placed = laid_out.paragraphs.iter().find(|p| p.id == paragraph.id);94 let placed = laid_out.paragraphs.iter().find(|p| p.id == paragraph.id);
91 let source = paragraph.combined_text();95 let source = &paragraph
96 .text()
97 .ok_or("Unsupported paragraph content")?
98 .text;
92 let projection;99 let projection;
93 let visible = if let Some(placed) = placed {100 let visible = if let Some(placed) = placed {
94 placed.projection.text()101 placed.projection.text()
...@@ -115,13 +122,13 @@ fn outline_json(...@@ -115,13 +122,13 @@ fn outline_json(
115 .collect();122 .collect();
116 paragraphs.push(json!({123 paragraphs.push(json!({
117 "id": paragraph.id, "parent": paragraph.parent, "level": paragraph.level,124 "id": paragraph.id, "parent": paragraph.parent, "level": paragraph.level,
125 "date_fields": paragraph.text().into_iter().filter_map(|text| text.date_field).map(|field| format!("{field:?}")).collect::<Vec<_>>(),
118 "format": paragraph.format, "collapsed": paragraph.collapsed,126 "format": paragraph.format, "collapsed": paragraph.collapsed,
119 "lists": paragraph.lists,127 "lists": paragraph.lists,
120 "tags": paragraph.tags.iter().chain(paragraph.text.iter().flat_map(|t| &t.tags)).collect::<Vec<_>>(),128 "tags": paragraph.tags.iter().chain(paragraph.text().into_iter().flat_map(|t| &t.tags)).collect::<Vec<_>>(),
121 "source_utf16": source.text().encode_utf16().count(),129 "source_utf16": source.text().encode_utf16().count(),
122 "visible_utf16": visible.text().encode_utf16().count(),130 "visible_utf16": visible.text().encode_utf16().count(),
123 "visible_text": visible.text(), "spans": visible.spans().iter().map(|s| json!({"end_utf8": s.end, "format": s.format})).collect::<Vec<_>>(),131 "visible_text": visible.text(), "spans": visible.spans().iter().map(|s| json!({"end_utf8": s.end, "format": s.format})).collect::<Vec<_>>(),
124 "unsupported": paragraph.unsupported.iter().map(|u| u.jcid).collect::<Vec<_>>(),
125 "height": layout.as_ref().map(|l| l.height()), "lines": lines,132 "height": layout.as_ref().map(|l| l.height()), "lines": lines,
126 "origin": placed.map(|p| p.origin),133 "origin": placed.map(|p| p.origin),
127 "tag_icons": placed.map(|p| p.tags.iter().map(|tag| json!({134 "tag_icons": placed.map(|p| p.tags.iter().map(|tag| json!({
...@@ -137,8 +144,12 @@ fn outline_json(...@@ -137,8 +144,12 @@ fn outline_json(
137 }144 }
138 Ok(145 Ok(
139 json!({"kind": "outline", "id": outline.id, "layout": outline.layout,146 json!({"kind": "outline", "id": outline.id, "layout": outline.layout,
140 "indents": outline.indents, "is_title": title_origin.is_some(), "title_origin": title_origin, "paragraphs": paragraphs,147 "indents": outline.indents, "is_title": title_origin.is_some(), "is_title_text": outline.title, "minimum_width": outline.min_width, "title_origin": title_origin, "paragraphs": paragraphs,
141 "size": laid_out.size,148 "size": laid_out.size,
149 "tables": laid_out.tables.iter().map(|table| json!({
150 "id": table.id, "borders": table.borders,
151 "cells": table.cells.iter().map(|cell| json!({"id": cell.id, "rect": cell.rect})).collect::<Vec<_>>()
152 })).collect::<Vec<_>>(),
142 "unsupported": outline.unsupported.iter().map(|u| u.jcid).collect::<Vec<_>>() }),153 "unsupported": outline.unsupported.iter().map(|u| u.jcid).collect::<Vec<_>>() }),
143 )154 )
144}155}
crates/canvas/src/date.rs created+247
...@@ -0,0 +1,247 @@
1use crate::{
2 document::TextDocument,
3 editor::EditorError,
4 layout::TextEngine,
5 outline::{OutlineLayout, TITLE_WIDTH},
6 page::{Definition, Outline, PageParagraph},
7 text::EditError,
8};
9use onestore::ExGuid;
10use std::collections::BTreeMap;
11
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13pub enum DateField {
14 Date = 0,
15 Time = 1,
16}
17
18#[derive(Clone)]
19pub struct PageDate {
20 timestamp: u64,
21 source: Outline,
22 layout: OutlineLayout,
23}
24
25impl PageDate {
26 /// FILETIME ticks, in 100 ns units since 1601-01-01 UTC.
27 pub fn timestamp(&self) -> u64 {
28 self.timestamp
29 }
30
31 pub fn source(&self) -> &Outline {
32 &self.source
33 }
34
35 pub fn layout(&self) -> &OutlineLayout {
36 &self.layout
37 }
38
39 pub fn fields(&self) -> impl Iterator<Item = (DateField, &PageParagraph)> {
40 let swapped = self
41 .source
42 .paragraphs
43 .iter()
44 .enumerate()
45 .any(|(index, paragraph)| {
46 matches!(
47 (index, paragraph.text().unwrap().date_field),
48 (0, Some(DateField::Time)) | (1, Some(DateField::Date))
49 )
50 });
51 self.source
52 .paragraphs
53 .iter()
54 .enumerate()
55 .map(move |(index, paragraph)| {
56 let field =
57 paragraph
58 .text()
59 .unwrap()
60 .date_field
61 .unwrap_or(if (index == 0) != swapped {
62 DateField::Date
63 } else {
64 DateField::Time
65 });
66 (field, paragraph)
67 })
68 }
69
70 pub fn new(
71 timestamp: u64,
72 source: Outline,
73 engine: &mut TextEngine,
74 definitions: &BTreeMap<ExGuid, Definition>,
75 ) -> Result<Self, EditorError> {
76 if source.title || source.paragraphs.is_empty() || source.paragraphs.len() > 2 {
77 return Err(EditError::UnsupportedContent.into());
78 }
79 let document = TextDocument::from_nodes(source.paragraphs.clone())?;
80 document.validate_flat()?;
81 if source
82 .paragraphs
83 .iter()
84 .any(|paragraph| paragraph.text().unwrap().text.spans().len() != 1)
85 {
86 return Err(EditError::UnsupportedContent.into());
87 }
88 let layout = source.layout_with_width(engine, definitions, TITLE_WIDTH)?;
89 let date = Self {
90 timestamp,
91 source,
92 layout,
93 };
94 let mut seen = [false; 2];
95 for (field, _) in date.fields() {
96 if std::mem::replace(&mut seen[field as usize], true) {
97 return Err(EditError::UnsupportedContent.into());
98 }
99 }
100 Ok(date)
101 }
102}
103
104#[cfg(test)]
105mod tests {
106 use super::*;
107 use crate::{
108 editor::{CanvasEditor, TextOutline},
109 text::Paragraph,
110 };
111
112 #[test]
113 fn date_and_text_edits_share_history_without_changing_identities_or_selection() {
114 let mut engine = TextEngine::default();
115 let definitions = BTreeMap::new();
116 let body = TextOutline::new(
117 &mut engine,
118 TextDocument::new(vec![Paragraph::new("Body".into(), Default::default())]).unwrap(),
119 240.0,
120 [36.0, 90.0],
121 )
122 .unwrap();
123 let duplicate = PageDate::new(1, body.snapshot(), &mut engine, &definitions).unwrap();
124 assert!(
125 CanvasEditor::from_text_outlines(vec![body.clone()], BTreeMap::new(), Some(duplicate))
126 .is_err()
127 );
128 let source = TextOutline::new(
129 &mut engine,
130 TextDocument::new(
131 ["Monday, September 07, 2026", "6:14 AM"]
132 .map(|text| Paragraph::new(text.into(), Default::default()))
133 .into(),
134 )
135 .unwrap(),
136 468.0,
137 [0.0; 2],
138 )
139 .unwrap()
140 .snapshot();
141 let original = source.paragraphs.clone();
142 let date = PageDate::new(100_123_456, source, &mut engine, &definitions).unwrap();
143 let original_layout = date.layout.paragraphs[0].text.id();
144 let mut editor =
145 CanvasEditor::from_text_outlines(vec![body], definitions, Some(date)).unwrap();
146 let body = editor.active_outline().document().clone();
147 editor.compose(&mut engine, "new ".into(), 4..4).unwrap();
148 let selection = editor.selection();
149 let text = ["Wednesday, September 09, 2026".into(), "6:14 AM".into()];
150 assert!(
151 editor
152 .change_date(&mut engine, 200_123_456, text.clone())
153 .unwrap()
154 );
155 assert!(editor.marked_range().is_none());
156 assert_eq!(editor.selection(), selection);
157 let changed = editor.date().unwrap().source.paragraphs.clone();
158 for (old, new) in original.iter().zip(&changed) {
159 assert_eq!(old.id, new.id);
160 assert_eq!(old.text().unwrap().id, new.text().unwrap().id);
161 let mut restored = new.clone();
162 restored.text_mut().unwrap().text = old.text().unwrap().text.clone();
163 assert_eq!(&restored, old);
164 }
165 assert!(editor.undo(&mut engine).unwrap());
166 assert_eq!(editor.date().unwrap().timestamp, 100_123_456);
167 assert_eq!(editor.date().unwrap().source.paragraphs, original);
168 assert_eq!(
169 editor.date().unwrap().layout.paragraphs[0].text.id(),
170 original_layout
171 );
172 assert_eq!(editor.selection(), selection);
173 assert!(editor.undo(&mut engine).unwrap());
174 assert_eq!(editor.active_outline().document(), &body);
175 assert!(editor.redo(&mut engine).unwrap());
176 assert!(editor.redo(&mut engine).unwrap());
177 assert_eq!(editor.date().unwrap().source.paragraphs, changed);
178 assert!(!editor.change_date(&mut engine, 200_123_456, text).unwrap());
179 editor.undo(&mut engine).unwrap();
180 editor
181 .change_date(
182 &mut engine,
183 300_123_456,
184 ["Friday".into(), "7:00 AM".into()],
185 )
186 .unwrap();
187 assert!(!editor.redo(&mut engine).unwrap());
188 }
189
190 #[test]
191 fn explicit_roles_control_reordered_and_time_only_fields() {
192 let mut engine = TextEngine::default();
193 let source = TextOutline::new(
194 &mut engine,
195 TextDocument::new(
196 ["6:14 AM", "Monday"]
197 .map(|text| Paragraph::new(text.into(), Default::default()))
198 .into(),
199 )
200 .unwrap(),
201 468.0,
202 [0.0; 2],
203 )
204 .unwrap()
205 .snapshot();
206 for count in [1, 2] {
207 let mut source = source.clone();
208 source.paragraphs.truncate(count);
209 source.paragraphs[0].text_mut().unwrap().date_field = Some(DateField::Time);
210 let date = PageDate::new(1, source, &mut engine, &BTreeMap::new()).unwrap();
211 assert_eq!(date.fields().next().unwrap().0, DateField::Time);
212 let mut editor = CanvasEditor::new(
213 &mut engine,
214 TextDocument::new(vec![Paragraph::new("Body".into(), Default::default())]).unwrap(),
215 200.0,
216 )
217 .unwrap();
218 let outlines = editor.outlines().to_vec();
219 editor =
220 CanvasEditor::from_text_outlines(outlines, BTreeMap::new(), Some(date)).unwrap();
221 editor
222 .change_date(&mut engine, 2, ["New date".into(), "New time".into()])
223 .unwrap();
224 assert_eq!(
225 editor.date().unwrap().source.paragraphs[0]
226 .text()
227 .unwrap()
228 .text
229 .text(),
230 "New time"
231 );
232 if count == 2 {
233 assert_eq!(
234 editor.date().unwrap().source.paragraphs[1]
235 .text()
236 .unwrap()
237 .text
238 .text(),
239 "New date"
240 );
241 let mut duplicate = editor.date().unwrap().source.clone();
242 duplicate.paragraphs[1].text_mut().unwrap().date_field = Some(DateField::Time);
243 assert!(PageDate::new(3, duplicate, &mut engine, &BTreeMap::new()).is_err());
244 }
245 }
246 }
247}
crates/canvas/src/document.rs+737-162
...@@ -1,8 +1,8 @@...@@ -1,8 +1,8 @@
1use crate::{1use crate::{
2 page::{PageParagraph, TextObject},2 page::{PageParagraph, ParagraphContent, TextObject},
3 text::{EditError, Paragraph, new_id},3 text::{EditError, Paragraph, new_id},
4};4};
5use onestore::document::Format;5use onestore::{ExGuid, document::Format};
6use std::{6use std::{
7 collections::{BTreeMap, BTreeSet},7 collections::{BTreeMap, BTreeSet},
8 ops::Range,8 ops::Range,
...@@ -14,7 +14,7 @@ pub struct TextPosition {...@@ -14,7 +14,7 @@ pub struct TextPosition {
14 pub offset: u32,14 pub offset: u32,
15}15}
1616
17/// Each editable paragraph owns one rich-text object; structural edits require flat content.17/// Paragraph positions enumerate text leaves in document order, including table cells.
18#[derive(Clone, Debug, PartialEq)]18#[derive(Clone, Debug, PartialEq)]
19pub struct TextDocument {19pub struct TextDocument {
20 nodes: Vec<PageParagraph>,20 nodes: Vec<PageParagraph>,
...@@ -22,58 +22,218 @@ pub struct TextDocument {...@@ -22,58 +22,218 @@ pub struct TextDocument {
2222
23#[derive(Clone, Debug, PartialEq)]23#[derive(Clone, Debug, PartialEq)]
24pub(crate) struct DocumentEdit {24pub(crate) struct DocumentEdit {
25 /// Cell identity, or None for the document root.
26 pub container: Option<ExGuid>,
25 pub range: Range<usize>,27 pub range: Range<usize>,
26 pub replacement: Vec<PageParagraph>,28 pub replacement: Vec<PageParagraph>,
29 /// Width patches address surviving tables outside the replacement subtree.
30 pub columns: BTreeMap<ExGuid, Vec<f32>>,
27}31}
2832
29fn node(text: Paragraph, format: Format) -> Result<PageParagraph, EditError> {33pub(crate) fn node(text: Paragraph, format: Format) -> Result<PageParagraph, EditError> {
30 Ok(PageParagraph {34 Ok(PageParagraph {
31 id: new_id()?,35 id: new_id()?,
32 parent: None,36 parent: None,
33 level: 1,37 level: 1,
34 format,38 format,
35 text: vec![TextObject {39 content: crate::page::ParagraphContent::Text(TextObject {
40 date_field: None,
36 id: new_id()?,41 id: new_id()?,
37 text,42 text,
38 tags: Vec::new(),43 tags: Vec::new(),
39 }],44 }),
40 unsupported: Vec::new(),
41 lists: Vec::new(),45 lists: Vec::new(),
42 tags: Vec::new(),46 tags: Vec::new(),
43 collapsed: false,47 collapsed: false,
48 style: None,
44 })49 })
45}50}
4651
47fn validate_nodes<'a>(nodes: impl Iterator<Item = &'a PageParagraph>) -> Result<(), EditError> {52pub(crate) fn edited_nodes<'a>(
48 let mut ids = BTreeSet::new();53 nodes: &'a [PageParagraph],
49 let mut parents = BTreeMap::new();54 container: Option<ExGuid>,
55 edit: Option<&'a DocumentEdit>,
56) -> impl Clone + Iterator<Item = &'a PageParagraph> {
57 let (range, replacement) = match edit.filter(|edit| edit.container == container) {
58 Some(edit) => (edit.range.clone(), edit.replacement.as_slice()),
59 None => (0..0, &[][..]),
60 };
61 nodes[..range.start]
62 .iter()
63 .chain(replacement)
64 .chain(&nodes[range.end..])
65}
66
67pub(crate) fn leaves<'a>(
68 nodes: &'a [PageParagraph],
69 edit: Option<&'a DocumentEdit>,
70) -> impl Iterator<Item = (Option<ExGuid>, usize, &'a PageParagraph)> {
71 descendants(nodes, edit).filter(|(_, _, node)| node.text().is_some())
72}
73
74pub(crate) fn descendants<'a>(
75 nodes: &'a [PageParagraph],
76 edit: Option<&'a DocumentEdit>,
77) -> impl Iterator<Item = (Option<ExGuid>, usize, &'a PageParagraph)> {
78 let mut current = (None, edited_nodes(nodes, None, edit).enumerate());
79 let mut pending = Vec::new();
80 std::iter::from_fn(move || {
81 loop {
82 let Some((index, node)) = current.1.next() else {
83 current = pending.pop()?;
84 continue;
85 };
86 let container = current.0;
87 if let ParagraphContent::Table(table) = &node.content {
88 pending.push(current.clone());
89 pending.extend(
90 table
91 .rows
92 .iter()
93 .rev()
94 .flat_map(|row| row.cells.iter().rev())
95 .map(|cell| {
96 (
97 Some(cell.id),
98 edited_nodes(&cell.paragraphs, Some(cell.id), edit).enumerate(),
99 )
100 }),
101 );
102 current = pending.pop().unwrap();
103 }
104 return Some((container, index, node));
105 }
106 })
107}
108
109pub(crate) fn swap_columns(nodes: &mut [PageParagraph], widths: &mut BTreeMap<ExGuid, Vec<f32>>) {
110 if widths.is_empty() {
111 return;
112 }
50 for node in nodes {113 for node in nodes {
51 if node.text.len() != 1 || !node.unsupported.is_empty() {114 if let ParagraphContent::Table(table) = &mut node.content {
52 return Err(EditError::UnsupportedContent);115 if let Some(widths) = widths.get_mut(&table.id) {
116 for (column, width) in table.columns.iter_mut().zip(widths) {
117 std::mem::swap(&mut column.width, width);
118 }
119 }
120 for cell in table.rows.iter_mut().flat_map(|row| &mut row.cells) {
121 swap_columns(&mut cell.paragraphs, widths);
122 }
53 }123 }
54 if !ids.insert(node.id) || !ids.insert(node.text[0].id) {124 }
125}
126
127pub(crate) fn validate_nodes(
128 nodes: &[PageParagraph],
129 edit: Option<&DocumentEdit>,
130 ids: &mut BTreeSet<ExGuid>,
131) -> Result<(), EditError> {
132 let mut pending = vec![(None, nodes, 0)];
133 while let Some((container, nodes, depth)) = pending.pop() {
134 if depth > 64 {
55 return Err(EditError::InvalidStructure);135 return Err(EditError::InvalidStructure);
56 }136 }
57 if node.level == 0137 let mut nodes = edited_nodes(nodes, container, edit).peekable();
58 || node138 if nodes.peek().is_none() {
59 .parent139 return Err(EditError::InvalidRange);
60 .is_some_and(|id| parents.get(&id).is_none_or(|level| *level >= node.level))140 }
61 {141 let mut parents = BTreeMap::new();
62 return Err(EditError::InvalidStructure);142 for node in nodes {
143 if !ids.insert(node.id)
144 || node.level == 0
145 || node
146 .parent
147 .is_some_and(|id| parents.get(&id).is_none_or(|level| *level >= node.level))
148 {
149 return Err(EditError::InvalidStructure);
150 }
151 parents.insert(node.id, node.level);
152 match &node.content {
153 ParagraphContent::Text(text) => {
154 if !ids.insert(text.id) {
155 return Err(EditError::InvalidStructure);
156 }
157 }
158 ParagraphContent::Unsupported(_) => return Err(EditError::UnsupportedContent),
159 ParagraphContent::Table(table) => {
160 if !ids.insert(table.id)
161 || table.rows.is_empty()
162 || table.columns.is_empty()
163 || table.columns.len() > 255
164 || table
165 .columns
166 .iter()
167 .any(|column| !column.width.is_finite() || column.width < 36.0)
168 {
169 return Err(EditError::InvalidStructure);
170 }
171 for row in &table.rows {
172 if !ids.insert(row.id) || row.cells.len() != table.columns.len() {
173 return Err(EditError::InvalidStructure);
174 }
175 for cell in &row.cells {
176 if !ids.insert(cell.id) {
177 return Err(EditError::InvalidStructure);
178 }
179 if !cell.unsupported.is_empty() {
180 return Err(EditError::UnsupportedContent);
181 }
182 pending.push((Some(cell.id), cell.paragraphs.as_slice(), depth + 1));
183 }
184 }
185 }
186 }
63 }187 }
64 parents.insert(node.id, node.level);
65 }188 }
66 Ok(())189 Ok(())
67}190}
68191
69fn validate_text(nodes: &[PageParagraph]) -> Result<(), EditError> {192fn validate_text(nodes: &[PageParagraph]) -> Result<(), EditError> {
70 for node in nodes {193 for (_, _, node) in leaves(nodes, None) {
71 let text = &node.text[0].text;194 let text = &node.text().unwrap().text;
72 text.utf16_offset(text.text().len())?;195 text.utf16_offset(text.text().len())?;
73 }196 }
74 Ok(())197 Ok(())
75}198}
76199
200pub(crate) fn validate_flat<'a>(
201 mut nodes: impl Iterator<Item = &'a PageParagraph>,
202) -> Result<(), EditError> {
203 if nodes.any(|node| {
204 !node.lists.is_empty()
205 || !node.tags.is_empty()
206 || node.collapsed
207 || node.parent.is_some()
208 || node.text().is_none_or(|text| !text.tags.is_empty())
209 }) {
210 return Err(EditError::UnsupportedContent);
211 }
212 Ok(())
213}
214
215pub(crate) fn container_mut(
216 nodes: &mut Vec<PageParagraph>,
217 id: Option<ExGuid>,
218) -> Option<&mut Vec<PageParagraph>> {
219 let Some(id) = id else {
220 return Some(nodes);
221 };
222 for node in nodes {
223 if let ParagraphContent::Table(table) = &mut node.content {
224 for cell in table.rows.iter_mut().flat_map(|row| &mut row.cells) {
225 if cell.id == id {
226 return Some(&mut cell.paragraphs);
227 }
228 if let Some(nodes) = container_mut(&mut cell.paragraphs, Some(id)) {
229 return Some(nodes);
230 }
231 }
232 }
233 }
234 None
235}
236
77impl TextDocument {237impl TextDocument {
78 pub fn new(paragraphs: Vec<Paragraph>) -> Result<Self, EditError> {238 pub fn new(paragraphs: Vec<Paragraph>) -> Result<Self, EditError> {
79 Self::from_nodes(239 Self::from_nodes(
...@@ -85,10 +245,7 @@ impl TextDocument {...@@ -85,10 +245,7 @@ impl TextDocument {
85 }245 }
86246
87 pub fn from_nodes(nodes: Vec<PageParagraph>) -> Result<Self, EditError> {247 pub fn from_nodes(nodes: Vec<PageParagraph>) -> Result<Self, EditError> {
88 if nodes.is_empty() {248 validate_nodes(&nodes, None, &mut BTreeSet::new())?;
89 return Err(EditError::InvalidRange);
90 }
91 validate_nodes(nodes.iter())?;
92 validate_text(&nodes)?;249 validate_text(&nodes)?;
93 Ok(Self { nodes })250 Ok(Self { nodes })
94 }251 }
...@@ -98,45 +255,66 @@ impl TextDocument {...@@ -98,45 +255,66 @@ impl TextDocument {
98 }255 }
99256
100 pub(crate) fn validate_flat(&self) -> Result<(), EditError> {257 pub(crate) fn validate_flat(&self) -> Result<(), EditError> {
101 if self.nodes.iter().any(|node| {258 validate_flat(self.nodes.iter())
102 !node.lists.is_empty()259 }
103 || !node.tags.is_empty()260
104 || node.collapsed261 pub fn text_nodes(&self) -> impl Iterator<Item = &PageParagraph> {
105 || node.parent.is_some()262 leaves(&self.nodes, None).map(|(_, _, node)| node)
106 || node.level != 1
107 || !node.text[0].tags.is_empty()
108 }) {
109 return Err(EditError::UnsupportedContent);
110 }
111 Ok(())
112 }263 }
113264
114 pub fn paragraphs(&self) -> impl ExactSizeIterator<Item = &Paragraph> + DoubleEndedIterator {265 pub fn paragraphs(&self) -> impl Iterator<Item = &Paragraph> {
115 self.nodes.iter().map(|node| &node.text[0].text)266 self.text_nodes().map(|node| &node.text().unwrap().text)
267 }
268
269 pub(crate) fn container(&self, id: Option<ExGuid>) -> Result<&[PageParagraph], EditError> {
270 let Some(id) = id else {
271 return Ok(&self.nodes);
272 };
273 let mut pending = vec![self.nodes.as_slice()];
274 while let Some(nodes) = pending.pop() {
275 for node in nodes {
276 if let ParagraphContent::Table(table) = &node.content {
277 for cell in table.rows.iter().flat_map(|row| &row.cells) {
278 if cell.id == id {
279 return Ok(&cell.paragraphs);
280 }
281 pending.push(&cell.paragraphs);
282 }
283 }
284 }
285 }
286 Err(EditError::InvalidRange)
116 }287 }
117288
118 pub fn slice(&self, range: Range<TextPosition>) -> Result<Vec<Paragraph>, EditError> {289 pub fn slice(&self, range: Range<TextPosition>) -> Result<Vec<Paragraph>, EditError> {
119 if range.start > range.end {290 if range.start > range.end {
120 return Err(EditError::InvalidRange);291 return Err(EditError::InvalidRange);
121 }292 }
122 let nodes = self293 let count = range
123 .nodes294 .end
124 .get(range.start.paragraph..=range.end.paragraph)295 .paragraph
296 .checked_sub(range.start.paragraph)
297 .and_then(|n| n.checked_add(1))
125 .ok_or(EditError::InvalidRange)?;298 .ok_or(EditError::InvalidRange)?;
126 nodes299 let result = self
127 .iter()300 .paragraphs()
301 .skip(range.start.paragraph)
302 .take(count)
128 .enumerate()303 .enumerate()
129 .map(|(index, node)| {304 .map(|(index, paragraph)| {
130 let paragraph = &node.text[0].text;
131 let start = if index == 0 { range.start.offset } else { 0 };305 let start = if index == 0 { range.start.offset } else { 0 };
132 let end = if index == nodes.len() - 1 {306 let end = if index == count - 1 {
133 range.end.offset307 range.end.offset
134 } else {308 } else {
135 paragraph.utf16_offset(paragraph.text().len())?309 paragraph.utf16_offset(paragraph.text().len())?
136 };310 };
137 paragraph.slice(start..end)311 paragraph.slice(start..end)
138 })312 })
139 .collect()313 .collect::<Result<Vec<_>, EditError>>()?;
314 if result.len() != count {
315 return Err(EditError::InvalidRange);
316 }
317 Ok(result)
140 }318 }
141319
142 pub(crate) fn replace(320 pub(crate) fn replace(
...@@ -147,59 +325,88 @@ impl TextDocument {...@@ -147,59 +325,88 @@ impl TextDocument {
147 if range.start > range.end {325 if range.start > range.end {
148 return Err(EditError::InvalidRange);326 return Err(EditError::InvalidRange);
149 }327 }
150 if range.start.paragraph != range.end.paragraph || replacement.len() != 1 {328 let (container, start, first) = leaves(&self.nodes, None)
151 self.validate_flat()?;329 .nth(range.start.paragraph)
152 }
153 let first = self
154 .nodes
155 .get(range.start.paragraph)
156 .ok_or(EditError::InvalidRange)?;330 .ok_or(EditError::InvalidRange)?;
157 let last = self331 let (end_container, end, last) = leaves(&self.nodes, None)
158 .nodes332 .nth(range.end.paragraph)
159 .get(range.end.paragraph)
160 .ok_or(EditError::InvalidRange)?;333 .ok_or(EditError::InvalidRange)?;
161 let mut prefix = first.text[0].text.slice(0..range.start.offset)?;334 if container != end_container {
162 let last_text = &last.text[0].text;335 return Err(EditError::UnsupportedContent);
336 }
337 if range.start.paragraph != range.end.paragraph || replacement.len() != 1 {
338 validate_flat(self.container(container)?.iter().enumerate().filter_map(
339 |(index, node)| {
340 ((start..=end).contains(&index) || node.text().is_some()).then_some(node)
341 },
342 ))?;
343 }
344 let mut prefix = first.text().unwrap().text.slice(0..range.start.offset)?;
345 let last_text = &last.text().unwrap().text;
163 let suffix =346 let suffix =
164 last_text.slice(range.end.offset..last_text.utf16_offset(last_text.text().len())?)?;347 last_text.slice(range.end.offset..last_text.utf16_offset(last_text.text().len())?)?;
165 let mut replacement = replacement.into_iter();348 let mut replacement = replacement.into_iter();
166 prefix.append(replacement.next().ok_or(EditError::InvalidRange)?)?;349 prefix.append(replacement.next().ok_or(EditError::InvalidRange)?)?;
167 let mut head = first.clone();350 let mut head = first.clone();
168 head.text[0].text = prefix;351 head.text_mut().unwrap().text = prefix;
169 let mut nodes = vec![head];352 let mut nodes = vec![head];
170 let following = replacement.len();353 let following = replacement.len();
171 for (index, text) in replacement.enumerate() {354 for (index, text) in replacement.enumerate() {
172 if index + 1 == following && range.start.paragraph != range.end.paragraph {355 if index + 1 == following && range.start.paragraph != range.end.paragraph {
173 let mut end = last.clone();356 let mut end = last.clone();
174 end.text[0].text = text;357 end.text_mut().unwrap().text = text;
175 nodes.push(end);358 nodes.push(end);
176 } else {359 } else {
177 nodes.push(node(text, first.format.clone())?);360 let mut next = node(text, first.format.clone())?;
361 next.level = first.level;
362 next.style = first.style;
363 nodes.push(next);
178 }364 }
179 }365 }
180 if !suffix.text().is_empty() {366 if !suffix.text().is_empty() {
181 let end = nodes.last_mut().unwrap();367 let end = nodes.last_mut().unwrap();
182 end.text[0].text.append(suffix)?;368 end.text_mut().unwrap().text.append(suffix)?;
183 }369 }
184 Ok(DocumentEdit {370 Ok(DocumentEdit {
185 range: range.start.paragraph..range.end.paragraph + 1,371 columns: BTreeMap::new(),
372 container,
373 range: start..end + 1,
186 replacement: nodes,374 replacement: nodes,
187 })375 })
188 }376 }
189377
190 pub(crate) fn validate_edit(&self, edit: &DocumentEdit) -> Result<(), EditError> {378 pub(crate) fn validate_edit(&self, edit: &DocumentEdit) -> Result<(), EditError> {
191 if edit.range.start > edit.range.end379 let nodes = self.container(edit.container)?;
192 || edit.range.end > self.nodes.len()380 if edit.range.start > edit.range.end || edit.range.end > nodes.len() {
193 || (edit.range.len() == self.nodes.len() && edit.replacement.is_empty())
194 {
195 return Err(EditError::InvalidRange);381 return Err(EditError::InvalidRange);
196 }382 }
197 validate_nodes(383 validate_nodes(&self.nodes, Some(edit), &mut BTreeSet::new())?;
198 self.nodes[..edit.range.start]384 if !edit.columns.is_empty() {
199 .iter()385 let replaced = descendants(&edit.replacement, None)
200 .chain(&edit.replacement)386 .filter_map(|(_, _, node)| match &node.content {
201 .chain(&self.nodes[edit.range.end..]),387 ParagraphContent::Table(table) => Some(table.id),
202 )?;388 _ => None,
389 })
390 .collect::<BTreeSet<_>>();
391 let tables = descendants(&self.nodes, Some(edit))
392 .filter_map(|(_, _, node)| match &node.content {
393 ParagraphContent::Table(table) => Some((table.id, table)),
394 _ => None,
395 })
396 .collect::<BTreeMap<_, _>>();
397 for (id, widths) in &edit.columns {
398 if replaced.contains(id)
399 || tables
400 .get(id)
401 .is_none_or(|table| table.columns.len() != widths.len())
402 || widths
403 .iter()
404 .any(|width| !width.is_finite() || *width < 36.0)
405 {
406 return Err(EditError::InvalidStructure);
407 }
408 }
409 }
203 validate_text(&edit.replacement)410 validate_text(&edit.replacement)
204 }411 }
205412
...@@ -211,20 +418,366 @@ impl TextDocument {...@@ -211,20 +418,366 @@ impl TextDocument {
211 .checked_add(edit.replacement.len())418 .checked_add(edit.replacement.len())
212 .ok_or(EditError::TextTooLong)?;419 .ok_or(EditError::TextTooLong)?;
213 let range = edit.range.start..end;420 let range = edit.range.start..end;
214 let replacement = self.nodes.splice(edit.range, edit.replacement).collect();421 let nodes =
215 Ok(DocumentEdit { range, replacement })422 container_mut(&mut self.nodes, edit.container).ok_or(EditError::InvalidRange)?;
423 let replacement = nodes.splice(edit.range, edit.replacement).collect();
424 let mut columns = edit.columns;
425 swap_columns(&mut self.nodes, &mut columns);
426 Ok(DocumentEdit {
427 container: edit.container,
428 range,
429 replacement,
430 columns,
431 })
216 }432 }
217}433}
218434
219#[cfg(test)]435#[cfg(test)]
220mod tests {436mod tests {
221 use super::*;437 use super::*;
438 use crate::page::{Table, TableCell, TableColumn, TableRow};
222 use onestore::document::Format;439 use onestore::document::Format;
223440
224 fn position(paragraph: usize, offset: u32) -> TextPosition {441 fn position(paragraph: usize, offset: u32) -> TextPosition {
225 TextPosition { paragraph, offset }442 TextPosition { paragraph, offset }
226 }443 }
227444
445 fn table_document() -> TextDocument {
446 let format = Format::default();
447 let mut cells = Vec::new();
448 for texts in [vec!["a🌲b", "second"], vec!["right"]] {
449 cells.push(TableCell {
450 id: new_id().unwrap(),
451 layout: Default::default(),
452 indents: vec![18.0, 0.0, 27.0, 27.0],
453 shading: None,
454 paragraphs: texts
455 .into_iter()
456 .map(|text| {
457 node(Paragraph::new(text.into(), format.clone()), format.clone()).unwrap()
458 })
459 .collect(),
460 unsupported: Vec::new(),
461 });
462 }
463 let table = PageParagraph {
464 id: new_id().unwrap(),
465 parent: None,
466 level: 1,
467 style: None,
468 format: format.clone(),
469 lists: Vec::new(),
470 tags: Vec::new(),
471 collapsed: false,
472 content: ParagraphContent::Table(Table {
473 id: new_id().unwrap(),
474 columns: vec![
475 TableColumn {
476 width: 72.0,
477 locked: true
478 };
479 2
480 ],
481 rows: vec![TableRow {
482 id: new_id().unwrap(),
483 cells,
484 }],
485 borders: Some(true),
486 layout: Default::default(),
487 tags: Vec::new(),
488 }),
489 };
490 TextDocument::from_nodes(vec![
491 node(
492 Paragraph::new("before".into(), format.clone()),
493 format.clone(),
494 )
495 .unwrap(),
496 table,
497 node(Paragraph::new("after".into(), format.clone()), format).unwrap(),
498 ])
499 .unwrap()
500 }
501
502 #[test]
503 fn text_and_column_widths_share_an_exact_inverse() {
504 let mut document = table_document();
505 let source = document.clone();
506 let ParagraphContent::Table(table) = &source.nodes()[1].content else {
507 panic!()
508 };
509 let id = table.id;
510 let neighbor = document.paragraphs().nth(3).unwrap().text().as_ptr();
511 let mut edit = document
512 .replace(
513 position(1, 1)..position(1, 1),
514 vec![Paragraph::new("X".into(), Format::default())],
515 )
516 .unwrap();
517 edit.columns.insert(id, vec![81.24, 36.0]);
518 let mut undo = document.apply(edit).unwrap();
519 assert_eq!(undo.columns[&id], [72.0, 72.0]);
520 let after = document.clone();
521 for _ in 0..3 {
522 let redo = document.apply(undo).unwrap();
523 assert_eq!(document, source);
524 assert_eq!(
525 document.paragraphs().nth(3).unwrap().text().as_ptr(),
526 neighbor
527 );
528 undo = document.apply(redo).unwrap();
529 assert_eq!(document, after);
530 assert_eq!(
531 document.paragraphs().nth(3).unwrap().text().as_ptr(),
532 neighbor
533 );
534 }
535 let resize = DocumentEdit {
536 container: None,
537 range: 0..0,
538 replacement: Vec::new(),
539 columns: [(id, vec![37.11, 99.875])].into(),
540 };
541 let undo = document.apply(resize).unwrap();
542 document.apply(undo).unwrap();
543 assert_eq!(document, after);
544 }
545
546 #[test]
547 fn invalid_column_patches_do_not_partially_edit_text() {
548 let mut document = table_document();
549 let source = document.clone();
550 let ParagraphContent::Table(table) = &source.nodes()[1].content else {
551 panic!()
552 };
553 let id = table.id;
554 for widths in [
555 vec![],
556 vec![72.0],
557 vec![72.0, 72.0, 72.0],
558 vec![72.0, 35.99],
559 vec![f32::NAN, 72.0],
560 vec![f32::INFINITY, 72.0],
561 ] {
562 let mut edit = document
563 .replace(
564 position(1, 1)..position(1, 1),
565 vec![Paragraph::new("X".into(), Format::default())],
566 )
567 .unwrap();
568 edit.columns.insert(id, widths);
569 assert!(document.apply(edit).is_err());
570 assert_eq!(document, source);
571 }
572 for (range, replacement, id) in [
573 (0..0, Vec::new(), new_id().unwrap()),
574 (1..2, Vec::new(), id),
575 (1..2, vec![source.nodes()[1].clone()], id),
576 ] {
577 let edit = DocumentEdit {
578 container: None,
579 range,
580 replacement,
581 columns: [(id, vec![81.0, 90.0])].into(),
582 };
583 assert!(document.apply(edit).is_err());
584 assert_eq!(document, source);
585 }
586 }
587
588 #[test]
589 fn cell_text_edits_preserve_the_table_and_restore_paragraph_identity() {
590 let mut document = table_document();
591 let original = document.clone();
592 let ParagraphContent::Table(table) = &document.nodes()[1].content else {
593 panic!()
594 };
595 let cell = table.rows[0].cells[0].id;
596 let other_text = table.rows[0].cells[1].paragraphs[0]
597 .text()
598 .unwrap()
599 .text
600 .text()
601 .as_ptr();
602 assert_eq!(
603 document
604 .paragraphs()
605 .map(Paragraph::text)
606 .collect::<Vec<_>>(),
607 ["before", "a🌲b", "second", "right", "after"]
608 );
609 assert_eq!(
610 document
611 .slice(position(1, 1)..position(3, 2))
612 .unwrap()
613 .iter()
614 .map(Paragraph::text)
615 .collect::<Vec<_>>(),
616 ["🌲b", "second", "ri"]
617 );
618 let edit = document
619 .replace(
620 position(1, 1)..position(2, 3),
621 vec![Paragraph::new("NEW".into(), Format::default())],
622 )
623 .unwrap();
624 assert_eq!(edit.container, Some(cell));
625 assert_eq!(edit.range, 0..2);
626 let undo = document.apply(edit).unwrap();
627 assert_eq!(
628 document
629 .paragraphs()
630 .map(Paragraph::text)
631 .collect::<Vec<_>>(),
632 ["before", "aNEWond", "right", "after"]
633 );
634 let ParagraphContent::Table(table) = &document.nodes()[1].content else {
635 panic!()
636 };
637 assert_eq!(
638 table.rows[0].cells[1].paragraphs[0]
639 .text()
640 .unwrap()
641 .text
642 .text()
643 .as_ptr(),
644 other_text
645 );
646 assert_eq!(
647 document.text_nodes().nth(1).unwrap().id,
648 original.text_nodes().nth(1).unwrap().id
649 );
650 assert_eq!(undo.container, Some(cell));
651 assert_eq!(undo.range, 0..1);
652 let redo = document.apply(undo).unwrap();
653 assert_eq!(document, original);
654 let undo = document.apply(redo).unwrap();
655 document.apply(undo).unwrap();
656 assert_eq!(document, original);
657 }
658
659 #[test]
660 fn surrounding_text_splits_without_flattening_the_table() {
661 let mut document = table_document();
662 let original = document.clone();
663 let edit = document
664 .replace(
665 position(4, 2)..position(4, 2),
666 vec![Paragraph::new(String::new(), Format::default()); 2],
667 )
668 .unwrap();
669 assert_eq!(edit.container, None);
670 assert_eq!(edit.range, 2..3);
671 let undo = document.apply(edit).unwrap();
672 assert_eq!(
673 document
674 .paragraphs()
675 .map(Paragraph::text)
676 .collect::<Vec<_>>(),
677 ["before", "a🌲b", "second", "right", "af", "ter"]
678 );
679 assert_eq!(document.nodes()[1], original.nodes()[1]);
680 document.apply(undo).unwrap();
681 assert_eq!(document, original);
682 assert_eq!(
683 document.replace(
684 position(0, 0)..position(4, 0),
685 vec![Paragraph::new(String::new(), Format::default())]
686 ),
687 Err(EditError::UnsupportedContent)
688 );
689 }
690
691 #[test]
692 fn cell_edits_validate_utf16_and_container_boundaries_before_mutation() {
693 let mut document = table_document();
694 let original = document.clone();
695 let text = || vec![Paragraph::new("X".into(), Format::default())];
696 assert_eq!(
697 document.replace(position(1, 2)..position(1, 3), text()),
698 Err(EditError::InvalidRange)
699 );
700 assert_eq!(
701 document.replace(position(1, 0)..position(3, 1), text()),
702 Err(EditError::UnsupportedContent)
703 );
704 let ParagraphContent::Table(table) = &document.nodes()[1].content else {
705 panic!()
706 };
707 let cell = table.rows[0].cells[1].id;
708 for edit in [
709 DocumentEdit {
710 columns: BTreeMap::new(),
711 container: Some(cell),
712 range: 0..1,
713 replacement: Vec::new(),
714 },
715 DocumentEdit {
716 columns: BTreeMap::new(),
717 container: Some(new_id().unwrap()),
718 range: 0..1,
719 replacement: vec![document.nodes()[0].clone()],
720 },
721 DocumentEdit {
722 columns: BTreeMap::new(),
723 container: Some(cell),
724 range: 0..usize::MAX,
725 replacement: Vec::new(),
726 },
727 ] {
728 assert_eq!(document.apply(edit), Err(EditError::InvalidRange));
729 assert_eq!(document, original);
730 }
731 let alias = DocumentEdit {
732 columns: BTreeMap::new(),
733 container: Some(cell),
734 range: 0..1,
735 replacement: vec![document.nodes()[0].clone()],
736 };
737 assert_eq!(document.apply(alias), Err(EditError::InvalidStructure));
738 assert_eq!(document, original);
739 }
740
741 #[test]
742 fn nested_cell_edits_keep_leaf_order_and_split_paragraphs_locally() {
743 let mut document = table_document();
744 let nested = table_document().nodes.remove(1);
745 let ParagraphContent::Table(table) = &document.nodes()[1].content else {
746 panic!()
747 };
748 let parent = table.rows[0].cells[0].id;
749 document
750 .apply(DocumentEdit {
751 columns: BTreeMap::new(),
752 container: Some(parent),
753 range: 0..2,
754 replacement: vec![nested],
755 })
756 .unwrap();
757 let original = document.clone();
758 let edit = document
759 .replace(
760 position(2, 3)..position(2, 3),
761 vec![
762 Paragraph::new("X".into(), Format::default()),
763 Paragraph::new("Y".into(), Format::default()),
764 ],
765 )
766 .unwrap();
767 assert_ne!(edit.container, Some(parent));
768 assert_eq!(edit.range, 1..2);
769 let undo = document.apply(edit).unwrap();
770 assert_eq!(
771 document
772 .paragraphs()
773 .map(Paragraph::text)
774 .collect::<Vec<_>>(),
775 ["before", "a🌲b", "secX", "Yond", "right", "right", "after"]
776 );
777 document.apply(undo).unwrap();
778 assert_eq!(document, original);
779 }
780
228 #[test]781 #[test]
229 #[ignore = "requires CANVAS_TEST_SECTION and CANVAS_TEST_PAGE private fixture inputs"]782 #[ignore = "requires CANVAS_TEST_SECTION and CANVAS_TEST_PAGE private fixture inputs"]
230 fn imported_nodes_preserve_identity_through_edit_and_undo() {783 fn imported_nodes_preserve_identity_through_edit_and_undo() {
...@@ -272,12 +825,12 @@ mod tests {...@@ -272,12 +825,12 @@ mod tests {
272 let edited = document.clone();825 let edited = document.clone();
273 assert_eq!(edited.nodes()[paragraph].id, original.nodes()[paragraph].id);826 assert_eq!(edited.nodes()[paragraph].id, original.nodes()[paragraph].id);
274 assert_eq!(827 assert_eq!(
275 edited.nodes()[paragraph].text[0].id,828 edited.nodes()[paragraph].text().unwrap().id,
276 original.nodes()[paragraph].text[0].id829 original.nodes()[paragraph].text().unwrap().id
277 );830 );
278 let mut restored_text = edited.nodes().to_vec();831 let mut restored_text = edited.nodes().to_vec();
279 restored_text[paragraph].text[0].text =832 restored_text[paragraph].text_mut().unwrap().text =
280 original.nodes()[paragraph].text[0].text.clone();833 original.nodes()[paragraph].text().unwrap().text.clone();
281 assert_eq!(restored_text, original.nodes());834 assert_eq!(restored_text, original.nodes());
282 let redo = document.apply(undo).unwrap();835 let redo = document.apply(undo).unwrap();
283 assert_eq!(document, original);836 assert_eq!(document, original);
...@@ -310,7 +863,7 @@ mod tests {...@@ -310,7 +863,7 @@ mod tests {
310 .unwrap()863 .unwrap()
311 })864 })
312 .collect::<Vec<_>>();865 .collect::<Vec<_>>();
313 source[2].text[0].text = Paragraph::new(866 source[2].text_mut().unwrap().text = Paragraph::new(
314 "tail".into(),867 "tail".into(),
315 Format {868 Format {
316 bold: Some(true),869 bold: Some(true),
...@@ -339,8 +892,8 @@ mod tests {...@@ -339,8 +892,8 @@ mod tests {
339 for index in [0, 2] {892 for index in [0, 2] {
340 assert_eq!(document.nodes()[index].id, original.nodes()[index].id);893 assert_eq!(document.nodes()[index].id, original.nodes()[index].id);
341 assert_eq!(894 assert_eq!(
342 document.nodes()[index].text[0].id,895 document.nodes()[index].text().unwrap().id,
343 original.nodes()[index].text[0].id896 original.nodes()[index].text().unwrap().id
344 );897 );
345 assert_eq!(898 assert_eq!(
346 document.nodes()[index].format,899 document.nodes()[index].format,
...@@ -350,10 +903,10 @@ mod tests {...@@ -350,10 +903,10 @@ mod tests {
350 let old_ids: BTreeSet<_> = original903 let old_ids: BTreeSet<_> = original
351 .nodes()904 .nodes()
352 .iter()905 .iter()
353 .flat_map(|n| [n.id, n.text[0].id])906 .flat_map(|n| [n.id, n.text().unwrap().id])
354 .collect();907 .collect();
355 assert!(!old_ids.contains(&document.nodes()[1].id));908 assert!(!old_ids.contains(&document.nodes()[1].id));
356 assert!(!old_ids.contains(&document.nodes()[1].text[0].id));909 assert!(!old_ids.contains(&document.nodes()[1].text().unwrap().id));
357 let edited = document.clone();910 let edited = document.clone();
358 let redo = document.apply(undo).unwrap();911 let redo = document.apply(undo).unwrap();
359 assert_eq!(document, original);912 assert_eq!(document, original);
...@@ -378,20 +931,27 @@ mod tests {...@@ -378,20 +931,27 @@ mod tests {
378 TextDocument::new(vec![Paragraph::new("text".into(), Format::default())]).unwrap();931 TextDocument::new(vec![Paragraph::new("text".into(), Format::default())]).unwrap();
379 let original = document.clone();932 let original = document.clone();
380 let duplicate = DocumentEdit {933 let duplicate = DocumentEdit {
934 columns: BTreeMap::new(),
935 container: None,
381 range: 1..1,936 range: 1..1,
382 replacement: original.nodes().to_vec(),937 replacement: original.nodes().to_vec(),
383 };938 };
384 assert_eq!(document.apply(duplicate), Err(EditError::InvalidStructure));939 assert_eq!(document.apply(duplicate), Err(EditError::InvalidStructure));
385 let mut unsupported = original.nodes().to_vec();940 let mut unsupported = original.nodes().to_vec();
386 unsupported[0]941 unsupported[0].content =
387 .text942 crate::page::ParagraphContent::Unsupported(crate::page::Unsupported {
388 .push(original.nodes()[0].text[0].clone());943 id: new_id().unwrap(),
944 jcid: 0x60012,
945 layout: Default::default(),
946 });
389 assert_eq!(947 assert_eq!(
390 TextDocument::from_nodes(unsupported.clone()),948 TextDocument::from_nodes(unsupported.clone()),
391 Err(EditError::UnsupportedContent)949 Err(EditError::UnsupportedContent)
392 );950 );
393 assert_eq!(951 assert_eq!(
394 document.apply(DocumentEdit {952 document.apply(DocumentEdit {
953 columns: BTreeMap::new(),
954 container: None,
395 range: 0..1,955 range: 0..1,
396 replacement: unsupported956 replacement: unsupported
397 }),957 }),
...@@ -415,7 +975,7 @@ mod tests {...@@ -415,7 +975,7 @@ mod tests {
415 nodes[2].parent = Some(nodes[1].id);975 nodes[2].parent = Some(nodes[1].id);
416 nodes[2].level = 5;976 nodes[2].level = 5;
417 let original = TextDocument::from_nodes(nodes.clone()).unwrap();977 let original = TextDocument::from_nodes(nodes.clone()).unwrap();
418 for parent in [nodes[2].id, nodes[0].text[0].id, new_id().unwrap()] {978 for parent in [nodes[2].id, nodes[0].text().unwrap().id, new_id().unwrap()] {
419 let mut invalid = nodes.clone();979 let mut invalid = nodes.clone();
420 invalid[1].parent = Some(parent);980 invalid[1].parent = Some(parent);
421 assert_eq!(981 assert_eq!(
...@@ -434,6 +994,8 @@ mod tests {...@@ -434,6 +994,8 @@ mod tests {
434 let mut document = original.clone();994 let mut document = original.clone();
435 assert_eq!(995 assert_eq!(
436 document.apply(DocumentEdit {996 document.apply(DocumentEdit {
997 columns: BTreeMap::new(),
998 container: None,
437 range: 0..1,999 range: 0..1,
438 replacement: Vec::new()1000 replacement: Vec::new()
439 }),1001 }),
...@@ -466,7 +1028,7 @@ mod tests {...@@ -466,7 +1028,7 @@ mod tests {
466 task_id: Some([7; 16]),1028 task_id: Some([7; 16]),
467 extra_set: 0,1029 extra_set: 0,
468 });1030 });
469 nodes[1].text[0].tags = nodes[1].tags.clone();1031 nodes[1].text_mut().unwrap().tags = nodes[1].tags.clone();
470 nodes[1].collapsed = true;1032 nodes[1].collapsed = true;
471 nodes[2].parent = Some(nodes[1].id);1033 nodes[2].parent = Some(nodes[1].id);
472 nodes[2].level = 3;1034 nodes[2].level = 3;
...@@ -498,7 +1060,7 @@ mod tests {...@@ -498,7 +1060,7 @@ mod tests {
498 let undo = document.apply(edit).unwrap();1060 let undo = document.apply(edit).unwrap();
499 let edited = document.clone();1061 let edited = document.clone();
500 let mut restored = edited.nodes().to_vec();1062 let mut restored = edited.nodes().to_vec();
501 restored[1].text[0].text = text.clone();1063 restored[1].text_mut().unwrap().text = text.clone();
502 assert_eq!(restored, original.nodes());1064 assert_eq!(restored, original.nodes());
503 let redo = document.apply(undo).unwrap();1065 let redo = document.apply(undo).unwrap();
504 assert_eq!(document, original);1066 assert_eq!(document, original);
...@@ -585,79 +1147,90 @@ mod tests {...@@ -585,79 +1147,90 @@ mod tests {
585 Paragraph::new("日本z".into(), regular.clone()),1147 Paragraph::new("日本z".into(), regular.clone()),
586 ])1148 ])
587 .unwrap();1149 .unwrap();
588 let positions: Vec<_> = original1150 for (original, regions) in [
589 .paragraphs()1151 (original, vec![0, 0, 0]),
590 .enumerate()1152 (table_document(), vec![0, 1, 1, 2, 3]),
591 .flat_map(|(index, paragraph)| {1153 ] {
592 (0..=paragraph.text().len()).filter_map(move |byte| {1154 let positions: Vec<_> = original
593 paragraph1155 .paragraphs()
594 .utf16_offset(byte)1156 .enumerate()
595 .ok()1157 .flat_map(|(index, paragraph)| {
596 .map(|offset| position(index, offset))1158 (0..=paragraph.text().len()).filter_map(move |byte| {
1159 paragraph
1160 .utf16_offset(byte)
1161 .ok()
1162 .map(|offset| position(index, offset))
1163 })
597 })1164 })
598 })1165 .collect();
599 .collect();1166 let replacements = [
600 let replacements = [1167 vec![Paragraph::new(String::new(), regular.clone())],
601 vec![Paragraph::new(String::new(), regular.clone())],1168 vec![Paragraph::new("x👩🏽‍💻".into(), bold.clone())],
602 vec![Paragraph::new("x👩🏽‍💻".into(), bold.clone())],1169 vec![
603 vec![1170 Paragraph::new(String::new(), bold.clone()),
604 Paragraph::new(String::new(), bold.clone()),1171 Paragraph::new(String::new(), regular.clone()),
605 Paragraph::new(String::new(), regular.clone()),1172 ],
606 ],1173 vec![
607 vec![1174 Paragraph::new("one".into(), bold.clone()),
608 Paragraph::new("one".into(), bold.clone()),1175 Paragraph::new(String::new(), regular.clone()),
609 Paragraph::new(String::new(), regular.clone()),1176 Paragraph::new("two".into(), bold.clone()),
610 Paragraph::new("two".into(), bold),1177 ],
611 ],1178 ];
612 ];1179 let plain = original
613 let plain = original1180 .paragraphs()
614 .paragraphs()1181 .map(Paragraph::text)
615 .map(Paragraph::text)1182 .collect::<Vec<_>>()
616 .collect::<Vec<_>>()1183 .join("\n");
617 .join("\n");1184 for &start in &positions {
618 for &start in &positions {1185 for &end in positions.iter().filter(|&&end| end >= start) {
619 for &end in positions.iter().filter(|&&end| end >= start) {1186 for replacement in &replacements {
620 for replacement in &replacements {1187 let mut document = original.clone();
621 let mut document = original.clone();1188 let planned = document.replace(start..end, replacement.clone());
622 let planned = document.replace(start..end, replacement.clone()).unwrap();1189 if regions[start.paragraph] != regions[end.paragraph] {
623 assert_eq!(document, original);1190 assert_eq!(planned, Err(EditError::UnsupportedContent));
624 let undo = document.apply(planned).unwrap();1191 assert_eq!(document, original);
625 assert!(document.paragraphs().len() > 0);1192 continue;
626 let byte = |position: TextPosition| {1193 }
627 original1194 let planned = planned.unwrap();
628 .paragraphs()1195 assert_eq!(document, original);
629 .take(position.paragraph)1196 let undo = document.apply(planned).unwrap();
630 .map(|p| p.text().len() + 1)1197 assert!(document.paragraphs().next().is_some());
631 .sum::<usize>()1198 let byte = |position: TextPosition| {
632 + original1199 original
633 .paragraphs()1200 .paragraphs()
634 .nth(position.paragraph)1201 .take(position.paragraph)
635 .unwrap()1202 .map(|p| p.text().len() + 1)
636 .byte_offset(position.offset)1203 .sum::<usize>()
637 .unwrap()1204 + original
638 };1205 .paragraphs()
639 let mut expected = plain.clone();1206 .nth(position.paragraph)
640 expected.replace_range(1207 .unwrap()
641 byte(start)..byte(end),1208 .byte_offset(position.offset)
642 &replacement1209 .unwrap()
643 .iter()1210 };
644 .map(Paragraph::text)1211 let mut expected = plain.clone();
645 .collect::<Vec<_>>()1212 expected.replace_range(
646 .join("\n"),1213 byte(start)..byte(end),
647 );1214 &replacement
648 assert_eq!(1215 .iter()
649 document1216 .map(Paragraph::text)
650 .paragraphs()1217 .collect::<Vec<_>>()
651 .map(Paragraph::text)1218 .join("\n"),
652 .collect::<Vec<_>>()1219 );
653 .join("\n"),1220 assert_eq!(
654 expected1221 document
655 );1222 .paragraphs()
656 let after = document.clone();1223 .map(Paragraph::text)
657 let redo = document.apply(undo).unwrap();1224 .collect::<Vec<_>>()
658 assert_eq!(document, original, "{start:?}..{end:?}");1225 .join("\n"),
659 document.apply(redo).unwrap();1226 expected
660 assert_eq!(document, after);1227 );
1228 let after = document.clone();
1229 let redo = document.apply(undo).unwrap();
1230 assert_eq!(document, original, "{start:?}..{end:?}");
1231 document.apply(redo).unwrap();
1232 assert_eq!(document, after);
1233 }
661 }1234 }
662 }1235 }
663 }1236 }
...@@ -686,6 +1259,8 @@ mod tests {...@@ -686,6 +1259,8 @@ mod tests {
686 assert!(1259 assert!(
687 document1260 document
688 .apply(DocumentEdit {1261 .apply(DocumentEdit {
1262 columns: BTreeMap::new(),
1263 container: None,
689 range,1264 range,
690 replacement: Vec::new()1265 replacement: Vec::new()
691 })1266 })
crates/canvas/src/editor.rs+4249-283
...@@ -1,8 +1,9 @@...@@ -1,8 +1,9 @@
1use crate::{1use crate::{
2 document::{DocumentEdit, TextDocument, TextPosition},2 date::PageDate,
3 document::{DocumentEdit, TextDocument, TextPosition, edited_nodes, leaves},
3 layout::{LayoutError, TextEngine},4 layout::{LayoutError, TextEngine},
4 outline::{OutlineLayout, ParagraphLayout, arrange, visible_paragraphs},5 outline::{OutlineLayout, ParagraphLayout, arrange, indentation, visible_paragraphs},
5 page::{Definition, Outline},6 page::{Definition, Outline, PageParagraph},
6 text::{EditError, Paragraph},7 text::{EditError, Paragraph},
7};8};
8use onestore::ExGuid;9use onestore::ExGuid;
...@@ -16,6 +17,12 @@ use std::{...@@ -16,6 +17,12 @@ use std::{
16 ops::Range,17 ops::Range,
17};18};
1819
20pub const DEFAULT_OUTLINE_WIDTH: f32 = 468.0;
21
22pub(crate) mod page;
23mod table;
24pub use page::ReadOnlyObject;
25
19#[derive(Clone, Copy, Debug)]26#[derive(Clone, Copy, Debug)]
20pub enum Movement {27pub enum Movement {
21 Left,28 Left,
...@@ -26,12 +33,24 @@ pub enum Movement {...@@ -26,12 +33,24 @@ pub enum Movement {
26 WordRight,33 WordRight,
27 LineStart,34 LineStart,
28 LineEnd,35 LineEnd,
36 ParagraphStart,
37 ParagraphEnd,
38 DocumentStart,
39 DocumentEnd,
40}
41
42#[derive(Clone, Copy, Debug, PartialEq, Eq)]
43pub enum SelectionUnit {
44 Grapheme,
45 Word,
46 Paragraph,
29}47}
3048
31#[derive(Debug)]49#[derive(Debug)]
32pub enum EditorError {50pub enum EditorError {
33 Edit(EditError),51 Edit(EditError),
34 Layout(LayoutError),52 Layout(LayoutError),
53 InvalidGeometry,
35}54}
3655
37impl fmt::Display for EditorError {56impl fmt::Display for EditorError {
...@@ -39,6 +58,9 @@ impl fmt::Display for EditorError {...@@ -39,6 +58,9 @@ impl fmt::Display for EditorError {
39 match self {58 match self {
40 Self::Edit(error) => error.fmt(f),59 Self::Edit(error) => error.fmt(f),
41 Self::Layout(error) => error.fmt(f),60 Self::Layout(error) => error.fmt(f),
61 Self::InvalidGeometry => {
62 f.write_str("The page contains invalid object dimensions or positions.")
63 }
42 }64 }
43 }65 }
44}66}
...@@ -73,6 +95,8 @@ impl From<[TextPosition; 2]> for Selection {...@@ -73,6 +95,8 @@ impl From<[TextPosition; 2]> for Selection {
73}95}
7496
75pub struct CanvasEditor {97pub struct CanvasEditor {
98 pub(crate) objects: Vec<page::Content>,
99 date: Option<PageDate>,
76 outlines: Vec<TextOutline>,100 outlines: Vec<TextOutline>,
77 definitions: BTreeMap<ExGuid, Definition>,101 definitions: BTreeMap<ExGuid, Definition>,
78 active: Focus,102 active: Focus,
...@@ -84,14 +108,21 @@ pub struct CanvasEditor {...@@ -84,14 +108,21 @@ pub struct CanvasEditor {
84108
85enum Focus {109enum Focus {
86 Outline(usize),110 Outline(usize),
111 Draft {
112 index: usize,
113 outline: Box<TextOutline>,
114 },
87 Caret {115 Caret {
88 outline: Box<TextOutline>,116 outline: Box<TextOutline>,
89 index: usize,117 index: usize,
90 },118 },
91}119}
92120
121#[derive(Clone)]
93pub struct TextOutline {122pub struct TextOutline {
94 pub id: onestore::ExGuid,123 pub id: onestore::ExGuid,
124 pub title: bool,
125 min_width: Option<f32>,
95 layout: onestore::document::Layout,126 layout: onestore::document::Layout,
96 document: TextDocument,127 document: TextDocument,
97 indents: Vec<f32>,128 indents: Vec<f32>,
...@@ -114,6 +145,15 @@ impl TextOutline {...@@ -114,6 +145,15 @@ impl TextOutline {
114 .all(|byte| byte == b' ')145 .all(|byte| byte == b' ')
115 }146 }
116147
148 /// Tests an outline-local point against the provisional paragraph band.
149 pub fn contains_extension(&self, point: [f32; 2]) -> bool {
150 !self.title
151 && self.document.validate_flat().is_ok()
152 && (0.0..=self.shaped.size[0]).contains(&point[0])
153 && point[1] > self.shaped.size[1]
154 && point[1] <= self.shaped.size[1] + 27.0
155 }
156
117 pub fn new(157 pub fn new(
118 engine: &mut TextEngine,158 engine: &mut TextEngine,
119 document: TextDocument,159 document: TextDocument,
...@@ -124,19 +164,30 @@ impl TextOutline {...@@ -124,19 +164,30 @@ impl TextOutline {
124 return Err(EditError::InvalidRange.into());164 return Err(EditError::InvalidRange.into());
125 }165 }
126 let indents = vec![18.0, 0.0, 27.0, 27.0];166 let indents = vec![18.0, 0.0, 27.0, 27.0];
127 let shaped = visible_paragraphs(document.nodes())167 let fixed_width = document.nodes().len() != 1
128 .map(|paragraph| {168 || document.nodes()[0]
129 ParagraphLayout::shape(engine, paragraph, width, &indents, &BTreeMap::new())169 .text()
130 })170 .is_none_or(|text| !text.text.text().is_empty());
131 .collect::<Result<Vec<_>, _>>()?;171 let shaped = OutlineLayout::flow(
132 let shaped = OutlineLayout::new(shaped, width, true)?;172 document.nodes().iter(),
173 &indents,
174 width,
175 fixed_width,
176 0,
177 None,
178 &mut |paragraph, width, indents| {
179 ParagraphLayout::shape(engine, paragraph, width, indents, &BTreeMap::new())
180 },
181 )?;
133 Ok(Self {182 Ok(Self {
134 id: crate::text::new_id()?,183 id: crate::text::new_id()?,
184 title: false,
185 min_width: None,
135 layout: onestore::document::Layout {186 layout: onestore::document::Layout {
136 x: Some(position[0]),187 x: Some(position[0]),
137 y: Some(position[1]),188 y: Some(position[1]),
138 max_width: Some(width),189 max_width: Some(width),
139 width_set_by_user: Some(true),190 width_set_by_user: Some(fixed_width),
140 ..Default::default()191 ..Default::default()
141 },192 },
142 document,193 document,
...@@ -162,10 +213,37 @@ impl TextOutline {...@@ -162,10 +213,37 @@ impl TextOutline {
162 {213 {
163 return Err(EditError::InvalidRange.into());214 return Err(EditError::InvalidRange.into());
164 }215 }
165 let shaped = outline.layout(engine, definitions)?;216 if outline
217 .min_width
218 .is_some_and(|width| !width.is_finite() || width <= 0.0)
219 {
220 return Err(LayoutError::InvalidWidth.into());
221 }
222 let shaped = if outline.title {
223 if outline
224 .layout
225 .max_height
226 .is_some_and(|height| !height.is_finite() || height < 0.0)
227 {
228 return Err(LayoutError::InvalidSpacing.into());
229 }
230 outline.layout_with_width(
231 engine,
232 definitions,
233 outline
234 .layout
235 .reserved_width
236 .or(outline.layout.max_width)
237 .unwrap_or(crate::outline::TITLE_WIDTH),
238 )?
239 } else {
240 outline.layout(engine, definitions)?
241 };
166 let document = TextDocument::from_nodes(outline.paragraphs.clone())?;242 let document = TextDocument::from_nodes(outline.paragraphs.clone())?;
167 Ok(Self {243 Ok(Self {
168 id: outline.id,244 id: outline.id,
245 title: outline.title,
246 min_width: outline.min_width,
169 layout: outline.layout.clone(),247 layout: outline.layout.clone(),
170 indents: outline.indents.clone(),248 indents: outline.indents.clone(),
171 document,249 document,
...@@ -178,9 +256,11 @@ impl TextOutline {...@@ -178,9 +256,11 @@ impl TextOutline {
178 })256 })
179 }257 }
180258
181 fn snapshot(&self) -> Outline {259 pub fn snapshot(&self) -> Outline {
182 Outline {260 Outline {
183 id: self.id,261 id: self.id,
262 title: self.title,
263 min_width: self.min_width,
184 layout: self.layout.clone(),264 layout: self.layout.clone(),
185 indents: self.indents.clone(),265 indents: self.indents.clone(),
186 paragraphs: self.document.nodes().to_vec(),266 paragraphs: self.document.nodes().to_vec(),
...@@ -196,25 +276,68 @@ impl TextOutline {...@@ -196,25 +276,68 @@ impl TextOutline {
196 &self.layout276 &self.layout
197 }277 }
198278
279 pub fn shaped(&self) -> &OutlineLayout {
280 &self.shaped
281 }
282
199 pub fn origin(&self) -> [f32; 2] {283 pub fn origin(&self) -> [f32; 2] {
200 [self.layout.x.unwrap_or(0.0), self.layout.y.unwrap_or(0.0)]284 [self.layout.x.unwrap_or(0.0), self.layout.y.unwrap_or(0.0)]
201 }285 }
202286
287 pub fn wrap_width(&self) -> f32 {
288 self.layout
289 .reserved_width
290 .or(self.layout.max_width)
291 .unwrap_or(crate::outline::TITLE_WIDTH)
292 }
293
294 /// Native typing room expands the interactive bounds without changing the wrap constraint.
203 pub fn bounds(&self) -> BoundingBox {295 pub fn bounds(&self) -> BoundingBox {
204 let [x, y] = self.origin().map(f64::from);296 let [x, y] = self.origin().map(f64::from);
297 let width = if self.layout.width_set_by_user == Some(true) {
298 self.shaped.size[0]
299 } else {
300 let text_width = if self.shaped.tables.is_empty() {
301 Some(self.shaped.size[0])
302 } else {
303 let roots = self
304 .document
305 .nodes()
306 .iter()
307 .filter(|node| node.text().is_some())
308 .map(|node| node.id)
309 .collect::<BTreeSet<_>>();
310 self.shaped
311 .paragraphs
312 .iter()
313 .filter(|paragraph| roots.contains(&paragraph.id))
314 .map(|paragraph| paragraph.origin[0] + paragraph.text.shaped.width())
315 .reduce(f32::max)
316 };
317 text_width.map_or(self.shaped.size[0], |width| {
318 (width + 36.0)
319 .max(72.0)
320 .min(self.wrap_width())
321 .max(self.shaped.size[0])
322 })
323 }
324 .max(self.min_width.unwrap_or(0.0));
205 BoundingBox {325 BoundingBox {
206 x0: x,326 x0: x,
207 y0: y,327 y0: y,
208 x1: x + f64::from(self.shaped.size[0]),328 x1: x + f64::from(width),
209 y1: y + f64::from(self.shaped.size[1]),329 y1: y + f64::from(if self.title {
330 self.shaped.size[1].max(self.layout.max_height.unwrap_or(0.0))
331 } else {
332 self.shaped.size[1]
333 }),
210 }334 }
211 }335 }
212336
213 pub fn layouts(&self) -> impl Iterator<Item = (usize, &ParagraphLayout)> {337 pub fn layouts(&self) -> impl Iterator<Item = (usize, &ParagraphLayout)> {
214 let mut shaped = self.shaped.paragraphs.iter().peekable();338 let mut shaped = self.shaped.paragraphs.iter().peekable();
215 self.document339 self.document
216 .nodes()340 .text_nodes()
217 .iter()
218 .enumerate()341 .enumerate()
219 .filter_map(move |(index, node)| {342 .filter_map(move |(index, node)| {
220 if shaped343 if shaped
...@@ -231,8 +354,8 @@ impl TextOutline {...@@ -231,8 +354,8 @@ impl TextOutline {
231 fn visible_index(&self, source: usize) -> Result<usize, EditError> {354 fn visible_index(&self, source: usize) -> Result<usize, EditError> {
232 let id = self355 let id = self
233 .document356 .document
234 .nodes()357 .text_nodes()
235 .get(source)358 .nth(source)
236 .ok_or(EditError::InvalidRange)?359 .ok_or(EditError::InvalidRange)?
237 .id;360 .id;
238 self.shaped361 self.shaped
...@@ -245,8 +368,7 @@ impl TextOutline {...@@ -245,8 +368,7 @@ impl TextOutline {
245 fn source_index(&self, visible: usize) -> usize {368 fn source_index(&self, visible: usize) -> usize {
246 let id = self.shaped.paragraphs[visible].id;369 let id = self.shaped.paragraphs[visible].id;
247 self.document370 self.document
248 .nodes()371 .text_nodes()
249 .iter()
250 .position(|paragraph| paragraph.id == id)372 .position(|paragraph| paragraph.id == id)
251 .unwrap()373 .unwrap()
252 }374 }
...@@ -254,6 +376,160 @@ impl TextOutline {...@@ -254,6 +376,160 @@ impl TextOutline {
254 pub fn paragraph_layout(&self, source: usize) -> Result<&ParagraphLayout, EditError> {376 pub fn paragraph_layout(&self, source: usize) -> Result<&ParagraphLayout, EditError> {
255 Ok(&self.shaped.paragraphs[self.visible_index(source)?])377 Ok(&self.shaped.paragraphs[self.visible_index(source)?])
256 }378 }
379
380 fn column(&self, x: f32, current: ExGuid) -> Result<Vec<usize>, EditError> {
381 let paragraphs = self
382 .shaped
383 .paragraphs
384 .iter()
385 .enumerate()
386 .map(|(index, paragraph)| (paragraph.id, index))
387 .collect::<BTreeMap<_, _>>();
388 let tables = self
389 .shaped
390 .tables
391 .iter()
392 .map(|table| (table.id, table))
393 .collect::<BTreeMap<_, _>>();
394 let mut result = Vec::new();
395 let mut pending = vec![visible_paragraphs(self.document.nodes().iter())];
396 while let Some(nodes) = pending.last_mut() {
397 let Some(node) = nodes.next() else {
398 pending.pop();
399 continue;
400 };
401 match &node.content {
402 crate::page::ParagraphContent::Text(_) => {
403 result.push(
404 *paragraphs
405 .get(&node.id)
406 .ok_or(EditError::InvalidStructure)?,
407 );
408 }
409 crate::page::ParagraphContent::Table(table) => {
410 let layout = tables.get(&table.id).ok_or(EditError::InvalidStructure)?;
411 let cells = layout
412 .cells
413 .get(..table.columns.len())
414 .ok_or(EditError::InvalidStructure)?;
415 let (first, last) = cells
416 .first()
417 .zip(cells.last())
418 .ok_or(EditError::InvalidStructure)?;
419 let x = x.clamp(first.rect[0], last.rect[2]);
420 let column = cells
421 .iter()
422 .enumerate()
423 .min_by(|(_, a), (_, b)| {
424 let distance = |cell: &crate::outline::CellLayout| {
425 (f64::from(x) - f64::from(x.clamp(cell.rect[0], cell.rect[2])))
426 .abs()
427 };
428 distance(a).total_cmp(&distance(b))
429 })
430 .ok_or(EditError::InvalidStructure)?
431 .0;
432 for row in table.rows.iter().rev() {
433 let cell = row
434 .cells
435 .iter()
436 .find(|cell| {
437 leaves(&cell.paragraphs, None)
438 .any(|(_, _, node)| node.id == current)
439 })
440 .unwrap_or(&row.cells[column]);
441 pending.push(visible_paragraphs(cell.paragraphs.iter()));
442 }
443 }
444 crate::page::ParagraphContent::Unsupported(_) => {
445 return Err(EditError::UnsupportedContent);
446 }
447 }
448 }
449 Ok(result)
450 }
451
452 fn paragraph_at(&self, x: f32, y: f32) -> Result<usize, EditError> {
453 if self.shaped.tables.is_empty() {
454 return Ok(self
455 .shaped
456 .paragraphs
457 .partition_point(|paragraph| paragraph.origin[1] <= y)
458 .saturating_sub(1));
459 }
460 let paragraphs = self
461 .shaped
462 .paragraphs
463 .iter()
464 .enumerate()
465 .map(|(index, paragraph)| (paragraph.id, (index, paragraph)))
466 .collect::<BTreeMap<_, _>>();
467 let tables = self
468 .shaped
469 .tables
470 .iter()
471 .map(|table| (table.id, table))
472 .collect::<BTreeMap<_, _>>();
473 let mut nodes = self.document.nodes();
474 loop {
475 let mut target = None;
476 for node in visible_paragraphs(nodes.iter()) {
477 let top = match &node.content {
478 crate::page::ParagraphContent::Text(_) => {
479 paragraphs
480 .get(&node.id)
481 .ok_or(EditError::InvalidStructure)?
482 .1
483 .origin[1]
484 }
485 crate::page::ParagraphContent::Table(table) => {
486 tables
487 .get(&table.id)
488 .and_then(|layout| layout.cells.first())
489 .ok_or(EditError::InvalidStructure)?
490 .rect[1]
491 - 1.86
492 }
493 crate::page::ParagraphContent::Unsupported(_) => {
494 return Err(EditError::UnsupportedContent);
495 }
496 };
497 if target.is_none() || top <= y {
498 target = Some(node);
499 }
500 if top > y {
501 break;
502 }
503 }
504 let target = target.ok_or(EditError::InvalidStructure)?;
505 let crate::page::ParagraphContent::Table(table) = &target.content else {
506 return paragraphs
507 .get(&target.id)
508 .map(|(index, _)| *index)
509 .ok_or(EditError::InvalidStructure);
510 };
511 let layout = tables.get(&table.id).ok_or(EditError::InvalidStructure)?;
512 let (first, last) = layout
513 .cells
514 .first()
515 .zip(layout.cells.last())
516 .ok_or(EditError::InvalidStructure)?;
517 let x = x.clamp(first.rect[0], last.rect[2]);
518 let y = y.clamp(first.rect[1], last.rect[3]);
519 let cell = layout
520 .cells
521 .iter()
522 .map(|cell| {
523 let dx = f64::from(x) - f64::from(x.clamp(cell.rect[0], cell.rect[2]));
524 let dy = f64::from(y) - f64::from(y.clamp(cell.rect[1], cell.rect[3]));
525 (cell, dy.abs(), dx.abs())
526 })
527 .min_by(|a, b| a.1.total_cmp(&b.1).then(a.2.total_cmp(&b.2)))
528 .ok_or(EditError::InvalidStructure)?
529 .0;
530 nodes = self.document.container(Some(cell.id))?;
531 }
532 }
257}533}
258534
259struct Composition {535struct Composition {
...@@ -262,6 +538,17 @@ struct Composition {...@@ -262,6 +538,17 @@ struct Composition {
262}538}
263539
264impl ParagraphLayout {540impl ParagraphLayout {
541 fn reset_origin(&mut self, x: f32) {
542 let offset = x - self.origin[0];
543 self.origin = [x, 0.0];
544 for (_, origin) in &mut self.markers {
545 origin[0] += offset;
546 }
547 for tag in &mut self.tags {
548 tag.origin[0] += offset;
549 }
550 }
551
265 fn cursor(&self, source: u32, affinity: Affinity) -> Result<Cursor, EditError> {552 fn cursor(&self, source: u32, affinity: Affinity) -> Result<Cursor, EditError> {
266 let visible = self.projection.visible_offset(source)?;553 let visible = self.projection.visible_offset(source)?;
267 let byte = self.projection.text().byte_offset(visible)?;554 let byte = self.projection.text().byte_offset(visible)?;
...@@ -273,6 +560,13 @@ impl ParagraphLayout {...@@ -273,6 +560,13 @@ impl ParagraphLayout {
273struct TextChange {560struct TextChange {
274 edit: DocumentEdit,561 edit: DocumentEdit,
275 selection: Selection,562 selection: Selection,
563 positions: Vec<Placement>,
564}
565
566#[derive(Clone)]
567struct Placement {
568 id: ExGuid,
569 position: [Option<f32>; 2],
276}570}
277571
278enum RestoreFocus {572enum RestoreFocus {
...@@ -285,14 +579,26 @@ enum RestoreFocus {...@@ -285,14 +579,26 @@ enum RestoreFocus {
285}579}
286580
287enum History {581enum History {
582 Date(Box<PageDate>),
583 Draft {
584 outlines: Box<[TextOutline; 2]>,
585 index: usize,
586 caret: Box<Outline>,
587 selection: Selection,
588 restore_caret: bool,
589 },
288 Text {590 Text {
289 outline: onestore::ExGuid,591 outline: onestore::ExGuid,
290 change: TextChange,592 change: Box<TextChange>,
291 },593 },
292 Position {594 Position {
293 outline: onestore::ExGuid,595 outline: onestore::ExGuid,
294 position: [Option<f32>; 2],596 position: [Option<f32>; 2],
295 },597 },
598 Layout {
599 outline: ExGuid,
600 layout: onestore::document::Layout,
601 },
296 Remove {602 Remove {
297 outline: onestore::ExGuid,603 outline: onestore::ExGuid,
298 focus: RestoreFocus,604 focus: RestoreFocus,
...@@ -331,6 +637,8 @@ impl CanvasEditor {...@@ -331,6 +637,8 @@ impl CanvasEditor {
331 Ok(Self {637 Ok(Self {
332 outlines,638 outlines,
333 definitions: BTreeMap::new(),639 definitions: BTreeMap::new(),
640 objects: Vec::new(),
641 date: None,
334 active,642 active,
335 undo: Vec::new(),643 undo: Vec::new(),
336 redo: Vec::new(),644 redo: Vec::new(),
...@@ -339,6 +647,212 @@ impl CanvasEditor {...@@ -339,6 +647,212 @@ impl CanvasEditor {
339 })647 })
340 }648 }
341649
650 pub fn from_page(
651 mut page: crate::page::Page,
652 engine: &mut TextEngine,
653 ) -> Result<Self, EditorError> {
654 let (objects, mut outlines, date) = page::build(&mut page, engine, true)?;
655 let needs_caret = outlines.is_empty();
656 if needs_caret {
657 let x = objects
658 .iter()
659 .filter_map(|object| match object {
660 page::Content::ReadOnly(object) => Some(object.rect()[2]),
661 _ => None,
662 })
663 .fold(0.0_f32, f32::max);
664 outlines.push(TextOutline::new(
665 engine,
666 TextDocument::new(vec![Paragraph::new(String::new(), Default::default())])?,
667 240.0,
668 [if x > 0.0 { x + 24.0 } else { 0.0 }, 0.0],
669 )?);
670 }
671 let mut editor = Self::from_text_outlines(outlines, page.definitions, date)?;
672 if needs_caret {
673 editor.active = Focus::Caret {
674 outline: Box::new(editor.outlines.pop().unwrap()),
675 index: 0,
676 };
677 }
678 editor.objects = objects;
679 let mut ids = BTreeSet::new();
680 if !editor.object_layouts().all(|(id, _)| ids.insert(id)) {
681 return Err(EditError::InvalidStructure.into());
682 }
683 Ok(editor)
684 }
685
686 /// Whether this outline occupies a slot in the imported page's paint order.
687 pub fn has_page_outline(&self, id: ExGuid) -> bool {
688 self.objects
689 .iter()
690 .any(|object| matches!(object, page::Content::Editable(candidate) if *candidate == id))
691 }
692
693 pub fn object_layouts(&self) -> impl Iterator<Item = (ExGuid, &onestore::document::Layout)> {
694 self.outlines
695 .iter()
696 .map(|outline| (outline.id, &outline.layout))
697 .chain(
698 self.date
699 .iter()
700 .map(|date| (date.source().id, &date.source().layout)),
701 )
702 .chain(self.objects.iter().filter_map(page::Content::layout))
703 }
704
705 fn object_layout_mut(&mut self, id: ExGuid) -> Option<&mut onestore::document::Layout> {
706 self.outlines
707 .iter_mut()
708 .map(|outline| (outline.id, &mut outline.layout))
709 .chain(
710 self.objects
711 .iter_mut()
712 .filter_map(page::Content::layout_mut),
713 )
714 .find_map(|(candidate, layout)| (candidate == id).then_some(layout))
715 }
716
717 fn header_bottom(&self, title: ExGuid, text_bottom: f32) -> f32 {
718 self.objects
719 .iter()
720 .filter_map(|object| match object {
721 page::Content::Outline {
722 source,
723 layout,
724 below_title,
725 } if *below_title == Some(title) => {
726 Some(text_bottom + source.layout.y.unwrap_or(0.0) + layout.size[1])
727 }
728 page::Content::Date { below_title } if *below_title == Some(title) => {
729 self.date.as_ref().map(|date| {
730 text_bottom + date.source().layout.y.unwrap_or(0.0) + date.layout().size[1]
731 })
732 }
733 _ => None,
734 })
735 .fold(text_bottom, f32::max)
736 }
737
738 pub fn leave_title(&mut self, engine: &mut TextEngine) -> Result<(), EditorError> {
739 let title = self.active_outline();
740 if !title.title {
741 return Ok(());
742 }
743 let bottom = self.header_bottom(title.id, title.bounds().y1 as f32);
744 let position = [
745 title.origin()[0],
746 ((((f64::from(bottom) - 14.4) / 18.0).ceil() + 1.0) * 18.0 + 14.4) as f32,
747 ];
748 if let Some((id, origin)) = self
749 .outlines()
750 .iter()
751 .rev()
752 .find(|outline| {
753 let bounds = outline.bounds();
754 let [x, y] = position.map(f64::from);
755 !outline.title
756 && x >= bounds.x0
757 && x <= bounds.x1
758 && y >= bounds.y0
759 && y <= bounds.y1
760 })
761 .map(|outline| (outline.id, outline.origin()))
762 {
763 self.focus_outline(id)?;
764 self.select_at(position[0] - origin[0], position[1] - origin[1], false)?;
765 } else {
766 self.place_caret(engine, position, DEFAULT_OUTLINE_WIDTH)?;
767 }
768 Ok(())
769 }
770
771 fn title_flow(&self, height: f32) -> Result<Vec<Placement>, EditorError> {
772 let title = self.active_outline();
773 if !title.title {
774 return Ok(Vec::new());
775 }
776 // Native flow uses text height even when the stored title bounds are taller.
777 let old = self.header_bottom(title.id, title.origin()[1] + title.shaped.size[1]);
778 let new = self.header_bottom(title.id, title.origin()[1] + height);
779 if !old.is_finite() || !new.is_finite() {
780 return Err(EditorError::InvalidGeometry);
781 }
782 if new <= old {
783 return Ok(Vec::new());
784 }
785 let left = title.origin()[0] - 36.0;
786 let right = title.origin()[0] + title.wrap_width() + 36.0;
787 let destination = new + 11.52;
788 let bodies = || {
789 self.outlines
790 .iter()
791 .filter(|outline| !outline.title)
792 .map(|outline| {
793 let rect = outline.bounds();
794 (
795 outline.id,
796 &outline.layout,
797 [
798 rect.x0 as f32,
799 rect.y0 as f32,
800 rect.x1 as f32,
801 rect.y1 as f32,
802 ],
803 )
804 })
805 .chain(self.objects.iter().filter_map(|object| match object {
806 page::Content::Image(image) if !image.background => {
807 let x = image.layout.x.unwrap_or(0.0);
808 let y = image.layout.y.unwrap_or(0.0);
809 Some((
810 image.id,
811 &image.layout,
812 [
813 x,
814 y,
815 x + image.layout.max_width?,
816 y + image.layout.max_height?,
817 ],
818 ))
819 }
820 page::Content::ReadOnly(object)
821 if !matches!(object.source, crate::page::PageObject::Title(_)) =>
822 {
823 Some((object.source.id(), object.source.layout(), object.rect()))
824 }
825 _ => None,
826 }))
827 };
828 let Some(first) = bodies()
829 .filter(|(_, _, rect)| {
830 rect[1] >= old + 10.8
831 && rect[1] < destination
832 && rect[0] <= right
833 && rect[2] >= left
834 })
835 .map(|(_, _, rect)| rect[1])
836 .min_by(f32::total_cmp)
837 else {
838 return Ok(Vec::new());
839 };
840 let delta = destination - first;
841 bodies()
842 .filter(|(_, _, rect)| rect[1] >= first)
843 .map(|(id, layout, rect)| {
844 let y = rect[1] + delta;
845 if !y.is_finite() || !(rect[3] + delta).is_finite() {
846 return Err(EditorError::InvalidGeometry);
847 }
848 Ok(Placement {
849 id,
850 position: [layout.x, Some(y)],
851 })
852 })
853 .collect()
854 }
855
342 pub fn from_outlines(856 pub fn from_outlines(
343 engine: &mut TextEngine,857 engine: &mut TextEngine,
344 outlines: Vec<Outline>,858 outlines: Vec<Outline>,
...@@ -348,33 +862,36 @@ impl CanvasEditor {...@@ -348,33 +862,36 @@ impl CanvasEditor {
348 .iter()862 .iter()
349 .map(|outline| TextOutline::from_outline(engine, outline, &definitions))863 .map(|outline| TextOutline::from_outline(engine, outline, &definitions))
350 .collect::<Result<_, _>>()?;864 .collect::<Result<_, _>>()?;
351 Self::from_text_outlines(outlines, definitions)865 Self::from_text_outlines(outlines, definitions, None)
352 }866 }
353867
354 pub fn from_text_outlines(868 pub fn from_text_outlines(
355 outlines: Vec<TextOutline>,869 outlines: Vec<TextOutline>,
356 definitions: BTreeMap<ExGuid, Definition>,870 definitions: BTreeMap<ExGuid, Definition>,
871 date: Option<PageDate>,
357 ) -> Result<Self, EditorError> {872 ) -> Result<Self, EditorError> {
358 if outlines.is_empty() {873 if outlines.is_empty() {
359 return Err(EditError::InvalidRange.into());874 return Err(EditError::InvalidRange.into());
360 }875 }
361 let mut ids = BTreeSet::new();876 let mut ids = BTreeSet::new();
362 for outline in &outlines {877 for (outline_id, paragraphs) in outlines
363 for id in878 .iter()
364 std::iter::once(outline.id).chain(879 .map(|outline| (outline.id, outline.document.nodes()))
365 outline.document.nodes().iter().flat_map(|p| {880 .chain(
366 std::iter::once(p.id).chain(p.text.iter().map(|text| text.id))881 date.iter()
367 }),882 .map(|date| (date.source().id, date.source().paragraphs.as_slice())),
368 )883 )
369 {884 {
370 if !ids.insert(id) {885 if !ids.insert(outline_id) {
371 return Err(EditError::InvalidStructure.into());886 return Err(EditError::InvalidStructure.into());
372 }
373 }887 }
888 crate::document::validate_nodes(paragraphs, None, &mut ids)?;
374 }889 }
375 Ok(Self {890 Ok(Self {
376 outlines,891 outlines,
377 definitions,892 definitions,
893 objects: Vec::new(),
894 date,
378 active: Focus::Outline(0),895 active: Focus::Outline(0),
379 undo: Vec::new(),896 undo: Vec::new(),
380 redo: Vec::new(),897 redo: Vec::new(),
...@@ -383,20 +900,62 @@ impl CanvasEditor {...@@ -383,20 +900,62 @@ impl CanvasEditor {
383 })900 })
384 }901 }
385902
903 pub fn date(&self) -> Option<&PageDate> {
904 self.date.as_ref()
905 }
906
907 pub fn change_date(
908 &mut self,
909 engine: &mut TextEngine,
910 timestamp: u64,
911 mut text: [String; 2],
912 ) -> Result<bool, EditorError> {
913 let date = self.date.as_ref().ok_or(EditError::InvalidRange)?;
914 if timestamp == date.timestamp() {
915 return Ok(false);
916 }
917 let mut source = date.source().clone();
918 for ((field, _), paragraph) in date.fields().zip(&mut source.paragraphs) {
919 let format = paragraph.text().unwrap().text.format_at(0)?.clone();
920 paragraph.text_mut().unwrap().text =
921 Paragraph::new(std::mem::take(&mut text[field as usize]), format);
922 }
923 let updated = PageDate::new(timestamp, source, engine, &self.definitions)?;
924 self.finish_composition();
925 self.undo
926 .push(History::Date(Box::new(self.date.replace(updated).unwrap())));
927 self.redo.clear();
928 Ok(true)
929 }
930
931 /// Stored content; arrow-created paragraphs appear only in `visible_outlines`.
386 pub fn outlines(&self) -> &[TextOutline] {932 pub fn outlines(&self) -> &[TextOutline] {
387 &self.outlines933 &self.outlines
388 }934 }
935 pub fn visible_outlines(&self) -> impl DoubleEndedIterator<Item = &TextOutline> {
936 self.outlines
937 .iter()
938 .enumerate()
939 .map(|(index, outline)| match &self.active {
940 Focus::Draft {
941 index: active,
942 outline,
943 } if index == *active => outline.as_ref(),
944 _ => outline,
945 })
946 }
947
389 pub fn active_outline(&self) -> &TextOutline {948 pub fn active_outline(&self) -> &TextOutline {
390 match &self.active {949 match &self.active {
391 Focus::Outline(index) => &self.outlines[*index],950 Focus::Outline(index) => &self.outlines[*index],
392 Focus::Caret { outline, .. } => outline,951 Focus::Caret { outline, .. } | Focus::Draft { outline, .. } => outline,
393 }952 }
394 }953 }
395954
396 fn active_outline_mut(&mut self) -> &mut TextOutline {955 fn active_outline_mut(&mut self) -> &mut TextOutline {
397 match &mut self.active {956 match &mut self.active {
398 Focus::Outline(index) => &mut self.outlines[*index],957 Focus::Outline(index) => &mut self.outlines[*index],
399 Focus::Caret { outline, .. } => outline,958 Focus::Caret { outline, .. } | Focus::Draft { outline, .. } => outline,
400 }959 }
401 }960 }
402961
...@@ -404,7 +963,7 @@ impl CanvasEditor {...@@ -404,7 +963,7 @@ impl CanvasEditor {
404 pub fn caret_outline(&self) -> Option<&TextOutline> {963 pub fn caret_outline(&self) -> Option<&TextOutline> {
405 match &self.active {964 match &self.active {
406 Focus::Caret { outline, .. } => Some(outline),965 Focus::Caret { outline, .. } => Some(outline),
407 Focus::Outline(_) => None,966 Focus::Outline(_) | Focus::Draft { .. } => None,
408 }967 }
409 }968 }
410969
...@@ -425,12 +984,105 @@ impl CanvasEditor {...@@ -425,12 +984,105 @@ impl CanvasEditor {
425 Ok(())984 Ok(())
426 }985 }
427986
987 fn blank_paragraph(&self, base: &PageParagraph) -> Result<PageParagraph, EditError> {
988 let format = match base.style {
989 Some(id) => self
990 .definitions
991 .get(&id)
992 .filter(|definition| {
993 matches!(definition.kind, onestore::document::Kind::Style { .. })
994 })
995 .ok_or(EditError::InvalidStructure)?
996 .format
997 .clone(),
998 None => Default::default(),
999 };
1000 let mut node =
1001 crate::document::node(Paragraph::new(String::new(), format.clone()), format)?;
1002 node.level = base.level;
1003 node.style = base.style;
1004 Ok(node)
1005 }
1006
1007 pub fn select_below(
1008 &mut self,
1009 engine: &mut TextEngine,
1010 id: ExGuid,
1011 point: [f32; 2],
1012 ) -> Result<bool, EditorError> {
1013 if point.iter().any(|coordinate| !coordinate.is_finite()) {
1014 return Err(EditError::InvalidRange.into());
1015 }
1016 self.finish_composition();
1017 let Some(index) = self.outlines.iter().position(|outline| outline.id == id) else {
1018 return Ok(false);
1019 };
1020 let outline = &self.outlines[index];
1021 if !outline.contains_extension(point) {
1022 return Ok(false);
1023 }
1024 let draft = Box::new(match &self.active {
1025 Focus::Draft {
1026 outline,
1027 index: active,
1028 } if *active == index => (**outline).clone(),
1029 _ => outline.clone(),
1030 });
1031 let previous = std::mem::replace(
1032 &mut self.active,
1033 Focus::Draft {
1034 outline: draft,
1035 index,
1036 },
1037 );
1038 let preferred_x = self.preferred_x;
1039 let result = (|| {
1040 // Tiny source font sizes must not make one click generate unbounded paragraphs.
1041 for _ in 0..256 {
1042 if self.active_outline().shaped.size[1] >= point[1] {
1043 break;
1044 }
1045 let outline = self.active_outline();
1046 let count = outline.document.nodes().len();
1047 let height = outline.shaped.size[1];
1048 let node = self.blank_paragraph(&outline.document.nodes()[count - 1])?;
1049 self.apply(
1050 engine,
1051 DocumentEdit {
1052 columns: BTreeMap::new(),
1053 container: None,
1054 range: count..count,
1055 replacement: vec![node],
1056 },
1057 outline.selection,
1058 false,
1059 None,
1060 )?;
1061 if self.active_outline().shaped.size[1] <= height {
1062 return Err(EditError::InvalidStructure.into());
1063 }
1064 }
1065 if self.active_outline().shaped.size[1] < point[1] {
1066 return Err(EditError::TextTooLong.into());
1067 }
1068 self.select_at(point[0], point[1], false)?;
1069 Ok(true)
1070 })();
1071 if result.is_err() {
1072 self.active = previous;
1073 self.preferred_x = preferred_x;
1074 }
1075 result
1076 }
1077
428 pub fn focus_outline(&mut self, id: onestore::ExGuid) -> Result<(), EditError> {1078 pub fn focus_outline(&mut self, id: onestore::ExGuid) -> Result<(), EditError> {
429 if self.active_outline().id != id && !self.outlines.iter().any(|outline| outline.id == id) {1079 if self.active_outline().id != id && !self.outlines.iter().any(|outline| outline.id == id) {
430 return Err(EditError::InvalidRange);1080 return Err(EditError::InvalidRange);
431 }1081 }
432 self.finish_composition();1082 self.finish_composition();
433 if let Some(index) = self.outlines.iter().position(|outline| outline.id == id) {1083 if let Some(index) = self.outlines.iter().position(|outline| outline.id == id)
1084 && !matches!(self.active, Focus::Draft { index: active, .. } if active == index)
1085 {
434 self.active = Focus::Outline(index);1086 self.active = Focus::Outline(index);
435 }1087 }
436 self.preferred_x = None;1088 self.preferred_x = None;
...@@ -453,7 +1105,9 @@ impl CanvasEditor {...@@ -453,7 +1105,9 @@ impl CanvasEditor {
453 self.undo.push(History::Remove {1105 self.undo.push(History::Remove {
454 outline: id,1106 outline: id,
455 focus: match &self.active {1107 focus: match &self.active {
456 Focus::Outline(index) => RestoreFocus::Outline(self.outlines[*index].id),1108 Focus::Outline(index) | Focus::Draft { index, .. } => {
1109 RestoreFocus::Outline(self.outlines[*index].id)
1110 }
457 Focus::Caret { outline, index } => RestoreFocus::Caret {1111 Focus::Caret { outline, index } => RestoreFocus::Caret {
458 source: Box::new(outline.snapshot()),1112 source: Box::new(outline.snapshot()),
459 selection: outline.selection,1113 selection: outline.selection,
...@@ -484,6 +1138,9 @@ impl CanvasEditor {...@@ -484,6 +1138,9 @@ impl CanvasEditor {
484 .find(|outline| outline.id == id)1138 .find(|outline| outline.id == id)
485 .ok_or(EditError::InvalidRange)?1139 .ok_or(EditError::InvalidRange)?
486 };1140 };
1141 if outline.title {
1142 return Err(EditError::UnsupportedContent);
1143 }
487 if position == outline.origin() {1144 if position == outline.origin() {
488 return Ok(());1145 return Ok(());
489 }1146 }
...@@ -524,27 +1181,68 @@ impl CanvasEditor {...@@ -524,27 +1181,68 @@ impl CanvasEditor {
524 {1181 {
525 return Ok(());1182 return Ok(());
526 }1183 }
527 let paragraphs = visible_paragraphs(outline.document.nodes())1184 let previous = outline.layout.clone();
528 .map(|paragraph| {1185 let resized = self.preview_resize(engine, width)?;
529 ParagraphLayout::shape(1186 self.finish_composition();
530 engine,1187 if let Focus::Draft { index, .. } = self.active {
531 paragraph,1188 self.active = Focus::Outline(index);
532 width,1189 }
533 &outline.indents,1190 let id = resized.id;
534 &self.definitions,1191 *self.active_outline_mut() = resized;
535 )1192 if self.caret_outline().is_none() {
536 })1193 self.undo.push(History::Layout {
537 .collect::<Result<_, _>>()?;1194 outline: id,
538 let shaped = OutlineLayout::new(paragraphs, width, true)?;1195 layout: previous,
539 let outline = self.active_outline_mut();1196 });
540 outline.layout.max_width = Some(width);1197 self.redo.clear();
541 outline.layout.reserved_width = None;1198 }
542 outline.layout.width_set_by_user = Some(true);
543 outline.shaped = shaped;
544 self.preferred_x = None;1199 self.preferred_x = None;
545 Ok(())1200 Ok(())
546 }1201 }
5471202
1203 pub fn preview_resize(
1204 &self,
1205 engine: &mut TextEngine,
1206 width: f32,
1207 ) -> Result<TextOutline, EditorError> {
1208 let outline = match &self.active {
1209 Focus::Draft { index, .. } => &self.outlines[*index],
1210 _ => self.active_outline(),
1211 };
1212 if outline.title {
1213 return Err(EditError::UnsupportedContent.into());
1214 }
1215 if !width.is_finite() || width <= 0.0 {
1216 return Err(LayoutError::InvalidWidth.into());
1217 }
1218 let width = width.max(outline.shaped.table_width());
1219 let shaped = OutlineLayout::flow(
1220 outline.document.nodes().iter(),
1221 &outline.indents,
1222 width,
1223 true,
1224 0,
1225 None,
1226 &mut |paragraph, width, indents| {
1227 ParagraphLayout::shape(engine, paragraph, width, indents, &self.definitions)
1228 },
1229 )?;
1230 let mut layout = outline.layout.clone();
1231 layout.max_width = Some(width);
1232 layout.reserved_width = None;
1233 layout.width_set_by_user = Some(true);
1234 Ok(TextOutline {
1235 id: outline.id,
1236 title: outline.title,
1237 min_width: outline.min_width,
1238 layout,
1239 document: outline.document.clone(),
1240 indents: outline.indents.clone(),
1241 shaped,
1242 selection: outline.selection,
1243 })
1244 }
1245
548 pub fn select(&mut self, selection: Selection) -> Result<(), EditError> {1246 pub fn select(&mut self, selection: Selection) -> Result<(), EditError> {
549 for position in selection.positions {1247 for position in selection.positions {
550 self.active_outline().visible_index(position.paragraph)?;1248 self.active_outline().visible_index(position.paragraph)?;
...@@ -587,35 +1285,71 @@ impl CanvasEditor {...@@ -587,35 +1285,71 @@ impl CanvasEditor {
587 }1285 }
5881286
589 pub fn select_at(&mut self, x: f32, y: f32, extend: bool) -> Result<(), EditError> {1287 pub fn select_at(&mut self, x: f32, y: f32, extend: bool) -> Result<(), EditError> {
1288 let mut selection = self.selection_at(x, y, SelectionUnit::Grapheme)?;
1289 if extend {
1290 selection.positions[0] = self.selection().positions[0];
1291 selection.affinities[0] = self.selection().affinities[0];
1292 }
1293 self.select(selection)
1294 }
1295
1296 pub fn selection_at(
1297 &self,
1298 x: f32,
1299 y: f32,
1300 unit: SelectionUnit,
1301 ) -> Result<Selection, EditError> {
590 if !x.is_finite() || !y.is_finite() {1302 if !x.is_finite() || !y.is_finite() {
591 return Err(EditError::InvalidRange);1303 return Err(EditError::InvalidRange);
592 }1304 }
593 let index = self1305 let index = self.active_outline().paragraph_at(x, y)?;
594 .active_outline()
595 .shaped
596 .paragraphs
597 .partition_point(|paragraph| paragraph.origin[1] <= y)
598 .saturating_sub(1);
599 let paragraph = &self.active_outline().shaped.paragraphs[index];1306 let paragraph = &self.active_outline().shaped.paragraphs[index];
600 let cursor = paragraph1307 let layout = &paragraph.text.shaped;
601 .text1308 let point = [x - paragraph.origin[0], y - paragraph.origin[1]];
602 .hit_test(x - paragraph.origin[0], y - paragraph.origin[1]);1309 let cursor = paragraph.text.hit_test(point[0], point[1]);
603 let visible = paragraph.projection.text().utf16_offset(cursor.index())?;1310 let local = match unit {
604 let position = TextPosition {1311 SelectionUnit::Grapheme => cursor.into(),
605 paragraph: self.active_outline().source_index(index),1312 SelectionUnit::Word => {
606 offset: paragraph1313 let rect = cursor.geometry(layout, 1.0);
607 .projection1314 ParagraphSelection::word_from_point(
608 .source_offset(visible, cursor.affinity())?,1315 layout,
609 };1316 point[0],
610 self.finish_composition();1317 ((rect.y0 + rect.y1) * 0.5) as f32,
611 self.preferred_x = None;1318 )
612 if !extend {1319 }
613 self.active_outline_mut().selection.positions[0] = position;1320 SelectionUnit::Paragraph => ParagraphSelection::new(
614 self.active_outline_mut().selection.affinities[0] = cursor.affinity();1321 Cursor::from_byte_index(layout, 0, Affinity::Downstream),
1322 Cursor::from_byte_index(layout, usize::MAX, Affinity::Upstream),
1323 ),
1324 };
1325 let mut selection = self.selection();
1326 for (slot, cursor) in [local.anchor(), local.focus()].into_iter().enumerate() {
1327 let visible = paragraph.projection.text().utf16_offset(cursor.index())?;
1328 selection.positions[slot] = TextPosition {
1329 paragraph: self.active_outline().source_index(index),
1330 offset: paragraph
1331 .projection
1332 .source_offset(visible, cursor.affinity())?,
1333 };
1334 selection.affinities[slot] = cursor.affinity();
615 }1335 }
616 self.active_outline_mut().selection.positions[1] = position;1336 if unit == SelectionUnit::Paragraph
617 self.active_outline_mut().selection.affinities[1] = cursor.affinity();1337 && index + 1 < self.active_outline().shaped.paragraphs.len()
618 Ok(())1338 {
1339 let document = &self.active_outline().document;
1340 let source = selection.positions[0].paragraph;
1341 let next = self.active_outline().source_index(index + 1);
1342 let (container, local, _) = leaves(document.nodes(), None).nth(source).unwrap();
1343 let (next_container, next_local, _) = leaves(document.nodes(), None).nth(next).unwrap();
1344 if container == next_container && next_local == local + 1 {
1345 selection.positions[1] = TextPosition {
1346 paragraph: next,
1347 offset: 0,
1348 };
1349 selection.affinities[1] = Affinity::Downstream;
1350 }
1351 }
1352 Ok(selection)
619 }1353 }
6201354
621 pub fn caret(&self, width: f32) -> Result<BoundingBox, EditError> {1355 pub fn caret(&self, width: f32) -> Result<BoundingBox, EditError> {
...@@ -631,9 +1365,118 @@ impl CanvasEditor {...@@ -631,9 +1365,118 @@ impl CanvasEditor {
631 Ok(rect)1365 Ok(rect)
632 }1366 }
6331367
634 pub fn move_selection(&mut self, movement: Movement, extend: bool) -> Result<(), EditError> {1368 pub fn move_selection(
1369 &mut self,
1370 engine: &mut TextEngine,
1371 movement: Movement,
1372 extend: bool,
1373 ) -> Result<(), EditorError> {
635 self.finish_composition();1374 self.finish_composition();
636 let [anchor, focus] = self.active_outline().selection.positions;1375 let [anchor, focus] = self.active_outline().selection.positions;
1376 if !extend
1377 && self.caret_outline().is_some()
1378 && self
1379 .active_outline()
1380 .document
1381 .paragraphs()
1382 .next()
1383 .unwrap()
1384 .text()
1385 .is_empty()
1386 && let Some(step) = match movement {
1387 Movement::Left => Some([-18.0, 0.0]),
1388 Movement::Right => Some([18.0, 0.0]),
1389 Movement::Up => Some([0.0, -18.0]),
1390 Movement::Down => Some([0.0, 18.0]),
1391 _ => None,
1392 }
1393 {
1394 let origin = self.active_outline().origin();
1395 let position = [origin[0] + step[0], origin[1] + step[1]];
1396 let center_y = position[1] + self.caret(1.0)?.height() as f32 * 0.5;
1397 let target = self
1398 .outlines
1399 .iter()
1400 .rev()
1401 .find(|outline| {
1402 let rect = outline.bounds();
1403 let bottom = rect.y1
1404 + if matches!(movement, Movement::Up) {
1405 36.0
1406 } else {
1407 0.0
1408 };
1409 (rect.x0..=rect.x1).contains(&f64::from(position[0]))
1410 && (rect.y0..=bottom).contains(&f64::from(center_y))
1411 })
1412 .map(|outline| (outline.id, outline.origin()));
1413 if let Some((id, origin)) = target {
1414 let x = position[0]
1415 + if step[0] == 0.0 {
1416 self.preferred_x.unwrap_or(0.0)
1417 } else {
1418 0.0
1419 }
1420 - origin[0];
1421 self.focus_outline(id)?;
1422 self.select_at(x, center_y - origin[1], false)?;
1423 self.preferred_x = (step[0] == 0.0).then_some(x);
1424 } else {
1425 let outline = self.active_outline_mut();
1426 outline.layout.x = Some(position[0]);
1427 outline.layout.y = Some(position[1]);
1428 if step[0] != 0.0 {
1429 self.preferred_x = None;
1430 }
1431 }
1432 return Ok(());
1433 }
1434 if matches!(
1435 movement,
1436 Movement::ParagraphStart
1437 | Movement::ParagraphEnd
1438 | Movement::DocumentStart
1439 | Movement::DocumentEnd
1440 ) {
1441 let outline = self.active_outline();
1442 let current = outline.visible_index(focus.paragraph)?;
1443 let end = matches!(movement, Movement::ParagraphEnd | Movement::DocumentEnd);
1444 let current_end = outline.document.paragraphs().nth(focus.paragraph).unwrap();
1445 let current_end = current_end.utf16_offset(current_end.text().len())?;
1446 let index = match movement {
1447 Movement::DocumentStart => 0,
1448 Movement::DocumentEnd => outline.shaped.paragraphs.len() - 1,
1449 Movement::ParagraphStart if focus.offset == 0 => current.saturating_sub(1),
1450 Movement::ParagraphEnd if focus.offset == current_end => {
1451 (current + 1).min(outline.shaped.paragraphs.len() - 1)
1452 }
1453 _ => current,
1454 };
1455 let source = outline.source_index(index);
1456 let paragraph = outline.document.paragraphs().nth(source).unwrap();
1457 let position = TextPosition {
1458 paragraph: source,
1459 offset: if end {
1460 paragraph.utf16_offset(paragraph.text().len())?
1461 } else {
1462 0
1463 },
1464 };
1465 let affinity = if end {
1466 Affinity::Upstream
1467 } else {
1468 Affinity::Downstream
1469 };
1470 let selection = &mut self.active_outline_mut().selection;
1471 if !extend {
1472 selection.positions[0] = position;
1473 selection.affinities[0] = affinity;
1474 }
1475 selection.positions[1] = position;
1476 selection.affinities[1] = affinity;
1477 self.preferred_x = None;
1478 return Ok(());
1479 }
637 let horizontal = matches!(1480 let horizontal = matches!(
638 movement,1481 movement,
639 Movement::Left | Movement::Right | Movement::WordLeft | Movement::WordRight1482 Movement::Left | Movement::Right | Movement::WordLeft | Movement::WordRight
...@@ -663,31 +1506,167 @@ impl CanvasEditor {...@@ -663,31 +1506,167 @@ impl CanvasEditor {
663 .lines()1506 .lines()
664 .position(|(_, line)| f64::from(line.top) == caret.y0)1507 .position(|(_, line)| f64::from(line.top) == caret.y0)
665 .unwrap();1508 .unwrap();
666 let target = if matches!(movement, Movement::Up) {1509 let up = matches!(movement, Movement::Up);
667 if line > 0 {1510 let target = if up {
668 paragraph.text.lines().nth(line - 1).map(|(_, line)| line)1511 line.checked_sub(1)
669 } else if index > 0 {1512 .and_then(|line| paragraph.text.lines().nth(line))
670 index -= 1;1513 } else {
671 self.active_outline().shaped.paragraphs[index]1514 paragraph.text.lines().nth(line + 1)
672 .text1515 }
673 .lines()1516 .map(|(_, line)| line);
674 .last()1517 let target = if target.is_some() {
675 .map(|(_, line)| line)1518 target
1519 } else {
1520 let neighbor = if self.active_outline().shaped.tables.is_empty() {
1521 if up {
1522 index.checked_sub(1)
1523 } else {
1524 (index + 1 < self.active_outline().shaped.paragraphs.len())
1525 .then_some(index + 1)
1526 }
676 } else {1527 } else {
677 None1528 let column = self.active_outline().column(x, paragraph.id)?;
678 }1529 let current = column
679 } else if let Some((_, line)) = paragraph.text.lines().nth(line + 1) {1530 .iter()
680 Some(line)1531 .position(|candidate| *candidate == index)
681 } else if index + 1 < self.active_outline().shaped.paragraphs.len() {1532 .ok_or(EditError::InvalidStructure)?;
682 index += 1;1533 if up {
683 self.active_outline().shaped.paragraphs[index]1534 current.checked_sub(1).map(|i| column[i])
684 .text1535 } else {
685 .lines()1536 column.get(current + 1).copied()
686 .next()1537 }
1538 };
1539 neighbor.and_then(|neighbor| {
1540 index = neighbor;
1541 let paragraph = &self.active_outline().shaped.paragraphs[index];
1542 if up {
1543 paragraph.text.lines().last()
1544 } else {
1545 paragraph.text.lines().next()
1546 }
687 .map(|(_, line)| line)1547 .map(|(_, line)| line)
688 } else {1548 })
689 None
690 };1549 };
1550 if target.is_none() && !extend && anchor == focus && !self.active_outline().title {
1551 let outline = self.active_outline();
1552 crate::document::validate_flat(
1553 outline
1554 .document
1555 .nodes()
1556 .iter()
1557 .filter(|node| node.text().is_some()),
1558 )?;
1559 let last = outline.document.nodes().len() - 1;
1560 let up = matches!(movement, Movement::Up);
1561 let base_node = &outline.document.nodes()[if up { 0 } else { last }];
1562 let node = self.blank_paragraph(base_node)?;
1563 let trailing = outline
1564 .document
1565 .nodes()
1566 .iter()
1567 .rev()
1568 .take_while(|node| {
1569 node.text()
1570 .is_some_and(|text| text.text.text().bytes().all(|byte| byte == b' '))
1571 })
1572 .count();
1573 let position = if up {
1574 Some([
1575 outline.origin()[0] + x,
1576 (((f64::from(outline.origin()[1]) / 18.0).ceil() - 3.0) * 18.0 + 14.4)
1577 as f32,
1578 ])
1579 } else if outline.origin()[0] >= 180.0 && trailing >= 2 {
1580 let node = &outline.document.nodes()[last.saturating_sub(trailing)];
1581 let bottom = match &node.content {
1582 crate::page::ParagraphContent::Text(_) => {
1583 let paragraph = outline
1584 .shaped
1585 .paragraphs
1586 .iter()
1587 .find(|paragraph| paragraph.id == node.id)
1588 .ok_or(EditError::InvalidStructure)?;
1589 f64::from(paragraph.origin[1]) + f64::from(paragraph.text.height())
1590 }
1591 crate::page::ParagraphContent::Table(table) => {
1592 let cell = outline
1593 .shaped
1594 .tables
1595 .iter()
1596 .find(|layout| layout.id == table.id)
1597 .and_then(|layout| layout.cells.last())
1598 .ok_or(EditError::InvalidStructure)?;
1599 f64::from(cell.rect[3]) + 1.68
1600 }
1601 crate::page::ParagraphContent::Unsupported(_) => {
1602 return Err(EditError::UnsupportedContent.into());
1603 }
1604 } + f64::from(outline.origin()[1]);
1605 let cell = (bottom - 14.4) / 18.0;
1606 // Stored coordinates can straddle a grid boundary by one f32 ULP.
1607 let grid = if (cell - cell.round()).abs()
1608 <= f64::from(f32::EPSILON) * cell.abs().max(1.0)
1609 {
1610 cell.round()
1611 } else {
1612 cell.ceil()
1613 };
1614 Some([outline.origin()[0], ((grid + 3.0) * 18.0 + 14.4) as f32])
1615 } else {
1616 None
1617 };
1618 if let Some(position) = position {
1619 let outline = TextOutline::new(
1620 engine,
1621 TextDocument::from_nodes(vec![node])?,
1622 DEFAULT_OUTLINE_WIDTH,
1623 position,
1624 )?;
1625 self.active = Focus::Caret {
1626 outline: Box::new(outline),
1627 index: self.outlines.len(),
1628 };
1629 self.preferred_x = Some(if up { 0.0 } else { x });
1630 return Ok(());
1631 }
1632 let edit = DocumentEdit {
1633 columns: BTreeMap::new(),
1634 container: None,
1635 range: last + 1..last + 1,
1636 replacement: vec![node],
1637 };
1638 let paragraph = outline.document.paragraphs().count();
1639 let previous = if let Focus::Outline(index) = self.active {
1640 let draft = self.outlines[index].clone();
1641 Some(std::mem::replace(
1642 &mut self.active,
1643 Focus::Draft {
1644 index,
1645 outline: Box::new(draft),
1646 },
1647 ))
1648 } else {
1649 None
1650 };
1651 if let Err(error) = self.apply(
1652 engine,
1653 edit,
1654 [TextPosition {
1655 paragraph,
1656 offset: 0,
1657 }; 2]
1658 .into(),
1659 false,
1660 None,
1661 ) {
1662 if let Some(previous) = previous {
1663 self.active = previous;
1664 }
1665 return Err(error);
1666 }
1667 self.preferred_x = Some(x);
1668 return Ok(());
1669 }
691 next = target1670 next = target
692 .map(|line| {1671 .map(|line| {
693 self.active_outline().shaped.paragraphs[index]1672 self.active_outline().shaped.paragraphs[index]
...@@ -713,11 +1692,16 @@ impl CanvasEditor {...@@ -713,11 +1692,16 @@ impl CanvasEditor {
713 next = match movement {1692 next = match movement {
714 Movement::Left => local.previous_visual(layout, extend),1693 Movement::Left => local.previous_visual(layout, extend),
715 Movement::Right => local.next_visual(layout, extend),1694 Movement::Right => local.next_visual(layout, extend),
716 Movement::WordLeft => local.previous_visual_word(layout, extend),1695 Movement::WordLeft => paragraph.text.word_cursor(local.focus(), true).into(),
717 Movement::WordRight => local.next_visual_word(layout, extend),1696 Movement::WordRight => paragraph.text.word_cursor(local.focus(), false).into(),
718 Movement::LineStart => local.line_start(layout, extend),1697 Movement::LineStart => local.line_start(layout, extend),
719 Movement::LineEnd => local.line_end(layout, extend),1698 Movement::LineEnd => local.line_end(layout, extend),
720 Movement::Up | Movement::Down => unreachable!(),1699 Movement::Up
1700 | Movement::Down
1701 | Movement::ParagraphStart
1702 | Movement::ParagraphEnd
1703 | Movement::DocumentStart
1704 | Movement::DocumentEnd => unreachable!(),
721 }1705 }
722 .focus();1706 .focus();
723 if horizontal && next == cursor && (extend || anchor == focus) {1707 if horizontal && next == cursor && (extend || anchor == focus) {
...@@ -741,15 +1725,45 @@ impl CanvasEditor {...@@ -741,15 +1725,45 @@ impl CanvasEditor {
741 line.top + line.height * 0.5,1725 line.top + line.height * 0.5,
742 );1726 );
743 if matches!(movement, Movement::WordLeft | Movement::WordRight) {1727 if matches!(movement, Movement::WordLeft | Movement::WordRight) {
744 next = if left {1728 next = paragraph.text.word_cursor(next, left);
745 next.previous_visual_word(&paragraph.text.shaped)
746 } else {
747 next.next_visual_word(&paragraph.text.shaped)
748 };
749 }1729 }
750 }1730 }
751 }1731 }
752 }1732 }
1733 if !extend
1734 && anchor == focus
1735 && next == cursor
1736 && self.active_outline().source_index(index) == focus.paragraph
1737 && matches!(movement, Movement::Left | Movement::Right)
1738 {
1739 let caret = self.caret(1.0)?;
1740 let origin = self.active_outline().origin();
1741 let x = caret.x0 + f64::from(origin[0]);
1742 let y = (caret.y0 + caret.y1) * 0.5 + f64::from(origin[1]);
1743 let target = self
1744 .outlines
1745 .iter()
1746 .filter(|outline| outline.id != self.active_outline().id)
1747 .filter_map(|outline| {
1748 let rect = outline.bounds();
1749 let distance = if left { x - rect.x1 } else { rect.x0 - x };
1750 ((rect.y0..rect.y1).contains(&y) && distance > 0.0).then_some((
1751 distance,
1752 outline.id,
1753 outline.origin(),
1754 ))
1755 })
1756 .min_by(|a, b| a.0.total_cmp(&b.0));
1757 if let Some((_, id, origin)) = target {
1758 self.focus_outline(id)?;
1759 self.select_at(
1760 if left { f32::MAX } else { -1.0 },
1761 y as f32 - origin[1],
1762 false,
1763 )?;
1764 return Ok(());
1765 }
1766 }
753 if !matches!(movement, Movement::Up | Movement::Down) {1767 if !matches!(movement, Movement::Up | Movement::Down) {
754 self.preferred_x = None;1768 self.preferred_x = None;
755 }1769 }
...@@ -799,10 +1813,11 @@ impl CanvasEditor {...@@ -799,10 +1813,11 @@ impl CanvasEditor {
799 )1813 )
800 };1814 };
801 let mut rectangles = Vec::new();1815 let mut rectangles = Vec::new();
802 for (index, paragraph) in self1816 for (visible, (index, paragraph)) in self
803 .active_outline()1817 .active_outline()
804 .layouts()1818 .layouts()
805 .filter(|(index, _)| *index >= start.paragraph && *index <= end.paragraph)1819 .enumerate()
1820 .filter(|(_, (index, _))| *index >= start.paragraph && *index <= end.paragraph)
806 {1821 {
807 let source = self1822 let source = self
808 .active_outline()1823 .active_outline()
...@@ -834,6 +1849,12 @@ impl CanvasEditor {...@@ -834,6 +1849,12 @@ impl CanvasEditor {
834 rect.x1 += f64::from(paragraph.origin[0]);1849 rect.x1 += f64::from(paragraph.origin[0]);
835 rect.y0 += f64::from(paragraph.origin[1]);1850 rect.y0 += f64::from(paragraph.origin[1]);
836 rect.y1 += f64::from(paragraph.origin[1]);1851 rect.y1 += f64::from(paragraph.origin[1]);
1852 if let Some(cell) = self.active_outline().shaped.paragraph_cell(visible) {
1853 let Some(clipped) = cell.clip(rect) else {
1854 continue;
1855 };
1856 rect = clipped;
1857 }
837 rectangles.push(rect);1858 rectangles.push(rect);
838 }1859 }
839 }1860 }
...@@ -897,6 +1918,60 @@ impl CanvasEditor {...@@ -897,6 +1918,60 @@ impl CanvasEditor {
897 self.replace(engine, replacement)1918 self.replace(engine, replacement)
898 }1919 }
8991920
1921 pub fn indent(&mut self, engine: &mut TextEngine, outdent: bool) -> Result<bool, EditorError> {
1922 let outline = self.active_outline();
1923 if outline.title {
1924 return Ok(false);
1925 }
1926 let [anchor, focus] = outline.selection.positions;
1927 let start = anchor.min(focus);
1928 let end = anchor.max(focus);
1929 let (container, local_start, _) = leaves(outline.document.nodes(), None)
1930 .nth(start.paragraph)
1931 .ok_or(EditError::InvalidRange)?;
1932 let (end_container, local_end, _) = leaves(outline.document.nodes(), None)
1933 .nth(end.paragraph)
1934 .ok_or(EditError::InvalidRange)?;
1935 if container != end_container {
1936 return Err(EditError::UnsupportedContent.into());
1937 }
1938 let range = local_start
1939 ..local_end + usize::from(end.offset != 0 || start.paragraph == end.paragraph);
1940 let nodes = outline.document.container(container)?;
1941 crate::document::validate_flat(nodes.iter().enumerate().filter_map(|(index, node)| {
1942 (range.contains(&index) || node.text().is_some()).then_some(node)
1943 }))?;
1944 let nodes = &nodes[range.clone()];
1945 if outdent && nodes.iter().all(|node| node.level == 1) {
1946 return Ok(false);
1947 }
1948 let replacement = nodes
1949 .iter()
1950 .map(|node| {
1951 let mut node = node.clone();
1952 node.level = if outdent {
1953 node.level.saturating_sub(1).max(1)
1954 } else {
1955 node.level
1956 .checked_add(1)
1957 .ok_or(EditError::InvalidStructure)?
1958 };
1959 Ok(node)
1960 })
1961 .collect::<Result<Vec<_>, EditError>>()?;
1962 self.commit(
1963 engine,
1964 DocumentEdit {
1965 columns: BTreeMap::new(),
1966 container,
1967 range,
1968 replacement,
1969 },
1970 outline.selection,
1971 )?;
1972 Ok(true)
1973 }
1974
900 pub fn delete(&mut self, engine: &mut TextEngine, backward: bool) -> Result<bool, EditorError> {1975 pub fn delete(&mut self, engine: &mut TextEngine, backward: bool) -> Result<bool, EditorError> {
901 let [anchor, focus] = self.active_outline().selection.positions;1976 let [anchor, focus] = self.active_outline().selection.positions;
902 let mut range = anchor.min(focus)..anchor.max(focus);1977 let mut range = anchor.min(focus)..anchor.max(focus);
...@@ -915,36 +1990,74 @@ impl CanvasEditor {...@@ -915,36 +1990,74 @@ impl CanvasEditor {
915 range.end.offset = paragraph1990 range.end.offset = paragraph
916 .projection1991 .projection
917 .source_offset(visible.utf16_offset(bytes.end)?, Affinity::Upstream)?;1992 .source_offset(visible.utf16_offset(bytes.end)?, Affinity::Upstream)?;
918 } else if backward && focus.paragraph > 0 {1993 } else {
919 let previous = self1994 let document = &self.active_outline().document;
920 .active_outline()1995 let Some(neighbor) = (if backward {
921 .document1996 focus.paragraph.checked_sub(1)
922 .paragraphs()1997 } else {
923 .nth(focus.paragraph - 1)1998 focus.paragraph.checked_add(1)
924 .unwrap();1999 }) else {
925 range.start = TextPosition {2000 return Ok(false);
926 paragraph: focus.paragraph - 1,
927 offset: previous.utf16_offset(previous.text().len())?,
928 };2001 };
929 range.end.offset = 0;2002 let (container, local, current) = leaves(document.nodes(), None)
930 } else if !backward
931 && focus.paragraph + 1 < self.active_outline().document.nodes().len()
932 {
933 let current = self
934 .active_outline()
935 .document
936 .paragraphs()
937 .nth(focus.paragraph)2003 .nth(focus.paragraph)
938 .unwrap();2004 .ok_or(EditError::InvalidRange)?;
939 range.start.offset = current.utf16_offset(current.text().len())?;2005 let Some((next_container, next_local, next)) =
940 range.end = TextPosition {2006 leaves(document.nodes(), None).nth(neighbor)
941 paragraph: focus.paragraph + 1,2007 else {
2008 return Ok(false);
2009 };
2010 if container != next_container || local.abs_diff(next_local) != 1 {
2011 return Ok(false);
2012 }
2013 let (previous, start, end) = if backward {
2014 (next, neighbor, focus.paragraph)
2015 } else {
2016 (current, focus.paragraph, neighbor)
2017 };
2018 let text = &previous.text().unwrap().text;
2019 range = TextPosition {
2020 paragraph: start,
2021 offset: text.utf16_offset(text.text().len())?,
2022 }..TextPosition {
2023 paragraph: end,
942 offset: 0,2024 offset: 0,
943 };2025 };
944 } else {
945 return Ok(false);
946 }2026 }
947 }2027 }
2028 self.delete_range(engine, range)
2029 }
2030
2031 pub fn delete_to(
2032 &mut self,
2033 engine: &mut TextEngine,
2034 movement: Movement,
2035 ) -> Result<bool, EditorError> {
2036 self.finish_composition();
2037 let original = self.selection();
2038 let preferred_x = self.preferred_x;
2039 if original.positions[0] == original.positions[1] {
2040 let result = self.move_selection(engine, movement, true);
2041 if let Err(error) = result {
2042 self.active_outline_mut().selection = original;
2043 self.preferred_x = preferred_x;
2044 return Err(error);
2045 }
2046 }
2047 let [anchor, focus] = self.selection().positions;
2048 self.active_outline_mut().selection = original;
2049 self.preferred_x = preferred_x;
2050 self.delete_range(engine, anchor.min(focus)..anchor.max(focus))
2051 }
2052
2053 fn delete_range(
2054 &mut self,
2055 engine: &mut TextEngine,
2056 range: Range<TextPosition>,
2057 ) -> Result<bool, EditorError> {
2058 if range.is_empty() {
2059 return Ok(false);
2060 }
948 let caret = range.start;2061 let caret = range.start;
949 let format = self2062 let format = self
950 .active_outline()2063 .active_outline()
...@@ -1011,7 +2124,23 @@ impl CanvasEditor {...@@ -1011,7 +2124,23 @@ impl CanvasEditor {
1011 history: History,2124 history: History,
1012 ) -> Result<History, (History, EditorError)> {2125 ) -> Result<History, (History, EditorError)> {
1013 let valid = match &history {2126 let valid = match &history {
1014 History::Text { outline, .. } | History::Position { outline, .. } => {2127 History::Date(date) => self
2128 .date
2129 .as_ref()
2130 .is_some_and(|current| current.source().id == date.source().id),
2131 History::Draft {
2132 outlines, index, ..
2133 } => {
2134 *index <= self.outlines.len()
2135 && self
2136 .outlines
2137 .iter()
2138 .position(|item| item.id == outlines[0].id)
2139 .is_none_or(|found| found == *index)
2140 }
2141 History::Text { outline, .. }
2142 | History::Position { outline, .. }
2143 | History::Layout { outline, .. } => {
1015 self.outlines.iter().any(|item| item.id == *outline)2144 self.outlines.iter().any(|item| item.id == *outline)
1016 }2145 }
1017 History::Remove { outline, focus } => {2146 History::Remove { outline, focus } => {
...@@ -1031,6 +2160,67 @@ impl CanvasEditor {...@@ -1031,6 +2160,67 @@ impl CanvasEditor {
1031 return Err((history, EditError::InvalidRange.into()));2160 return Err((history, EditError::InvalidRange.into()));
1032 }2161 }
1033 let inverse = match history {2162 let inverse = match history {
2163 History::Date(date) => History::Date(Box::new(self.date.replace(*date).unwrap())),
2164 History::Draft {
2165 outlines,
2166 index,
2167 caret,
2168 selection,
2169 restore_caret,
2170 } => {
2171 let draft = if restore_caret {
2172 match TextOutline::from_outline(engine, &caret, &self.definitions) {
2173 Ok(mut draft) => {
2174 draft.selection = selection;
2175 Some(draft)
2176 }
2177 Err(error) => {
2178 return Err((
2179 History::Draft {
2180 outlines,
2181 index,
2182 caret,
2183 selection,
2184 restore_caret,
2185 },
2186 error,
2187 ));
2188 }
2189 }
2190 } else {
2191 None
2192 };
2193 let outline = outlines[usize::from(!restore_caret)].clone();
2194 if self
2195 .outlines
2196 .get(index)
2197 .is_some_and(|item| item.id == outline.id)
2198 {
2199 self.outlines.remove(index);
2200 }
2201 self.active = if outline.is_empty() {
2202 Focus::Caret {
2203 outline: Box::new(outline),
2204 index,
2205 }
2206 } else {
2207 self.outlines.insert(index, outline);
2208 match draft {
2209 Some(outline) => Focus::Draft {
2210 index,
2211 outline: Box::new(outline),
2212 },
2213 None => Focus::Outline(index),
2214 }
2215 };
2216 History::Draft {
2217 outlines,
2218 index,
2219 caret,
2220 selection,
2221 restore_caret: !restore_caret,
2222 }
2223 }
1034 History::Text { outline, change } => {2224 History::Text { outline, change } => {
1035 let index = self2225 let index = self
1036 .outlines2226 .outlines
...@@ -1038,8 +2228,13 @@ impl CanvasEditor {...@@ -1038,8 +2228,13 @@ impl CanvasEditor {
1038 .position(|item| item.id == outline)2228 .position(|item| item.id == outline)
1039 .unwrap();2229 .unwrap();
1040 let previous = std::mem::replace(&mut self.active, Focus::Outline(index));2230 let previous = std::mem::replace(&mut self.active, Focus::Outline(index));
1041 let inverse = match self.apply(engine, change.edit.clone(), change.selection, false)2231 let inverse = match self.apply(
1042 {2232 engine,
2233 change.edit.clone(),
2234 change.selection,
2235 false,
2236 Some(&change.positions),
2237 ) {
1043 Ok(change) => change,2238 Ok(change) => change,
1044 Err(error) => {2239 Err(error) => {
1045 self.active = previous;2240 self.active = previous;
...@@ -1048,7 +2243,7 @@ impl CanvasEditor {...@@ -1048,7 +2243,7 @@ impl CanvasEditor {
1048 };2243 };
1049 History::Text {2244 History::Text {
1050 outline,2245 outline,
1051 change: inverse,2246 change: Box::new(inverse),
1052 }2247 }
1053 }2248 }
1054 History::Position { outline, position } => {2249 History::Position { outline, position } => {
...@@ -1066,6 +2261,35 @@ impl CanvasEditor {...@@ -1066,6 +2261,35 @@ impl CanvasEditor {
1066 self.active = Focus::Outline(index);2261 self.active = Focus::Outline(index);
1067 inverse2262 inverse
1068 }2263 }
2264 History::Layout { outline, layout } => {
2265 let index = self
2266 .outlines
2267 .iter()
2268 .position(|item| item.id == outline)
2269 .unwrap();
2270 let mut source = self.outlines[index].snapshot();
2271 let previous = std::mem::replace(&mut source.layout, layout);
2272 let mut resized =
2273 match TextOutline::from_outline(engine, &source, &self.definitions) {
2274 Ok(resized) => resized,
2275 Err(error) => {
2276 return Err((
2277 History::Layout {
2278 outline,
2279 layout: source.layout,
2280 },
2281 error,
2282 ));
2283 }
2284 };
2285 resized.selection = self.outlines[index].selection;
2286 self.outlines[index] = resized;
2287 self.active = Focus::Outline(index);
2288 History::Layout {
2289 outline,
2290 layout: previous,
2291 }
2292 }
1069 History::Remove { outline, focus } => {2293 History::Remove { outline, focus } => {
1070 let index = self2294 let index = self
1071 .outlines2295 .outlines
...@@ -1206,10 +2430,23 @@ impl CanvasEditor {...@@ -1206,10 +2430,23 @@ impl CanvasEditor {
1206 affinities: [Affinity::Downstream; 2],2430 affinities: [Affinity::Downstream; 2],
1207 },2431 },
1208 false,2432 false,
2433 None,
1209 )?;2434 )?;
1210 let original = if let Some(composition) = self.composition.take() {2435 let original = if let Some(composition) = self.composition.take() {
1211 let mut original = composition.original;2436 let mut original = composition.original;
1212 original.edit.range = inverse.edit.range;2437 original.edit.range = inverse.edit.range;
2438 for (id, widths) in inverse.edit.columns {
2439 original.edit.columns.entry(id).or_insert(widths);
2440 }
2441 for position in inverse.positions {
2442 if !original
2443 .positions
2444 .iter()
2445 .any(|original| original.id == position.id)
2446 {
2447 original.positions.push(position);
2448 }
2449 }
1213 original2450 original
1214 } else {2451 } else {
1215 inverse2452 inverse
...@@ -1232,7 +2469,13 @@ impl CanvasEditor {...@@ -1232,7 +2469,13 @@ impl CanvasEditor {
1232 return Ok(false);2469 return Ok(false);
1233 };2470 };
1234 let original = composition.original.clone();2471 let original = composition.original.clone();
1235 self.apply(engine, original.edit, original.selection, false)?;2472 self.apply(
2473 engine,
2474 original.edit,
2475 original.selection,
2476 false,
2477 Some(&original.positions),
2478 )?;
1236 self.composition = None;2479 self.composition = None;
1237 Ok(true)2480 Ok(true)
1238 }2481 }
...@@ -1262,13 +2505,122 @@ impl CanvasEditor {...@@ -1262,13 +2505,122 @@ impl CanvasEditor {
1262 edit: DocumentEdit,2505 edit: DocumentEdit,
1263 selection: Selection,2506 selection: Selection,
1264 ) -> Result<(), EditorError> {2507 ) -> Result<(), EditorError> {
1265 let inverse = self.apply(engine, edit, selection, true)?;2508 let inverse = self.apply(engine, edit, selection, true, None)?;
1266 self.record_change(inverse);2509 self.record_change(inverse);
1267 Ok(())2510 Ok(())
1268 }2511 }
12692512
1270 fn record_change(&mut self, change: TextChange) {2513 fn record_change(&mut self, change: TextChange) {
1271 if self.caret_outline().is_some() {2514 if let Focus::Draft { index, .. } = self.active {
2515 let Focus::Draft { mut outline, .. } =
2516 std::mem::replace(&mut self.active, Focus::Outline(index))
2517 else {
2518 unreachable!()
2519 };
2520 let mut caret = outline.snapshot();
2521 crate::document::container_mut(&mut caret.paragraphs, change.edit.container)
2522 .expect("inverse edit container exists")
2523 .splice(change.edit.range, change.edit.replacement);
2524 let mut columns = change.edit.columns;
2525 crate::document::swap_columns(&mut caret.paragraphs, &mut columns);
2526 let last_selected = outline.selection.positions[0]
2527 .paragraph
2528 .max(outline.selection.positions[1].paragraph);
2529 let mut count = outline.document.nodes().len();
2530 let mut selected_end = 0;
2531 let selected_root = outline
2532 .document
2533 .nodes()
2534 .iter()
2535 .position(|node| {
2536 selected_end += leaves(std::slice::from_ref(node), None).count();
2537 selected_end > last_selected
2538 })
2539 .expect("selection addresses a text leaf");
2540 let original_ids = self.outlines[index]
2541 .document
2542 .nodes()
2543 .iter()
2544 .map(|node| node.id)
2545 .collect::<BTreeSet<_>>();
2546 while count > selected_root + 1 {
2547 let node = &outline.document.nodes()[count - 1];
2548 if node.text().is_none_or(|text| !text.text.text().is_empty())
2549 || original_ids.contains(&node.id)
2550 {
2551 break;
2552 }
2553 count -= 1;
2554 }
2555 if count < outline.document.nodes().len() {
2556 let removed = outline.document.nodes().len() - count;
2557 outline
2558 .document
2559 .apply(DocumentEdit {
2560 columns: BTreeMap::new(),
2561 container: None,
2562 range: count..outline.document.nodes().len(),
2563 replacement: Vec::new(),
2564 })
2565 .expect("removing a provisional suffix preserves a valid document");
2566 outline
2567 .shaped
2568 .paragraphs
2569 .truncate(outline.shaped.paragraphs.len() - removed);
2570 if outline.shaped.tables.is_empty() {
2571 let (origins, size) = arrange(
2572 outline.shaped.paragraphs.iter(),
2573 outline.wrap_width(),
2574 outline.layout.width_set_by_user == Some(true),
2575 )
2576 .expect("a prefix of the validated layout fits");
2577 for (paragraph, y) in outline.shaped.paragraphs.iter_mut().zip(origins) {
2578 paragraph.origin[1] = y;
2579 }
2580 outline.shaped.size = size;
2581 } else {
2582 let cached = outline
2583 .shaped
2584 .paragraphs
2585 .iter()
2586 .map(|paragraph| (paragraph.id, paragraph))
2587 .collect::<BTreeMap<_, _>>();
2588 let shaped = OutlineLayout::flow(
2589 outline.document.nodes().iter(),
2590 &outline.indents,
2591 outline.wrap_width(),
2592 outline.layout.width_set_by_user == Some(true),
2593 0,
2594 None,
2595 &mut |node, width, indents| {
2596 let mut paragraph = cached[&node.id].clone();
2597 paragraph.reset_origin(indentation(node.level, indents, width)?);
2598 Ok(paragraph)
2599 },
2600 )
2601 .expect("removing a provisional suffix preserves table layout");
2602 outline.shaped = shaped;
2603 }
2604 }
2605 if outline.document == self.outlines[index].document {
2606 self.outlines[index].selection = outline.selection;
2607 return;
2608 }
2609 let versions = Box::new([self.outlines[index].clone(), (*outline).clone()]);
2610 if outline.is_empty() {
2611 self.outlines.remove(index);
2612 self.active = Focus::Caret { outline, index };
2613 } else {
2614 self.outlines[index] = *outline;
2615 }
2616 self.undo.push(History::Draft {
2617 outlines: versions,
2618 index,
2619 caret: Box::new(caret),
2620 selection: change.selection,
2621 restore_caret: true,
2622 });
2623 } else if self.caret_outline().is_some() {
1272 if self.active_outline().is_empty() {2624 if self.active_outline().is_empty() {
1273 return;2625 return;
1274 }2626 }
...@@ -1291,7 +2643,7 @@ impl CanvasEditor {...@@ -1291,7 +2643,7 @@ impl CanvasEditor {
1291 },2643 },
1292 });2644 });
1293 self.outlines.insert(index, *outline);2645 self.outlines.insert(index, *outline);
1294 } else if self.active_outline().is_empty() {2646 } else if self.active_outline().is_empty() && !self.active_outline().title {
1295 let Focus::Outline(index) = self.active else {2647 let Focus::Outline(index) = self.active else {
1296 unreachable!()2648 unreachable!()
1297 };2649 };
...@@ -1316,136 +2668,2597 @@ impl CanvasEditor {...@@ -1316,136 +2668,2597 @@ impl CanvasEditor {
1316 } else {2668 } else {
1317 self.undo.push(History::Text {2669 self.undo.push(History::Text {
1318 outline: self.active_outline().id,2670 outline: self.active_outline().id,
1319 change,2671 change: Box::new(change),
1320 });2672 });
1321 }2673 }
1322 self.redo.clear();2674 self.redo.clear();
1323 }2675 }
13242676
1325 fn apply(2677 fn apply(
1326 &mut self,2678 &mut self,
1327 engine: &mut TextEngine,2679 engine: &mut TextEngine,
1328 edit: DocumentEdit,2680 edit: DocumentEdit,
1329 selection: Selection,2681 selection: Selection,
1330 finish_composition: bool,2682 finish_composition: bool,
1331 ) -> Result<TextChange, EditorError> {2683 positions: Option<&[Placement]>,
1332 let outline = self.active_outline();2684 ) -> Result<TextChange, EditorError> {
1333 outline.document.validate_edit(&edit)?;2685 let outline = self.active_outline();
1334 let width = outline2686 outline.document.validate_edit(&edit)?;
1335 .layout2687 let width = outline.wrap_width();
1336 .reserved_width2688 let (visible, shaped, origins, size, tables) = if outline
1337 .or(outline.layout.max_width)2689 .document
1338 .unwrap();2690 .nodes()
1339 let visible_start = outline2691 .iter()
1340 .layouts()2692 .chain(&edit.replacement)
1341 .take_while(|(i, _)| *i < edit.range.start)2693 .any(|node| matches!(node.content, crate::page::ParagraphContent::Table(_)))
1342 .count();2694 {
1343 let visible_end = outline2695 let sources = outline
1344 .layouts()2696 .document
1345 .take_while(|(i, _)| *i < edit.range.end)2697 .text_nodes()
1346 .count();2698 .map(|node| (node.id, node))
1347 if visible_end - visible_start != edit.range.len() {2699 .collect::<BTreeMap<_, _>>();
1348 return Err(EditError::UnsupportedContent.into());2700 let existing = outline
2701 .shaped
2702 .paragraphs
2703 .iter()
2704 .map(|layout| (layout.id, (sources[&layout.id], layout)))
2705 .collect::<BTreeMap<_, _>>();
2706 let layout = OutlineLayout::flow(
2707 edited_nodes(outline.document.nodes(), None, Some(&edit)),
2708 &outline.indents,
2709 width,
2710 outline.layout.width_set_by_user == Some(true),
2711 0,
2712 Some(&edit),
2713 &mut |node, width, indents| {
2714 let indent = indentation(node.level, indents, width)?;
2715 if let Some((source, cached)) = existing.get(&node.id)
2716 && *source == node
2717 && cached.text.shaped.layout_max_advance() == width - indent
2718 && cached
2719 .markers
2720 .iter()
2721 .all(|(marker, _)| marker.shaped.layout_max_advance() == width)
2722 {
2723 let mut result = (*cached).clone();
2724 result.reset_origin(indent);
2725 return Ok(result);
2726 }
2727 ParagraphLayout::shape(engine, node, width, indents, &self.definitions)
2728 },
2729 )?;
2730 let origins = layout
2731 .paragraphs
2732 .iter()
2733 .map(|p| p.origin[1])
2734 .collect::<Vec<_>>();
2735 (
2736 0..outline.shaped.paragraphs.len(),
2737 layout.paragraphs,
2738 origins,
2739 layout.size,
2740 layout.tables,
2741 )
2742 } else {
2743 let visible_start = outline
2744 .layouts()
2745 .take_while(|(i, _)| *i < edit.range.start)
2746 .count();
2747 let visible_end = outline
2748 .layouts()
2749 .take_while(|(i, _)| *i < edit.range.end)
2750 .count();
2751 if visible_end - visible_start != edit.range.len() {
2752 return Err(EditError::UnsupportedContent.into());
2753 }
2754 let shaped = edit
2755 .replacement
2756 .iter()
2757 .map(|paragraph| {
2758 ParagraphLayout::shape(
2759 engine,
2760 paragraph,
2761 width,
2762 &outline.indents,
2763 &self.definitions,
2764 )
2765 })
2766 .collect::<Result<Vec<_>, _>>()?;
2767 let (origins, size) = arrange(
2768 outline.shaped.paragraphs[..visible_start]
2769 .iter()
2770 .chain(&shaped)
2771 .chain(&outline.shaped.paragraphs[visible_end..]),
2772 width,
2773 outline.layout.width_set_by_user == Some(true),
2774 )?;
2775 (
2776 visible_start..visible_end,
2777 shaped,
2778 origins,
2779 size,
2780 Vec::new(),
2781 )
2782 };
2783 for position in selection.positions {
2784 let (_, _, node) = leaves(outline.document.nodes(), Some(&edit))
2785 .nth(position.paragraph)
2786 .ok_or(EditError::InvalidRange)?;
2787 node.text().unwrap().text.byte_offset(position.offset)?;
2788 if !outline.shaped.paragraphs[..visible.start]
2789 .iter()
2790 .chain(&shaped)
2791 .chain(&outline.shaped.paragraphs[visible.end..])
2792 .any(|paragraph| paragraph.id == node.id)
2793 {
2794 return Err(EditError::InvalidRange.into());
2795 }
2796 }
2797 let placements = match positions {
2798 Some(positions) => positions.to_vec(),
2799 None => self.title_flow(size[1])?,
2800 };
2801 let previous_positions = placements
2802 .iter()
2803 .map(|placement| {
2804 let layout = self
2805 .object_layouts()
2806 .find_map(|(id, layout)| (id == placement.id).then_some(layout))
2807 .ok_or(EditError::InvalidStructure)?;
2808 if placement
2809 .position
2810 .iter()
2811 .flatten()
2812 .any(|value| !value.is_finite())
2813 {
2814 return Err(EditorError::InvalidGeometry);
2815 }
2816 Ok(Placement {
2817 id: placement.id,
2818 position: [layout.x, layout.y],
2819 })
2820 })
2821 .collect::<Result<Vec<_>, EditorError>>()?;
2822 if finish_composition && self.composition.is_some() {
2823 // Committing marked text can remove provisional paragraphs from the validated view.
2824 self.finish_composition();
2825 return self.apply(engine, edit, selection, false, positions);
2826 }
2827 let outline = self.active_outline_mut();
2828 let inverse = outline.document.apply(edit)?;
2829 outline.shaped.paragraphs.splice(visible, shaped);
2830 outline.shaped.tables = tables;
2831 for (paragraph, y) in outline.shaped.paragraphs.iter_mut().zip(origins) {
2832 paragraph.origin[1] = y;
2833 }
2834 outline.shaped.size = size;
2835 self.preferred_x = None;
2836 let previous_selection =
2837 std::mem::replace(&mut self.active_outline_mut().selection, selection);
2838 for placement in placements {
2839 let layout = self
2840 .object_layout_mut(placement.id)
2841 .expect("validated object position");
2842 [layout.x, layout.y] = placement.position;
2843 }
2844 Ok(TextChange {
2845 edit: inverse,
2846 selection: previous_selection,
2847 positions: previous_positions,
2848 })
2849 }
2850}
2851
2852fn inserted_position(
2853 mut position: TextPosition,
2854 text: &str,
2855 offset: u32,
2856) -> Result<TextPosition, EditError> {
2857 let mut units = 0_u32;
2858 for character in text.chars() {
2859 if units == offset {
2860 return Ok(position);
2861 }
2862 units = units
2863 .checked_add(character.len_utf16() as u32)
2864 .ok_or(EditError::TextTooLong)?;
2865 if units > offset {
2866 return Err(EditError::InvalidRange);
2867 }
2868 if character == '\n' {
2869 position.paragraph = position
2870 .paragraph
2871 .checked_add(1)
2872 .ok_or(EditError::TextTooLong)?;
2873 position.offset = 0;
2874 } else {
2875 position.offset = position
2876 .offset
2877 .checked_add(character.len_utf16() as u32)
2878 .ok_or(EditError::TextTooLong)?;
2879 }
2880 }
2881 if units == offset {
2882 Ok(position)
2883 } else {
2884 Err(EditError::InvalidRange)
2885 }
2886}
2887
2888#[cfg(test)]
2889mod tests {
2890 use super::*;
2891 use onestore::document::Format;
2892
2893 fn table_editor(engine: &mut TextEngine) -> CanvasEditor {
2894 use crate::{
2895 page::{ParagraphContent, Table, TableCell, TableColumn, TableRow},
2896 text::new_id,
2897 };
2898 let mut editor = CanvasEditor::new(
2899 engine,
2900 TextDocument::new(vec![Paragraph::new("Left".into(), Format::default())]).unwrap(),
2901 180.0,
2902 )
2903 .unwrap();
2904 let first = editor.active_outline().document.nodes()[0].clone();
2905 let wrapper = PageParagraph {
2906 id: new_id().unwrap(),
2907 parent: None,
2908 level: 1,
2909 style: None,
2910 format: Format::default(),
2911 lists: Vec::new(),
2912 tags: Vec::new(),
2913 collapsed: false,
2914 content: ParagraphContent::Table(Table {
2915 id: new_id().unwrap(),
2916 columns: vec![
2917 TableColumn {
2918 width: 72.0,
2919 locked: true
2920 };
2921 2
2922 ],
2923 rows: [["Left", "Right"], ["Below", "Last"]]
2924 .into_iter()
2925 .enumerate()
2926 .map(|(row, texts)| TableRow {
2927 id: new_id().unwrap(),
2928 cells: texts
2929 .into_iter()
2930 .enumerate()
2931 .map(|(column, text)| TableCell {
2932 id: new_id().unwrap(),
2933 layout: Default::default(),
2934 indents: vec![18.0, 0.0, 27.0],
2935 shading: None,
2936 paragraphs: vec![if row == 0 && column == 0 {
2937 first.clone()
2938 } else {
2939 crate::document::node(
2940 Paragraph::new(text.into(), Format::default()),
2941 Format::default(),
2942 )
2943 .unwrap()
2944 }],
2945 unsupported: Vec::new(),
2946 })
2947 .collect(),
2948 })
2949 .collect(),
2950 borders: Some(true),
2951 layout: Default::default(),
2952 tags: Vec::new(),
2953 }),
2954 };
2955 editor
2956 .commit(
2957 engine,
2958 DocumentEdit {
2959 columns: BTreeMap::new(),
2960 container: None,
2961 range: 0..1,
2962 replacement: vec![wrapper],
2963 },
2964 [TextPosition {
2965 paragraph: 1,
2966 offset: 0,
2967 }; 2]
2968 .into(),
2969 )
2970 .unwrap();
2971 editor
2972 }
2973
2974 #[test]
2975 fn structured_construction_and_resize_preserve_cells_and_enclose_locked_columns() {
2976 let mut engine = TextEngine::default();
2977 let mut source = table_editor(&mut engine).active_outline().snapshot();
2978 let crate::page::ParagraphContent::Table(table) = &mut source.paragraphs[0].content else {
2979 panic!()
2980 };
2981 table.rows[0].cells[0].paragraphs[0]
2982 .text_mut()
2983 .unwrap()
2984 .text = Paragraph::new(String::new(), Format::default());
2985 source.paragraphs.push(
2986 crate::document::node(
2987 Paragraph::new(
2988 "Text below the table must use the same width during drag and after release."
2989 .into(),
2990 Format::default(),
2991 ),
2992 Format::default(),
2993 )
2994 .unwrap(),
2995 );
2996 let document = TextDocument::from_nodes(source.paragraphs.clone()).unwrap();
2997 let mut editor = CanvasEditor::new(&mut engine, document.clone(), 180.0).unwrap();
2998 let imported = TextOutline::from_outline(&mut engine, &source, &BTreeMap::new()).unwrap();
2999 let cells = |outline: &TextOutline| {
3000 outline
3001 .shaped
3002 .tables
3003 .iter()
3004 .flat_map(|table| {
3005 table
3006 .cells
3007 .iter()
3008 .map(move |cell| (table.id, table.borders, cell.id, cell.rect))
3009 })
3010 .collect::<Vec<_>>()
3011 };
3012 assert_eq!(editor.active_outline().layout.width_set_by_user, Some(true));
3013 assert_eq!(editor.active_outline().shaped.size, imported.shaped.size);
3014 assert_eq!(cells(editor.active_outline()), cells(&imported));
3015 for (actual, expected) in editor
3016 .active_outline()
3017 .shaped
3018 .paragraphs
3019 .iter()
3020 .zip(&imported.shaped.paragraphs)
3021 {
3022 assert_eq!(actual.id, expected.id);
3023 assert_eq!(actual.origin, expected.origin);
3024 assert_eq!(actual.text.height(), expected.text.height());
3025 }
3026 let minimum = 2.0 * (72.0 + 4.98) - 1.83;
3027 let preview = editor.preview_resize(&mut engine, 36.0).unwrap();
3028 assert!((preview.bounds().width() as f32 - minimum).abs() < 0.0001);
3029 assert_eq!(preview.document, document);
3030 assert_eq!(cells(&preview), cells(&imported));
3031 assert!((preview.wrap_width() - minimum).abs() < 0.0001);
3032 let final_width_preview = editor
3033 .preview_resize(&mut engine, preview.bounds().width() as f32)
3034 .unwrap();
3035 assert_eq!(preview.shaped.size, final_width_preview.shaped.size);
3036 assert_eq!(
3037 preview
3038 .shaped
3039 .paragraphs
3040 .last()
3041 .unwrap()
3042 .text
3043 .lines()
3044 .count(),
3045 final_width_preview
3046 .shaped
3047 .paragraphs
3048 .last()
3049 .unwrap()
3050 .text
3051 .lines()
3052 .count()
3053 );
3054 assert_eq!(editor.active_outline().bounds().width(), 180.0);
3055 assert!(editor.undo.is_empty());
3056 editor.resize(&mut engine, 36.0).unwrap();
3057 assert_eq!(editor.active_outline().bounds(), preview.bounds());
3058 editor.undo(&mut engine).unwrap();
3059 assert_eq!(editor.active_outline().bounds().width(), 180.0);
3060 editor.redo(&mut engine).unwrap();
3061 assert_eq!(editor.active_outline().bounds(), preview.bounds());
3062 assert_eq!(editor.active_outline().document, document);
3063 }
3064
3065 #[test]
3066 fn nested_table_extents_remain_reachable_beyond_the_parent_columns() {
3067 use crate::page::ParagraphContent;
3068 let mut engine = TextEngine::default();
3069 let mut source = table_editor(&mut engine).active_outline().snapshot();
3070 let mut nested = table_editor(&mut engine).active_outline().document.nodes()[0].clone();
3071 let ParagraphContent::Table(table) = &mut nested.content else {
3072 panic!()
3073 };
3074 table.columns[0].width = 240.0;
3075 table.columns[1].width = 240.0;
3076 let ParagraphContent::Table(table) = &mut source.paragraphs[0].content else {
3077 panic!()
3078 };
3079 table.rows[0].cells[1].paragraphs = vec![nested];
3080 source.layout.width_set_by_user = Some(false);
3081 let mut editor =
3082 CanvasEditor::from_outlines(&mut engine, vec![source], BTreeMap::new()).unwrap();
3083 let paragraph = editor.active_outline().paragraph_layout(2).unwrap();
3084 let point = [
3085 paragraph.origin[0] + 1.0,
3086 paragraph.origin[1] + paragraph.text.height() * 0.5,
3087 ];
3088 assert!(point[0] > 180.0);
3089 assert!(f64::from(point[0]) < editor.active_outline().bounds().x1);
3090 assert_eq!(
3091 editor
3092 .selection_at(point[0], point[1], SelectionUnit::Grapheme)
3093 .unwrap()
3094 .positions[1]
3095 .paragraph,
3096 2
3097 );
3098 let preview = editor.preview_resize(&mut engine, 36.0).unwrap();
3099 assert!(preview.bounds().x1 > f64::from(point[0]));
3100 editor
3101 .select(
3102 [TextPosition {
3103 paragraph: 2,
3104 offset: 0,
3105 }; 2]
3106 .into(),
3107 )
3108 .unwrap();
3109 editor.insert(&mut engine, "!").unwrap();
3110 assert!(editor.active_outline().bounds().x1 > f64::from(point[0]));
3111 editor.undo(&mut engine).unwrap();
3112 assert!(editor.active_outline().bounds().x1 > f64::from(point[0]));
3113 }
3114
3115 #[test]
3116 fn automatic_table_bounds_add_typing_room_only_to_root_text() {
3117 let mut engine = TextEngine::default();
3118 let mut source = table_editor(&mut engine).active_outline().snapshot();
3119 source.layout.width_set_by_user = Some(false);
3120 let outline = TextOutline::from_outline(&mut engine, &source, &BTreeMap::new()).unwrap();
3121 assert_eq!(outline.bounds().width(), f64::from(outline.shaped.size[0]));
3122 source.paragraphs.push(
3123 crate::document::node(
3124 Paragraph::new(
3125 "A root paragraph needs room to continue typing".into(),
3126 Format::default(),
3127 ),
3128 Format::default(),
3129 )
3130 .unwrap(),
3131 );
3132 let outline = TextOutline::from_outline(&mut engine, &source, &BTreeMap::new()).unwrap();
3133 let paragraph = outline.shaped.paragraphs.last().unwrap();
3134 let expected = (paragraph.origin[0] + paragraph.text.shaped.width() + 36.0)
3135 .clamp(72.0, 180.0)
3136 .max(outline.shaped.size[0]);
3137 assert_eq!(outline.bounds().width(), f64::from(expected));
3138 assert!(outline.bounds().width() > f64::from(outline.shaped.size[0]));
3139 }
3140
3141 #[test]
3142 fn cell_mouse_targets_and_paragraph_selection_follow_columns() {
3143 let mut engine = TextEngine::default();
3144 let mut editor = table_editor(&mut engine);
3145 editor
3146 .select(
3147 [TextPosition {
3148 paragraph: 0,
3149 offset: 4,
3150 }; 2]
3151 .into(),
3152 )
3153 .unwrap();
3154 editor.insert(&mut engine, "\nSecond\nThird").unwrap();
3155 for source in 0..6 {
3156 let paragraph = editor.active_outline().paragraph_layout(source).unwrap();
3157 let (_, line) = paragraph.text.lines().next().unwrap();
3158 let x = paragraph.origin[0] + 1.0;
3159 let y = paragraph.origin[1] + line.top + line.height * 0.5;
3160 for unit in [
3161 SelectionUnit::Grapheme,
3162 SelectionUnit::Word,
3163 SelectionUnit::Paragraph,
3164 ] {
3165 let selection = editor.selection_at(x, y, unit).unwrap();
3166 assert_eq!(selection.positions[0].paragraph, source);
3167 if unit == SelectionUnit::Paragraph {
3168 let end = if source < 2 {
3169 TextPosition {
3170 paragraph: source + 1,
3171 offset: 0,
3172 }
3173 } else {
3174 let text = editor
3175 .active_outline()
3176 .document
3177 .paragraphs()
3178 .nth(source)
3179 .unwrap();
3180 TextPosition {
3181 paragraph: source,
3182 offset: text.utf16_offset(text.text().len()).unwrap(),
3183 }
3184 };
3185 assert_eq!(selection.positions[1], end);
3186 }
3187 }
3188 }
3189 let right = editor.active_outline().shaped.tables[0].cells[1].rect;
3190 for x in [right[0] + 0.5, right[2] - 0.5, f32::MAX] {
3191 let selection = editor
3192 .selection_at(x, right[3] - 1.0, SelectionUnit::Grapheme)
3193 .unwrap();
3194 assert_eq!(selection.positions[1].paragraph, 3);
3195 }
3196 let left = editor.active_outline().shaped.tables[0].cells[0].rect;
3197 let selection = editor
3198 .selection_at(-f32::MAX, left[3] - 1.0, SelectionUnit::Grapheme)
3199 .unwrap();
3200 assert_eq!(selection.positions[1].paragraph, 2);
3201 let history = editor.undo.len();
3202 let before = editor.active_outline().document.clone();
3203 editor
3204 .select_at(right[0] + 5.0, right[1] + 8.0, false)
3205 .unwrap();
3206 editor
3207 .select_at(left[0] + 5.0, left[3] - 2.0, true)
3208 .unwrap();
3209 assert_eq!(editor.selection().positions[0].paragraph, 3);
3210 assert_eq!(editor.selection().positions[1].paragraph, 2);
3211 assert!(!editor.selection_rects().unwrap().is_empty());
3212 assert_eq!(editor.active_outline().document, before);
3213 assert_eq!(editor.undo.len(), history);
3214 }
3215
3216 #[test]
3217 fn nested_table_mouse_targets_resolve_the_source_cell() {
3218 use crate::{page::ParagraphContent, text::new_id};
3219 let mut engine = TextEngine::default();
3220 let nested = table_editor(&mut engine).active_outline().document.nodes()[0].clone();
3221 let mut editor = table_editor(&mut engine);
3222 let mut wrapper = editor.active_outline().document.nodes()[0].clone();
3223 let ParagraphContent::Table(table) = &mut wrapper.content else {
3224 panic!()
3225 };
3226 table.columns[0].width = 180.0;
3227 let cell = &mut table.rows[0].cells[0];
3228 cell.paragraphs.push(nested);
3229 let mut tail = cell.paragraphs[0].clone();
3230 tail.id = new_id().unwrap();
3231 tail.text_mut().unwrap().id = new_id().unwrap();
3232 cell.paragraphs.push(tail);
3233 let before = crate::document::node(
3234 Paragraph::new("Before".into(), Format::default()),
3235 Format::default(),
3236 )
3237 .unwrap();
3238 let after = crate::document::node(
3239 Paragraph::new("After".into(), Format::default()),
3240 Format::default(),
3241 )
3242 .unwrap();
3243 editor
3244 .commit(
3245 &mut engine,
3246 DocumentEdit {
3247 columns: BTreeMap::new(),
3248 container: None,
3249 range: 0..1,
3250 replacement: vec![before, wrapper, after],
3251 },
3252 [TextPosition {
3253 paragraph: 0,
3254 offset: 0,
3255 }; 2]
3256 .into(),
3257 )
3258 .unwrap();
3259 assert_eq!(editor.active_outline().document.paragraphs().count(), 11);
3260 for source in 0..11 {
3261 let paragraph = editor.active_outline().paragraph_layout(source).unwrap();
3262 let (_, line) = paragraph.text.lines().next().unwrap();
3263 let selection = editor
3264 .selection_at(
3265 paragraph.origin[0] + 1.0,
3266 paragraph.origin[1] + line.height * 0.5,
3267 SelectionUnit::Grapheme,
3268 )
3269 .unwrap();
3270 assert_eq!(selection.positions[0].paragraph, source);
3271 }
3272 let paragraph = editor.active_outline().paragraph_layout(1).unwrap();
3273 let selection = editor
3274 .selection_at(
3275 paragraph.origin[0] + 1.0,
3276 paragraph.origin[1] + 5.0,
3277 SelectionUnit::Paragraph,
3278 )
3279 .unwrap();
3280 assert_eq!(
3281 selection.positions[1],
3282 TextPosition {
3283 paragraph: 1,
3284 offset: 4
3285 }
3286 );
3287 let original = editor.active_outline().document.clone();
3288 editor
3289 .select(
3290 [TextPosition {
3291 paragraph: 10,
3292 offset: 2,
3293 }; 2]
3294 .into(),
3295 )
3296 .unwrap();
3297 assert!(editor.indent(&mut engine, false).unwrap());
3298 assert_eq!(editor.active_outline().document.nodes()[2].level, 2);
3299 editor.undo(&mut engine).unwrap();
3300 assert_eq!(editor.active_outline().document, original);
3301 for (position, backward) in [
3302 (
3303 TextPosition {
3304 paragraph: 0,
3305 offset: 6,
3306 },
3307 false,
3308 ),
3309 (
3310 TextPosition {
3311 paragraph: 10,
3312 offset: 0,
3313 },
3314 true,
3315 ),
3316 ] {
3317 editor.select([position; 2].into()).unwrap();
3318 assert!(!editor.delete(&mut engine, backward).unwrap());
3319 assert_eq!(editor.active_outline().document, original);
3320 }
3321 }
3322
3323 #[test]
3324 fn cell_delete_boundaries_and_local_indentation_preserve_neighbors() {
3325 let mut engine = TextEngine::default();
3326 let mut editor = table_editor(&mut engine);
3327 let original = editor.active_outline().document.clone();
3328 let history = editor.undo.len();
3329 for paragraph in 0..4 {
3330 let text = original.paragraphs().nth(paragraph).unwrap();
3331 for (backward, offset) in [
3332 (true, 0),
3333 (false, text.utf16_offset(text.text().len()).unwrap()),
3334 ] {
3335 let selection = [TextPosition { paragraph, offset }; 2].into();
3336 editor.select(selection).unwrap();
3337 assert!(!editor.delete(&mut engine, backward).unwrap());
3338 assert_eq!(editor.active_outline().document, original);
3339 assert_eq!(editor.selection(), selection);
3340 assert_eq!(editor.undo.len(), history);
3341 }
3342 }
3343 editor
3344 .select(
3345 [TextPosition {
3346 paragraph: 2,
3347 offset: 2,
3348 }; 2]
3349 .into(),
3350 )
3351 .unwrap();
3352 editor.insert(&mut engine, "\n").unwrap();
3353 let split = editor.active_outline().document.clone();
3354 for backward in [true, false] {
3355 let position = if backward {
3356 TextPosition {
3357 paragraph: 3,
3358 offset: 0,
3359 }
3360 } else {
3361 TextPosition {
3362 paragraph: 2,
3363 offset: 2,
3364 }
3365 };
3366 let selection = [position; 2].into();
3367 editor.select(selection).unwrap();
3368 assert!(editor.delete(&mut engine, backward).unwrap());
3369 assert_eq!(editor.active_outline().document, original);
3370 editor.undo(&mut engine).unwrap();
3371 assert_eq!(editor.active_outline().document, split);
3372 assert_eq!(editor.selection(), selection);
3373 }
3374 let selection = [
3375 TextPosition {
3376 paragraph: 2,
3377 offset: 1,
3378 },
3379 TextPosition {
3380 paragraph: 3,
3381 offset: 0,
3382 },
3383 ]
3384 .into();
3385 editor.select(selection).unwrap();
3386 assert!(editor.indent(&mut engine, false).unwrap());
3387 assert_eq!(
3388 editor
3389 .active_outline()
3390 .document
3391 .text_nodes()
3392 .map(|node| node.level)
3393 .collect::<Vec<_>>(),
3394 [1, 1, 2, 1, 1]
3395 );
3396 assert_eq!(editor.selection(), selection);
3397 editor.undo(&mut engine).unwrap();
3398 assert_eq!(editor.active_outline().document, split);
3399 editor.redo(&mut engine).unwrap();
3400 assert!(editor.indent(&mut engine, true).unwrap());
3401 assert_eq!(editor.active_outline().document, split);
3402 }
3403
3404 #[test]
3405 fn overflowing_cell_caret_moves_to_the_next_rows_visual_column() {
3406 use crate::page::ParagraphContent;
3407 let mut engine = TextEngine::default();
3408 let mut source = table_editor(&mut engine).active_outline().snapshot();
3409 let ParagraphContent::Table(table) = &mut source.paragraphs[0].content else {
3410 panic!()
3411 };
3412 table.rows[0].cells[0].paragraphs[0]
3413 .text_mut()
3414 .unwrap()
3415 .text = Paragraph::new(
3416 "M".into(),
3417 Format {
3418 font_size: Some(130.0),
3419 ..Default::default()
3420 },
3421 );
3422 let mut editor =
3423 CanvasEditor::from_outlines(&mut engine, vec![source], BTreeMap::new()).unwrap();
3424 let original = editor.active_outline().document.clone();
3425 editor
3426 .select(
3427 [
3428 TextPosition {
3429 paragraph: 0,
3430 offset: 0,
3431 },
3432 TextPosition {
3433 paragraph: 0,
3434 offset: 1,
3435 },
3436 ]
3437 .into(),
3438 )
3439 .unwrap();
3440 let [_, _, right, _] = editor
3441 .active_outline()
3442 .shaped
3443 .paragraph_cell(0)
3444 .unwrap()
3445 .text_bounds();
3446 let selection = editor.selection_rects().unwrap();
3447 assert!(!selection.is_empty());
3448 assert!(selection.iter().all(|rect| rect.x1 <= f64::from(right)));
3449 for extend in [false, true] {
3450 editor
3451 .select(
3452 [TextPosition {
3453 paragraph: 0,
3454 offset: 1,
3455 }; 2]
3456 .into(),
3457 )
3458 .unwrap();
3459 let caret = editor.caret(1.0).unwrap();
3460 assert!(
3461 caret.x0 > f64::from(editor.active_outline().shaped.tables[0].cells[0].rect[2])
3462 );
3463 editor
3464 .move_selection(&mut engine, Movement::Down, extend)
3465 .unwrap();
3466 assert_eq!(editor.selection().positions[1].paragraph, 3);
3467 editor
3468 .move_selection(&mut engine, Movement::Up, extend)
3469 .unwrap();
3470 assert_eq!(
3471 editor.selection().positions[1],
3472 TextPosition {
3473 paragraph: 1,
3474 offset: 5
3475 }
3476 );
3477 assert_eq!(editor.active_outline().document, original);
3478 assert!(editor.undo.is_empty());
3479 }
3480 }
3481
3482 #[test]
3483 fn table_vertical_arrows_preserve_columns_and_cell_paragraphs() {
3484 let mut engine = TextEngine::default();
3485 let mut editor = table_editor(&mut engine);
3486 let original = editor.active_outline().document.clone();
3487 for column in 0..2 {
3488 editor
3489 .select(
3490 [TextPosition {
3491 paragraph: column,
3492 offset: 2,
3493 }; 2]
3494 .into(),
3495 )
3496 .unwrap();
3497 editor
3498 .move_selection(&mut engine, Movement::Down, false)
3499 .unwrap();
3500 assert_eq!(editor.selection().positions[1].paragraph, column + 2);
3501 editor
3502 .move_selection(&mut engine, Movement::Up, false)
3503 .unwrap();
3504 assert_eq!(
3505 editor.selection().positions[1],
3506 TextPosition {
3507 paragraph: column,
3508 offset: 2
3509 }
3510 );
3511 }
3512 editor
3513 .select(
3514 [TextPosition {
3515 paragraph: 0,
3516 offset: 2,
3517 }; 2]
3518 .into(),
3519 )
3520 .unwrap();
3521 editor.insert(&mut engine, "\n").unwrap();
3522 editor
3523 .select(
3524 [TextPosition {
3525 paragraph: 0,
3526 offset: 1,
3527 }; 2]
3528 .into(),
3529 )
3530 .unwrap();
3531 editor
3532 .move_selection(&mut engine, Movement::Down, false)
3533 .unwrap();
3534 assert_eq!(editor.selection().positions[1].paragraph, 1);
3535 editor
3536 .move_selection(&mut engine, Movement::Down, false)
3537 .unwrap();
3538 assert_eq!(editor.selection().positions[1].paragraph, 3);
3539 editor
3540 .select(
3541 [TextPosition {
3542 paragraph: 2,
3543 offset: 2,
3544 }; 2]
3545 .into(),
3546 )
3547 .unwrap();
3548 editor
3549 .move_selection(&mut engine, Movement::Down, false)
3550 .unwrap();
3551 assert_eq!(editor.selection().positions[1].paragraph, 4);
3552 editor
3553 .move_selection(&mut engine, Movement::Up, false)
3554 .unwrap();
3555 assert_eq!(
3556 editor.selection().positions[1],
3557 TextPosition {
3558 paragraph: 2,
3559 offset: 2
3560 }
3561 );
3562 editor.undo(&mut engine).unwrap();
3563 assert_eq!(editor.active_outline().document, original);
3564 }
3565
3566 #[test]
3567 fn table_edges_create_provisional_space_and_restore_it_with_history() {
3568 let mut engine = TextEngine::default();
3569 let mut editor = table_editor(&mut engine);
3570 editor.active_outline_mut().layout.x = Some(120.0);
3571 editor.active_outline_mut().layout.y = Some(120.0);
3572 let source = editor.active_outline().document.clone();
3573 editor
3574 .select(
3575 [TextPosition {
3576 paragraph: 3,
3577 offset: 4,
3578 }; 2]
3579 .into(),
3580 )
3581 .unwrap();
3582 let history = editor.undo.len();
3583 editor
3584 .move_selection(&mut engine, Movement::Down, false)
3585 .unwrap();
3586 assert_eq!(editor.outlines()[0].document, source);
3587 assert_eq!(editor.undo.len(), history);
3588 assert_eq!(
3589 editor.selection().positions,
3590 [TextPosition {
3591 paragraph: 4,
3592 offset: 0
3593 }; 2]
3594 );
3595 let draft = editor.active_outline().document.clone();
3596 let caret = editor.selection();
3597 editor.insert(&mut engine, "X").unwrap();
3598 let committed = editor.active_outline().document.clone();
3599 assert_eq!(committed.nodes().len(), 2);
3600 assert_eq!(committed.paragraphs().last().unwrap().text(), "X");
3601 for _ in 0..2 {
3602 editor.undo(&mut engine).unwrap();
3603 assert_eq!(editor.outlines()[0].document, source);
3604 assert_eq!(editor.active_outline().document, draft);
3605 assert_eq!(editor.selection(), caret);
3606 editor.redo(&mut engine).unwrap();
3607 assert_eq!(editor.active_outline().document, committed);
3608 }
3609 editor.undo(&mut engine).unwrap();
3610 editor
3611 .select(
3612 [TextPosition {
3613 paragraph: 0,
3614 offset: 0,
3615 }; 2]
3616 .into(),
3617 )
3618 .unwrap();
3619 editor
3620 .move_selection(&mut engine, Movement::Up, false)
3621 .unwrap();
3622 assert_eq!(editor.caret_outline().unwrap().origin(), [120.0, 86.4]);
3623 assert_eq!(editor.outlines()[0].document, source);
3624 editor.insert(&mut engine, "X").unwrap();
3625 assert_eq!(editor.outlines().len(), 2);
3626 editor.undo(&mut engine).unwrap();
3627 assert!(editor.caret_outline().is_some());
3628 assert_eq!(editor.outlines().len(), 1);
3629 assert_eq!(editor.outlines()[0].document, source);
3630 }
3631
3632 #[test]
3633 fn editing_a_cell_trims_only_provisional_root_paragraphs() {
3634 let mut engine = TextEngine::default();
3635 for compose in [false, true] {
3636 let mut editor = table_editor(&mut engine);
3637 let source = editor.active_outline().document.clone();
3638 editor
3639 .select(
3640 [TextPosition {
3641 paragraph: 3,
3642 offset: 4,
3643 }; 2]
3644 .into(),
3645 )
3646 .unwrap();
3647 for _ in 0..2 {
3648 editor
3649 .move_selection(&mut engine, Movement::Down, false)
3650 .unwrap();
3651 }
3652 for _ in 0..2 {
3653 editor
3654 .move_selection(&mut engine, Movement::Up, false)
3655 .unwrap();
3656 }
3657 assert_eq!(editor.selection().positions[1].paragraph, 3);
3658 let draft = editor.active_outline().document.clone();
3659 assert_eq!(draft.nodes().len(), 3);
3660 let selection = editor.selection();
3661 if compose {
3662 editor
3663 .compose(&mut engine, "temporary".into(), 9..9)
3664 .unwrap();
3665 editor.cancel_composition(&mut engine).unwrap();
3666 assert_eq!(editor.active_outline().document, draft);
3667 editor.compose(&mut engine, "marked".into(), 6..6).unwrap();
3668 editor.commit_text(&mut engine, "X".into()).unwrap();
3669 } else {
3670 editor.insert(&mut engine, "X").unwrap();
3671 }
3672 let after = editor.active_outline().document.clone();
3673 assert_eq!(after.nodes().len(), 1);
3674 assert_eq!(after.paragraphs().nth(3).unwrap().text(), "LastX");
3675 let fresh = editor
3676 .active_outline()
3677 .snapshot()
3678 .layout(&mut engine, &BTreeMap::new())
3679 .unwrap();
3680 assert_eq!(editor.active_outline().shaped.size, fresh.size);
3681 assert_eq!(editor.active_outline().shaped.paragraphs.len(), 4);
3682 editor.undo(&mut engine).unwrap();
3683 assert_eq!(editor.outlines()[0].document, source);
3684 assert_eq!(editor.active_outline().document, draft);
3685 assert_eq!(editor.selection(), selection);
3686 editor.redo(&mut engine).unwrap();
3687 assert_eq!(editor.active_outline().document, after);
3688 }
3689 }
3690
3691 #[test]
3692 fn editing_during_cell_composition_keeps_table_flow_and_history() {
3693 let mut engine = TextEngine::default();
3694 let mut editor = table_editor(&mut engine);
3695 let source = editor.active_outline().document.clone();
3696 editor
3697 .select(
3698 [TextPosition {
3699 paragraph: 3,
3700 offset: 4,
3701 }; 2]
3702 .into(),
3703 )
3704 .unwrap();
3705 for _ in 0..2 {
3706 editor
3707 .move_selection(&mut engine, Movement::Down, false)
3708 .unwrap();
3709 }
3710 for _ in 0..2 {
3711 editor
3712 .move_selection(&mut engine, Movement::Up, false)
3713 .unwrap();
3714 }
3715 let draft = editor.active_outline().document.clone();
3716 editor.compose(&mut engine, "a".into(), 1..1).unwrap();
3717 editor.insert(&mut engine, "b").unwrap();
3718 assert_eq!(
3719 editor
3720 .active_outline()
3721 .document
3722 .paragraphs()
3723 .last()
3724 .unwrap()
3725 .text(),
3726 "Lastab"
3727 );
3728 let fresh = editor
3729 .active_outline()
3730 .snapshot()
3731 .layout(&mut engine, &BTreeMap::new())
3732 .unwrap();
3733 assert_eq!(editor.active_outline().shaped.size, fresh.size);
3734 assert_eq!(
3735 editor.active_outline().shaped.paragraphs.len(),
3736 fresh.paragraphs.len()
3737 );
3738 editor.undo(&mut engine).unwrap();
3739 assert_eq!(
3740 editor
3741 .active_outline()
3742 .document
3743 .paragraphs()
3744 .last()
3745 .unwrap()
3746 .text(),
3747 "Lasta"
3748 );
3749 editor.undo(&mut engine).unwrap();
3750 assert_eq!(editor.outlines()[0].document, source);
3751 assert_eq!(editor.active_outline().document, draft);
3752 }
3753
3754 #[test]
3755 fn table_horizontal_arrows_follow_reading_order() {
3756 let mut engine = TextEngine::default();
3757 let mut editor = table_editor(&mut engine);
3758 let original = editor.active_outline().document.clone();
3759 let history = editor.undo.len();
3760 for paragraph in 0..4 {
3761 let text = original.paragraphs().nth(paragraph).unwrap();
3762 let end = text.utf16_offset(text.text().len()).unwrap();
3763 editor
3764 .select(
3765 [TextPosition {
3766 paragraph,
3767 offset: end,
3768 }; 2]
3769 .into(),
3770 )
3771 .unwrap();
3772 editor
3773 .move_selection(&mut engine, Movement::Right, false)
3774 .unwrap();
3775 let next = if paragraph < 3 {
3776 TextPosition {
3777 paragraph: paragraph + 1,
3778 offset: 0,
3779 }
3780 } else {
3781 TextPosition {
3782 paragraph,
3783 offset: end,
3784 }
3785 };
3786 assert_eq!(editor.selection().positions, [next; 2]);
3787 if paragraph < 3 {
3788 editor
3789 .move_selection(&mut engine, Movement::Left, false)
3790 .unwrap();
3791 assert_eq!(
3792 editor.selection().positions,
3793 [TextPosition {
3794 paragraph,
3795 offset: end
3796 }; 2]
3797 );
3798 }
3799 }
3800 assert_eq!(editor.active_outline().document, original);
3801 assert_eq!(editor.undo.len(), history);
3802 assert!(editor.caret_outline().is_none());
3803 }
3804
3805 #[test]
3806 fn column_width_edits_reflow_and_restore_structured_drafts() {
3807 use crate::page::ParagraphContent;
3808 let mut engine = TextEngine::default();
3809 for draft in [false, true] {
3810 let mut editor = table_editor(&mut engine);
3811 let stored = editor.outlines()[0].document.clone();
3812 editor
3813 .select(
3814 [TextPosition {
3815 paragraph: 3,
3816 offset: 4,
3817 }; 2]
3818 .into(),
3819 )
3820 .unwrap();
3821 if draft {
3822 for _ in 0..2 {
3823 editor
3824 .move_selection(&mut engine, Movement::Down, false)
3825 .unwrap();
3826 }
3827 for _ in 0..2 {
3828 editor
3829 .move_selection(&mut engine, Movement::Up, false)
3830 .unwrap();
3831 }
3832 }
3833 let before = editor.active_outline().document.clone();
3834 let selection = editor.selection();
3835 let ParagraphContent::Table(table) = &before.nodes()[0].content else {
3836 panic!()
3837 };
3838 let position = selection.positions[1];
3839 let mut edit = before
3840 .replace(
3841 position..position,
3842 vec![Paragraph::new(
3843 " long cell content".into(),
3844 Format::default(),
3845 )],
3846 )
3847 .unwrap();
3848 edit.columns.insert(table.id, vec![96.375, 36.0]);
3849 editor.commit(&mut engine, edit, selection).unwrap();
3850 let after = editor.active_outline().document.clone();
3851 let fresh = editor
3852 .active_outline()
3853 .snapshot()
3854 .layout(&mut engine, &BTreeMap::new())
3855 .unwrap();
3856 assert_eq!(editor.active_outline().shaped.size, fresh.size);
3857 assert_eq!(
3858 editor.active_outline().shaped.tables[0]
3859 .cells
3860 .iter()
3861 .map(|c| c.rect)
3862 .collect::<Vec<_>>(),
3863 fresh.tables[0]
3864 .cells
3865 .iter()
3866 .map(|c| c.rect)
3867 .collect::<Vec<_>>()
3868 );
3869 for _ in 0..2 {
3870 editor.undo(&mut engine).unwrap();
3871 assert_eq!(editor.active_outline().document, before);
3872 assert_eq!(editor.outlines()[0].document, stored);
3873 assert_eq!(editor.selection(), selection);
3874 editor.redo(&mut engine).unwrap();
3875 assert_eq!(editor.active_outline().document, after);
3876 }
3877 let selection = editor.selection();
3878 let history = editor.undo.len();
3879 for widths in [vec![f32::MAX; 2], vec![36.0, f32::NAN]] {
3880 let position = selection.positions[1];
3881 let mut edit = after
3882 .replace(
3883 position..position,
3884 vec![Paragraph::new("invalid".into(), Format::default())],
3885 )
3886 .unwrap();
3887 edit.columns.insert(table.id, widths);
3888 assert!(editor.commit(&mut engine, edit, selection).is_err());
3889 assert_eq!(editor.active_outline().document, after);
3890 assert_eq!(editor.selection(), selection);
3891 assert_eq!(editor.undo.len(), history);
3892 }
3893 }
3894 }
3895
3896 #[test]
3897 fn cell_edits_reuse_neighbor_layouts_and_restore_table_geometry() {
3898 let mut engine = TextEngine::default();
3899 let mut editor = table_editor(&mut engine);
3900 let before = editor.active_outline().document.clone();
3901 let layout_ids = editor
3902 .active_outline()
3903 .shaped
3904 .paragraphs
3905 .iter()
3906 .map(|p| p.text.id())
3907 .collect::<Vec<_>>();
3908 let size = editor.active_outline().shaped.size;
3909 let cell_rects = editor.active_outline().shaped.tables[0]
3910 .cells
3911 .iter()
3912 .map(|cell| cell.rect)
3913 .collect::<Vec<_>>();
3914 let right_text = editor
3915 .active_outline()
3916 .document
3917 .paragraphs()
3918 .nth(1)
3919 .unwrap()
3920 .text()
3921 .as_ptr();
3922 let selection = [TextPosition {
3923 paragraph: 0,
3924 offset: 2,
3925 }; 2]
3926 .into();
3927 editor.select(selection).unwrap();
3928 editor
3929 .insert(&mut engine, " long text that wraps\nsecond paragraph")
3930 .unwrap();
3931 let outline = editor.active_outline();
3932 assert_eq!(outline.document.paragraphs().count(), 5);
3933 assert!(outline.shaped.size[1] > size[1]);
3934 assert_eq!(
3935 outline
3936 .document
3937 .paragraphs()
3938 .nth(2)
3939 .unwrap()
3940 .text()
3941 .as_ptr(),
3942 right_text
3943 );
3944 for (index, original) in layout_ids.iter().enumerate().skip(1) {
3945 assert_eq!(
3946 outline.paragraph_layout(index + 1).unwrap().text.id(),
3947 *original
3948 );
3949 }
3950 assert_eq!(
3951 outline
3952 .layouts()
3953 .map(|(index, _)| index)
3954 .collect::<Vec<_>>(),
3955 [0, 1, 2, 3, 4]
3956 );
3957 assert_eq!(
3958 outline.shaped.tables[0].cells[0].rect[3],
3959 outline.shaped.tables[0].cells[1].rect[3]
3960 );
3961 assert!(outline.shaped.tables[0].cells[2].rect[1] > cell_rects[2][1]);
3962 let after = outline.document.clone();
3963 let after_selection = outline.selection;
3964 let after_rects = outline.shaped.tables[0]
3965 .cells
3966 .iter()
3967 .map(|cell| cell.rect)
3968 .collect::<Vec<_>>();
3969 for _ in 0..3 {
3970 editor.undo(&mut engine).unwrap();
3971 let outline = editor.active_outline();
3972 assert_eq!(outline.document, before);
3973 assert_eq!(outline.selection, selection);
3974 assert_eq!(outline.shaped.size, size);
3975 assert_eq!(
3976 outline.shaped.tables[0]
3977 .cells
3978 .iter()
3979 .map(|cell| cell.rect)
3980 .collect::<Vec<_>>(),
3981 cell_rects
3982 );
3983 editor.redo(&mut engine).unwrap();
3984 let outline = editor.active_outline();
3985 assert_eq!(outline.document, after);
3986 assert_eq!(outline.selection, after_selection);
3987 assert_eq!(
3988 outline.shaped.tables[0]
3989 .cells
3990 .iter()
3991 .map(|cell| cell.rect)
3992 .collect::<Vec<_>>(),
3993 after_rects
3994 );
3995 }
3996 editor.undo(&mut engine).unwrap();
3997 editor.undo(&mut engine).unwrap();
3998 assert_eq!(
3999 editor
4000 .active_outline()
4001 .document
4002 .paragraphs()
4003 .next()
4004 .unwrap()
4005 .text(),
4006 "Left"
4007 );
4008 assert!(editor.active_outline().shaped.tables.is_empty());
4009 editor.redo(&mut engine).unwrap();
4010 assert_eq!(editor.active_outline().document, before);
4011 assert_eq!(editor.active_outline().shaped.size, size);
4012 }
4013
4014 #[test]
4015 fn cell_composition_and_failed_layout_leave_atomic_history() {
4016 let mut engine = TextEngine::default();
4017 let mut editor = table_editor(&mut engine);
4018 editor
4019 .select(
4020 [TextPosition {
4021 paragraph: 2,
4022 offset: 2,
4023 }; 2]
4024 .into(),
4025 )
4026 .unwrap();
4027 let before = editor.active_outline().document.clone();
4028 let selection = editor.selection();
4029 let cells = editor.active_outline().shaped.tables[0]
4030 .cells
4031 .iter()
4032 .map(|cell| cell.rect)
4033 .collect::<Vec<_>>();
4034 let ids = editor
4035 .active_outline()
4036 .shaped
4037 .paragraphs
4038 .iter()
4039 .map(|p| p.text.id())
4040 .collect::<Vec<_>>();
4041 let history = editor.undo.len();
4042 editor.compose(&mut engine, "🧊\n中".into(), 4..4).unwrap();
4043 editor.compose(&mut engine, "short".into(), 5..5).unwrap();
4044 assert!(editor.cancel_composition(&mut engine).unwrap());
4045 assert_eq!(editor.active_outline().document, before);
4046 assert_eq!(editor.selection(), selection);
4047 assert_eq!(editor.undo.len(), history);
4048 assert_eq!(
4049 editor.active_outline().shaped.tables[0]
4050 .cells
4051 .iter()
4052 .map(|cell| cell.rect)
4053 .collect::<Vec<_>>(),
4054 cells
4055 );
4056 for index in [0, 1, 3] {
4057 assert_eq!(
4058 editor
4059 .active_outline()
4060 .paragraph_layout(index)
4061 .unwrap()
4062 .text
4063 .id(),
4064 ids[index]
4065 );
4066 }
4067 editor.compose(&mut engine, "🧊\n中".into(), 4..4).unwrap();
4068 editor.commit_text(&mut engine, "done".into()).unwrap();
4069 assert_eq!(editor.undo.len(), history + 1);
4070 editor.undo(&mut engine).unwrap();
4071 assert_eq!(editor.active_outline().document, before);
4072 assert_eq!(editor.selection(), selection);
4073 let redo = editor.redo.len();
4074 assert!(
4075 editor
4076 .replace(
4077 &mut engine,
4078 vec![Paragraph::new(
4079 "bad".into(),
4080 Format {
4081 font_size: Some(f32::NAN),
4082 ..Format::default()
4083 }
4084 )]
4085 )
4086 .is_err()
4087 );
4088 assert_eq!(editor.active_outline().document, before);
4089 assert_eq!(editor.selection(), selection);
4090 assert_eq!(editor.undo.len(), history);
4091 assert_eq!(editor.redo.len(), redo);
4092 editor.redo(&mut engine).unwrap();
4093 assert_eq!(
4094 editor
4095 .active_outline()
4096 .document
4097 .paragraphs()
4098 .nth(2)
4099 .unwrap()
4100 .text(),
4101 "Bedonelow"
4102 );
4103 }
4104
4105 #[test]
4106 fn retained_cell_edits_match_fresh_layout_for_every_text_range() {
4107 let mut engine = TextEngine::default();
4108 let base = table_editor(&mut engine);
4109 for (index, paragraph) in base.active_outline().document.paragraphs().enumerate() {
4110 let offsets = paragraph
4111 .text()
4112 .char_indices()
4113 .map(|(byte, _)| paragraph.utf16_offset(byte).unwrap())
4114 .chain(std::iter::once(
4115 paragraph.utf16_offset(paragraph.text().len()).unwrap(),
4116 ))
4117 .collect::<Vec<_>>();
4118 for &start in &offsets {
4119 for &end in offsets.iter().filter(|&&end| end >= start) {
4120 for text in ["", "🧊", "x\ny"] {
4121 let mut editor = CanvasEditor::from_text_outlines(
4122 vec![base.active_outline().clone()],
4123 BTreeMap::new(),
4124 None,
4125 )
4126 .unwrap();
4127 let selection = [
4128 TextPosition {
4129 paragraph: index,
4130 offset: start,
4131 },
4132 TextPosition {
4133 paragraph: index,
4134 offset: end,
4135 },
4136 ]
4137 .into();
4138 editor.select(selection).unwrap();
4139 editor.insert(&mut engine, text).unwrap();
4140 let retained = &editor.active_outline().shaped;
4141 let fresh = editor
4142 .active_outline()
4143 .snapshot()
4144 .layout(&mut engine, &editor.definitions)
4145 .unwrap();
4146 assert_eq!(retained.size, fresh.size);
4147 assert_eq!(
4148 retained.tables[0]
4149 .cells
4150 .iter()
4151 .map(|c| c.rect)
4152 .collect::<Vec<_>>(),
4153 fresh.tables[0]
4154 .cells
4155 .iter()
4156 .map(|c| c.rect)
4157 .collect::<Vec<_>>()
4158 );
4159 for (retained, fresh) in retained.paragraphs.iter().zip(&fresh.paragraphs) {
4160 assert_eq!(retained.id, fresh.id);
4161 assert_eq!(retained.origin, fresh.origin);
4162 assert_eq!(retained.text.height(), fresh.text.height());
4163 assert_eq!(
4164 retained
4165 .text
4166 .lines()
4167 .map(|(line, bounds)| (
4168 line.metrics().advance,
4169 bounds.source.clone()
4170 ))
4171 .collect::<Vec<_>>(),
4172 fresh
4173 .text
4174 .lines()
4175 .map(|(line, bounds)| (
4176 line.metrics().advance,
4177 bounds.source.clone()
4178 ))
4179 .collect::<Vec<_>>()
4180 );
4181 }
4182 editor.undo(&mut engine).unwrap();
4183 assert_eq!(
4184 editor.active_outline().document,
4185 base.active_outline().document
4186 );
4187 assert_eq!(editor.selection(), selection);
4188 }
4189 }
4190 }
4191 }
4192 }
4193
4194 #[test]
4195 fn cross_outline_validation_includes_table_row_and_cell_ids() {
4196 use crate::page::ParagraphContent;
4197 let mut engine = TextEngine::default();
4198 let first = table_editor(&mut engine).active_outline().clone();
4199 let second = table_editor(&mut engine).active_outline().clone();
4200 let ParagraphContent::Table(first_table) = &first.document.nodes()[0].content else {
4201 panic!()
4202 };
4203 for alias in 0..3 {
4204 let mut second = second.clone();
4205 let mut nodes = second.document.nodes().to_vec();
4206 let ParagraphContent::Table(table) = &mut nodes[0].content else {
4207 panic!()
4208 };
4209 match alias {
4210 0 => table.id = first_table.id,
4211 1 => table.rows[0].id = first_table.rows[0].id,
4212 _ => table.rows[0].cells[0].id = first_table.rows[0].cells[0].id,
4213 }
4214 second.document = TextDocument::from_nodes(nodes).unwrap();
4215 assert!(matches!(
4216 CanvasEditor::from_text_outlines(
4217 vec![first.clone(), second],
4218 BTreeMap::new(),
4219 None
4220 ),
4221 Err(EditorError::Edit(EditError::InvalidStructure))
4222 ));
4223 }
4224 }
4225
4226 #[test]
4227 fn title_flow_moves_page_objects_atomically_and_cancels_composition() {
4228 use crate::page::{Image, Page, PageObject, Title};
4229 let mut engine = TextEngine::default();
4230 let mut title = TextOutline::new(
4231 &mut engine,
4232 TextDocument::new(vec![Paragraph::new("Header".into(), Default::default())]).unwrap(),
4233 468.0,
4234 [0.0; 2],
4235 )
4236 .unwrap()
4237 .snapshot();
4238 title.title = true;
4239 title.layout.max_width = None;
4240 title.layout.width_set_by_user = None;
4241 let body = TextOutline::new(
4242 &mut engine,
4243 TextDocument::new(vec![Paragraph::new("Anchor".into(), Default::default())]).unwrap(),
4244 72.0,
4245 [120.0, 80.0],
4246 )
4247 .unwrap()
4248 .snapshot();
4249 let body_id = body.id;
4250 let images = [
4251 (120.0, 90.0, false),
4252 (600.0, 150.0, false),
4253 (120.0, 0.0, false),
4254 (120.0, 80.0, true),
4255 ]
4256 .map(|(x, y, background)| Image {
4257 id: crate::text::new_id().unwrap(),
4258 layout: onestore::document::Layout {
4259 x: Some(x),
4260 y: Some(y),
4261 max_width: Some(72.0),
4262 max_height: Some(40.0),
4263 ..Default::default()
4264 },
4265 bytes: Some(std::sync::Arc::from(b"deferred image payload".as_slice())),
4266 alt: None,
4267 background,
4268 });
4269 let image_ids = images.each_ref().map(|image| image.id);
4270 let image_bytes = images
4271 .each_ref()
4272 .map(|image| image.bytes.as_ref().unwrap().clone());
4273 let mut objects = vec![
4274 PageObject::Title(Title {
4275 id: crate::text::new_id().unwrap(),
4276 date: None,
4277 layout: Default::default(),
4278 outlines: vec![title],
4279 }),
4280 PageObject::Outline(body),
4281 ];
4282 objects.extend(images.into_iter().map(PageObject::Image));
4283 let mut editor = CanvasEditor::from_page(
4284 Page {
4285 title: "Header".into(),
4286 created: None,
4287 margin_origin: [36.0, 14.4],
4288 definitions: BTreeMap::new(),
4289 objects,
4290 },
4291 &mut engine,
4292 )
4293 .unwrap();
4294 let positions = |editor: &CanvasEditor| {
4295 editor
4296 .object_layouts()
4297 .map(|(id, layout)| (id, layout.clone()))
4298 .collect::<Vec<_>>()
4299 };
4300 let original = positions(&editor);
4301 let text = "Header\u{000b}Second\u{000b}Third\u{000b}Fourth\u{000b}Fifth\u{000b}Sixth";
4302 let end = text.encode_utf16().count().try_into().unwrap();
4303 editor.select_all().unwrap();
4304 editor.compose(&mut engine, text.into(), end..end).unwrap();
4305 let moved = positions(&editor);
4306 let y = |items: &Vec<(ExGuid, onestore::document::Layout)>, id| {
4307 items
4308 .iter()
4309 .find(|(candidate, _)| *candidate == id)
4310 .unwrap()
4311 .1
4312 .y
4313 .unwrap()
4314 };
4315 let delta = y(&moved, body_id) - y(&original, body_id);
4316 assert!(delta > 0.0);
4317 for id in &image_ids[..2] {
4318 assert!((y(&moved, *id) - y(&original, *id) - delta).abs() < 0.001);
4319 }
4320 for id in &image_ids[2..] {
4321 assert_eq!(y(&moved, *id), y(&original, *id));
4322 }
4323 editor.compose(&mut engine, "Header".into(), 6..6).unwrap();
4324 assert_eq!(positions(&editor), moved);
4325 editor.cancel_composition(&mut engine).unwrap();
4326 assert_eq!(positions(&editor), original);
4327 assert!(!editor.undo(&mut engine).unwrap());
4328 editor.select_all().unwrap();
4329 editor.compose(&mut engine, text.into(), end..end).unwrap();
4330 let taller = format!("{text}\u{000b}Seventh\u{000b}Eighth");
4331 let taller_end = taller.encode_utf16().count().try_into().unwrap();
4332 editor
4333 .compose(&mut engine, taller, taller_end..taller_end)
4334 .unwrap();
4335 let farther = positions(&editor);
4336 assert!(y(&farther, body_id) > y(&moved, body_id));
4337 editor.commit_text(&mut engine, "Header".into()).unwrap();
4338 assert_eq!(positions(&editor), farther);
4339 editor.undo(&mut engine).unwrap();
4340 assert_eq!(positions(&editor), original);
4341 assert!(!editor.undo(&mut engine).unwrap());
4342 editor.redo(&mut engine).unwrap();
4343 assert_eq!(positions(&editor), farther);
4344 editor.undo(&mut engine).unwrap();
4345 editor.select_all().unwrap();
4346 editor.insert(&mut engine, text).unwrap();
4347 assert_eq!(positions(&editor), moved);
4348 editor.select_all().unwrap();
4349 editor.insert(&mut engine, "Header").unwrap();
4350 assert_eq!(positions(&editor), moved);
4351 editor.undo(&mut engine).unwrap();
4352 assert_eq!(positions(&editor), moved);
4353 editor.undo(&mut engine).unwrap();
4354 assert_eq!(positions(&editor), original);
4355 editor.redo(&mut engine).unwrap();
4356 editor.redo(&mut engine).unwrap();
4357 assert_eq!(positions(&editor), moved);
4358 for (id, bytes) in image_ids.into_iter().zip(image_bytes) {
4359 let image = editor
4360 .objects
4361 .iter()
4362 .find_map(|object| match object {
4363 page::Content::Image(image) if image.id == id => Some(image),
4364 _ => None,
4365 })
4366 .unwrap();
4367 assert!(std::sync::Arc::ptr_eq(
4368 image.bytes.as_ref().unwrap(),
4369 &bytes
4370 ));
4371 }
4372 }
4373
4374 #[test]
4375 fn floating_outline_exit_uses_document_position_and_keeps_real_blank_lines() {
4376 let mut engine = TextEngine::default();
4377 for (x, real_blanks, size, exit_y) in [
4378 (179.9, 0, 11.0, None),
4379 (180.0, 0, 11.0, Some(230.4)),
4380 (270.0, 1, 11.0, Some(230.4)),
4381 (270.0, 0, 22.0, Some(248.4)),
4382 (270.0, 0, 44.0, Some(320.4)),
4383 ] {
4384 let mut paragraphs = vec![Paragraph::new(
4385 "Text".into(),
4386 Format {
4387 font_size: Some(size),
4388 ..Default::default()
4389 },
4390 )];
4391 paragraphs
4392 .extend((0..real_blanks).map(|_| Paragraph::new(String::new(), Format::default())));
4393 let source = TextDocument::new(paragraphs).unwrap();
4394 let outline = TextOutline::new(&mut engine, source.clone(), 72.0, [x, 158.4]).unwrap();
4395 let mut editor =
4396 CanvasEditor::from_text_outlines(vec![outline], BTreeMap::new(), None).unwrap();
4397 editor
4398 .move_selection(&mut engine, Movement::DocumentEnd, false)
4399 .unwrap();
4400 for _ in 0..3 - real_blanks {
4401 editor
4402 .move_selection(&mut engine, Movement::Down, false)
4403 .unwrap();
4404 }
4405 assert_eq!(editor.outlines[0].document, source);
4406 assert_eq!(editor.caret_outline().is_some(), exit_y.is_some());
4407 if let Some(y) = exit_y {
4408 assert_eq!(editor.active_outline().origin(), [x, y]);
4409 assert_eq!(
4410 editor.active_outline().layout.max_width,
4411 Some(DEFAULT_OUTLINE_WIDTH)
4412 );
4413 editor.insert(&mut engine, "New").unwrap();
4414 assert_eq!(editor.outlines.len(), 2);
4415 editor.undo(&mut engine).unwrap();
4416 assert_eq!(editor.outlines.len(), 1);
4417 assert_eq!(editor.active_outline().origin(), [x, y]);
4418 } else {
4419 assert_eq!(editor.active_outline().document.nodes().len(), 4);
4420 }
4421 assert!(editor.undo.is_empty());
4422 }
4423 }
4424
4425 #[test]
4426 fn vertical_exit_and_reentry_preserve_the_text_column_and_source() {
4427 let mut engine = TextEngine::default();
4428 for (x, y, above) in [
4429 (36.0, 158.4, 122.4),
4430 (180.0, 158.4, 122.4),
4431 (180.0, 178.0, 140.4),
4432 (180.0, 180.0, 140.4),
4433 (180.0, 184.0, 158.4),
4434 ] {
4435 let source =
4436 TextDocument::new(vec![Paragraph::new("Text".into(), Format::default())]).unwrap();
4437 let outline = TextOutline::new(&mut engine, source.clone(), 72.0, [x, y]).unwrap();
4438 let id = outline.id;
4439 let mut editor =
4440 CanvasEditor::from_text_outlines(vec![outline], BTreeMap::new(), None).unwrap();
4441 editor
4442 .move_selection(&mut engine, Movement::DocumentEnd, false)
4443 .unwrap();
4444 let column = editor.caret(1.0).unwrap().x0 as f32;
4445 editor
4446 .move_selection(&mut engine, Movement::Up, false)
4447 .unwrap();
4448 assert_eq!(
4449 editor.caret_outline().unwrap().origin(),
4450 [x + column, above]
4451 );
4452 editor.focus_outline(id).unwrap();
4453 if x >= 180.0 {
4454 for _ in 0..3 {
4455 editor
4456 .move_selection(&mut engine, Movement::Down, false)
4457 .unwrap();
4458 }
4459 assert!(editor.caret_outline().is_some());
4460 editor
4461 .move_selection(&mut engine, Movement::Up, false)
4462 .unwrap();
4463 assert!(editor.caret_outline().is_some());
4464 editor
4465 .move_selection(&mut engine, Movement::Up, false)
4466 .unwrap();
4467 if y == 184.0 {
4468 assert!(editor.caret_outline().is_some());
4469 editor
4470 .move_selection(&mut engine, Movement::Up, false)
4471 .unwrap();
4472 }
4473 assert_eq!(editor.active_outline().id, id);
4474 assert_eq!(editor.selection().positions[1].offset, 4);
4475 }
4476 assert_eq!(editor.outlines[0].document, source);
4477 assert!(editor.undo.is_empty());
4478 }
4479 }
4480
4481 #[test]
4482 fn provisional_paragraphs_use_the_base_style_instead_of_local_run_formatting() {
4483 let mut engine = TextEngine::default();
4484 let base = Format {
4485 font: Some("Arial".into()),
4486 font_size: Some(11.0),
4487 ..Default::default()
4488 };
4489 let style = ExGuid {
4490 n: 9,
4491 ..Default::default()
4492 };
4493 let mut nodes = TextDocument::new(vec![Paragraph::new(
4494 "Text".into(),
4495 Format {
4496 font_size: Some(22.0),
4497 bold: Some(true),
4498 ..base.clone()
4499 },
4500 )])
4501 .unwrap()
4502 .nodes()
4503 .to_vec();
4504 nodes[0].style = Some(style);
4505 let outline = TextOutline::new(
4506 &mut engine,
4507 TextDocument::from_nodes(nodes).unwrap(),
4508 240.0,
4509 [0.0; 2],
4510 )
4511 .unwrap();
4512 let mut editor = CanvasEditor::from_text_outlines(
4513 vec![outline],
4514 BTreeMap::from([(
4515 style,
4516 Definition {
4517 kind: onestore::document::Kind::Style {
4518 name: Some("p".into()),
4519 },
4520 format: base.clone(),
4521 },
4522 )]),
4523 None,
4524 )
4525 .unwrap();
4526 editor
4527 .move_selection(&mut engine, Movement::Down, false)
4528 .unwrap();
4529 let paragraph = &editor.active_outline().document.nodes()[1];
4530 assert_eq!(paragraph.style, Some(style));
4531 assert_eq!(paragraph.text().unwrap().text.spans()[0].format, base);
4532 assert_eq!(
4533 editor.active_outline().shaped.paragraphs[1].text.height(),
4534 engine
4535 .layout(&Paragraph::new(String::new(), base), 240.0)
4536 .unwrap()
4537 .height()
4538 );
4539 assert_eq!(editor.outlines[0].document.nodes().len(), 1);
4540 }
4541
4542 #[test]
4543 fn clicks_below_use_base_style_preserve_source_and_share_draft_history() {
4544 let mut engine = TextEngine::default();
4545 for font_size in [11.0, 22.0] {
4546 let style = ExGuid {
4547 n: 900,
4548 ..Default::default()
4549 };
4550 let base = Format {
4551 font: Some("Arial".into()),
4552 font_size: Some(font_size),
4553 ..Default::default()
4554 };
4555 let mut nodes = TextDocument::new(vec![Paragraph::new(
4556 "Body".into(),
4557 Format {
4558 font_size: Some(44.0),
4559 bold: Some(true),
4560 ..base.clone()
4561 },
4562 )])
4563 .unwrap()
4564 .nodes()
4565 .to_vec();
4566 nodes[0].style = Some(style);
4567 let outline = TextOutline::new(
4568 &mut engine,
4569 TextDocument::from_nodes(nodes).unwrap(),
4570 240.0,
4571 [180.0, 90.0],
4572 )
4573 .unwrap();
4574 let id = outline.id;
4575 let source = outline.document.clone();
4576 let layout = outline.layout.clone();
4577 let text_id = outline.shaped.paragraphs[0].text.id();
4578 let bottom = outline.shaped.size[1];
4579 let mut editor = CanvasEditor::from_text_outlines(
4580 vec![outline],
4581 BTreeMap::from([(
4582 style,
4583 Definition {
4584 kind: onestore::document::Kind::Style {
4585 name: Some("p".into()),
4586 },
4587 format: base.clone(),
4588 },
4589 )]),
4590 None,
4591 )
4592 .unwrap();
4593 assert!(
4594 editor
4595 .select_below(&mut engine, id, [10.0, bottom + 20.0])
4596 .unwrap()
4597 );
4598 let blanks = if font_size == 11.0 { 2 } else { 1 };
4599 assert_eq!(editor.active_outline().document.nodes().len(), blanks + 1);
4600 assert_eq!(
4601 editor.selection().positions[1],
4602 TextPosition {
4603 paragraph: blanks,
4604 offset: 0
4605 }
4606 );
4607 assert_eq!(editor.outlines[0].document, source);
4608 assert_eq!(
4609 editor.active_outline().shaped.paragraphs[0].text.id(),
4610 text_id
4611 );
4612 for paragraph in &editor.active_outline().document.nodes()[1..] {
4613 assert_eq!(paragraph.style, Some(style));
4614 assert_eq!(paragraph.text().unwrap().text.spans()[0].format, base);
4615 }
4616 let pending = editor.active_outline().document.clone();
4617 let selection = editor.selection();
4618 assert!(
4619 !editor
4620 .select_below(&mut engine, id, [10.0, bottom + 28.0])
4621 .unwrap()
4622 );
4623 assert_eq!(editor.active_outline().document, pending);
4624 assert_eq!(editor.selection(), selection);
4625 assert!(editor.undo.is_empty());
4626 editor.compose(&mut engine, "日本".into(), 2..2).unwrap();
4627 assert!(editor.cancel_composition(&mut engine).unwrap());
4628 assert_eq!(editor.active_outline().document, pending);
4629 editor.insert(&mut engine, "X").unwrap();
4630 let edited = editor.active_outline().document.clone();
4631 assert_eq!(editor.outlines[0].layout, layout);
4632 assert_eq!(edited.nodes()[0], source.nodes()[0]);
4633 assert!(editor.undo(&mut engine).unwrap());
4634 assert_eq!(editor.active_outline().document, pending);
4635 assert_eq!(editor.outlines[0].document, source);
4636 assert_eq!(editor.selection(), selection);
4637 assert!(editor.redo(&mut engine).unwrap());
4638 assert_eq!(editor.active_outline().document, edited);
4639 assert!(editor.undo(&mut engine).unwrap());
4640 assert!(
4641 editor
4642 .select_below(&mut engine, id, [10.0, bottom + 1.0])
4643 .unwrap()
4644 );
4645 assert_eq!(editor.active_outline().document.nodes().len(), blanks + 1);
4646 editor
4647 .select([selection.positions[1], editor.selection().positions[1]].into())
4648 .unwrap();
4649 assert_eq!(editor.outlines[0].document, source);
4650 assert_eq!(editor.redo.len(), 1);
4651 editor
4652 .place_caret(&mut engine, [600.0, 600.0], 240.0)
4653 .unwrap();
4654 let pending = editor.active_outline().document.clone();
4655 let selection = editor.selection();
4656 editor.definitions.get_mut(&style).unwrap().format.font_size = Some(0.001);
4657 assert!(
4658 editor
4659 .select_below(&mut engine, id, [10.0, bottom + 20.0])
4660 .is_err()
4661 );
4662 assert_eq!(editor.active_outline().document, pending);
4663 assert_eq!(editor.selection(), selection);
4664 assert_eq!(editor.outlines[0].document, source);
4665 assert_eq!(editor.redo.len(), 1);
4666 }
4667 }
4668
4669 #[test]
4670 fn editing_during_provisional_composition_keeps_only_committed_layout_height() {
4671 let mut engine = TextEngine::default();
4672 let source =
4673 TextDocument::new(vec![Paragraph::new("body".into(), Format::default())]).unwrap();
4674 let mut editor = CanvasEditor::new(&mut engine, source.clone(), 240.0).unwrap();
4675 for _ in 0..3 {
4676 editor
4677 .move_selection(&mut engine, Movement::Down, false)
4678 .unwrap();
4679 }
4680 for _ in 0..2 {
4681 editor
4682 .move_selection(&mut engine, Movement::Up, false)
4683 .unwrap();
4684 }
4685 editor.compose(&mut engine, "a".into(), 1..1).unwrap();
4686 editor.insert(&mut engine, "b").unwrap();
4687 let expected = TextOutline::from_outline(
4688 &mut engine,
4689 &editor.active_outline().snapshot(),
4690 &BTreeMap::new(),
4691 )
4692 .unwrap();
4693 assert_eq!(editor.active_outline().shaped.size, expected.shaped.size);
4694 assert_eq!(editor.active_outline().document.nodes().len(), 2);
4695 editor.undo(&mut engine).unwrap();
4696 assert_eq!(
4697 editor
4698 .active_outline()
4699 .document
4700 .paragraphs()
4701 .last()
4702 .unwrap()
4703 .text(),
4704 "a"
4705 );
4706 editor.undo(&mut engine).unwrap();
4707 assert_eq!(editor.outlines[0].document, source);
4708 assert_eq!(editor.active_outline().document.nodes().len(), 4);
4709 }
4710
4711 #[test]
4712 fn arrow_created_paragraphs_are_provisional_and_share_existing_shaping() {
4713 let mut engine = TextEngine::default();
4714 let source =
4715 TextDocument::new(vec![Paragraph::new("body".into(), Format::default())]).unwrap();
4716 let mut editor = CanvasEditor::new(&mut engine, source.clone(), 240.0).unwrap();
4717 let shaped = editor.outlines[0].shaped.paragraphs[0]
4718 .text
4719 .shaped
4720 .styles()
4721 .as_ptr();
4722 for _ in 0..3 {
4723 editor
4724 .move_selection(&mut engine, Movement::Down, false)
4725 .unwrap();
4726 }
4727 assert_eq!(editor.outlines[0].document, source);
4728 assert_eq!(editor.active_outline().document.nodes().len(), 4);
4729 assert_eq!(
4730 editor
4731 .visible_outlines()
4732 .next()
4733 .unwrap()
4734 .document
4735 .nodes()
4736 .len(),
4737 4
4738 );
4739 assert_eq!(
4740 editor.active_outline().shaped.paragraphs[0]
4741 .text
4742 .shaped
4743 .styles()
4744 .as_ptr(),
4745 shaped
4746 );
4747 assert!(editor.undo.is_empty());
4748 editor
4749 .move_selection(&mut engine, Movement::Up, false)
4750 .unwrap();
4751 editor
4752 .move_selection(&mut engine, Movement::Up, false)
4753 .unwrap();
4754 let pending = editor.active_outline().document.clone();
4755 let selection = editor.selection();
4756 editor.insert(&mut engine, "X").unwrap();
4757 assert_eq!(
4758 editor.outlines[0]
4759 .document
4760 .paragraphs()
4761 .map(Paragraph::text)
4762 .collect::<Vec<_>>(),
4763 ["body", "X"]
4764 );
4765 assert_eq!(
4766 editor.outlines[0].shaped.paragraphs[0]
4767 .text
4768 .shaped
4769 .styles()
4770 .as_ptr(),
4771 shaped
4772 );
4773 editor.undo(&mut engine).unwrap();
4774 assert_eq!(editor.outlines[0].document, source);
4775 assert_eq!(editor.active_outline().document, pending);
4776 assert_eq!(editor.selection(), selection);
4777 editor
4778 .place_caret(&mut engine, [500.0, 500.0], 200.0)
4779 .unwrap();
4780 editor.redo(&mut engine).unwrap();
4781 assert_eq!(
4782 editor.outlines[0]
4783 .document
4784 .paragraphs()
4785 .map(Paragraph::text)
4786 .collect::<Vec<_>>(),
4787 ["body", "X"]
4788 );
4789 editor.undo(&mut engine).unwrap();
4790 assert_eq!(editor.active_outline().document, pending);
4791 assert_eq!(editor.selection(), selection);
4792 editor
4793 .place_caret(&mut engine, [500.0, 500.0], 200.0)
4794 .unwrap();
4795 assert_eq!(editor.outlines[0].document, source);
4796 }
4797
4798 #[test]
4799 fn deleting_through_provisional_paragraphs_removes_the_outline_and_restores_draft_on_undo() {
4800 let mut engine = TextEngine::default();
4801 let source =
4802 TextDocument::new(vec![Paragraph::new("body".into(), Format::default())]).unwrap();
4803 let mut editor = CanvasEditor::new(&mut engine, source.clone(), 240.0).unwrap();
4804 for _ in 0..2 {
4805 editor
4806 .move_selection(&mut engine, Movement::Down, false)
4807 .unwrap();
4808 }
4809 let pending = editor.active_outline().document.clone();
4810 editor.select_all().unwrap();
4811 let selection = editor.selection();
4812 editor.delete(&mut engine, true).unwrap();
4813 assert!(editor.outlines.is_empty());
4814 assert!(editor.caret_outline().is_some());
4815 editor
4816 .place_caret(&mut engine, [500.0, 500.0], 200.0)
4817 .unwrap();
4818 for _ in 0..2 {
4819 editor.undo(&mut engine).unwrap();
4820 assert_eq!(editor.outlines[0].document, source);
4821 assert_eq!(editor.active_outline().document, pending);
4822 assert_eq!(editor.selection(), selection);
4823 editor.redo(&mut engine).unwrap();
4824 assert!(editor.outlines.is_empty());
4825 }
4826 }
4827
4828 #[test]
4829 fn provisional_composition_cancels_and_backspace_reenters_without_history() {
4830 let mut engine = TextEngine::default();
4831 let source =
4832 TextDocument::new(vec![Paragraph::new("body".into(), Format::default())]).unwrap();
4833 let mut editor = CanvasEditor::new(&mut engine, source.clone(), 240.0).unwrap();
4834 editor
4835 .move_selection(&mut engine, Movement::Down, false)
4836 .unwrap();
4837 let pending = editor.active_outline().document.clone();
4838 editor.compose(&mut engine, "日本".into(), 2..2).unwrap();
4839 assert_eq!(editor.outlines[0].document, source);
4840 editor.cancel_composition(&mut engine).unwrap();
4841 assert_eq!(editor.active_outline().document, pending);
4842 editor.compose(&mut engine, "日本".into(), 2..2).unwrap();
4843 editor.finish_composition();
4844 assert_eq!(
4845 editor.outlines[0]
4846 .document
4847 .paragraphs()
4848 .map(Paragraph::text)
4849 .collect::<Vec<_>>(),
4850 ["body", "日本"]
4851 );
4852 editor.undo(&mut engine).unwrap();
4853 assert_eq!(editor.active_outline().document, pending);
4854 editor.delete(&mut engine, true).unwrap();
4855 assert_eq!(editor.outlines[0].document, source);
4856 assert!(matches!(editor.active, Focus::Outline(0)));
4857 assert!(editor.undo.is_empty());
4858 assert!(editor.redo(&mut engine).unwrap());
4859 }
4860
4861 #[test]
4862 fn word_navigation_terminates_at_soft_wrapped_bidi_cycles() {
4863 let mut engine = TextEngine::default();
4864 let paragraph = Paragraph::from_runs(vec![
4865 (
4866 "العربية".into(),
4867 Format {
4868 font_size: Some(12.0),
4869 bold: Some(false),
4870 italic: Some(false),
4871 ..Default::default()
4872 },
4873 ),
4874 ("ية 日本".into(), Format::default()),
4875 ]);
4876 let source = TextDocument::new(vec![paragraph]).unwrap();
4877 let mut editor = CanvasEditor::new(&mut engine, source.clone(), 1.0).unwrap();
4878 let selection = Selection {
4879 positions: [TextPosition {
4880 paragraph: 0,
4881 offset: 7,
4882 }; 2],
4883 affinities: [Affinity::Upstream; 2],
4884 };
4885 editor.select(selection).unwrap();
4886 editor.delete_to(&mut engine, Movement::WordRight).unwrap();
4887 assert_eq!(
4888 editor
4889 .active_outline()
4890 .document
4891 .paragraphs()
4892 .next()
4893 .unwrap()
4894 .text(),
4895 "ية 日本"
4896 );
4897 editor.undo(&mut engine).unwrap();
4898 assert_eq!(editor.active_outline().document, source);
4899 assert_eq!(editor.selection(), selection);
4900 for offset in 0..=12 {
4901 for affinity in [Affinity::Upstream, Affinity::Downstream] {
4902 for movement in [Movement::WordLeft, Movement::WordRight] {
4903 editor
4904 .select(Selection {
4905 positions: [TextPosition {
4906 paragraph: 0,
4907 offset,
4908 }; 2],
4909 affinities: [affinity; 2],
4910 })
4911 .unwrap();
4912 editor.move_selection(&mut engine, movement, true).unwrap();
4913 editor.select(editor.selection()).unwrap();
4914 }
4915 }
4916 }
4917 }
4918
4919 #[test]
4920 fn indentation_reflows_and_split_join_preserves_levels_and_undo() {
4921 let mut engine = TextEngine::default();
4922 let source = TextDocument::new(vec![
4923 Paragraph::new("alpha beta gamma delta".into(), Format::default()),
4924 Paragraph::new("untouched".into(), Format::default()),
4925 ])
4926 .unwrap();
4927 let mut editor = CanvasEditor::new(&mut engine, source.clone(), 110.0).unwrap();
4928 let untouched = editor.active_outline().shaped.paragraphs[1]
4929 .text
4930 .shaped
4931 .styles()
4932 .as_ptr();
4933 let selection: Selection = [TextPosition {
4934 paragraph: 0,
4935 offset: 6,
4936 }; 2]
4937 .into();
4938 editor.select(selection).unwrap();
4939 editor.indent(&mut engine, false).unwrap();
4940 assert_eq!(editor.active_outline().document.nodes()[0].level, 2);
4941 assert_eq!(editor.active_outline().shaped.paragraphs[0].origin[0], 27.0);
4942 assert_eq!(
4943 editor.active_outline().shaped.paragraphs[1]
4944 .text
4945 .shaped
4946 .styles()
4947 .as_ptr(),
4948 untouched
4949 );
4950 editor.insert(&mut engine, "\n").unwrap();
4951 assert_eq!(
4952 editor
4953 .active_outline()
4954 .document
4955 .nodes()
4956 .iter()
4957 .map(|n| n.level)
4958 .collect::<Vec<_>>(),
4959 [2, 2, 1]
4960 );
4961 editor.delete(&mut engine, true).unwrap();
4962 assert_eq!(
4963 editor.active_outline().document.nodes()[0]
4964 .text()
4965 .unwrap()
4966 .text
4967 .text(),
4968 "alpha beta gamma delta"
4969 );
4970 editor.undo(&mut engine).unwrap();
4971 editor.undo(&mut engine).unwrap();
4972 editor.undo(&mut engine).unwrap();
4973 assert_eq!(editor.active_outline().document, source);
4974 assert_eq!(editor.selection(), selection);
4975 assert!(!editor.indent(&mut engine, true).unwrap());
4976 assert!(editor.redo(&mut engine).unwrap());
4977 }
4978
4979 #[test]
4980 fn indentation_excludes_selected_next_paragraph_start_and_rejects_overflow_atomically() {
4981 let mut engine = TextEngine::default();
4982 let source = TextDocument::new(vec![
4983 Paragraph::new("first".into(), Format::default()),
4984 Paragraph::new("second".into(), Format::default()),
4985 ])
4986 .unwrap();
4987 let mut editor = CanvasEditor::new(&mut engine, source, 50.0).unwrap();
4988 let selection: Selection = [
4989 TextPosition {
4990 paragraph: 1,
4991 offset: 0,
4992 },
4993 TextPosition {
4994 paragraph: 0,
4995 offset: 0,
4996 },
4997 ]
4998 .into();
4999 editor.select(selection).unwrap();
5000 editor.indent(&mut engine, false).unwrap();
5001 let indented = editor.active_outline().document.clone();
5002 assert_eq!(
5003 indented.nodes().iter().map(|n| n.level).collect::<Vec<_>>(),
5004 [2, 1]
5005 );
5006 assert!(editor.indent(&mut engine, false).is_err());
5007 assert_eq!(editor.active_outline().document, indented);
5008 assert_eq!(editor.selection(), selection);
5009 editor.undo(&mut engine).unwrap();
5010 assert!(!editor.undo(&mut engine).unwrap());
5011 }
5012
5013 #[test]
5014 fn paragraph_and_document_navigation_preserve_selection_anchor() {
5015 let mut engine = TextEngine::default();
5016 let source = TextDocument::new(vec![
5017 Paragraph::new("one two three four five".into(), Format::default()),
5018 Paragraph::new("🌳 last".into(), Format::default()),
5019 ])
5020 .unwrap();
5021 let mut editor = CanvasEditor::new(&mut engine, source.clone(), 45.0).unwrap();
5022 let start = TextPosition {
5023 paragraph: 0,
5024 offset: 8,
5025 };
5026 editor.select([start; 2].into()).unwrap();
5027 editor
5028 .move_selection(&mut engine, Movement::ParagraphEnd, true)
5029 .unwrap();
5030 assert_eq!(
5031 editor.selection().positions,
5032 [
5033 start,
5034 TextPosition {
5035 paragraph: 0,
5036 offset: 23
5037 }
5038 ]
5039 );
5040 editor
5041 .move_selection(&mut engine, Movement::ParagraphEnd, true)
5042 .unwrap();
5043 assert_eq!(
5044 editor.selection().positions,
5045 [
5046 start,
5047 TextPosition {
5048 paragraph: 1,
5049 offset: 7
5050 }
5051 ]
5052 );
5053 editor
5054 .move_selection(&mut engine, Movement::DocumentStart, true)
5055 .unwrap();
5056 assert_eq!(
5057 editor.selection().positions,
5058 [
5059 start,
5060 TextPosition {
5061 paragraph: 0,
5062 offset: 0
5063 }
5064 ]
5065 );
5066 editor
5067 .move_selection(&mut engine, Movement::DocumentEnd, false)
5068 .unwrap();
5069 assert_eq!(
5070 editor.selection().positions,
5071 [TextPosition {
5072 paragraph: 1,
5073 offset: 7
5074 }; 2]
5075 );
5076 editor
5077 .move_selection(&mut engine, Movement::ParagraphStart, false)
5078 .unwrap();
5079 assert_eq!(
5080 editor.selection().positions,
5081 [TextPosition {
5082 paragraph: 1,
5083 offset: 0
5084 }; 2]
5085 );
5086 editor
5087 .move_selection(&mut engine, Movement::ParagraphStart, false)
5088 .unwrap();
5089 assert_eq!(
5090 editor.selection().positions,
5091 [TextPosition {
5092 paragraph: 0,
5093 offset: 0
5094 }; 2]
5095 );
5096 assert_eq!(editor.active_outline().document, source);
5097 assert!(!editor.undo(&mut engine).unwrap());
5098 }
5099
5100 #[test]
5101 fn word_deletion_restores_original_caret_and_styled_source_on_undo() {
5102 let mut engine = TextEngine::default();
5103 let source = TextDocument::new(vec![Paragraph::new(
5104 "first café 🌳 last".into(),
5105 Format {
5106 bold: Some(true),
5107 ..Default::default()
5108 },
5109 )])
5110 .unwrap();
5111 let mut editor = CanvasEditor::new(&mut engine, source.clone(), 200.0).unwrap();
5112 let selection: Selection = [TextPosition {
5113 paragraph: 0,
5114 offset: 10,
5115 }; 2]
5116 .into();
5117 editor.select(selection).unwrap();
5118 assert!(editor.delete_to(&mut engine, Movement::WordLeft).unwrap());
5119 assert_eq!(
5120 editor
5121 .active_outline()
5122 .document
5123 .paragraphs()
5124 .next()
5125 .unwrap()
5126 .text(),
5127 "first 🌳 last"
5128 );
5129 editor.undo(&mut engine).unwrap();
5130 assert_eq!(editor.active_outline().document, source);
5131 assert_eq!(editor.selection(), selection);
5132 editor.redo(&mut engine).unwrap();
5133 editor.undo(&mut engine).unwrap();
5134 editor
5135 .move_selection(&mut engine, Movement::DocumentStart, false)
5136 .unwrap();
5137 assert!(!editor.delete_to(&mut engine, Movement::WordLeft).unwrap());
5138 assert!(editor.redo(&mut engine).unwrap());
5139 }
5140
5141 #[test]
5142 fn horizontal_navigation_and_blank_grid_keep_documents_and_history_unchanged() {
5143 let mut engine = TextEngine::default();
5144 let mut outlines = Vec::new();
5145 for (text, origin) in [
5146 ("Solo", [36.0, 158.4]),
5147 ("Right", [504.0, 158.4]),
5148 ("Below", [504.0, 320.4]),
5149 ] {
5150 let document =
5151 TextDocument::new(vec![Paragraph::new(text.into(), Format::default())]).unwrap();
5152 outlines.push(TextOutline::new(&mut engine, document, 72.0, origin).unwrap());
1349 }5153 }
1350 let shaped = edit5154 let ids: Vec<_> = outlines.iter().map(|outline| outline.id).collect();
1351 .replacement5155 let originals: Vec<_> = outlines
1352 .iter()5156 .iter()
1353 .map(|paragraph| {5157 .map(|outline| outline.document.clone())
1354 ParagraphLayout::shape(5158 .collect();
1355 engine,5159 let mut editor = CanvasEditor::from_text_outlines(outlines, BTreeMap::new(), None).unwrap();
1356 paragraph,5160 editor
1357 width,5161 .move_selection(&mut engine, Movement::LineEnd, false)
1358 &outline.indents,5162 .unwrap();
1359 &self.definitions,5163 editor
1360 )5164 .move_selection(&mut engine, Movement::Right, true)
1361 })5165 .unwrap();
1362 .collect::<Result<Vec<_>, _>>()?;5166 assert_eq!(editor.active_outline().id, ids[0]);
1363 let proposed_layouts = || {5167 editor
1364 outline.shaped.paragraphs[..visible_start]5168 .move_selection(&mut engine, Movement::Right, false)
1365 .iter()5169 .unwrap();
1366 .chain(&shaped)5170 assert_eq!(editor.active_outline().id, ids[1]);
1367 .chain(&outline.shaped.paragraphs[visible_end..])5171 assert_eq!(editor.selection().positions[1].offset, 0);
1368 };5172 editor
1369 let (origins, size) = arrange(5173 .move_selection(&mut engine, Movement::Left, false)
1370 proposed_layouts(),5174 .unwrap();
1371 width,5175 assert_eq!(editor.active_outline().id, ids[0]);
1372 outline.layout.width_set_by_user == Some(true),5176 assert_eq!(editor.selection().positions[1].offset, 4);
1373 )?;5177 editor.focus_outline(ids[1]).unwrap();
1374 for position in selection.positions {5178 editor
1375 let node = outline.document.nodes()[..edit.range.start]5179 .move_selection(&mut engine, Movement::LineEnd, false)
5180 .unwrap();
5181 editor
5182 .move_selection(&mut engine, Movement::Right, false)
5183 .unwrap();
5184 assert_eq!(editor.active_outline().id, ids[1]);
5185 assert_eq!(editor.selection().positions[1].offset, 5);
5186 editor
5187 .place_caret(&mut engine, [486.0, 194.4], 468.0)
5188 .unwrap();
5189 editor
5190 .move_selection(&mut engine, Movement::Right, false)
5191 .unwrap();
5192 assert_eq!(editor.active_outline().origin(), [504.0, 194.4]);
5193 editor
5194 .move_selection(&mut engine, Movement::Up, false)
5195 .unwrap();
5196 assert_eq!(editor.active_outline().id, ids[1]);
5197 assert_eq!(editor.selection().positions[1].offset, 0);
5198 assert!(
5199 editor
5200 .outlines
1376 .iter()5201 .iter()
1377 .chain(&edit.replacement)5202 .zip(&originals)
1378 .chain(&outline.document.nodes()[edit.range.end..])5203 .all(|(outline, original)| &outline.document == original)
1379 .nth(position.paragraph)5204 );
1380 .ok_or(EditError::InvalidRange)?;5205 assert!(!editor.undo(&mut engine).unwrap());
1381 node.text[0].text.byte_offset(position.offset)?;
1382 if !proposed_layouts().any(|paragraph| paragraph.id == node.id) {
1383 return Err(EditError::InvalidRange.into());
1384 }
1385 }
1386 if finish_composition {
1387 self.finish_composition();
1388 }
1389 let outline = self.active_outline_mut();
1390 let inverse = outline.document.apply(edit)?;
1391 outline
1392 .shaped
1393 .paragraphs
1394 .splice(visible_start..visible_end, shaped);
1395 for (paragraph, y) in outline.shaped.paragraphs.iter_mut().zip(origins) {
1396 paragraph.origin[1] = y;
1397 }
1398 outline.shaped.size = size;
1399 self.preferred_x = None;
1400 let previous_selection =
1401 std::mem::replace(&mut self.active_outline_mut().selection, selection);
1402 Ok(TextChange {
1403 edit: inverse,
1404 selection: previous_selection,
1405 })
1406 }5206 }
1407}
14085207
1409fn inserted_position(5208 #[test]
1410 mut position: TextPosition,5209 fn word_and_paragraph_selection_use_source_ranges_across_wraps() {
1411 text: &str,5210 let mut engine = TextEngine::default();
1412 offset: u32,5211 let document = TextDocument::new(vec![Paragraph::new(
1413) -> Result<TextPosition, EditError> {5212 "alpha beta gamma 🌳 delta".into(),
1414 let mut units = 0_u32;5213 Format::default(),
1415 for character in text.chars() {5214 )])
1416 if units == offset {5215 .unwrap();
1417 return Ok(position);5216 let mut editor = CanvasEditor::new(&mut engine, document, 70.0).unwrap();
1418 }5217 let point = TextPosition {
1419 units = units5218 paragraph: 0,
1420 .checked_add(character.len_utf16() as u32)5219 offset: 7,
1421 .ok_or(EditError::TextTooLong)?;5220 };
1422 if units > offset {5221 editor.select([point; 2].into()).unwrap();
1423 return Err(EditError::InvalidRange);5222 let caret = editor.caret(1.0).unwrap();
1424 }5223 let x = caret.x0 as f32;
1425 if character == '\n' {5224 let y = ((caret.y0 + caret.y1) * 0.5) as f32;
1426 position.paragraph = position5225 let word = editor.selection_at(x, y, SelectionUnit::Word).unwrap();
1427 .paragraph5226 assert_eq!(word.positions.map(|p| p.offset), [6, 10]);
1428 .checked_add(1)5227 let paragraph = editor.selection_at(x, y, SelectionUnit::Paragraph).unwrap();
1429 .ok_or(EditError::TextTooLong)?;5228 assert_eq!(paragraph.positions.map(|p| p.offset), [0, 25]);
1430 position.offset = 0;5229 assert_eq!(editor.selection().positions, [point; 2]);
1431 } else {
1432 position.offset = position
1433 .offset
1434 .checked_add(character.len_utf16() as u32)
1435 .ok_or(EditError::TextTooLong)?;
1436 }
1437 }
1438 if units == offset {
1439 Ok(position)
1440 } else {
1441 Err(EditError::InvalidRange)
1442 }5230 }
1443}
14445231
1445#[cfg(test)]5232 #[test]
1446mod tests {5233 fn pointer_selection_uses_canvas_line_boxes_after_many_emoji_lines() {
1447 use super::*;5234 let text = "alpha 🌳 beta\n".repeat(30);
1448 use onestore::document::Format;5235 let start = text[..text.rfind("beta").unwrap()].encode_utf16().count() as u32;
5236 let mut engine = TextEngine::default();
5237 let document = TextDocument::new(vec![Paragraph::new(text, Format::default())]).unwrap();
5238 let mut editor = CanvasEditor::new(&mut engine, document, 70.0).unwrap();
5239 let position = TextPosition {
5240 paragraph: 0,
5241 offset: start + 1,
5242 };
5243 editor.select([position; 2].into()).unwrap();
5244 let caret = editor.caret(1.0).unwrap();
5245 let point = [caret.x0 as f32, ((caret.y0 + caret.y1) * 0.5) as f32];
5246 assert_eq!(
5247 editor
5248 .selection_at(point[0], point[1], SelectionUnit::Word)
5249 .unwrap()
5250 .positions
5251 .map(|p| p.offset),
5252 [start, start + 4]
5253 );
5254 assert_eq!(
5255 editor
5256 .selection_at(point[0], point[1], SelectionUnit::Grapheme)
5257 .unwrap()
5258 .positions,
5259 [position; 2]
5260 );
5261 }
14495262
1450 #[test]5263 #[test]
1451 fn retiring_text_restores_source_selection_order_and_geometry_through_history() {5264 fn retiring_text_restores_source_selection_order_and_geometry_through_history() {
...@@ -1476,7 +5289,7 @@ mod tests {...@@ -1476,7 +5289,7 @@ mod tests {
1476 .iter()5289 .iter()
1477 .map(|outline| outline.id)5290 .map(|outline| outline.id)
1478 .collect::<Vec<_>>();5291 .collect::<Vec<_>>();
1479 let mut editor = CanvasEditor::from_text_outlines(outlines, BTreeMap::new()).unwrap();5292 let mut editor = CanvasEditor::from_text_outlines(outlines, BTreeMap::new(), None).unwrap();
1480 editor.focus_outline(id).unwrap();5293 editor.focus_outline(id).unwrap();
1481 editor.select_all().unwrap();5294 editor.select_all().unwrap();
1482 let selection = editor.selection();5295 let selection = editor.selection();
...@@ -1551,7 +5364,8 @@ mod tests {...@@ -1551,7 +5364,8 @@ mod tests {
1551 let first = outlines[0].id;5364 let first = outlines[0].id;
1552 let second = outlines[1].id;5365 let second = outlines[1].id;
1553 let original = outlines[0].document.clone();5366 let original = outlines[0].document.clone();
1554 let mut editor = CanvasEditor::from_text_outlines(outlines, BTreeMap::new()).unwrap();5367 let mut editor =
5368 CanvasEditor::from_text_outlines(outlines, BTreeMap::new(), None).unwrap();
1555 editor.select_all().unwrap();5369 editor.select_all().unwrap();
1556 editor.compose(&mut engine, String::new(), 0..0).unwrap();5370 editor.compose(&mut engine, String::new(), 0..0).unwrap();
1557 assert_eq!(editor.outlines.len(), 2);5371 assert_eq!(editor.outlines.len(), 2);
...@@ -1626,6 +5440,24 @@ mod tests {...@@ -1626,6 +5440,24 @@ mod tests {
1626 editor.delete(&mut engine, true).unwrap();5440 editor.delete(&mut engine, true).unwrap();
1627 assert!(editor.outlines.is_empty());5441 assert!(editor.outlines.is_empty());
1628 assert_eq!(editor.active_outline().document, spaces);5442 assert_eq!(editor.active_outline().document, spaces);
5443 let undo_count = editor.undo.len();
5444 editor.select_all().unwrap();
5445 assert!(editor.delete_to(&mut engine, Movement::LineStart).unwrap());
5446 assert!(editor.outlines.is_empty());
5447 assert_eq!(editor.undo.len(), undo_count);
5448 assert!(editor.undo(&mut engine).unwrap());
5449 assert_eq!(
5450 editor
5451 .active_outline()
5452 .document
5453 .paragraphs()
5454 .next()
5455 .unwrap()
5456 .text(),
5457 " x"
5458 );
5459 assert!(editor.redo(&mut engine).unwrap());
5460 assert_eq!(editor.active_outline().document, spaces);
1629 for _ in 0..2 {5461 for _ in 0..2 {
1630 editor.undo(&mut engine).unwrap();5462 editor.undo(&mut engine).unwrap();
1631 }5463 }
...@@ -1779,7 +5611,7 @@ mod tests {...@@ -1779,7 +5611,7 @@ mod tests {
1779 .into_iter()5611 .into_iter()
1780 .find_map(|object| match object {5612 .find_map(|object| match object {
1781 PageObject::Outline(outline)5613 PageObject::Outline(outline)
1782 if outline.paragraphs[0].combined_text().text() == "Spacing first" =>5614 if outline.paragraphs[0].text().unwrap().text.text() == "Spacing first" =>
1783 {5615 {
1784 Some(outline)5616 Some(outline)
1785 }5617 }
...@@ -1811,6 +5643,89 @@ mod tests {...@@ -1811,6 +5643,89 @@ mod tests {
1811 }5643 }
1812 }5644 }
18135645
5646 #[test]
5647 fn resize_preview_preserves_history_and_commit_restores_automatic_width_on_undo() {
5648 let mut engine = TextEngine::default();
5649 let mut editor = CanvasEditor::new(
5650 &mut engine,
5651 TextDocument::new(vec![Paragraph::new(String::new(), Format::default())]).unwrap(),
5652 468.0,
5653 )
5654 .unwrap();
5655 editor.insert(&mut engine, "One").unwrap();
5656 let source = editor.active_outline().snapshot();
5657 let selection = editor.selection();
5658 editor.insert(&mut engine, "!").unwrap();
5659 editor.undo(&mut engine).unwrap();
5660 for width in [120.0, 360.0, 180.0] {
5661 let preview = editor.preview_resize(&mut engine, width).unwrap();
5662 assert_eq!(preview.bounds().width(), f64::from(width));
5663 assert_eq!(preview.document, editor.active_outline().document);
5664 assert_eq!(editor.active_outline().layout, source.layout);
5665 assert_eq!(editor.undo.len(), 1);
5666 assert_eq!(editor.redo.len(), 1);
5667 }
5668 assert!(editor.preview_resize(&mut engine, f32::NAN).is_err());
5669 editor.resize(&mut engine, 180.0).unwrap();
5670 assert!(editor.redo.is_empty());
5671 assert_eq!(editor.undo.len(), 2);
5672 editor.undo(&mut engine).unwrap();
5673 assert_eq!(editor.active_outline().layout, source.layout);
5674 assert_eq!(editor.active_outline().bounds().width(), 72.0);
5675 assert_eq!(editor.selection(), selection);
5676 editor.redo(&mut engine).unwrap();
5677 assert_eq!(editor.active_outline().bounds().width(), 180.0);
5678 editor.resize(&mut engine, 180.0).unwrap();
5679 assert_eq!(editor.undo.len(), 2);
5680 }
5681
5682 #[test]
5683 fn new_empty_outline_grows_shrinks_and_keeps_a_manually_set_width() {
5684 let mut engine = TextEngine::default();
5685 let mut editor = CanvasEditor::new(
5686 &mut engine,
5687 TextDocument::new(vec![Paragraph::new(String::new(), Format::default())]).unwrap(),
5688 468.0,
5689 )
5690 .unwrap();
5691 assert_eq!(editor.active_outline().bounds().width(), 72.0);
5692 assert_eq!(
5693 editor.active_outline().layout.width_set_by_user,
5694 Some(false)
5695 );
5696 editor
5697 .insert(&mut engine, "A short sentence expands the container")
5698 .unwrap();
5699 let expanded = editor.active_outline().bounds().width();
5700 assert!(expanded > 72.0 && expanded < 468.0);
5701 editor.select_all().unwrap();
5702 editor.insert(&mut engine, &"words ".repeat(80)).unwrap();
5703 assert_eq!(editor.active_outline().bounds().width(), 468.0);
5704 assert!(
5705 editor
5706 .active_outline()
5707 .paragraph_layout(0)
5708 .unwrap()
5709 .text
5710 .lines()
5711 .count()
5712 > 1
5713 );
5714 editor.select_all().unwrap();
5715 editor.insert(&mut engine, "One").unwrap();
5716 assert_eq!(editor.active_outline().bounds().width(), 72.0);
5717 editor.resize(&mut engine, 180.0).unwrap();
5718 editor.select_all().unwrap();
5719 editor.insert(&mut engine, "Tiny").unwrap();
5720 assert_eq!(editor.active_outline().bounds().width(), 180.0);
5721 editor
5722 .insert(&mut engine, &" longer words".repeat(10))
5723 .unwrap();
5724 assert_eq!(editor.active_outline().bounds().width(), 180.0);
5725 editor.undo(&mut engine).unwrap();
5726 assert_eq!(editor.active_outline().bounds().width(), 180.0);
5727 }
5728
1814 #[test]5729 #[test]
1815 fn resizing_an_automatic_outline_sets_its_explicit_width_and_preserves_spacing_metadata() {5730 fn resizing_an_automatic_outline_sets_its_explicit_width_and_preserves_spacing_metadata() {
1816 let document = TextDocument::new(vec![Paragraph::new(5731 let document = TextDocument::new(vec![Paragraph::new(
...@@ -1823,6 +5738,8 @@ mod tests {...@@ -1823,6 +5738,8 @@ mod tests {
1823 .unwrap();5738 .unwrap();
1824 let source = document.nodes().to_vec();5739 let source = document.nodes().to_vec();
1825 let outline = Outline {5740 let outline = Outline {
5741 title: false,
5742 min_width: None,
1826 id: crate::text::new_id().unwrap(),5743 id: crate::text::new_id().unwrap(),
1827 layout: onestore::document::Layout {5744 layout: onestore::document::Layout {
1828 max_width: Some(220.0),5745 max_width: Some(220.0),
...@@ -1913,6 +5830,8 @@ mod tests {...@@ -1913,6 +5830,8 @@ mod tests {
1913 },5830 },
1914 )]);5831 )]);
1915 let outline = Outline {5832 let outline = Outline {
5833 title: false,
5834 min_width: None,
1916 id: crate::text::new_id().unwrap(),5835 id: crate::text::new_id().unwrap(),
1917 layout: onestore::document::Layout {5836 layout: onestore::document::Layout {
1918 x: Some(30.0),5837 x: Some(30.0),
...@@ -1975,6 +5894,8 @@ mod tests {...@@ -1975,6 +5894,8 @@ mod tests {
1975 );5894 );
1976 let rebuilt = Outline {5895 let rebuilt = Outline {
1977 id,5896 id,
5897 title: false,
5898 min_width: None,
1978 layout: editor.active_outline().layout.clone(),5899 layout: editor.active_outline().layout.clone(),
1979 indents: vec![18.0, 0.0, 27.0],5900 indents: vec![18.0, 0.0, 27.0],
1980 paragraphs: editor.active_outline().document.nodes().to_vec(),5901 paragraphs: editor.active_outline().document.nodes().to_vec(),
...@@ -2019,13 +5940,21 @@ mod tests {...@@ -2019,13 +5940,21 @@ mod tests {
2019 .into(),5940 .into(),
2020 )5941 )
2021 .unwrap();5942 .unwrap();
2022 editor.move_selection(Movement::Right, false).unwrap();5943 editor
5944 .move_selection(&mut engine, Movement::Right, false)
5945 .unwrap();
2023 assert_eq!(editor.selection().positions[1].paragraph, 4);5946 assert_eq!(editor.selection().positions[1].paragraph, 4);
2024 editor.move_selection(Movement::Left, false).unwrap();5947 editor
5948 .move_selection(&mut engine, Movement::Left, false)
5949 .unwrap();
2025 assert_eq!(editor.selection().positions[1].paragraph, 2);5950 assert_eq!(editor.selection().positions[1].paragraph, 2);
2026 editor.move_selection(Movement::Down, false).unwrap();5951 editor
5952 .move_selection(&mut engine, Movement::Down, false)
5953 .unwrap();
2027 assert_eq!(editor.selection().positions[1].paragraph, 4);5954 assert_eq!(editor.selection().positions[1].paragraph, 4);
2028 editor.move_selection(Movement::Up, false).unwrap();5955 editor
5956 .move_selection(&mut engine, Movement::Up, false)
5957 .unwrap();
2029 assert_eq!(editor.selection().positions[1].paragraph, 2);5958 assert_eq!(editor.selection().positions[1].paragraph, 2);
2030 editor.select_all().unwrap();5959 editor.select_all().unwrap();
2031 assert!(!editor.selection_rects().unwrap().is_empty());5960 assert!(!editor.selection_rects().unwrap().is_empty());
...@@ -2083,7 +6012,7 @@ mod tests {...@@ -2083,7 +6012,7 @@ mod tests {
2083 assert_eq!(layout_snapshot(&editor.active_outline().shaped), expected);6012 assert_eq!(layout_snapshot(&editor.active_outline().shaped), expected);
2084 let visible: Vec<_> = editor.active_outline().layouts().map(|(i, _)| i).collect();6013 let visible: Vec<_> = editor.active_outline().layouts().map(|(i, _)| i).collect();
2085 for paragraph in visible {6014 for paragraph in visible {
2086 let text = &nodes[paragraph].text[0].text;6015 let text = &nodes[paragraph].text().unwrap().text;
2087 let byte = text6016 let byte = text
2088 .text()6017 .text()
2089 .char_indices()6018 .char_indices()
...@@ -2099,6 +6028,8 @@ mod tests {...@@ -2099,6 +6028,8 @@ mod tests {
2099 let edited = editor.active_outline().document.clone();6028 let edited = editor.active_outline().document.clone();
2100 let rebuilt = Outline {6029 let rebuilt = Outline {
2101 id,6030 id,
6031 title: false,
6032 min_width: None,
2102 layout: source_layout.clone(),6033 layout: source_layout.clone(),
2103 indents: editor.active_outline().indents.clone(),6034 indents: editor.active_outline().indents.clone(),
2104 paragraphs: edited.nodes().to_vec(),6035 paragraphs: edited.nodes().to_vec(),
...@@ -2145,14 +6076,18 @@ mod tests {...@@ -2145,14 +6076,18 @@ mod tests {
2145 offset: 3,6076 offset: 3,
2146 };6077 };
2147 for _ in 0..32 {6078 for _ in 0..32 {
2148 editor.move_selection(Movement::Right, false).unwrap();6079 editor
6080 .move_selection(&mut engine, Movement::Right, false)
6081 .unwrap();
2149 if editor.selection().positions[1] == last {6082 if editor.selection().positions[1] == last {
2150 break;6083 break;
2151 }6084 }
2152 }6085 }
2153 assert_eq!(editor.selection().positions[1], last);6086 assert_eq!(editor.selection().positions[1], last);
2154 for _ in 0..32 {6087 for _ in 0..32 {
2155 editor.move_selection(Movement::Left, false).unwrap();6088 editor
6089 .move_selection(&mut engine, Movement::Left, false)
6090 .unwrap();
2156 if editor.selection().positions[1] == first {6091 if editor.selection().positions[1] == first {
2157 break;6092 break;
2158 }6093 }
...@@ -2188,11 +6123,16 @@ mod tests {...@@ -2188,11 +6123,16 @@ mod tests {
2188 let after = editor.active_outline().document.clone();6123 let after = editor.active_outline().document.clone();
2189 editor.resize(&mut engine, 35.0).unwrap();6124 editor.resize(&mut engine, 35.0).unwrap();
2190 editor.undo(&mut engine).unwrap();6125 editor.undo(&mut engine).unwrap();
6126 assert_eq!(editor.active_outline().document, after);
6127 assert_eq!(editor.active_outline().layout.max_width, Some(100.0));
6128 editor.undo(&mut engine).unwrap();
2191 assert_eq!(editor.active_outline().document, original);6129 assert_eq!(editor.active_outline().document, original);
2192 assert_eq!(editor.selection().positions, [anchor, focus]);6130 assert_eq!(editor.selection().positions, [anchor, focus]);
2193 editor.redo(&mut engine).unwrap();6131 editor.redo(&mut engine).unwrap();
2194 assert_eq!(editor.active_outline().document, after);6132 assert_eq!(editor.active_outline().document, after);
2195 assert_eq!(editor.selection().positions[1].offset, 4);6133 assert_eq!(editor.selection().positions[1].offset, 4);
6134 editor.redo(&mut engine).unwrap();
6135 assert_eq!(editor.active_outline().layout.max_width, Some(35.0));
2196 let styles = editor.active_outline().shaped.paragraphs[0]6136 let styles = editor.active_outline().shaped.paragraphs[0]
2197 .text6137 .text
2198 .shaped6138 .shaped
...@@ -2206,7 +6146,7 @@ mod tests {...@@ -2206,7 +6146,7 @@ mod tests {
2206 Movement::LineStart,6146 Movement::LineStart,
2207 Movement::LineEnd,6147 Movement::LineEnd,
2208 ] {6148 ] {
2209 editor.move_selection(movement, false).unwrap();6149 editor.move_selection(&mut engine, movement, false).unwrap();
2210 assert_eq!(6150 assert_eq!(
2211 editor.active_outline().shaped.paragraphs[0]6151 editor.active_outline().shaped.paragraphs[0]
2212 .text6152 .text
...@@ -2358,7 +6298,10 @@ mod tests {...@@ -2358,7 +6298,10 @@ mod tests {
2358 assert_eq!(editor.active_outline().document, provisional);6298 assert_eq!(editor.active_outline().document, provisional);
2359 assert_eq!(editor.selection().positions, selection);6299 assert_eq!(editor.selection().positions, selection);
2360 assert_eq!(editor.marked_range(), marked);6300 assert_eq!(editor.marked_range(), marked);
2361 editor.resize(&mut engine, 25.0).unwrap();6301 let preview = editor.preview_resize(&mut engine, 25.0).unwrap();
6302 assert_eq!(preview.document, provisional);
6303 assert_eq!(preview.layout.max_width, Some(25.0));
6304 assert_eq!(editor.active_outline().layout.max_width, Some(70.0));
2362 editor.cancel_composition(&mut engine).unwrap();6305 editor.cancel_composition(&mut engine).unwrap();
2363 assert_eq!(editor.active_outline().document, original);6306 assert_eq!(editor.active_outline().document, original);
2364 assert_eq!(editor.selection().positions, [last, first]);6307 assert_eq!(editor.selection().positions, [last, first]);
...@@ -2391,7 +6334,9 @@ mod tests {...@@ -2391,7 +6334,9 @@ mod tests {
2391 };6334 };
2392 editor.select([start, start].into()).unwrap();6335 editor.select([start, start].into()).unwrap();
2393 let x = editor.caret(1.0).unwrap().x0;6336 let x = editor.caret(1.0).unwrap().x0;
2394 editor.move_selection(Movement::Down, false).unwrap();6337 editor
6338 .move_selection(&mut engine, Movement::Down, false)
6339 .unwrap();
2395 assert_eq!(6340 assert_eq!(
2396 editor.selection().positions[1],6341 editor.selection().positions[1],
2397 TextPosition {6342 TextPosition {
...@@ -2399,7 +6344,9 @@ mod tests {...@@ -2399,7 +6344,9 @@ mod tests {
2399 offset: 06344 offset: 0
2400 }6345 }
2401 );6346 );
2402 editor.move_selection(Movement::Down, false).unwrap();6347 editor
6348 .move_selection(&mut engine, Movement::Down, false)
6349 .unwrap();
2403 assert_eq!(6350 assert_eq!(
2404 editor.selection().positions[1],6351 editor.selection().positions[1],
2405 TextPosition {6352 TextPosition {
...@@ -2408,8 +6355,12 @@ mod tests {...@@ -2408,8 +6355,12 @@ mod tests {
2408 }6355 }
2409 );6356 );
2410 assert_eq!(editor.caret(1.0).unwrap().x0, x);6357 assert_eq!(editor.caret(1.0).unwrap().x0, x);
2411 editor.move_selection(Movement::Up, true).unwrap();6358 editor
2412 editor.move_selection(Movement::Up, true).unwrap();6359 .move_selection(&mut engine, Movement::Up, true)
6360 .unwrap();
6361 editor
6362 .move_selection(&mut engine, Movement::Up, true)
6363 .unwrap();
2413 assert_eq!(6364 assert_eq!(
2414 editor.selection().positions,6365 editor.selection().positions,
2415 [6366 [
...@@ -2420,14 +6371,18 @@ mod tests {...@@ -2420,14 +6371,18 @@ mod tests {
2420 start6371 start
2421 ]6372 ]
2422 );6373 );
2423 editor.move_selection(Movement::Left, false).unwrap();6374 editor
6375 .move_selection(&mut engine, Movement::Left, false)
6376 .unwrap();
2424 assert_eq!(editor.selection().positions, [start; 2]);6377 assert_eq!(editor.selection().positions, [start; 2]);
2425 let boundary = TextPosition {6378 let boundary = TextPosition {
2426 paragraph: 1,6379 paragraph: 1,
2427 offset: 0,6380 offset: 0,
2428 };6381 };
2429 editor.select([boundary, boundary].into()).unwrap();6382 editor.select([boundary, boundary].into()).unwrap();
2430 editor.move_selection(Movement::Left, false).unwrap();6383 editor
6384 .move_selection(&mut engine, Movement::Left, false)
6385 .unwrap();
2431 assert_eq!(6386 assert_eq!(
2432 editor.selection().positions[1],6387 editor.selection().positions[1],
2433 TextPosition {6388 TextPosition {
...@@ -2435,7 +6390,9 @@ mod tests {...@@ -2435,7 +6390,9 @@ mod tests {
2435 offset: 106390 offset: 10
2436 }6391 }
2437 );6392 );
2438 editor.move_selection(Movement::Right, false).unwrap();6393 editor
6394 .move_selection(&mut engine, Movement::Right, false)
6395 .unwrap();
2439 assert_eq!(editor.selection().positions[1], boundary);6396 assert_eq!(editor.selection().positions[1], boundary);
2440 }6397 }
24416398
...@@ -2829,6 +6786,7 @@ mod tests {...@@ -2829,6 +6786,7 @@ mod tests {
2829 rebuilt.active_outline().document6786 rebuilt.active_outline().document
2830 );6787 );
2831 let selection = editor.selection().positions;6788 let selection = editor.selection().positions;
6789 let previous_width = editor.active_outline().layout.max_width.unwrap();
2832 editor.resize(&mut engine, 30.0).unwrap();6790 editor.resize(&mut engine, 30.0).unwrap();
2833 let rebuilt =6791 let rebuilt =
2834 CanvasEditor::new(&mut engine, editor.active_outline().document.clone(), 30.0).unwrap();6792 CanvasEditor::new(&mut engine, editor.active_outline().document.clone(), 30.0).unwrap();
...@@ -2837,8 +6795,16 @@ mod tests {...@@ -2837,8 +6795,16 @@ mod tests {
2837 assert!(editor.resize(&mut engine, f32::NAN).is_err());6795 assert!(editor.resize(&mut engine, f32::NAN).is_err());
2838 assert_eq!(ys(editor.active_outline()), ys(rebuilt.active_outline()));6796 assert_eq!(ys(editor.active_outline()), ys(rebuilt.active_outline()));
2839 editor.undo(&mut engine).unwrap();6797 editor.undo(&mut engine).unwrap();
2840 let rebuilt =6798 assert_eq!(
2841 CanvasEditor::new(&mut engine, editor.active_outline().document.clone(), 30.0).unwrap();6799 editor.active_outline().layout.max_width,
6800 Some(previous_width)
6801 );
6802 let rebuilt = CanvasEditor::new(
6803 &mut engine,
6804 editor.active_outline().document.clone(),
6805 previous_width,
6806 )
6807 .unwrap();
2842 assert_eq!(ys(editor.active_outline()), ys(rebuilt.active_outline()));6808 assert_eq!(ys(editor.active_outline()), ys(rebuilt.active_outline()));
2843 }6809 }
28446810
crates/canvas/src/editor/page.rs created+313
...@@ -0,0 +1,313 @@
1use super::{EditorError, TextOutline};
2use crate::{
3 date::PageDate,
4 layout::{LayoutError, TextEngine, TextLayout},
5 outline::OutlineLayout,
6 page::{Image, Outline, Page, PageObject},
7 text::Paragraph,
8};
9
10pub(crate) enum Content {
11 Date {
12 below_title: Option<onestore::ExGuid>,
13 },
14 Outline {
15 source: Outline,
16 layout: OutlineLayout,
17 below_title: Option<onestore::ExGuid>,
18 },
19 Image(Image),
20 Editable(onestore::ExGuid),
21 ReadOnly(Box<ReadOnlyObject>),
22}
23
24pub struct ReadOnlyObject {
25 pub source: PageObject,
26 pub message: &'static str,
27 pub(crate) label: TextLayout,
28}
29
30impl Content {
31 pub(super) fn layout(&self) -> Option<(onestore::ExGuid, &onestore::document::Layout)> {
32 match self {
33 Self::Outline { source, .. } => Some((source.id, &source.layout)),
34 Self::Image(source) => Some((source.id, &source.layout)),
35 Self::ReadOnly(object) => Some((object.source.id(), object.source.layout())),
36 Self::Date { .. } | Self::Editable(_) => None,
37 }
38 }
39
40 pub(super) fn layout_mut(
41 &mut self,
42 ) -> Option<(onestore::ExGuid, &mut onestore::document::Layout)> {
43 match self {
44 Self::Outline { source, .. } => Some((source.id, &mut source.layout)),
45 Self::Image(source) => Some((source.id, &mut source.layout)),
46 Self::ReadOnly(object) => Some((object.source.id(), object.source.layout_mut())),
47 Self::Date { .. } | Self::Editable(_) => None,
48 }
49 }
50}
51
52impl ReadOnlyObject {
53 fn new(
54 mut source: PageObject,
55 margin: [f32; 2],
56 message: &'static str,
57 engine: &mut TextEngine,
58 ) -> Result<Box<Self>, EditorError> {
59 let offset = if matches!(source, PageObject::Title(_)) {
60 margin
61 } else {
62 [0.0; 2]
63 };
64 let layout = source.layout_mut();
65 let x = layout.x.unwrap_or(0.0) + offset[0];
66 let y = layout.y.unwrap_or(0.0) + offset[1];
67 if offset[0] != 0.0 {
68 layout.x = Some(x);
69 }
70 if offset[1] != 0.0 {
71 layout.y = Some(y);
72 }
73 let width = layout.max_width.unwrap_or(160.0);
74 let height = layout.max_height.unwrap_or(42.0);
75 if [x, y, width, height].iter().any(|v| !v.is_finite()) || width <= 0.0 || height <= 0.0 {
76 return Err(EditorError::InvalidGeometry);
77 }
78 let width = width.max(160.0);
79 let label = engine.layout(
80 &Paragraph::new(
81 message.into(),
82 onestore::document::Format {
83 font: Some("Arial".into()),
84 font_size: Some(11.0),
85 color: Some(0x005d554e),
86 ..Default::default()
87 },
88 ),
89 width - 16.0,
90 )?;
91 let height = height.max(label.height() + 16.0);
92 let rect = [x, y, x + width, y + height];
93 if rect.iter().any(|v| !v.is_finite()) {
94 return Err(EditorError::InvalidGeometry);
95 }
96 Ok(Box::new(Self {
97 source,
98 message,
99 label,
100 }))
101 }
102 pub fn rect(&self) -> [f32; 4] {
103 let layout = self.source.layout();
104 let x = layout.x.unwrap_or(0.0);
105 let y = layout.y.unwrap_or(0.0);
106 [
107 x,
108 y,
109 x + layout.max_width.unwrap_or(160.0).max(160.0),
110 y + layout
111 .max_height
112 .unwrap_or(42.0)
113 .max(self.label.height() + 16.0),
114 ]
115 }
116}
117
118#[expect(
119 clippy::type_complexity,
120 reason = "the three owned parts of an imported page"
121)]
122pub(crate) fn build(
123 page: &mut Page,
124 engine: &mut TextEngine,
125 editable: bool,
126) -> Result<(Vec<Content>, Vec<TextOutline>, Option<PageDate>), EditorError> {
127 if page.margin_origin.iter().any(|v| !v.is_finite()) {
128 return Err(EditorError::InvalidGeometry);
129 }
130 let mut objects = Vec::new();
131 let mut outlines = Vec::new();
132 let mut date = None;
133 for object in std::mem::take(&mut page.objects) {
134 match &object {
135 PageObject::Outline(outline) => {
136 let origin = [
137 outline.layout.x.unwrap_or(0.0),
138 outline.layout.y.unwrap_or(0.0),
139 ];
140 if origin.iter().any(|v| !v.is_finite()) {
141 return Err(EditorError::InvalidGeometry);
142 }
143 if editable {
144 match TextOutline::from_outline(engine, outline, &page.definitions) {
145 Ok(outline) => {
146 objects.push(Content::Editable(outline.id));
147 outlines.push(outline);
148 }
149 Err(EditorError::Layout(LayoutError::UnsupportedContent)) => {
150 objects.push(Content::ReadOnly(ReadOnlyObject::new(
151 object,
152 page.margin_origin,
153 "Unsupported content\nRead-only",
154 engine,
155 )?))
156 }
157 Err(error) => return Err(error),
158 }
159 } else {
160 match outline.layout(engine, &page.definitions) {
161 Ok(layout) => objects.push(Content::Outline {
162 source: outline.clone(),
163 layout,
164 below_title: None,
165 }),
166 Err(LayoutError::UnsupportedContent) => {
167 objects.push(Content::ReadOnly(ReadOnlyObject::new(
168 object,
169 page.margin_origin,
170 "Unsupported content\nRead-only",
171 engine,
172 )?))
173 }
174 Err(error) => return Err(error.into()),
175 }
176 }
177 }
178 PageObject::Title(title) => {
179 let layouts = match title.layout(engine, &page.definitions) {
180 Ok(layouts) => layouts,
181 Err(LayoutError::UnsupportedContent) => {
182 objects.push(Content::ReadOnly(ReadOnlyObject::new(
183 object,
184 page.margin_origin,
185 "Unsupported content\nRead-only",
186 engine,
187 )?));
188 continue;
189 }
190 Err(error) => return Err(error.into()),
191 };
192 let mut anchor = if editable {
193 title
194 .outlines
195 .iter()
196 .zip(&layouts)
197 .find(|(source, _)| source.title)
198 .map(|(source, (origin, layout))| {
199 (
200 source.id,
201 title.layout.y.unwrap_or(0.0)
202 + page.margin_origin[1]
203 + origin[1]
204 + layout.size[1].max(source.layout.max_height.unwrap_or(0.0)),
205 )
206 })
207 } else {
208 None
209 };
210 for (index, (origin, layout)) in layouts.into_iter().enumerate() {
211 let mut origin = [
212 title.layout.x.unwrap_or(0.0) + origin[0] + page.margin_origin[0],
213 title.layout.y.unwrap_or(0.0) + origin[1] + page.margin_origin[1],
214 ];
215 if origin.iter().any(|v| !v.is_finite()) {
216 return Err(EditorError::InvalidGeometry);
217 }
218 if editable && title.outlines[index].title {
219 let mut source = title.outlines[index].clone();
220 source.layout.x = Some(origin[0]);
221 source.layout.y = Some(origin[1]);
222 let outline =
223 TextOutline::from_outline(engine, &source, &page.definitions)?;
224 anchor = Some((outline.id, outline.bounds().y1 as f32));
225 objects.push(Content::Editable(outline.id));
226 outlines.push(outline);
227 } else {
228 if let Some((_, bottom)) = anchor {
229 origin[1] -= bottom;
230 }
231 if editable
232 && title.date == Some(title.outlines[index].id)
233 && let Some(timestamp) = page.created
234 {
235 let mut source = title.outlines[index].clone();
236 source.layout.x = Some(origin[0]);
237 source.layout.y = Some(origin[1]);
238 match PageDate::new(timestamp, source, engine, &page.definitions) {
239 Ok(value) => {
240 if date.replace(value).is_some() {
241 return Err(EditorError::InvalidGeometry);
242 }
243 objects.push(Content::Date {
244 below_title: anchor.map(|(id, _)| id),
245 });
246 continue;
247 }
248 Err(EditorError::Edit(
249 crate::text::EditError::UnsupportedContent,
250 )) => {}
251 Err(error) => return Err(error),
252 }
253 }
254 let mut source = title.outlines[index].clone();
255 source.layout.x = Some(origin[0]);
256 source.layout.y = Some(origin[1]);
257 objects.push(Content::Outline {
258 source,
259 layout,
260 below_title: anchor.map(|(id, _)| id),
261 });
262 }
263 }
264 }
265 PageObject::Image(source) => {
266 if source.bytes.is_none()
267 || source.layout.max_width.is_none()
268 || source.layout.max_height.is_none()
269 {
270 objects.push(Content::ReadOnly(ReadOnlyObject::new(
271 object,
272 page.margin_origin,
273 "Image unavailable\nRead-only",
274 engine,
275 )?));
276 continue;
277 }
278 let origin = [
279 source.layout.x.unwrap_or(0.0),
280 source.layout.y.unwrap_or(0.0),
281 ];
282 let size = [
283 source
284 .layout
285 .max_width
286 .ok_or(EditorError::InvalidGeometry)?,
287 source
288 .layout
289 .max_height
290 .ok_or(EditorError::InvalidGeometry)?,
291 ];
292 if origin.iter().any(|v| !v.is_finite())
293 || size.iter().any(|v| !v.is_finite() || *v <= 0.0)
294 || !(origin[0] + size[0]).is_finite()
295 || !(origin[1] + size[1]).is_finite()
296 {
297 return Err(EditorError::InvalidGeometry);
298 }
299 let PageObject::Image(source) = object else {
300 unreachable!()
301 };
302 objects.push(Content::Image(source));
303 }
304 PageObject::Unsupported(_) => objects.push(Content::ReadOnly(ReadOnlyObject::new(
305 object,
306 page.margin_origin,
307 "Unsupported content\nRead-only",
308 engine,
309 )?)),
310 }
311 }
312 Ok((objects, outlines, date))
313}
crates/canvas/src/editor/table.rs created+706
...@@ -0,0 +1,706 @@
1use super::*;
2use crate::page::{ParagraphContent, TableCell, TableColumn, TableRow};
3use crate::text::new_id;
4
5struct CellLocation<'a> {
6 container: Option<ExGuid>,
7 paragraph: usize,
8 node: &'a PageParagraph,
9 row: usize,
10 column: usize,
11}
12
13fn locate(document: &TextDocument, id: ExGuid) -> Option<CellLocation<'_>> {
14 let mut pending = vec![(None, document.nodes())];
15 while let Some((container, nodes)) = pending.pop() {
16 for (paragraph, node) in nodes.iter().enumerate() {
17 if let ParagraphContent::Table(table) = &node.content {
18 for (row, source) in table.rows.iter().enumerate() {
19 for (column, cell) in source.cells.iter().enumerate() {
20 if cell.id == id {
21 return Some(CellLocation {
22 container,
23 paragraph,
24 node,
25 row,
26 column,
27 });
28 }
29 pending.push((Some(cell.id), cell.paragraphs.as_slice()));
30 }
31 }
32 }
33 }
34 }
35 None
36}
37
38fn empty_cell(source: &TableCell) -> Result<TableCell, EditError> {
39 let (_, _, paragraph) = leaves(&source.paragraphs, None)
40 .next()
41 .ok_or(EditError::UnsupportedContent)?;
42 let format = paragraph.text().unwrap().text.format_at(0)?;
43 let mut node = crate::document::node(
44 Paragraph::new(String::new(), format.clone()),
45 paragraph.format.clone(),
46 )?;
47 node.style = paragraph.style;
48 Ok(TableCell {
49 id: new_id()?,
50 layout: Default::default(),
51 indents: source.indents.clone(),
52 shading: source.shading,
53 paragraphs: vec![node],
54 unsupported: Vec::new(),
55 })
56}
57
58fn cell_range(document: &TextDocument, cell: &TableCell) -> Result<Range<TextPosition>, EditError> {
59 let mut content = leaves(&cell.paragraphs, None);
60 let (_, _, first) = content.next().ok_or(EditError::InvalidStructure)?;
61 let (_, _, last) = content.last().unwrap_or((None, 0, first));
62 let start = document
63 .text_nodes()
64 .position(|node| node.id == first.id)
65 .ok_or(EditError::InvalidStructure)?;
66 let end = document
67 .text_nodes()
68 .position(|node| node.id == last.id)
69 .ok_or(EditError::InvalidStructure)?;
70 let text = &last.text().unwrap().text;
71 Ok(TextPosition {
72 paragraph: start,
73 offset: 0,
74 }..TextPosition {
75 paragraph: end,
76 offset: text.utf16_offset(text.text().len())?,
77 })
78}
79
80impl CanvasEditor {
81 pub fn tab(&mut self, engine: &mut TextEngine, backward: bool) -> Result<(), EditorError> {
82 if self.active_outline().title && !backward {
83 return self.leave_title(engine);
84 }
85 let outline = self.active_outline();
86 let [anchor, focus] = outline.selection.positions;
87 let (cell, local, source) = leaves(outline.document.nodes(), None)
88 .nth(focus.paragraph)
89 .ok_or(EditError::InvalidRange)?;
90 let Some(cell) = cell else {
91 if backward || anchor != focus || focus.offset == 0 {
92 self.indent(engine, backward)?;
93 return Ok(());
94 }
95 let format = source.text().unwrap().text.format_at(focus.offset)?;
96 let mut split = outline.document.replace(
97 focus..focus,
98 vec![
99 Paragraph::new(String::new(), format.clone()),
100 Paragraph::new(String::new(), format.clone()),
101 ],
102 )?;
103 let cells = split
104 .replacement
105 .drain(..)
106 .map(|mut paragraph| {
107 paragraph.level = 1;
108 paragraph.parent = None;
109 Ok(TableCell {
110 id: new_id()?,
111 layout: Default::default(),
112 indents: outline.indents.clone(),
113 shading: None,
114 paragraphs: vec![paragraph],
115 unsupported: Vec::new(),
116 })
117 })
118 .collect::<Result<Vec<_>, EditError>>()?;
119 let wrapper = PageParagraph {
120 id: new_id()?,
121 parent: source.parent,
122 level: source.level,
123 style: None,
124 format: source.format.clone(),
125 lists: Vec::new(),
126 tags: Vec::new(),
127 collapsed: false,
128 content: ParagraphContent::Table(crate::page::Table {
129 id: new_id()?,
130 columns: vec![
131 TableColumn {
132 width: 37.11,
133 locked: false
134 };
135 2
136 ],
137 rows: vec![TableRow {
138 id: new_id()?,
139 cells,
140 }],
141 borders: Some(true),
142 layout: Default::default(),
143 tags: Vec::new(),
144 }),
145 };
146 split.replacement.push(wrapper);
147 return self.commit(
148 engine,
149 split,
150 [TextPosition {
151 paragraph: focus.paragraph + 1,
152 offset: 0,
153 }; 2]
154 .into(),
155 );
156 };
157 let location = locate(&outline.document, cell).ok_or(EditError::InvalidStructure)?;
158 let ParagraphContent::Table(table) = &location.node.content else {
159 unreachable!()
160 };
161 let slot = location.row * table.columns.len() + location.column;
162 let next = if backward {
163 slot.checked_sub(1)
164 } else if slot + 1 < table.rows.len() * table.columns.len() {
165 Some(slot + 1)
166 } else {
167 None
168 };
169 if let Some(next) = next {
170 let target = &table.rows[next / table.columns.len()].cells[next % table.columns.len()];
171 let range = cell_range(&outline.document, target)?;
172 return self
173 .select([range.start, range.end].into())
174 .map_err(Into::into);
175 }
176 if backward {
177 return Ok(());
178 }
179 let mut wrapper = location.node.clone();
180 let ParagraphContent::Table(table) = &mut wrapper.content else {
181 unreachable!()
182 };
183 let next = if table.rows.len() == 1 {
184 let format = source.text().unwrap().text.format_at(focus.offset)?;
185 let split = outline.document.replace(
186 focus..focus,
187 vec![
188 Paragraph::new(String::new(), format.clone()),
189 Paragraph::new(String::new(), format.clone()),
190 ],
191 )?;
192 let cell = &mut table.rows[0].cells[location.column];
193 cell.paragraphs.splice(split.range, split.replacement);
194 let paragraphs = cell.paragraphs.split_off(local + 1);
195 let target = TableCell {
196 id: new_id()?,
197 layout: Default::default(),
198 indents: cell.indents.clone(),
199 shading: cell.shading,
200 paragraphs,
201 unsupported: Vec::new(),
202 };
203 table.rows[0].cells.push(target);
204 table.columns.push(TableColumn {
205 width: 37.11,
206 locked: false,
207 });
208 TextPosition {
209 paragraph: focus.paragraph + 1,
210 offset: 0,
211 }
212 } else {
213 let cells = table
214 .rows
215 .last()
216 .unwrap()
217 .cells
218 .iter()
219 .map(empty_cell)
220 .collect::<Result<Vec<_>, _>>()?;
221 table.rows.push(TableRow {
222 id: new_id()?,
223 cells,
224 });
225 let paragraph = cell_range(
226 &outline.document,
227 &table.rows[location.row].cells[location.column],
228 )?
229 .end
230 .paragraph
231 + 1;
232 TextPosition {
233 paragraph,
234 offset: 0,
235 }
236 };
237 self.commit(
238 engine,
239 DocumentEdit {
240 columns: BTreeMap::new(),
241 container: location.container,
242 range: location.paragraph..location.paragraph + 1,
243 replacement: vec![wrapper],
244 },
245 [next; 2].into(),
246 )
247 }
248 pub fn enter(&mut self, engine: &mut TextEngine, soft: bool) -> Result<(), EditorError> {
249 if soft {
250 return self.insert(engine, "\u{000b}");
251 }
252 if self.active_outline().title {
253 return self.leave_title(engine);
254 }
255 let outline = self.active_outline();
256 let [anchor, focus] = outline.selection.positions;
257 let (cell, local, source) = leaves(outline.document.nodes(), None)
258 .nth(focus.paragraph)
259 .ok_or(EditError::InvalidRange)?;
260 let Some(cell) = cell.filter(|_| anchor == focus) else {
261 return self.insert(engine, "\n");
262 };
263 let location = locate(&outline.document, cell).ok_or(EditError::InvalidStructure)?;
264 let ParagraphContent::Table(table) = &location.node.content else {
265 unreachable!()
266 };
267 let row = &table.rows[location.row];
268 let exit = location.row + 1 == table.rows.len()
269 && location.row > 0
270 && location.column == 0
271 && local == 0
272 && focus.offset == 0
273 && row.cells.iter().all(|cell| {
274 cell.paragraphs
275 .iter()
276 .all(|node| node.text().is_some_and(|text| text.text.text().is_empty()))
277 });
278 let text = &source.text().unwrap().text;
279 let append = location.row + 1 == table.rows.len()
280 && location.column + 1 == table.columns.len()
281 && local + 1 == row.cells[location.column].paragraphs.len()
282 && focus.offset == text.utf16_offset(text.text().len())?;
283 if !exit && !append {
284 return self.insert(engine, "\n");
285 }
286 let mut wrapper = location.node.clone();
287 let ParagraphContent::Table(table) = &mut wrapper.content else {
288 unreachable!()
289 };
290 let (replacement, next) = if exit {
291 table.rows.pop();
292 let first = self.blank_paragraph(source)?;
293 let second = self.blank_paragraph(source)?;
294 (
295 vec![wrapper, first, second],
296 TextPosition {
297 paragraph: focus.paragraph + 1,
298 offset: 0,
299 },
300 )
301 } else {
302 let cells = row
303 .cells
304 .iter()
305 .map(empty_cell)
306 .collect::<Result<Vec<_>, _>>()?;
307 table.rows.push(TableRow {
308 id: new_id()?,
309 cells,
310 });
311 (
312 vec![wrapper],
313 TextPosition {
314 paragraph: focus.paragraph + 1,
315 offset: 0,
316 },
317 )
318 };
319 self.commit(
320 engine,
321 DocumentEdit {
322 columns: BTreeMap::new(),
323 container: location.container,
324 range: location.paragraph..location.paragraph + 1,
325 replacement,
326 },
327 [next; 2].into(),
328 )
329 }
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335 use onestore::document::Format;
336
337 fn editor(engine: &mut TextEngine, text: &str) -> CanvasEditor {
338 CanvasEditor::new(
339 engine,
340 TextDocument::new(vec![Paragraph::new(text.into(), Format::default())]).unwrap(),
341 180.0,
342 )
343 .unwrap()
344 }
345
346 fn cells(editor: &CanvasEditor) -> Vec<Vec<String>> {
347 let ParagraphContent::Table(table) = &editor.active_outline().document.nodes()[0].content
348 else {
349 panic!()
350 };
351 table
352 .rows
353 .iter()
354 .flat_map(|row| &row.cells)
355 .map(|cell| {
356 cell.paragraphs
357 .iter()
358 .map(|node| node.text().unwrap().text.text().to_owned())
359 .collect()
360 })
361 .collect()
362 }
363
364 #[test]
365 fn tab_creates_splits_and_selects_native_cells() {
366 let mut engine = TextEngine::default();
367 let mut editor = editor(&mut engine, "abcdef");
368 let original = editor.active_outline().document.clone();
369 editor
370 .select(
371 [TextPosition {
372 paragraph: 0,
373 offset: 3,
374 }; 2]
375 .into(),
376 )
377 .unwrap();
378 editor.tab(&mut engine, false).unwrap();
379 assert_eq!(cells(&editor), [vec!["abc"], vec!["def"]]);
380 assert_eq!(
381 editor
382 .active_outline()
383 .document
384 .text_nodes()
385 .next()
386 .unwrap()
387 .id,
388 original.nodes()[0].id
389 );
390 assert_eq!(
391 editor.selection().positions,
392 [TextPosition {
393 paragraph: 1,
394 offset: 0
395 }; 2]
396 );
397 editor.insert(&mut engine, "X").unwrap();
398 editor.tab(&mut engine, false).unwrap();
399 assert_eq!(cells(&editor), [vec!["abc"], vec!["X"], vec!["def"]]);
400 editor.tab(&mut engine, true).unwrap();
401 assert_eq!(
402 editor.selection().positions,
403 [
404 TextPosition {
405 paragraph: 1,
406 offset: 0
407 },
408 TextPosition {
409 paragraph: 1,
410 offset: 1
411 }
412 ]
413 );
414 editor.insert(&mut engine, "REPLACED").unwrap();
415 assert_eq!(cells(&editor)[1], ["REPLACED"]);
416 editor.tab(&mut engine, true).unwrap();
417 assert_eq!(
418 editor.selection().positions,
419 [
420 TextPosition {
421 paragraph: 0,
422 offset: 0
423 },
424 TextPosition {
425 paragraph: 0,
426 offset: 3
427 }
428 ]
429 );
430 editor
431 .select(
432 [TextPosition {
433 paragraph: 0,
434 offset: 2,
435 }; 2]
436 .into(),
437 )
438 .unwrap();
439 editor.tab(&mut engine, true).unwrap();
440 editor.insert(&mut engine, "!").unwrap();
441 assert_eq!(cells(&editor)[0], ["ab!c"]);
442 editor.tab(&mut engine, false).unwrap();
443 assert_eq!(
444 editor.selection().positions[1],
445 TextPosition {
446 paragraph: 1,
447 offset: 8
448 }
449 );
450 assert_eq!(editor.active_outline().layout.max_width, Some(180.0));
451 for _ in 0..5 {
452 assert!(editor.undo(&mut engine).unwrap());
453 }
454 assert_eq!(editor.active_outline().document, original);
455 for _ in 0..5 {
456 assert!(editor.redo(&mut engine).unwrap());
457 }
458 assert_eq!(
459 cells(&editor),
460 [vec!["ab!c"], vec!["REPLACED"], vec!["def"]]
461 );
462 }
463
464 #[test]
465 fn adding_rows_preserves_nested_cells_and_undo() {
466 let mut engine = TextEngine::default();
467 let mut outer = editor(&mut engine, "");
468 outer.insert(&mut engine, "Alpha").unwrap();
469 outer.tab(&mut engine, false).unwrap();
470 outer.insert(&mut engine, "Beta").unwrap();
471 outer.enter(&mut engine, false).unwrap();
472 outer.insert(&mut engine, "Gamma").unwrap();
473 outer.tab(&mut engine, false).unwrap();
474 outer.insert(&mut engine, "Delta").unwrap();
475 let mut inner = editor(&mut engine, "");
476 inner.insert(&mut engine, "Nested").unwrap();
477 inner.tab(&mut engine, false).unwrap();
478 let mut source = outer.active_outline().snapshot();
479 let ParagraphContent::Table(table) = &mut source.paragraphs[0].content else {
480 panic!()
481 };
482 table.rows[1].cells[0].paragraphs = inner.active_outline().document.nodes().to_vec();
483 for tab in [false, true] {
484 let mut editor =
485 CanvasEditor::from_outlines(&mut engine, vec![source.clone()], BTreeMap::new())
486 .unwrap();
487 let original = editor.active_outline().document.clone();
488 let end = TextPosition {
489 paragraph: original.text_nodes().count() - 1,
490 offset: 5,
491 };
492 editor.select([end; 2].into()).unwrap();
493 if tab {
494 editor.tab(&mut engine, false).unwrap();
495 } else {
496 editor.enter(&mut engine, false).unwrap();
497 }
498 let added = editor.active_outline().document.clone();
499 let ParagraphContent::Table(before) = &original.nodes()[0].content else {
500 panic!()
501 };
502 let ParagraphContent::Table(after) = &added.nodes()[0].content else {
503 panic!()
504 };
505 assert_eq!(after.rows.len(), 3);
506 assert_eq!(&after.rows[..2], &before.rows);
507 assert_eq!(after.columns, before.columns);
508 for cell in &after.rows[2].cells {
509 assert_eq!(cell.paragraphs.len(), 1);
510 assert!(cell.paragraphs[0].text().unwrap().text.text().is_empty());
511 }
512 assert_eq!(editor.selection().positions[1].paragraph, end.paragraph + 1);
513 editor.insert(&mut engine, "New row").unwrap();
514 assert_eq!(
515 editor
516 .active_outline()
517 .document
518 .paragraphs()
519 .nth(end.paragraph + 1)
520 .unwrap()
521 .text(),
522 "New row"
523 );
524 assert!(editor.undo(&mut engine).unwrap());
525 assert_eq!(editor.active_outline().document, added);
526 assert!(editor.undo(&mut engine).unwrap());
527 assert_eq!(editor.active_outline().document, original);
528 assert_eq!(editor.selection().positions, [end; 2]);
529 assert!(editor.redo(&mut engine).unwrap());
530 assert_eq!(editor.active_outline().document, added);
531 }
532 }
533
534 #[test]
535 fn native_rows_exit_and_text_have_separate_undo_steps() {
536 let mut engine = TextEngine::default();
537 let mut editor = editor(&mut engine, "Alpha");
538 editor
539 .select(
540 [TextPosition {
541 paragraph: 0,
542 offset: 5,
543 }; 2]
544 .into(),
545 )
546 .unwrap();
547 editor.tab(&mut engine, false).unwrap();
548 editor.insert(&mut engine, "Beta").unwrap();
549 editor.enter(&mut engine, false).unwrap();
550 assert_eq!(
551 editor.selection().positions,
552 [TextPosition {
553 paragraph: 2,
554 offset: 0
555 }; 2]
556 );
557 editor.insert(&mut engine, "Gamma").unwrap();
558 editor.tab(&mut engine, false).unwrap();
559 editor.insert(&mut engine, "Delta").unwrap();
560 let two_rows = editor.active_outline().document.clone();
561 editor.tab(&mut engine, false).unwrap();
562 editor.insert(&mut engine, "Epsilon").unwrap();
563 assert_eq!(
564 cells(&editor),
565 [
566 vec!["Alpha"],
567 vec!["Beta"],
568 vec!["Gamma"],
569 vec!["Delta"],
570 vec!["Epsilon"],
571 vec![""]
572 ]
573 );
574 editor.undo(&mut engine).unwrap();
575 assert_eq!(cells(&editor)[4], [""]);
576 editor.undo(&mut engine).unwrap();
577 assert_eq!(editor.active_outline().document, two_rows);
578 editor.redo(&mut engine).unwrap();
579 editor.enter(&mut engine, false).unwrap();
580 assert_eq!(cells(&editor).len(), 4);
581 assert_eq!(editor.active_outline().document.nodes().len(), 3);
582 assert_eq!(
583 editor.selection().positions,
584 [TextPosition {
585 paragraph: 5,
586 offset: 0
587 }; 2]
588 );
589 editor.insert(&mut engine, "Outside").unwrap();
590 assert_eq!(
591 editor
592 .active_outline()
593 .document
594 .paragraphs()
595 .last()
596 .unwrap()
597 .text(),
598 "Outside"
599 );
600 editor.undo(&mut engine).unwrap();
601 editor.undo(&mut engine).unwrap();
602 assert_eq!(cells(&editor).len(), 6);
603 }
604
605 #[test]
606 fn enter_and_soft_break_stay_in_cells_until_the_final_end() {
607 let mut engine = TextEngine::default();
608 let mut editor = editor(&mut engine, "Alpha");
609 editor
610 .select(
611 [TextPosition {
612 paragraph: 0,
613 offset: 5,
614 }; 2]
615 .into(),
616 )
617 .unwrap();
618 editor.tab(&mut engine, false).unwrap();
619 editor.insert(&mut engine, "Beta").unwrap();
620 editor
621 .select(
622 [TextPosition {
623 paragraph: 1,
624 offset: 2,
625 }; 2]
626 .into(),
627 )
628 .unwrap();
629 editor.enter(&mut engine, false).unwrap();
630 assert_eq!(cells(&editor), [vec!["Alpha"], vec!["Be", "ta"]]);
631 editor.enter(&mut engine, true).unwrap();
632 assert_eq!(cells(&editor)[1], ["Be", "\u{000b}ta"]);
633 editor
634 .select(
635 [TextPosition {
636 paragraph: 0,
637 offset: 5,
638 }; 2]
639 .into(),
640 )
641 .unwrap();
642 editor.enter(&mut engine, false).unwrap();
643 assert_eq!(cells(&editor)[0], ["Alpha", ""]);
644 }
645
646 #[test]
647 fn paragraph_start_and_selection_tab_indent_without_creating_a_table() {
648 let mut engine = TextEngine::default();
649 let mut editor = editor(&mut engine, "Alpha");
650 editor.tab(&mut engine, false).unwrap();
651 assert_eq!(editor.active_outline().document.nodes()[0].level, 2);
652 editor
653 .select(
654 [
655 TextPosition {
656 paragraph: 0,
657 offset: 1,
658 },
659 TextPosition {
660 paragraph: 0,
661 offset: 3,
662 },
663 ]
664 .into(),
665 )
666 .unwrap();
667 let selection = editor.selection();
668 editor.tab(&mut engine, false).unwrap();
669 assert_eq!(editor.active_outline().document.nodes()[0].level, 3);
670 assert_eq!(
671 editor
672 .active_outline()
673 .document
674 .paragraphs()
675 .next()
676 .unwrap()
677 .text(),
678 "Alpha"
679 );
680 assert_eq!(editor.selection(), selection);
681 editor.tab(&mut engine, true).unwrap();
682 assert_eq!(editor.active_outline().document.nodes()[0].level, 2);
683 editor
684 .select(
685 [TextPosition {
686 paragraph: 0,
687 offset: 5,
688 }; 2]
689 .into(),
690 )
691 .unwrap();
692 editor.tab(&mut engine, false).unwrap();
693 assert_eq!(editor.active_outline().document.nodes()[0].level, 2);
694 assert_eq!(
695 editor
696 .active_outline()
697 .document
698 .text_nodes()
699 .next()
700 .unwrap()
701 .level,
702 1
703 );
704 assert_eq!(editor.active_outline().shaped.paragraphs[0].origin[0], 27.0);
705 }
706}
crates/canvas/src/gpu/canvas.wgsl created+87
...@@ -0,0 +1,87 @@
1struct Vertex {
2 @builtin(position) position: vec4<f32>,
3 @location(0) uv: vec2<f32>,
4 @location(1) color: vec4<f32>,
5 @location(2) local: vec2<f32>,
6 @location(3) @interpolate(flat) shape: vec4<f32>,
7 @location(4) @interpolate(flat) stroke: f32,
8}
9
10@group(0) @binding(0) var atlas: texture_2d<f32>;
11@group(0) @binding(1) var atlas_sampler: sampler;
12
13@vertex
14fn vertex(@location(0) position: vec2<f32>, @location(1) uv: vec2<f32>,
15 @location(2) color: vec4<f32>, @location(3) local: vec2<f32>,
16 @location(4) shape: vec4<f32>, @location(5) stroke: f32) -> Vertex {
17 return Vertex(vec4<f32>(position, 0.0, 1.0), uv, color, local, shape, stroke);
18}
19
20fn ellipse_arc(angle: f32, radius: vec2<f32>) -> f32 {
21 if radius.x == radius.y {
22 return angle * radius.x;
23 }
24 // Four-point Gauss-Legendre quadrature.
25 let nodes = vec4<f32>(-0.86113631, -0.33998104, 0.33998104, 0.86113631);
26 let weights = vec4<f32>(0.34785485, 0.65214515, 0.65214515, 0.34785485);
27 let half = angle * 0.5;
28 let scale = max(radius.x, radius.y);
29 var sum = 0.0;
30 for (var i = 0u; i < 4u; i += 1u) {
31 let t = half * (nodes[i] + 1.0);
32 sum += weights[i] * length((radius / scale) * vec2<f32>(sin(t), cos(t)));
33 }
34 return half * sum * scale;
35}
36
37@fragment
38fn fragment(input: Vertex) -> @location(0) vec4<f32> {
39 var color = textureSample(atlas, atlas_sampler, input.uv) * input.color;
40 if input.shape.x > 0.0 && input.shape.y > 0.0 {
41 let q = abs(input.local) - input.shape.xy + input.shape.zw;
42 var distance: f32;
43 if input.shape.z == input.shape.w {
44 distance = length(max(q, vec2<f32>(0.0))) + min(max(q.x, q.y), 0.0) - input.shape.z;
45 } else if all(q > vec2<f32>(0.0)) {
46 let radius = max(input.shape.zw, vec2<f32>(0.0001));
47 let normalized = q / radius;
48 let k0 = length(normalized);
49 distance = k0 * (k0 - 1.0) / max(length(normalized / radius), 1e-20);
50 } else {
51 let edge = abs(input.local) - input.shape.xy;
52 distance = max(edge.x, edge.y);
53 }
54 var coverage = clamp(0.5 - distance, 0.0, 1.0);
55 let width = abs(input.stroke);
56 if width > 0.0 {
57 coverage *= clamp(distance + width + 0.5, 0.0, 1.0);
58 }
59 if input.stroke < 0.0 {
60 var radius = max(input.shape.zw - width * 0.5, vec2<f32>(0.0));
61 if any(radius == vec2<f32>(0.0)) {
62 radius = vec2<f32>(0.0);
63 }
64 let straight = input.shape.xy - width * 0.5 - radius;
65 let p = abs(input.local);
66 let quarter = straight.x + straight.y + ellipse_arc(1.57079632679, radius);
67 var along: f32;
68 if p.y <= straight.y {
69 along = p.y;
70 } else if p.x <= straight.x || radius.x == 0.0 {
71 along = quarter - p.x;
72 } else {
73 let angle = atan2((p.y - straight.y) / radius.y, (p.x - straight.x) / radius.x);
74 along = straight.y + ellipse_arc(angle, radius);
75 }
76 if input.local.x < 0.0 {
77 along = select(2.0 * quarter - along, 2.0 * quarter + along, input.local.y < 0.0);
78 } else if input.local.y < 0.0 {
79 along = 4.0 * quarter - along;
80 }
81 let phase = fract(along / (4.0 * width)) * (4.0 * width);
82 coverage *= clamp(width + 0.5 - abs(phase - 2.0 * width), 0.0, 1.0);
83 }
84 color *= coverage;
85 }
86 return color;
87}
crates/canvas/src/gpu/mod.rs created+1546
...@@ -0,0 +1,1546 @@
1pub mod page;
2#[cfg(test)]
3mod profile;
4mod tags;
5
6use crate::{
7 layout::TextLayout,
8 outline::{ParagraphTag, TagIcon},
9};
10use bytemuck::{Pod, Zeroable};
11use image::ImageDecoder;
12use parley::{PositionedLayoutItem, fontique::Blob};
13use std::{
14 collections::{HashMap, HashSet},
15 ops::Range,
16};
17use swash::{
18 FontRef,
19 scale::{Render, ScaleContext, Source, StrikeWith, image::Content},
20 zeno::{Angle, Format, Transform, Vector},
21};
22
23const ATLAS_SIZE: u32 = 2048;
24const MAX_GLYPHS: usize = 8192;
25const MAX_VERTICES: usize = 65_536;
26const MAX_IMAGE_BYTES: u64 = 64 * 1024 * 1024;
27const MAX_IMAGES: usize = 256;
28
29#[derive(Clone)]
30pub struct RasterImage {
31 size: [u32; 2],
32 pixels: Blob<u8>,
33}
34
35impl RasterImage {
36 pub fn decode(encoded: &[u8]) -> Result<Self, RenderError> {
37 if encoded.len() as u64 > MAX_IMAGE_BYTES {
38 return Err(RenderError::ImageBudget);
39 }
40 let format = image::guess_format(encoded).map_err(RenderError::ImageDecode)?;
41 let mut reader = image::ImageReader::with_format(std::io::Cursor::new(encoded), format);
42 let mut limits = image::Limits::default();
43 limits.max_image_width = Some(16_384);
44 limits.max_image_height = Some(16_384);
45 limits.max_alloc = Some(MAX_IMAGE_BYTES);
46 reader.limits(limits.clone());
47 let mut decoder = reader.into_decoder().map_err(RenderError::ImageDecode)?;
48 let (width, height) = decoder.dimensions();
49 if u64::from(width) * u64::from(height) * 4 > MAX_IMAGE_BYTES
50 || decoder.total_bytes() > MAX_IMAGE_BYTES
51 {
52 return Err(RenderError::ImageBudget);
53 }
54 limits
55 .reserve(decoder.total_bytes())
56 .map_err(RenderError::ImageDecode)?;
57 decoder
58 .set_limits(limits)
59 .map_err(RenderError::ImageDecode)?;
60 let pixels = image::DynamicImage::from_decoder(decoder)
61 .map_err(RenderError::ImageDecode)?
62 .into_rgba8();
63 Self::new([width, height], pixels.into_raw())
64 }
65
66 /// Immutable, straight-alpha sRGB RGBA pixels retain one cache identity across clones.
67 pub fn new(size: [u32; 2], mut pixels: Vec<u8>) -> Result<Self, RenderError> {
68 let bytes = u64::from(size[0])
69 .checked_mul(u64::from(size[1]))
70 .and_then(|v| v.checked_mul(4))
71 .ok_or(RenderError::InvalidImage)?;
72 if size.contains(&0) || bytes != pixels.len() as u64 {
73 return Err(RenderError::InvalidImage);
74 }
75 if bytes > MAX_IMAGE_BYTES {
76 return Err(RenderError::ImageBudget);
77 }
78 // Premultiply in linear light before filtering; transparent RGB must not bleed at edges.
79 for pixel in pixels.chunks_exact_mut(4) {
80 let alpha = pixel[3];
81 if alpha == 255 {
82 continue;
83 }
84 let linear = colorref(u32::from_le_bytes(pixel.try_into().unwrap()));
85 for (byte, value) in pixel[..3].iter_mut().zip(linear) {
86 let value = value * f32::from(alpha) / 255.0;
87 *byte = srgb_byte(value);
88 }
89 }
90 Ok(Self {
91 size,
92 pixels: pixels.into(),
93 })
94 }
95}
96
97struct CachedImage {
98 binding: wgpu::BindGroup,
99 bytes: u64,
100}
101
102struct Batch {
103 vertices: Range<u32>,
104 image: Option<u64>,
105 scissor: [u32; 4],
106}
107
108#[repr(C)]
109#[derive(Clone, Copy, Pod, Zeroable)]
110struct Vertex {
111 position: [f32; 2],
112 uv: [f32; 2],
113 color: [f32; 4],
114 local: [f32; 2],
115 shape: [f32; 4],
116 stroke: f32,
117}
118
119#[derive(Hash, PartialEq, Eq)]
120enum AtlasKey {
121 Text {
122 font: u64,
123 index: u32,
124 glyph: u16,
125 size: u32,
126 coords: Vec<i16>,
127 phase: [u8; 2],
128 embolden: bool,
129 skew: u32,
130 },
131 Tag {
132 icon: TagIcon,
133 size: u32,
134 phase: [u8; 2],
135 },
136}
137
138#[derive(Clone, Copy)]
139struct AtlasGlyph {
140 x: u32,
141 y: u32,
142 width: u32,
143 height: u32,
144 left: i32,
145 top: i32,
146 color: bool,
147}
148
149#[derive(Clone, Copy)]
150pub struct Viewport {
151 pub size: [u32; 2],
152 /// Physical pixels per document point.
153 pub scale: f32,
154 /// Physical pixel position of the document origin.
155 pub origin: [f32; 2],
156}
157
158impl Viewport {
159 pub fn document_point(&self, point: [f32; 2]) -> [f32; 2] {
160 [
161 (point[0] - self.origin[0]) / self.scale,
162 (point[1] - self.origin[1]) / self.scale,
163 ]
164 }
165
166 fn visible_image_rect(&self, rect: [f32; 4]) -> Result<Option<[f32; 4]>, RenderError> {
167 let rect = [
168 rect[0] * self.scale + self.origin[0],
169 rect[1] * self.scale + self.origin[1],
170 rect[2] * self.scale + self.origin[0],
171 rect[3] * self.scale + self.origin[1],
172 ];
173 if rect.iter().any(|v| !v.is_finite()) || rect[2] < rect[0] || rect[3] < rect[1] {
174 return Err(RenderError::InvalidPrimitive);
175 }
176 Ok((rect[0] < self.size[0] as f32
177 && rect[1] < self.size[1] as f32
178 && rect[2] > 0.0
179 && rect[3] > 0.0
180 && rect[0] < rect[2]
181 && rect[1] < rect[3])
182 .then_some(rect))
183 }
184}
185
186#[derive(Clone, Copy, Debug, PartialEq)]
187pub enum Stroke {
188 Solid(f32),
189 /// Dash and gap lengths are twice the stroke width.
190 Dashed(f32),
191}
192
193/// Coordinates are document points; colors are linear RGBA.
194pub enum Primitive<'a> {
195 Tag {
196 tag: &'a ParagraphTag,
197 origin: [f32; 2],
198 },
199 Text {
200 layout: &'a TextLayout,
201 origin: [f32; 2],
202 /// Document-space paint bounds preserve the full shaping and caret advances.
203 clip: Option<[f32; 4]>,
204 },
205 Rect {
206 rect: [f32; 4],
207 color: [f32; 4],
208 },
209 RoundedRect {
210 rect: [f32; 4],
211 radius: [f32; 2],
212 /// None fills the shape; stroke widths are positive document points.
213 stroke: Option<Stroke>,
214 color: [f32; 4],
215 },
216 Image {
217 image: &'a RasterImage,
218 rect: [f32; 4],
219 },
220}
221
222#[derive(Debug)]
223pub enum RenderError {
224 AtlasFull,
225 FrameTooLarge,
226 UnreadableFont,
227 UnsupportedGlyph,
228 InvalidViewport,
229 InvalidPrimitive,
230 InvalidImage,
231 ImageBudget,
232 ImageTooLarge,
233 ImageDecode(image::ImageError),
234}
235
236pub struct Renderer {
237 pub device: wgpu::Device,
238 pub queue: wgpu::Queue,
239 pipeline: wgpu::RenderPipeline,
240 image_pipeline: wgpu::RenderPipeline,
241 atlas: wgpu::Texture,
242 bind_group: wgpu::BindGroup,
243 image_sampler: wgpu::Sampler,
244 vertex_buffer: wgpu::Buffer,
245 vertices: Vec<Vertex>,
246 glyphs: HashMap<AtlasKey, Option<AtlasGlyph>>,
247 images: HashMap<u64, CachedImage>,
248 batches: Vec<Batch>,
249 scaler: ScaleContext,
250 pen: [u32; 2],
251 row_height: u32,
252}
253
254impl Renderer {
255 pub fn new(device: wgpu::Device, queue: wgpu::Queue, format: wgpu::TextureFormat) -> Self {
256 let shader = device.create_shader_module(wgpu::include_wgsl!("canvas.wgsl"));
257 let binding_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
258 label: Some("Canvas texture"),
259 entries: &[
260 wgpu::BindGroupLayoutEntry {
261 binding: 0,
262 visibility: wgpu::ShaderStages::FRAGMENT,
263 ty: wgpu::BindingType::Texture {
264 sample_type: wgpu::TextureSampleType::Float { filterable: true },
265 view_dimension: wgpu::TextureViewDimension::D2,
266 multisampled: false,
267 },
268 count: None,
269 },
270 wgpu::BindGroupLayoutEntry {
271 binding: 1,
272 visibility: wgpu::ShaderStages::FRAGMENT,
273 ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
274 count: None,
275 },
276 ],
277 });
278 let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
279 label: Some("Canvas"),
280 bind_group_layouts: &[Some(&binding_layout)],
281 ..Default::default()
282 });
283 let make_pipeline = |blend| {
284 device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
285 label: Some("Canvas"),
286 layout: Some(&pipeline_layout),
287 vertex: wgpu::VertexState {
288 module: &shader,
289 entry_point: Some("vertex"),
290 compilation_options: Default::default(),
291 buffers: &[Some(wgpu::VertexBufferLayout {
292 array_stride: size_of::<Vertex>() as u64,
293 step_mode: wgpu::VertexStepMode::Vertex,
294 attributes: &wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Float32x4, 3 => Float32x2, 4 => Float32x4, 5 => Float32],
295 })],
296 },
297 fragment: Some(wgpu::FragmentState {
298 module: &shader,
299 entry_point: Some("fragment"),
300 compilation_options: Default::default(),
301 targets: &[Some(wgpu::ColorTargetState {
302 format,
303 blend: Some(blend),
304 write_mask: wgpu::ColorWrites::ALL,
305 })],
306 }),
307 primitive: Default::default(),
308 depth_stencil: None,
309 multisample: Default::default(),
310 multiview_mask: None,
311 cache: None,
312 })
313 };
314 let pipeline = make_pipeline(wgpu::BlendState::ALPHA_BLENDING);
315 let image_pipeline = make_pipeline(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING);
316 let atlas = device.create_texture(&wgpu::TextureDescriptor {
317 label: Some("Glyph atlas"),
318 size: wgpu::Extent3d {
319 width: ATLAS_SIZE,
320 height: ATLAS_SIZE,
321 depth_or_array_layers: 1,
322 },
323 mip_level_count: 1,
324 sample_count: 1,
325 dimension: wgpu::TextureDimension::D2,
326 format: wgpu::TextureFormat::Rgba8UnormSrgb,
327 usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
328 view_formats: &[],
329 });
330 let view = atlas.create_view(&Default::default());
331 let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
332 mag_filter: wgpu::FilterMode::Nearest,
333 min_filter: wgpu::FilterMode::Nearest,
334 ..Default::default()
335 });
336 let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
337 label: Some("Glyph atlas"),
338 layout: &pipeline.get_bind_group_layout(0),
339 entries: &[
340 wgpu::BindGroupEntry {
341 binding: 0,
342 resource: wgpu::BindingResource::TextureView(&view),
343 },
344 wgpu::BindGroupEntry {
345 binding: 1,
346 resource: wgpu::BindingResource::Sampler(&sampler),
347 },
348 ],
349 });
350 let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
351 label: Some("Canvas vertices"),
352 size: (MAX_VERTICES * size_of::<Vertex>()) as u64,
353 usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
354 mapped_at_creation: false,
355 });
356 let image_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
357 label: Some("Canvas image filtering"),
358 mag_filter: wgpu::FilterMode::Linear,
359 min_filter: wgpu::FilterMode::Linear,
360 ..Default::default()
361 });
362 queue.write_texture(
363 atlas.as_image_copy(),
364 &[255; 4],
365 wgpu::TexelCopyBufferLayout {
366 offset: 0,
367 bytes_per_row: Some(4),
368 rows_per_image: Some(1),
369 },
370 wgpu::Extent3d {
371 width: 1,
372 height: 1,
373 depth_or_array_layers: 1,
374 },
375 );
376 Self {
377 device,
378 queue,
379 pipeline,
380 image_pipeline,
381 atlas,
382 bind_group,
383 image_sampler,
384 vertex_buffer,
385 vertices: Vec::new(),
386 glyphs: HashMap::new(),
387 images: HashMap::new(),
388 batches: Vec::new(),
389 scaler: ScaleContext::with_max_entries(32),
390 pen: [1, 0],
391 row_height: 1,
392 }
393 }
394
395 pub fn clear_glyph_cache(&mut self) {
396 self.glyphs.clear();
397 self.pen = [1, 0];
398 self.row_height = 1;
399 }
400
401 pub fn draw(
402 &mut self,
403 target: &wgpu::TextureView,
404 viewport: Viewport,
405 primitives: &[Primitive<'_>],
406 ) -> Result<(), RenderError> {
407 if viewport.size.contains(&0)
408 || !viewport.scale.is_finite()
409 || viewport.scale <= 0.0
410 || viewport.origin.iter().any(|v| !v.is_finite())
411 {
412 return Err(RenderError::InvalidViewport);
413 }
414 let mut active_images = HashSet::new();
415 let mut image_bytes = 0;
416 for primitive in primitives {
417 if let Primitive::Image { image, rect } = primitive
418 && viewport.visible_image_rect(*rect)?.is_some()
419 && active_images.insert(image.pixels.id())
420 {
421 image_bytes += image.pixels.as_ref().len() as u64;
422 if image_bytes > MAX_IMAGE_BYTES || active_images.len() > MAX_IMAGES {
423 return Err(RenderError::ImageBudget);
424 }
425 }
426 }
427 for attempt in 0..2 {
428 self.vertices.clear();
429 self.batches.clear();
430 match self.prepare(viewport, primitives, &active_images) {
431 Err(RenderError::AtlasFull) if attempt == 0 => self.clear_glyph_cache(),
432 result => {
433 result?;
434 break;
435 }
436 }
437 }
438 self.queue
439 .write_buffer(&self.vertex_buffer, 0, bytemuck::cast_slice(&self.vertices));
440 let mut encoder = self.device.create_command_encoder(&Default::default());
441 {
442 let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
443 label: Some("Canvas"),
444 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
445 view: target,
446 depth_slice: None,
447 resolve_target: None,
448 ops: wgpu::Operations {
449 load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
450 store: wgpu::StoreOp::Store,
451 },
452 })],
453 ..Default::default()
454 });
455 pass.set_vertex_buffer(0, self.vertex_buffer.slice(..));
456 for batch in &self.batches {
457 let [x, y, width, height] = batch.scissor;
458 pass.set_scissor_rect(x, y, width, height);
459 pass.set_pipeline(if batch.image.is_some() {
460 &self.image_pipeline
461 } else {
462 &self.pipeline
463 });
464 let binding = batch
465 .image
466 .map(|id| &self.images[&id].binding)
467 .unwrap_or(&self.bind_group);
468 pass.set_bind_group(0, binding, &[]);
469 pass.draw(batch.vertices.clone(), 0..1);
470 }
471 }
472 self.queue.submit([encoder.finish()]);
473 Ok(())
474 }
475
476 fn prepare(
477 &mut self,
478 viewport: Viewport,
479 primitives: &[Primitive<'_>],
480 active_images: &HashSet<u64>,
481 ) -> Result<(), RenderError> {
482 for primitive in primitives {
483 let start = self.vertices.len() as u32;
484 let mut image_id = None;
485 let mut scissor = [0, 0, viewport.size[0], viewport.size[1]];
486 match primitive {
487 Primitive::Tag { tag, origin } => {
488 let origin = [
489 viewport.origin[0] + (origin[0] + tag.origin[0]) * viewport.scale,
490 viewport.origin[1] + (origin[1] + tag.origin[1]) * viewport.scale,
491 ];
492 self.tag(Viewport { origin, ..viewport }, tag)?;
493 }
494 Primitive::Text {
495 layout,
496 origin,
497 clip,
498 } => {
499 let origin = [
500 viewport.origin[0] + origin[0] * viewport.scale,
501 viewport.origin[1] + origin[1] * viewport.scale,
502 ];
503 if origin.iter().any(|v| !v.is_finite()) {
504 return Err(RenderError::InvalidPrimitive);
505 }
506 if let Some(clip) = clip {
507 let Some(rect) = viewport.visible_image_rect(*clip)? else {
508 continue;
509 };
510 let left = rect[0].max(0.0).floor() as u32;
511 let top = rect[1].max(0.0).floor() as u32;
512 let right = rect[2].min(viewport.size[0] as f32).ceil() as u32;
513 let bottom = rect[3].min(viewport.size[1] as f32).ceil() as u32;
514 scissor = [left, top, right - left, bottom - top];
515 }
516 self.text(Viewport { origin, ..viewport }, layout)?;
517 }
518 Primitive::Rect { rect, color } => {
519 self.quad(
520 viewport,
521 [
522 rect[0] * viewport.scale + viewport.origin[0],
523 rect[1] * viewport.scale + viewport.origin[1],
524 rect[2] * viewport.scale + viewport.origin[0],
525 rect[3] * viewport.scale + viewport.origin[1],
526 ],
527 [0.5 / ATLAS_SIZE as f32; 4],
528 *color,
529 )?;
530 }
531 Primitive::RoundedRect {
532 rect,
533 radius,
534 stroke,
535 color,
536 } => {
537 if radius
538 .iter()
539 .any(|value| !value.is_finite() || *value < 0.0)
540 {
541 return Err(RenderError::InvalidPrimitive);
542 }
543 let width = match stroke {
544 None => 0.0,
545 Some(Stroke::Solid(width) | Stroke::Dashed(width)) => {
546 if !width.is_finite() || *width <= 0.0 || width * viewport.scale == 0.0
547 {
548 return Err(RenderError::InvalidPrimitive);
549 }
550 *width
551 }
552 };
553 let rect = [
554 rect[0] * viewport.scale + viewport.origin[0],
555 rect[1] * viewport.scale + viewport.origin[1],
556 rect[2] * viewport.scale + viewport.origin[0],
557 rect[3] * viewport.scale + viewport.origin[1],
558 ];
559 self.quad(viewport, rect, [0.5 / ATLAS_SIZE as f32; 4], *color)?;
560 let half = [(rect[2] - rect[0]) * 0.5, (rect[3] - rect[1]) * 0.5];
561 if !(4.0 * (half[0] + half[1])).is_finite() {
562 return Err(RenderError::InvalidPrimitive);
563 }
564 let radius = if radius.contains(&0.0) {
565 [0.0; 2]
566 } else {
567 std::array::from_fn(|axis| (radius[axis] * viewport.scale).min(half[axis]))
568 };
569 let width = (width * viewport.scale).min(half[0].min(half[1]));
570 // The stroke sign selects its dash pattern at the shader boundary.
571 let width = if matches!(stroke, Some(Stroke::Dashed(_))) {
572 -width
573 } else {
574 width
575 };
576 for vertex in &mut self.vertices[start as usize..] {
577 vertex.shape = [half[0], half[1], radius[0], radius[1]];
578 vertex.stroke = width;
579 }
580 }
581 Primitive::Image { image, rect } => {
582 if let Some(rect) = viewport.visible_image_rect(*rect)? {
583 self.image(image, active_images)?;
584 image_id = Some(image.pixels.id());
585 self.quad(viewport, rect, [0.0, 0.0, 1.0, 1.0], [1.0; 4])?;
586 }
587 }
588 }
589 let end = self.vertices.len() as u32;
590 if start != end {
591 if let Some(last) = self.batches.last_mut()
592 && last.image == image_id
593 && last.scissor == scissor
594 {
595 last.vertices.end = end;
596 } else {
597 self.batches.push(Batch {
598 vertices: start..end,
599 image: image_id,
600 scissor,
601 });
602 }
603 }
604 }
605 Ok(())
606 }
607
608 fn image(&mut self, image: &RasterImage, active: &HashSet<u64>) -> Result<(), RenderError> {
609 if self.images.contains_key(&image.pixels.id()) {
610 return Ok(());
611 }
612 if image
613 .size
614 .iter()
615 .any(|v| *v > self.device.limits().max_texture_dimension_2d)
616 {
617 return Err(RenderError::ImageTooLarge);
618 }
619 let bytes = image.pixels.as_ref().len() as u64;
620 while self.images.values().map(|i| i.bytes).sum::<u64>() + bytes > MAX_IMAGE_BYTES
621 || self.images.len() >= MAX_IMAGES
622 {
623 let id = self
624 .images
625 .keys()
626 .find(|id| !active.contains(id))
627 .copied()
628 .ok_or(RenderError::ImageBudget)?;
629 self.images.remove(&id);
630 }
631 let size = wgpu::Extent3d {
632 width: image.size[0],
633 height: image.size[1],
634 depth_or_array_layers: 1,
635 };
636 let texture = self.device.create_texture(&wgpu::TextureDescriptor {
637 label: Some("Canvas image"),
638 size,
639 mip_level_count: 1,
640 sample_count: 1,
641 dimension: wgpu::TextureDimension::D2,
642 format: wgpu::TextureFormat::Rgba8UnormSrgb,
643 usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
644 view_formats: &[],
645 });
646 self.queue.write_texture(
647 texture.as_image_copy(),
648 image.pixels.as_ref(),
649 wgpu::TexelCopyBufferLayout {
650 offset: 0,
651 bytes_per_row: Some(image.size[0] * 4),
652 rows_per_image: Some(image.size[1]),
653 },
654 size,
655 );
656 let binding = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
657 label: Some("Canvas image"),
658 layout: &self.pipeline.get_bind_group_layout(0),
659 entries: &[
660 wgpu::BindGroupEntry {
661 binding: 0,
662 resource: wgpu::BindingResource::TextureView(
663 &texture.create_view(&Default::default()),
664 ),
665 },
666 wgpu::BindGroupEntry {
667 binding: 1,
668 resource: wgpu::BindingResource::Sampler(&self.image_sampler),
669 },
670 ],
671 });
672 self.images
673 .insert(image.pixels.id(), CachedImage { binding, bytes });
674 Ok(())
675 }
676
677 fn text(&mut self, viewport: Viewport, layout: &TextLayout) -> Result<(), RenderError> {
678 let scale = viewport.scale;
679 let origin = viewport.origin;
680 for (line, line_box) in layout.lines() {
681 let top = line_box.top * scale + origin[1];
682 if top + line_box.height * scale < 0.0 || top > viewport.size[1] as f32 {
683 continue;
684 }
685 for item in line.items() {
686 let PositionedLayoutItem::GlyphRun(glyph_run) = item else {
687 continue;
688 };
689 let run = glyph_run.run();
690 let data = &run.font().font;
691 let size = run.font_size() * scale;
692 if !size.is_finite() || size > ATLAS_SIZE as f32 {
693 return Err(RenderError::AtlasFull);
694 }
695 let synthesis = run.synthesis();
696 let coords: Vec<i16> = run
697 .normalized_coords()
698 .iter()
699 .map(|c| c.to_bits())
700 .collect();
701 let color = glyph_run.style().brush.color;
702 for glyph in glyph_run.positioned_glyphs() {
703 let x = glyph.x * scale + origin[0];
704 let y =
705 (glyph.y + line_box.baseline - line.metrics().baseline) * scale + origin[1];
706 // Quantization affects raster coverage only, never advances or line breaks.
707 let x = (x * 4.0).round() * 0.25;
708 let y = (y * 4.0).round() * 0.25;
709 let glyph_id = glyph
710 .id
711 .try_into()
712 .map_err(|_| RenderError::UnsupportedGlyph)?;
713 let phase = [((x - x.floor()) * 4.0) as u8, ((y - y.floor()) * 4.0) as u8];
714 let key = AtlasKey::Text {
715 font: data.data.id(),
716 index: data.index,
717 glyph: glyph_id,
718 size: size.to_bits(),
719 coords: coords.clone(),
720 phase,
721 embolden: synthesis.embolden(),
722 skew: synthesis.skew().unwrap_or(0.0).to_bits(),
723 };
724 let cached = if let Some(cached) = self.glyphs.get(&key) {
725 *cached
726 } else {
727 if self.glyphs.len() >= MAX_GLYPHS {
728 return Err(RenderError::AtlasFull);
729 }
730 let font = FontRef::from_index(data.data.as_ref(), data.index as usize)
731 .ok_or(RenderError::UnreadableFont)?;
732 let mut scaler = self
733 .scaler
734 .builder_with_id(font, [data.data.id(), u64::from(data.index)])
735 .size(size)
736 .hint(false)
737 .normalized_coords(coords.iter().copied())
738 .build();
739 let mut render = Render::new(&[
740 Source::ColorOutline(0),
741 Source::ColorBitmap(StrikeWith::BestFit),
742 Source::Outline,
743 ]);
744 // Swash rasterizes outlines in Y-up coordinates.
745 render.format(Format::Alpha).offset(Vector::new(
746 f32::from(phase[0]) * 0.25,
747 -f32::from(phase[1]) * 0.25,
748 ));
749 if synthesis.embolden() {
750 render.embolden(size / 24.0);
751 }
752 if let Some(skew) = synthesis.skew() {
753 render.transform(Some(Transform::skew(
754 Angle::from_degrees(skew),
755 Angle::from_degrees(0.0),
756 )));
757 }
758 let image = render.render(&mut scaler, glyph_id);
759 let cached = if let Some(image) =
760 image.filter(|i| i.placement.width > 0 && i.placement.height > 0)
761 {
762 Some(self.upload(image)?)
763 } else {
764 None
765 };
766 self.glyphs.insert(key, cached);
767 cached
768 };
769 if let Some(glyph) = cached {
770 let mut left = x.floor() + glyph.left as f32;
771 let mut top = y.floor() - glyph.top as f32;
772 let mut width = glyph.width as f32;
773 let mut height = glyph.height as f32;
774 if glyph.color {
775 let line_top = line_box.top * scale + origin[1];
776 let line_height = line_box.height * scale;
777 let ratio = (line_height / height).min(1.0);
778 left += width * (1.0 - ratio) * 0.5;
779 width *= ratio;
780 height *= ratio;
781 top = top
782 .clamp(line_top, (line_top + line_height - height).max(line_top));
783 }
784 let atlas_size = ATLAS_SIZE as f32;
785 self.quad(
786 viewport,
787 [left, top, left + width, top + height],
788 [
789 glyph.x as f32 / atlas_size,
790 glyph.y as f32 / atlas_size,
791 (glyph.x + glyph.width) as f32 / atlas_size,
792 (glyph.y + glyph.height) as f32 / atlas_size,
793 ],
794 if glyph.color {
795 [1.0; 4]
796 } else {
797 colorref(color)
798 },
799 )?;
800 }
801 }
802 let metrics = run.font_metrics();
803 for (decoration, offset, thickness) in [
804 (
805 &glyph_run.style().underline,
806 metrics.underline_offset,
807 metrics.underline_size,
808 ),
809 (
810 &glyph_run.style().strikethrough,
811 metrics.strikethrough_offset,
812 metrics.strikethrough_size,
813 ),
814 ] {
815 if let Some(decoration) = decoration {
816 let x = glyph_run.offset() * scale + origin[0];
817 let y = (line_box.baseline - decoration.offset.unwrap_or(offset)) * scale
818 + origin[1];
819 self.quad(
820 viewport,
821 [
822 x,
823 y,
824 x + glyph_run.advance() * scale,
825 y + (decoration.size.unwrap_or(thickness) * scale).max(1.0),
826 ],
827 [0.5 / ATLAS_SIZE as f32; 4],
828 colorref(decoration.brush.color),
829 )?;
830 }
831 }
832 }
833 }
834 Ok(())
835 }
836
837 fn upload(&mut self, image: swash::scale::image::Image) -> Result<AtlasGlyph, RenderError> {
838 let p = image.placement;
839 if p.width + 2 > ATLAS_SIZE || p.height + 2 > ATLAS_SIZE {
840 return Err(RenderError::AtlasFull);
841 }
842 if self.pen[0] + p.width + 1 > ATLAS_SIZE {
843 self.pen[0] = 0;
844 self.pen[1] += self.row_height + 1;
845 self.row_height = 0;
846 }
847 if self.pen[1] + p.height + 1 > ATLAS_SIZE {
848 return Err(RenderError::AtlasFull);
849 }
850 let cached = AtlasGlyph {
851 x: self.pen[0],
852 y: self.pen[1],
853 width: p.width,
854 height: p.height,
855 left: p.left,
856 top: p.top,
857 color: image.content == Content::Color,
858 };
859 let rgba = match image.content {
860 Content::Mask => image
861 .data
862 .iter()
863 .flat_map(|a| [255, 255, 255, *a])
864 .collect(),
865 _ => image.data,
866 };
867 self.queue.write_texture(
868 wgpu::TexelCopyTextureInfo {
869 origin: wgpu::Origin3d {
870 x: cached.x,
871 y: cached.y,
872 z: 0,
873 },
874 ..self.atlas.as_image_copy()
875 },
876 &rgba,
877 wgpu::TexelCopyBufferLayout {
878 offset: 0,
879 bytes_per_row: Some(p.width * 4),
880 rows_per_image: Some(p.height),
881 },
882 wgpu::Extent3d {
883 width: p.width,
884 height: p.height,
885 depth_or_array_layers: 1,
886 },
887 );
888 self.pen[0] += p.width + 1;
889 self.row_height = self.row_height.max(p.height);
890 Ok(cached)
891 }
892
893 fn tag(&mut self, viewport: Viewport, tag: &ParagraphTag) -> Result<(), RenderError> {
894 let size = ParagraphTag::SIZE * viewport.scale;
895 let [x, y] = viewport.origin.map(|v| (v * 4.0).round() * 0.25);
896 if !size.is_finite() || [x, y].iter().any(|v| !v.is_finite()) {
897 return Err(RenderError::InvalidPrimitive);
898 }
899 if x + size + 1.0 < 0.0
900 || y + size + 1.0 < 0.0
901 || x - 1.0 > viewport.size[0] as f32
902 || y - 1.0 > viewport.size[1] as f32
903 {
904 return Ok(());
905 }
906 if size + 3.0 > ATLAS_SIZE as f32 {
907 return Err(RenderError::AtlasFull);
908 }
909 let phase = [((x - x.floor()) * 4.0) as u8, ((y - y.floor()) * 4.0) as u8];
910 let key = AtlasKey::Tag {
911 icon: tag.icon,
912 size: size.to_bits(),
913 phase,
914 };
915 let glyph = if let Some(Some(glyph)) = self.glyphs.get(&key) {
916 *glyph
917 } else {
918 if self.glyphs.len() >= MAX_GLYPHS {
919 return Err(RenderError::AtlasFull);
920 }
921 let glyph = self.upload(tags::rasterize(tag.icon, size, phase))?;
922 self.glyphs.insert(key, Some(glyph));
923 glyph
924 };
925 let left = x.floor();
926 let top = y.floor();
927 let atlas = ATLAS_SIZE as f32;
928 self.quad(
929 viewport,
930 [
931 left,
932 top,
933 left + glyph.width as f32,
934 top + glyph.height as f32,
935 ],
936 [
937 glyph.x as f32 / atlas,
938 glyph.y as f32 / atlas,
939 (glyph.x + glyph.width) as f32 / atlas,
940 (glyph.y + glyph.height) as f32 / atlas,
941 ],
942 [1.0, 1.0, 1.0, if tag.disabled { 0.45 } else { 1.0 }],
943 )
944 }
945
946 fn quad(
947 &mut self,
948 viewport: Viewport,
949 rect: [f32; 4],
950 uv: [f32; 4],
951 color: [f32; 4],
952 ) -> Result<(), RenderError> {
953 if rect.iter().chain(&color).any(|v| !v.is_finite())
954 || rect[2] < rect[0]
955 || rect[3] < rect[1]
956 {
957 return Err(RenderError::InvalidPrimitive);
958 }
959 if rect[2] < 0.0
960 || rect[3] < 0.0
961 || rect[0] > viewport.size[0] as f32
962 || rect[1] > viewport.size[1] as f32
963 {
964 return Ok(());
965 }
966 if self.vertices.len() + 6 > MAX_VERTICES {
967 return Err(RenderError::FrameTooLarge);
968 }
969 let x0 = rect[0] * 2.0 / viewport.size[0] as f32 - 1.0;
970 let x1 = rect[2] * 2.0 / viewport.size[0] as f32 - 1.0;
971 let y0 = 1.0 - rect[1] * 2.0 / viewport.size[1] as f32;
972 let y1 = 1.0 - rect[3] * 2.0 / viewport.size[1] as f32;
973 if [x0, x1, y0, y1].iter().any(|v| !v.is_finite()) {
974 return Err(RenderError::InvalidPrimitive);
975 }
976 let [hx, hy] = [(rect[2] - rect[0]) * 0.5, (rect[3] - rect[1]) * 0.5];
977 for (position, uv, local) in [
978 ([x0, y0], [uv[0], uv[1]], [-hx, -hy]),
979 ([x0, y1], [uv[0], uv[3]], [-hx, hy]),
980 ([x1, y1], [uv[2], uv[3]], [hx, hy]),
981 ([x0, y0], [uv[0], uv[1]], [-hx, -hy]),
982 ([x1, y1], [uv[2], uv[3]], [hx, hy]),
983 ([x1, y0], [uv[2], uv[1]], [hx, -hy]),
984 ] {
985 self.vertices.push(Vertex {
986 position,
987 uv,
988 color,
989 local,
990 shape: [0.0; 4],
991 stroke: 0.0,
992 });
993 }
994 Ok(())
995 }
996}
997
998fn srgb_byte(value: f32) -> u8 {
999 let srgb = if value <= 0.0031308 {
1000 value * 12.92
1001 } else {
1002 1.055 * value.powf(1.0 / 2.4) - 0.055
1003 };
1004 (srgb * 255.0).round() as u8
1005}
1006
1007pub fn colorref(color: u32) -> [f32; 4] {
1008 let linear = |byte: u32| {
1009 let value = (byte & 255) as f32 / 255.0;
1010 if value <= 0.04045 {
1011 value / 12.92
1012 } else {
1013 ((value + 0.055) / 1.055).powf(2.4)
1014 }
1015 };
1016 [linear(color), linear(color >> 8), linear(color >> 16), 1.0]
1017}
1018
1019#[cfg(test)]
1020mod tests {
1021 use super::*;
1022 use crate::{layout::TextEngine, text::Paragraph};
1023 use onestore::document::Format as TextFormat;
1024 use std::time::Duration;
1025
1026 #[test]
1027 fn decoding_preserves_pixels_and_rejects_large_or_truncated_images() {
1028 let mut encoded = Vec::new();
1029 let mut encoder = png::Encoder::new(&mut encoded, 2, 1);
1030 encoder.set_color(png::ColorType::Rgba);
1031 encoder.set_depth(png::BitDepth::Eight);
1032 let rgba = [255, 64, 0, 255, 0, 128, 255, 128];
1033 encoder
1034 .write_header()
1035 .unwrap()
1036 .write_image_data(&rgba)
1037 .unwrap();
1038 let decoded = RasterImage::decode(&encoded).unwrap();
1039 assert_eq!(decoded.size, [2, 1]);
1040 assert_eq!(decoded.pixels.as_ref(), [255, 64, 0, 255, 0, 93, 188, 128]);
1041 for end in 0..encoded.len() - 12 {
1042 assert!(RasterImage::decode(&encoded[..end]).is_err(), "{end}");
1043 }
1044
1045 let mut jpeg = Vec::new();
1046 image::codecs::jpeg::JpegEncoder::new(&mut jpeg)
1047 .encode(&[255; 4 * 4 * 3], 4, 4, image::ExtendedColorType::Rgb8)
1048 .unwrap();
1049 let decoded = RasterImage::decode(&jpeg).unwrap();
1050 assert_eq!(decoded.size, [4, 4]);
1051 assert_eq!(decoded.pixels.as_ref(), [255; 4 * 4 * 4]);
1052
1053 for size in [[8192, 8192], [16_385, 1]] {
1054 let mut header = Vec::new();
1055 let mut encoder = png::Encoder::new(&mut header, size[0], size[1]);
1056 encoder.set_color(png::ColorType::Rgba);
1057 encoder.set_depth(png::BitDepth::Eight);
1058 let mut writer = encoder.write_header().unwrap();
1059 writer.write_chunk(png::chunk::IDAT, &[]).unwrap();
1060 drop(writer);
1061 let error = RasterImage::decode(&header).err().unwrap();
1062 if size[0] == 8192 {
1063 assert!(matches!(error, RenderError::ImageBudget), "{error:?}");
1064 } else {
1065 assert!(
1066 matches!(
1067 error,
1068 RenderError::ImageDecode(image::ImageError::Limits(_))
1069 ),
1070 "{error:?}"
1071 );
1072 }
1073 }
1074 }
1075
1076 #[test]
1077 fn image_pixels_have_valid_dimensions_and_immutable_clone_identity() {
1078 assert!(matches!(
1079 RasterImage::new([0, 1], Vec::new()),
1080 Err(RenderError::InvalidImage)
1081 ));
1082 assert!(matches!(
1083 RasterImage::new([2, 2], vec![255; 15]),
1084 Err(RenderError::InvalidImage)
1085 ));
1086 assert!(matches!(
1087 RasterImage::new([u32::MAX; 2], Vec::new()),
1088 Err(RenderError::InvalidImage)
1089 ));
1090 let image = RasterImage::new([1, 1], vec![1, 2, 3, 255]).unwrap();
1091 assert_eq!(image.pixels.id(), image.clone().pixels.id());
1092 }
1093
1094 #[test]
1095 #[ignore = "requires a native GPU adapter"]
1096 fn eviction_and_repaint_reproduce_pixels() {
1097 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
1098 let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
1099 let (device, queue) =
1100 pollster::block_on(adapter.request_device(&Default::default())).unwrap();
1101 let format = wgpu::TextureFormat::Rgba8UnormSrgb;
1102 let target = device.create_texture(&wgpu::TextureDescriptor {
1103 label: Some("Canvas readback test"),
1104 size: wgpu::Extent3d {
1105 width: 512,
1106 height: 256,
1107 depth_or_array_layers: 1,
1108 },
1109 mip_level_count: 1,
1110 sample_count: 1,
1111 dimension: wgpu::TextureDimension::D2,
1112 format,
1113 usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
1114 view_formats: &[],
1115 });
1116 let readback = device.create_buffer(&wgpu::BufferDescriptor {
1117 label: Some("Canvas readback"),
1118 size: 512 * 256 * 4,
1119 usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
1120 mapped_at_creation: false,
1121 });
1122 let mut renderer = Renderer::new(device, queue, format);
1123 let mut engine = TextEngine::default();
1124 let layout = engine
1125 .layout(
1126 &Paragraph::new(
1127 "Hello 🌳\nCafé e\u{301} שלום".into(),
1128 TextFormat {
1129 underline: Some(true),
1130 ..TextFormat::default()
1131 },
1132 ),
1133 220.0,
1134 )
1135 .unwrap();
1136 let viewport = Viewport {
1137 size: [512, 256],
1138 scale: 2.0,
1139 origin: [24.0; 2],
1140 };
1141 let mut captures = Vec::new();
1142 let image = RasterImage::new(
1143 [2, 2],
1144 vec![255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 0, 0, 0, 0],
1145 )
1146 .unwrap();
1147 let transparent_edge =
1148 RasterImage::new([2, 1], vec![255, 0, 0, 255, 0, 0, 255, 0]).unwrap();
1149 let tags = [
1150 TagIcon::CheckBox { checked: false },
1151 TagIcon::CheckBox { checked: true },
1152 TagIcon::Question,
1153 TagIcon::Music,
1154 ]
1155 .map(|icon| ParagraphTag {
1156 icon,
1157 origin: [0.0; 2],
1158 label: String::new(),
1159 disabled: false,
1160 });
1161 let phase_layout = engine
1162 .layout(
1163 &Paragraph::new(
1164 "H".into(),
1165 TextFormat {
1166 font: Some("Arial".into()),
1167 font_size: Some(11.0),
1168 ..TextFormat::default()
1169 },
1170 ),
1171 20.0,
1172 )
1173 .unwrap();
1174 let mut primitives = vec![
1175 Primitive::Rect {
1176 rect: [0.0, 0.0, 70.0, 14.0],
1177 color: [0.55, 0.73, 1.0, 0.5],
1178 },
1179 Primitive::Text {
1180 clip: None,
1181 layout: &layout,
1182 origin: [0.0; 2],
1183 },
1184 Primitive::Text {
1185 clip: None,
1186 layout: &layout,
1187 origin: [8.0, 64.0],
1188 },
1189 Primitive::Image {
1190 image: &image,
1191 rect: [128.0, 8.0, 160.0, 40.0],
1192 },
1193 Primitive::Rect {
1194 rect: [128.0, 8.0, 132.0, 12.0],
1195 color: [0.0, 0.0, 1.0, 1.0],
1196 },
1197 Primitive::Image {
1198 image: &transparent_edge,
1199 rect: [176.0, 8.0, 208.0, 40.0],
1200 },
1201 Primitive::RoundedRect {
1202 rect: [128.0, 48.0, 148.0, 68.0],
1203 radius: [5.0, 10.0],
1204 stroke: None,
1205 color: [1.0, 0.0, 0.0, 1.0],
1206 },
1207 Primitive::RoundedRect {
1208 rect: [164.0, 48.0, 184.0, 68.0],
1209 radius: [5.0; 2],
1210 stroke: Some(Stroke::Solid(1.0)),
1211 color: [0.0, 0.0, 1.0, 1.0],
1212 },
1213 Primitive::RoundedRect {
1214 rect: [200.0, 48.0, 240.0, 68.0],
1215 radius: [3.0, 10.0],
1216 stroke: Some(Stroke::Dashed(1.0)),
1217 color: [0.0, 0.0, 1.0, 1.0],
1218 },
1219 Primitive::Tag {
1220 tag: &tags[0],
1221 origin: [128.125, 80.25],
1222 },
1223 Primitive::Tag {
1224 tag: &tags[1],
1225 origin: [148.125, 80.25],
1226 },
1227 Primitive::Tag {
1228 tag: &tags[2],
1229 origin: [168.125, 80.25],
1230 },
1231 Primitive::Tag {
1232 tag: &tags[3],
1233 origin: [188.125, 80.25],
1234 },
1235 ];
1236 primitives.extend((0..9).map(|step| Primitive::Text {
1237 clip: None,
1238 layout: &phase_layout,
1239 origin: [8.0 + 24.0 * step as f32, 96.0 + 0.125 * step as f32],
1240 }));
1241 for pass in 0..6 {
1242 if pass == 2 {
1243 renderer.clear_glyph_cache();
1244 renderer.images.clear();
1245 } else if pass == 3 {
1246 renderer = Renderer::new(renderer.device.clone(), renderer.queue.clone(), format);
1247 }
1248 let clipping = [
1249 Primitive::Text {
1250 layout: &layout,
1251 origin: [0.0; 2],
1252 clip: (pass == 5).then_some([2.5, 1.25, 30.75, 20.5]),
1253 },
1254 Primitive::Rect {
1255 rect: [40.0, 40.0, 70.0, 50.0],
1256 color: [0.0, 0.0, 1.0, 1.0],
1257 },
1258 ];
1259 renderer
1260 .draw(
1261 &target.create_view(&Default::default()),
1262 viewport,
1263 if pass < 4 { &primitives } else { &clipping },
1264 )
1265 .unwrap();
1266 let mut encoder = renderer.device.create_command_encoder(&Default::default());
1267 encoder.copy_texture_to_buffer(
1268 target.as_image_copy(),
1269 wgpu::TexelCopyBufferInfo {
1270 buffer: &readback,
1271 layout: wgpu::TexelCopyBufferLayout {
1272 offset: 0,
1273 bytes_per_row: Some(512 * 4),
1274 rows_per_image: Some(256),
1275 },
1276 },
1277 wgpu::Extent3d {
1278 width: 512,
1279 height: 256,
1280 depth_or_array_layers: 1,
1281 },
1282 );
1283 renderer.queue.submit([encoder.finish()]);
1284 let (sender, receiver) = std::sync::mpsc::channel();
1285 readback.map_async(wgpu::MapMode::Read, .., move |result| {
1286 sender.send(result).unwrap();
1287 });
1288 renderer
1289 .device
1290 .poll(wgpu::PollType::Wait {
1291 submission_index: None,
1292 timeout: Some(Duration::from_secs(5)),
1293 })
1294 .unwrap();
1295 receiver
1296 .recv_timeout(Duration::from_secs(5))
1297 .unwrap()
1298 .unwrap();
1299 captures.push(readback.get_mapped_range(..).unwrap().to_vec());
1300 readback.unmap();
1301 }
1302 let centroids: Vec<_> = (0..9)
1303 .map(|step| {
1304 let mut weight = 0.0_f64;
1305 let mut moment = 0.0_f64;
1306 for y in 212..252 {
1307 for x in (40 + 48 * step)..(72 + 48 * step) {
1308 let value = captures[0][(y * 512 + x) * 4];
1309 let coverage = 1.0 - f64::from(colorref(u32::from(value))[0]);
1310 weight += coverage;
1311 moment += coverage * y as f64;
1312 }
1313 }
1314 assert!(weight > 10.0, "phase sample {step} was not painted");
1315 moment / weight
1316 })
1317 .collect();
1318 let pixel = |x: usize, y: usize| &captures[0][(y * 512 + x) * 4..(y * 512 + x) * 4 + 4];
1319 assert_eq!(pixel(300, 140), [255, 0, 0, 255]);
1320 assert_eq!(pixel(280, 120), [255; 4]);
1321 assert_eq!(pixel(282, 124), [255; 4]);
1322 assert_eq!(pixel(281, 140), [255, 0, 0, 255]);
1323 assert_eq!(pixel(372, 140), [255; 4]);
1324 assert_eq!(pixel(372, 120), [0, 0, 255, 255]);
1325 assert_eq!(pixel(352, 120), [255; 4]);
1326 assert_eq!(pixel(424, 120), [255; 4]);
1327 assert_eq!(pixel(464, 140), [255; 4]);
1328 assert!((440..480).any(|x| pixel(x, 120)[0] < 40));
1329 assert!((440..480).any(|x| pixel(x, 120)[0] > 250));
1330 for x in 440..472 {
1331 assert!(pixel(x, 120)[0].abs_diff(pixel(x + 8, 120)[0]) <= 1);
1332 }
1333 for (step, centroid) in centroids.iter().enumerate() {
1334 let movement = centroid - centroids[0];
1335 assert!(
1336 (movement - step as f64 * 0.25).abs() < 0.06,
1337 "quarter-pixel step {step} moved the glyph by {movement} px"
1338 );
1339 }
1340 assert!(!renderer.glyphs.is_empty());
1341 assert!(renderer.glyphs.len() <= MAX_GLYPHS);
1342 assert_eq!(
1343 renderer
1344 .glyphs
1345 .keys()
1346 .filter(|key| matches!(key, AtlasKey::Tag { .. }))
1347 .count(),
1348 4
1349 );
1350 for x in [280, 320, 360, 400] {
1351 let colored = (184..210)
1352 .flat_map(|y| (x..x + 26).map(move |x| (y * 512 + x) * 4))
1353 .filter(|offset| captures[0][*offset..*offset + 3].iter().any(|v| *v < 180))
1354 .count();
1355 assert!(colored > 10, "tag at {x} was not painted");
1356 }
1357 let count = renderer.glyphs.len();
1358 renderer
1359 .draw(
1360 &target.create_view(&Default::default()),
1361 viewport,
1362 &[Primitive::Tag {
1363 tag: &tags[0],
1364 origin: [10000.0; 2],
1365 }],
1366 )
1367 .unwrap();
1368 assert_eq!(renderer.glyphs.len(), count);
1369 assert!(
1370 captures[0]
1371 .chunks_exact(4)
1372 .filter(|pixel| pixel[0] < 200)
1373 .count()
1374 > 100
1375 );
1376 assert_eq!(captures[0], captures[1]);
1377 assert_eq!(captures[0], captures[2]);
1378 assert_eq!(captures[0], captures[3]);
1379 assert_ne!(captures[4], captures[5]);
1380 for y in 0..256 {
1381 for x in 0..512 {
1382 let offset = (y * 512 + x) * 4;
1383 let expected = if ((29..86).contains(&x) && (26..65).contains(&y))
1384 || ((104..164).contains(&x) && (104..124).contains(&y))
1385 {
1386 &captures[4][offset..offset + 4]
1387 } else {
1388 &[255; 4]
1389 };
1390 assert_eq!(
1391 &captures[5][offset..offset + 4],
1392 expected,
1393 "clipping at {x},{y}"
1394 );
1395 }
1396 }
1397 for (x, y, color) in [
1398 (288, 48, [255, 0, 0, 255]),
1399 (336, 96, [255; 4]),
1400 (284, 44, [0, 0, 255, 255]),
1401 ] {
1402 let offset = (y * 512 + x) * 4;
1403 assert_eq!(captures[0][offset..offset + 4], color);
1404 }
1405 let edge = &captures[0][(48 * 512 + 408) * 4..(48 * 512 + 408) * 4 + 4];
1406 assert_eq!(edge[0], 255, "{edge:?}");
1407 assert_eq!(edge[1], edge[2], "{edge:?}");
1408 assert!((180..=200).contains(&edge[1]), "{edge:?}");
1409 assert_eq!(renderer.images.len(), 2);
1410 for primitive in [
1411 Primitive::RoundedRect {
1412 rect: [-1e38, 0.0, 1e38, 1e38],
1413 radius: [3.0; 2],
1414 stroke: Some(Stroke::Dashed(1.0)),
1415 color: [1.0; 4],
1416 },
1417 Primitive::RoundedRect {
1418 rect: [0.0, 0.0, 10.0, 10.0],
1419 radius: [f32::NAN, 2.0],
1420 stroke: None,
1421 color: [1.0; 4],
1422 },
1423 Primitive::RoundedRect {
1424 rect: [0.0, 0.0, 10.0, 10.0],
1425 radius: [2.0; 2],
1426 stroke: Some(Stroke::Solid(0.0)),
1427 color: [1.0; 4],
1428 },
1429 Primitive::RoundedRect {
1430 rect: [0.0, 0.0, 10.0, 10.0],
1431 radius: [2.0; 2],
1432 stroke: Some(Stroke::Dashed(f32::NAN)),
1433 color: [1.0; 4],
1434 },
1435 Primitive::Rect {
1436 rect: [0.0, 0.0, f32::NAN, 1.0],
1437 color: [1.0; 4],
1438 },
1439 Primitive::Rect {
1440 rect: [2.0, 0.0, 1.0, 1.0],
1441 color: [1.0; 4],
1442 },
1443 Primitive::Text {
1444 clip: None,
1445 layout: &layout,
1446 origin: [f32::MAX; 2],
1447 },
1448 Primitive::Text {
1449 clip: Some([0.0, 0.0, f32::NAN, 1.0]),
1450 layout: &layout,
1451 origin: [0.0; 2],
1452 },
1453 Primitive::Text {
1454 clip: Some([2.0, 0.0, 1.0, 1.0]),
1455 layout: &layout,
1456 origin: [0.0; 2],
1457 },
1458 ] {
1459 assert!(matches!(
1460 renderer.draw(
1461 &target.create_view(&Default::default()),
1462 viewport,
1463 &[primitive]
1464 ),
1465 Err(RenderError::InvalidPrimitive)
1466 ));
1467 }
1468 let too_many: Vec<_> = (0..=MAX_IMAGES)
1469 .map(|_| RasterImage::new([1, 1], vec![255; 4]).unwrap())
1470 .collect();
1471 let primitives: Vec<_> = too_many
1472 .iter()
1473 .map(|image| Primitive::Image {
1474 image,
1475 rect: [0.0, 0.0, 1.0, 1.0],
1476 })
1477 .collect();
1478 assert!(matches!(
1479 renderer.draw(
1480 &target.create_view(&Default::default()),
1481 viewport,
1482 &primitives
1483 ),
1484 Err(RenderError::ImageBudget)
1485 ));
1486 let primitives: Vec<_> = too_many
1487 .iter()
1488 .map(|image| Primitive::Image {
1489 image,
1490 rect: [-20.0, -20.0, -19.0, -19.0],
1491 })
1492 .collect();
1493 renderer
1494 .draw(
1495 &target.create_view(&Default::default()),
1496 viewport,
1497 &primitives,
1498 )
1499 .unwrap();
1500 assert_eq!(renderer.images.len(), 2);
1501 let mut seen = HashSet::from([image.pixels.id(), transparent_edge.pixels.id()]);
1502 let mut previous = None;
1503 for _ in 0..5 {
1504 let large = RasterImage::new([2048; 2], vec![255; 2048 * 2048 * 4]).unwrap();
1505 seen.insert(large.pixels.id());
1506 let mut primitives = vec![Primitive::Image {
1507 image: &large,
1508 rect: [0.0, 0.0, 16.0, 16.0],
1509 }];
1510 if let Some(image) = &previous {
1511 primitives.push(Primitive::Image {
1512 image,
1513 rect: [16.0, 0.0, 32.0, 16.0],
1514 });
1515 }
1516 renderer
1517 .draw(
1518 &target.create_view(&Default::default()),
1519 viewport,
1520 &primitives,
1521 )
1522 .unwrap();
1523 assert!(renderer.images.contains_key(&large.pixels.id()));
1524 if let Some(image) = &previous {
1525 assert!(renderer.images.contains_key(&image.pixels.id()));
1526 }
1527 assert!(renderer.images.values().map(|i| i.bytes).sum::<u64>() <= MAX_IMAGE_BYTES);
1528 previous = Some(large);
1529 }
1530 assert!(renderer.images.len() <= MAX_IMAGES);
1531 assert!(seen.iter().any(|id| !renderer.images.contains_key(id)));
1532 renderer.images.clear();
1533 renderer
1534 .draw(
1535 &target.create_view(&Default::default()),
1536 viewport,
1537 &[Primitive::Image {
1538 image: &image,
1539 rect: [128.0, 8.0, 160.0, 40.0],
1540 }],
1541 )
1542 .unwrap();
1543 assert_eq!(renderer.images.len(), 1);
1544 assert!(renderer.images.contains_key(&image.pixels.id()));
1545 }
1546}
crates/canvas/src/gpu/page.rs created+1553
...@@ -0,0 +1,1553 @@
1use super::{Primitive, RasterImage, RenderError, colorref};
2use crate::editor::ReadOnlyObject;
3use crate::editor::page::Content;
4use crate::{
5 date::DateField,
6 editor::{CanvasEditor, EditorError},
7 layout::{LayoutError, TextEngine},
8 page::Page,
9};
10use std::fmt;
11
12/// Retained drawing data in the source page's coordinate system.
13pub struct PageScene {
14 reference: Option<Vec<Content>>,
15 images: std::collections::BTreeMap<onestore::ExGuid, RasterImage>,
16}
17
18#[derive(Debug, PartialEq)]
19pub enum SceneHit<T> {
20 Outline(T),
21 Date(DateField),
22 ReadOnly(usize),
23}
24
25fn outline_origin(
26 mut origin: [f32; 2],
27 title: Option<onestore::ExGuid>,
28 editor: Option<&CanvasEditor>,
29) -> Result<[f32; 2], SceneError> {
30 if let Some(id) = title {
31 let title = editor
32 .and_then(|editor| editor.visible_outlines().find(|outline| outline.id == id))
33 .ok_or(SceneError::MissingOutline)?;
34 origin[1] += title.bounds().y1 as f32;
35 }
36 Ok(origin)
37}
38
39#[derive(Debug)]
40pub enum SceneError {
41 Layout(LayoutError),
42 Editor(EditorError),
43 MissingOutline,
44 Image(RenderError),
45 MissingImage,
46 InvalidGeometry,
47}
48
49impl fmt::Display for SceneError {
50 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51 match self {
52 Self::Layout(error) => error.fmt(f),
53 Self::Editor(error) => error.fmt(f),
54 Self::MissingOutline => f.write_str("An editable outline is missing from this page."),
55 Self::Image(error) => write!(f, "Image rendering failed: {error:?}"),
56 Self::MissingImage => f.write_str("An image is missing its data or dimensions."),
57 Self::InvalidGeometry => {
58 f.write_str("The page contains invalid object dimensions or positions.")
59 }
60 }
61 }
62}
63impl std::error::Error for SceneError {}
64impl From<LayoutError> for SceneError {
65 fn from(error: LayoutError) -> Self {
66 Self::Layout(error)
67 }
68}
69
70impl From<EditorError> for SceneError {
71 fn from(error: EditorError) -> Self {
72 match error {
73 EditorError::InvalidGeometry => Self::InvalidGeometry,
74 error => Self::Editor(error),
75 }
76 }
77}
78
79impl PageScene {
80 pub fn new(mut page: Page, engine: &mut TextEngine) -> Result<Self, SceneError> {
81 let (objects, _, _) =
82 crate::editor::page::build(&mut page, engine, false).map_err(SceneError::from)?;
83 let images = Self::decode_images(&objects)?;
84 Ok(Self {
85 reference: Some(objects),
86 images,
87 })
88 }
89
90 pub fn from_page(
91 page: Page,
92 engine: &mut TextEngine,
93 ) -> Result<(Self, CanvasEditor), SceneError> {
94 let editor = CanvasEditor::from_page(page, engine).map_err(SceneError::from)?;
95 let images = Self::decode_images(&editor.objects)?;
96 Ok((
97 Self {
98 reference: None,
99 images,
100 },
101 editor,
102 ))
103 }
104
105 fn decode_images(
106 objects: &[Content],
107 ) -> Result<std::collections::BTreeMap<onestore::ExGuid, RasterImage>, SceneError> {
108 let mut images = std::collections::BTreeMap::new();
109 let mut bytes = 0_u64;
110 for object in objects {
111 if let Content::Image(source) = object {
112 let image =
113 RasterImage::decode(source.bytes.as_deref().ok_or(SceneError::MissingImage)?)
114 .map_err(SceneError::Image)?;
115 bytes += image.pixels.as_ref().len() as u64;
116 if bytes > super::MAX_IMAGE_BYTES {
117 return Err(SceneError::Image(RenderError::ImageBudget));
118 }
119 if images.insert(source.id, image).is_some() {
120 return Err(SceneError::InvalidGeometry);
121 }
122 }
123 }
124 Ok(images)
125 }
126
127 fn objects<'a>(
128 &'a self,
129 editor: Option<&'a CanvasEditor>,
130 ) -> Result<&'a [Content], SceneError> {
131 self.reference
132 .as_deref()
133 .or_else(|| editor.map(|editor| editor.objects.as_slice()))
134 .ok_or(SceneError::MissingOutline)
135 }
136
137 pub fn read_only<'a>(
138 &'a self,
139 editor: Option<&'a CanvasEditor>,
140 ) -> impl Iterator<Item = &'a ReadOnlyObject> {
141 self.objects(editor)
142 .into_iter()
143 .flatten()
144 .filter_map(|object| match object {
145 Content::ReadOnly(object) => Some(object.as_ref()),
146 _ => None,
147 })
148 }
149
150 /// Bounds of noneditable content in page coordinates.
151 pub fn content_bounds<'a>(
152 &'a self,
153 editor: &'a CanvasEditor,
154 ) -> impl Iterator<Item = [f32; 4]> + 'a {
155 self.objects(Some(editor))
156 .into_iter()
157 .flatten()
158 .filter_map(move |object| match object {
159 Content::Outline {
160 source,
161 layout,
162 below_title,
163 } => {
164 let origin = outline_origin(
165 [
166 source.layout.x.unwrap_or(0.0),
167 source.layout.y.unwrap_or(0.0),
168 ],
169 *below_title,
170 Some(editor),
171 )
172 .ok()?;
173 Some([
174 origin[0],
175 origin[1],
176 origin[0] + layout.size[0],
177 origin[1] + layout.size[1],
178 ])
179 }
180 Content::Date { below_title } => {
181 let date = editor.date()?;
182 let origin = outline_origin(
183 [
184 date.source().layout.x.unwrap_or(0.0),
185 date.source().layout.y.unwrap_or(0.0),
186 ],
187 *below_title,
188 Some(editor),
189 )
190 .ok()?;
191 let size = date.layout().size;
192 Some([
193 origin[0],
194 origin[1],
195 origin[0] + size[0],
196 origin[1] + size[1],
197 ])
198 }
199 Content::Image(source) => Some([
200 source.layout.x.unwrap_or(0.0),
201 source.layout.y.unwrap_or(0.0),
202 source.layout.x.unwrap_or(0.0) + source.layout.max_width?,
203 source.layout.y.unwrap_or(0.0) + source.layout.max_height?,
204 ]),
205 Content::ReadOnly(object) => Some(object.rect()),
206 Content::Editable(_) => None,
207 })
208 }
209
210 pub fn date_fields(
211 &self,
212 editor: &CanvasEditor,
213 ) -> impl Iterator<Item = (DateField, [f32; 4])> {
214 let mut fields = [None; 2];
215 if let Some(date) = editor.date()
216 && let Some((origin, title)) = self
217 .objects(Some(editor))
218 .into_iter()
219 .flatten()
220 .find_map(|object| match object {
221 Content::Date { below_title } => Some((
222 [
223 date.source().layout.x.unwrap_or(0.0),
224 date.source().layout.y.unwrap_or(0.0),
225 ],
226 *below_title,
227 )),
228 _ => None,
229 })
230 && let Ok(origin) = outline_origin(origin, title, Some(editor))
231 {
232 for (slot, (field, paragraph)) in fields.iter_mut().zip(
233 date.fields()
234 .map(|(field, _)| field)
235 .zip(&date.layout().paragraphs),
236 ) {
237 let x = origin[0] + paragraph.origin[0];
238 let y = origin[1] + paragraph.origin[1];
239 let width = paragraph
240 .text
241 .lines()
242 .map(|(line, _)| line.metrics().advance)
243 .fold(0.0_f32, f32::max);
244 *slot = Some((
245 field,
246 [x, y, x + width.max(12.0), y + paragraph.text.height()],
247 ));
248 }
249 }
250 fields.into_iter().flatten()
251 }
252
253 /// The point and outline hit callback use scene coordinates.
254 pub fn hit_test<T>(
255 &self,
256 point: [f32; 2],
257 editor: Option<&CanvasEditor>,
258 mut outline: impl FnMut(onestore::ExGuid) -> Option<T>,
259 ) -> Option<SceneHit<T>> {
260 let mut readonly = self.read_only(editor).count();
261 for object in self.objects(editor).ok()?.iter().rev() {
262 match object {
263 Content::Date { .. } => {
264 if let Some(editor) = editor
265 && let Some((field, _)) = self.date_fields(editor).find(|(_, rect)| {
266 (rect[0]..=rect[2]).contains(&point[0])
267 && (rect[1]..=rect[3]).contains(&point[1])
268 })
269 {
270 return Some(SceneHit::Date(field));
271 }
272 }
273 Content::Editable(id) => {
274 if let Some(hit) = outline(*id) {
275 return Some(SceneHit::Outline(hit));
276 }
277 }
278 Content::ReadOnly(object) => {
279 readonly -= 1;
280 let [x0, y0, x1, y1] = object.rect();
281 if (x0..=x1).contains(&point[0]) && (y0..=y1).contains(&point[1]) {
282 return Some(SceneHit::ReadOnly(readonly));
283 }
284 }
285 Content::Outline { .. } | Content::Image(_) => {}
286 }
287 }
288 None
289 }
290
291 /// The offset is in document points.
292 pub fn append_primitives<'a>(
293 &'a self,
294 primitives: &mut Vec<Primitive<'a>>,
295 offset: [f32; 2],
296 ) -> Result<(), SceneError> {
297 self.append_primitives_with(primitives, offset, None, |_, _, _| {
298 Err(SceneError::MissingOutline)
299 })
300 }
301
302 pub fn append_primitives_with<'a, E: From<SceneError>>(
303 &'a self,
304 primitives: &mut Vec<Primitive<'a>>,
305 offset: [f32; 2],
306 editor: Option<&'a CanvasEditor>,
307 mut outline: impl FnMut(onestore::ExGuid, [f32; 2], &mut Vec<Primitive<'a>>) -> Result<(), E>,
308 ) -> Result<(), E> {
309 for content in self.objects(editor)? {
310 let origin = match content {
311 Content::Outline {
312 source,
313 below_title,
314 ..
315 } => outline_origin(
316 [
317 source.layout.x.unwrap_or(0.0),
318 source.layout.y.unwrap_or(0.0),
319 ],
320 *below_title,
321 editor,
322 )?,
323 Content::Date { below_title } => {
324 let date = editor
325 .and_then(CanvasEditor::date)
326 .ok_or(SceneError::MissingOutline)?;
327 outline_origin(
328 [
329 date.source().layout.x.unwrap_or(0.0),
330 date.source().layout.y.unwrap_or(0.0),
331 ],
332 *below_title,
333 editor,
334 )?
335 }
336 Content::Image(source) => [
337 source.layout.x.unwrap_or(0.0),
338 source.layout.y.unwrap_or(0.0),
339 ],
340 Content::ReadOnly(object) => {
341 let [x0, y0, x1, y1] = object.rect();
342 let rect = [
343 x0 + offset[0],
344 y0 + offset[1],
345 x1 + offset[0],
346 y1 + offset[1],
347 ];
348 primitives.push(Primitive::Rect {
349 rect,
350 color: colorref(0x00e4ddd6),
351 });
352 primitives.push(Primitive::Rect {
353 rect: [rect[0] + 1.0, rect[1] + 1.0, rect[2] - 1.0, rect[3] - 1.0],
354 color: colorref(0x00faf7f3),
355 });
356 primitives.push(Primitive::Text {
357 clip: None,
358 layout: &object.label,
359 origin: [rect[0] + 8.0, rect[1] + 8.0],
360 });
361 continue;
362 }
363 Content::Editable(id) => {
364 outline(*id, offset, primitives)?;
365 continue;
366 }
367 };
368 let object_origin = [origin[0] + offset[0], origin[1] + offset[1]];
369 match content {
370 Content::Image(source) => primitives.push(Primitive::Image {
371 image: self
372 .images
373 .get(&source.id)
374 .ok_or(SceneError::MissingImage)?,
375 rect: [
376 object_origin[0],
377 object_origin[1],
378 object_origin[0]
379 + source.layout.max_width.ok_or(SceneError::MissingImage)?,
380 object_origin[1]
381 + source.layout.max_height.ok_or(SceneError::MissingImage)?,
382 ],
383 }),
384 Content::Outline { .. } | Content::Date { .. } => {
385 let outline = match content {
386 Content::Outline { layout, .. } => layout,
387 _ => editor
388 .and_then(CanvasEditor::date)
389 .ok_or(SceneError::MissingOutline)?
390 .layout(),
391 };
392 outline.append_table_primitives(primitives, object_origin);
393 outline.append_background_primitives(primitives, object_origin);
394 for (index, paragraph) in outline.paragraphs.iter().enumerate() {
395 let origin = [
396 object_origin[0] + paragraph.origin[0],
397 object_origin[1] + paragraph.origin[1],
398 ];
399 let clip = outline.paragraph_cell(index).map(|cell| {
400 let [left, top, right, bottom] = cell.text_bounds();
401 [
402 left + object_origin[0],
403 top + object_origin[1],
404 right + object_origin[0],
405 bottom + object_origin[1],
406 ]
407 });
408 primitives.push(Primitive::Text {
409 clip,
410 layout: &paragraph.text,
411 origin,
412 });
413 for (layout, origin) in &paragraph.markers {
414 primitives.push(Primitive::Text {
415 clip,
416 layout,
417 origin: [
418 object_origin[0] + origin[0],
419 object_origin[1] + (origin[1] + paragraph.origin[1]),
420 ],
421 });
422 }
423 for tag in &paragraph.tags {
424 primitives.push(Primitive::Tag {
425 tag,
426 origin: [object_origin[0], object_origin[1] + paragraph.origin[1]],
427 });
428 }
429 }
430 }
431 Content::Editable(_) | Content::ReadOnly(_) => unreachable!(),
432 }
433 }
434 Ok(())
435 }
436}
437
438impl crate::outline::OutlineLayout {
439 pub fn append_background_primitives(
440 &self,
441 primitives: &mut Vec<Primitive<'_>>,
442 origin: [f32; 2],
443 ) {
444 for (index, paragraph) in self.paragraphs.iter().enumerate() {
445 for (mut rect, color) in paragraph.text.backgrounds() {
446 rect.x0 += f64::from(paragraph.origin[0]);
447 rect.x1 += f64::from(paragraph.origin[0]);
448 rect.y0 += f64::from(paragraph.origin[1]);
449 rect.y1 += f64::from(paragraph.origin[1]);
450 if let Some(cell) = self.paragraph_cell(index) {
451 let Some(clipped) = cell.clip(rect) else {
452 continue;
453 };
454 rect = clipped;
455 }
456 primitives.push(Primitive::Rect {
457 rect: [
458 rect.x0 as f32 + origin[0],
459 rect.y0 as f32 + origin[1],
460 rect.x1 as f32 + origin[0],
461 rect.y1 as f32 + origin[1],
462 ],
463 color: colorref(color),
464 });
465 }
466 }
467 }
468
469 pub fn append_table_primitives(&self, primitives: &mut Vec<Primitive<'_>>, origin: [f32; 2]) {
470 for table in &self.tables {
471 let (Some(first), Some(last)) = (table.cells.first(), table.cells.last()) else {
472 continue;
473 };
474 if !table.borders {
475 continue;
476 }
477 let bounds = [first.rect[0], first.rect[1], last.rect[2], last.rect[3]];
478 let color = colorref(0x00a3a3a3);
479 primitives.push(Primitive::RoundedRect {
480 rect: [
481 bounds[0] + origin[0] - 0.375,
482 bounds[1] + origin[1] - 0.375,
483 bounds[2] + origin[0] + 0.375,
484 bounds[3] + origin[1] + 0.375,
485 ],
486 radius: [3.6; 2],
487 stroke: Some(super::Stroke::Solid(0.75)),
488 color,
489 });
490 for cell in &table.cells {
491 let [left, top, right, bottom] = cell.rect;
492 if right < bounds[2] {
493 primitives.push(Primitive::Rect {
494 rect: [
495 right + origin[0] - 0.375,
496 top + origin[1],
497 right + origin[0] + 0.375,
498 bottom + origin[1],
499 ],
500 color,
501 });
502 }
503 if bottom < bounds[3] {
504 primitives.push(Primitive::Rect {
505 rect: [
506 left + origin[0],
507 bottom + origin[1] - 0.375,
508 right + origin[0],
509 bottom + origin[1] + 0.375,
510 ],
511 color,
512 });
513 }
514 }
515 }
516 }
517}
518
519#[cfg(test)]
520mod tests {
521 use super::*;
522 use crate::{
523 editor::TextOutline,
524 page::{Image, PageObject},
525 text::Paragraph,
526 };
527 use onestore::{ExGuid, document::Layout};
528 use std::{collections::BTreeMap, sync::Arc};
529
530 #[test]
531 fn title_editing_keeps_identity_and_date_geometry_through_composition_and_undo() {
532 use crate::{document::TextDocument, page::Title};
533 let mut engine = TextEngine::default();
534 let mut fields = ["Header", "September 8, 2026"].map(|text| {
535 let document =
536 TextDocument::new(vec![Paragraph::new(text.into(), Default::default())]).unwrap();
537 TextOutline::new(&mut engine, document, 468.0, [0.0; 2])
538 .unwrap()
539 .snapshot()
540 });
541 for field in &mut fields {
542 field.layout.max_width = None;
543 field.layout.width_set_by_user = None;
544 field.layout.max_height = Some(21.6);
545 }
546 fields[0].title = true;
547 fields[0].min_width = Some(162.0);
548 fields[1].layout.x = Some(2.0);
549 fields[1].layout.y = Some(3.0);
550 let id = fields[0].id;
551 let source = fields[0].paragraphs.clone();
552 let page = Page {
553 created: Some(1),
554 title: "Header".into(),
555 margin_origin: [36.0, 14.4],
556 definitions: BTreeMap::new(),
557 objects: vec![PageObject::Title(Title {
558 date: Some(fields[1].id),
559 id: ExGuid::default(),
560 layout: Layout {
561 x: Some(10.0),
562 y: Some(20.0),
563 ..Default::default()
564 },
565 outlines: fields.into(),
566 })],
567 };
568 let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap();
569 assert!(editor.active_outline().title);
570 assert_eq!(editor.active_outline().origin(), [46.0, 34.4]);
571 assert_eq!(editor.active_outline().layout().max_width, None);
572 let before = scene.content_bounds(&editor).collect::<Vec<_>>();
573 assert_eq!(before[0][0], 48.0);
574 assert!((before[0][1] - 62.6).abs() < 0.00001);
575 let text = "A long title with multiple wrapped lines ".repeat(10);
576 editor.select_all().unwrap();
577 let end = text.encode_utf16().count() as u32;
578 editor.compose(&mut engine, text.clone(), end..end).unwrap();
579 let wrapped = scene.content_bounds(&editor).collect::<Vec<_>>();
580 assert!(wrapped[0][1] > before[0][1] + 20.0);
581 assert_eq!(
582 wrapped[0][1],
583 editor.active_outline().bounds().y1 as f32 + 3.0 + 3.6
584 );
585 editor.cancel_composition(&mut engine).unwrap();
586 assert_eq!(editor.active_outline().document().nodes(), source);
587 assert_eq!(scene.content_bounds(&editor).collect::<Vec<_>>(), before);
588 editor.commit_text(&mut engine, text).unwrap();
589 assert_eq!(scene.content_bounds(&editor).collect::<Vec<_>>(), wrapped);
590 assert_eq!(editor.active_outline().id, id);
591 assert_eq!(
592 editor.active_outline().document().nodes()[0].id,
593 source[0].id
594 );
595 editor.undo(&mut engine).unwrap();
596 assert_eq!(editor.active_outline().document().nodes(), source);
597 assert_eq!(scene.content_bounds(&editor).collect::<Vec<_>>(), before);
598 editor.redo(&mut engine).unwrap();
599 editor.select_all().unwrap();
600 editor.insert(&mut engine, "").unwrap();
601 assert_eq!(editor.outlines().len(), 1);
602 assert!(editor.caret_outline().is_none());
603 assert_eq!(editor.active_outline().id, id);
604 assert_eq!(scene.content_bounds(&editor).collect::<Vec<_>>(), before);
605 assert!(editor.move_outline(id, [0.0; 2]).is_err());
606 assert!(editor.resize(&mut engine, 90.0).is_err());
607 editor.undo(&mut engine).unwrap();
608 let primitives = paint_editor(&scene, &editor);
609 let Primitive::Text { origin, .. } = primitives.last().unwrap() else {
610 panic!()
611 };
612 assert_eq!(origin[1], wrapped[0][1]);
613 let title = editor.active_outline().document().clone();
614 editor
615 .change_date(
616 &mut engine,
617 2,
618 ["A date label that wraps ".repeat(30), String::new()],
619 )
620 .unwrap();
621 let changed = scene.content_bounds(&editor).collect::<Vec<_>>();
622 assert_eq!(changed[0][1], wrapped[0][1]);
623 assert!(changed[0][3] > wrapped[0][3]);
624 let (field, rect) = scene.date_fields(&editor).next().unwrap();
625 assert_eq!(field, DateField::Date);
626 assert_eq!(rect[1], changed[0][1]);
627 assert_eq!(rect[3], changed[0][3]);
628 editor.undo(&mut engine).unwrap();
629 assert_eq!(editor.date().unwrap().timestamp(), 1);
630 assert_eq!(scene.content_bounds(&editor).collect::<Vec<_>>(), wrapped);
631 assert_eq!(editor.active_outline().document(), &title);
632 }
633
634 #[test]
635 #[ignore = "requires the native baseline section and Carlito via CANVAS_TEST_SECTION/CANVAS_TEST_SUBSTITUTE"]
636 fn native_title_metrics_and_exit() {
637 let bytes = std::fs::read(std::env::var_os("CANVAS_TEST_SECTION").unwrap()).unwrap();
638 let store = onestore::Store::parse(&bytes).unwrap();
639 let index = onestore::RevisionIndex::parse(&store).unwrap();
640 let document = onestore::document::Document::parse(&index).unwrap();
641 let page = Page::from_document(&document, "Baseline anchors").unwrap();
642 let mut engine = TextEngine::default();
643 engine
644 .register_substitute(parley::fontique::Blob::new(Arc::new(
645 std::fs::read(std::env::var_os("CANVAS_TEST_SUBSTITUTE").unwrap()).unwrap(),
646 )))
647 .unwrap();
648 let mut editor = CanvasEditor::from_page(page, &mut engine).unwrap();
649 let title_id = editor
650 .outlines()
651 .iter()
652 .find(|outline| outline.title)
653 .unwrap()
654 .id;
655 for (text, lines, y) in [
656 ("Header", 1, 86.4),
657 (
658 "A page title that is intentionally long enough to wrap across several lines at this window size while retaining its own date and time below the text",
659 3,
660 140.4,
661 ),
662 ] {
663 editor.focus_outline(title_id).unwrap();
664 editor.select_all().unwrap();
665 editor.insert(&mut engine, text).unwrap();
666 let paragraph = editor.active_outline().layouts().next().unwrap().1;
667 assert_eq!(paragraph.text.lines().count(), lines);
668 assert!((paragraph.text.lines().next().unwrap().1.height - 20.751953).abs() < 0.001);
669 editor.leave_title(&mut engine).unwrap();
670 assert_eq!(editor.caret_outline().unwrap().origin(), [36.0, y]);
671 editor.undo(&mut engine).unwrap();
672 }
673 let date = editor.date().unwrap();
674 let timestamp = date.timestamp();
675 let source = date.source().paragraphs.clone();
676 assert_eq!(
677 date.fields().map(|(field, _)| field).collect::<Vec<_>>(),
678 [DateField::Date, DateField::Time]
679 );
680 let positions = editor
681 .outlines()
682 .iter()
683 .map(TextOutline::origin)
684 .collect::<Vec<_>>();
685 editor
686 .change_date(
687 &mut engine,
688 timestamp + 2 * 86_400 * 10_000_000,
689 ["Wednesday, September 09, 2026".into(), "6:14 AM".into()],
690 )
691 .unwrap();
692 assert_eq!(
693 editor
694 .outlines()
695 .iter()
696 .map(TextOutline::origin)
697 .collect::<Vec<_>>(),
698 positions
699 );
700 assert_eq!(editor.date().unwrap().source().paragraphs[1], source[1]);
701 editor.undo(&mut engine).unwrap();
702 assert_eq!(editor.date().unwrap().timestamp(), timestamp);
703 assert_eq!(editor.date().unwrap().source().paragraphs, source);
704 for (run_size, base_size, screen_y, paragraphs) in [
705 (11.0, 11.0, 235.0, Some(2)),
706 (11.0, 11.0, 252.0, Some(3)),
707 (11.0, 11.0, 256.0, Some(3)),
708 (11.0, 11.0, 260.0, None),
709 (22.0, 11.0, 306.0, Some(3)),
710 (22.0, 22.0, 306.0, Some(2)),
711 (22.0, 22.0, 324.0, None),
712 ] {
713 let style = ExGuid {
714 n: 900,
715 ..Default::default()
716 };
717 let format = onestore::document::Format {
718 font: Some("Calibri".into()),
719 font_size: Some(run_size),
720 ..Default::default()
721 };
722 let mut nodes = crate::document::TextDocument::new(vec![Paragraph::new(
723 "Anchor0".into(),
724 format.clone(),
725 )])
726 .unwrap()
727 .nodes()
728 .to_vec();
729 nodes[0].style = Some(style);
730 let outline = TextOutline::new(
731 &mut engine,
732 crate::document::TextDocument::from_nodes(nodes).unwrap(),
733 72.0,
734 [120.0, 90.0],
735 )
736 .unwrap();
737 let id = outline.id;
738 let source = outline.document().clone();
739 let mut editor = CanvasEditor::from_text_outlines(
740 vec![outline],
741 BTreeMap::from([(
742 style,
743 crate::page::Definition {
744 kind: onestore::document::Kind::Style {
745 name: Some("p".into()),
746 },
747 format: onestore::document::Format {
748 font_size: Some(base_size),
749 ..format
750 },
751 },
752 )]),
753 None,
754 )
755 .unwrap();
756 let point = [
757 (220.0 - 48.0) * 0.75 - 120.0,
758 (screen_y - 83.0) * 0.75 - 90.0,
759 ];
760 assert_eq!(
761 editor.select_below(&mut engine, id, point).unwrap(),
762 paragraphs.is_some(),
763 "run={run_size} base={base_size} y={screen_y}"
764 );
765 assert_eq!(editor.outlines()[0].document(), &source);
766 if let Some(count) = paragraphs {
767 editor.insert(&mut engine, "X").unwrap();
768 assert_eq!(editor.active_outline().document().nodes().len(), count);
769 assert_eq!(editor.active_outline().origin(), [120.0, 90.0]);
770 assert_eq!(
771 editor
772 .active_outline()
773 .document()
774 .paragraphs()
775 .last()
776 .unwrap()
777 .text(),
778 "X"
779 );
780 assert!(
781 (editor
782 .active_outline()
783 .layouts()
784 .last()
785 .unwrap()
786 .1
787 .text
788 .height()
789 - base_size * (12.207_031 / 10.0))
790 .abs()
791 < 0.001
792 );
793 }
794 }
795 }
796
797 #[test]
798 #[ignore = "requires the native baseline section and Carlito via CANVAS_TEST_SECTION/CANVAS_TEST_SUBSTITUTE"]
799 fn native_title_flow() {
800 use crate::{document::TextDocument, page::Image};
801 let bytes = std::fs::read(std::env::var_os("CANVAS_TEST_SECTION").unwrap()).unwrap();
802 let store = onestore::Store::parse(&bytes).unwrap();
803 let index = onestore::RevisionIndex::parse(&store).unwrap();
804 let document = onestore::document::Document::parse(&index).unwrap();
805 let mut engine = TextEngine::default();
806 engine
807 .register_substitute(parley::fontique::Blob::new(Arc::new(
808 std::fs::read(std::env::var_os("CANVAS_TEST_SUBSTITUTE").unwrap()).unwrap(),
809 )))
810 .unwrap();
811 let long = "A page title that is intentionally long enough to wrap across several lines at this window size while retaining its own date and time below the text";
812 for (font_size, text, x, y, expected) in [
813 (17.0, long, 120.0, 90.0, 116.18992),
814 (17.0, long, 120.0, 110.0, 116.18992),
815 (17.0, long, 120.0, 150.0, 150.0),
816 (17.0, long, 120.0, 73.8, 73.8),
817 (17.0, long, 120.0, 74.0, 116.18992),
818 (17.0, long, 120.0, 40.0, 40.0),
819 (17.0, long, 540.0, 90.0, 116.18992),
820 (17.0, long, 550.0, 90.0, 90.0),
821 (17.0, long, 600.0, 90.0, 90.0),
822 (17.0, long, 0.0, 90.0, 116.18992),
823 (17.0, long, -50.0, 90.0, 116.18992),
824 (17.0, long, -100.0, 90.0, 90.0),
825 (17.0, "Header\u{000b}Second", 120.0, 90.0, 95.43796),
826 (17.0, "Header\u{000b}Second", 250.0, 90.0, 95.43796),
827 (17.0, "Header\u{000b}Second", 540.0, 90.0, 95.43796),
828 (17.0, "Header\u{000b}Second", 550.0, 90.0, 90.0),
829 (11.0, long, 120.0, 60.0, 60.0),
830 (11.0, long, 120.0, 70.0, 80.78945),
831 (22.0, long, 120.0, 80.0, 80.0),
832 (22.0, long, 120.0, 90.0, 134.5003),
833 ] {
834 let mut page = Page::from_document(&document, "Baseline anchors").unwrap();
835 page.objects
836 .retain(|object| matches!(object, PageObject::Title(_)));
837 let PageObject::Title(title) = &mut page.objects[0] else {
838 unreachable!()
839 };
840 let title = title
841 .outlines
842 .iter_mut()
843 .find(|outline| outline.title)
844 .unwrap();
845 title.paragraphs[0].text_mut().unwrap().text = Paragraph::new(
846 "Header".into(),
847 onestore::document::Format {
848 font: Some("Calibri".into()),
849 font_size: Some(font_size),
850 ..Default::default()
851 },
852 );
853 let body = TextOutline::new(
854 &mut engine,
855 TextDocument::new(vec![Paragraph::new("Anchor".into(), Default::default())])
856 .unwrap(),
857 72.0,
858 [x, y],
859 )
860 .unwrap()
861 .snapshot();
862 let id = body.id;
863 page.objects.push(PageObject::Outline(body));
864 let mut editor = CanvasEditor::from_page(page, &mut engine).unwrap();
865 editor.select_all().unwrap();
866 editor.insert(&mut engine, text).unwrap();
867 let position = |editor: &CanvasEditor| {
868 editor
869 .outlines()
870 .iter()
871 .find(|outline| outline.id == id)
872 .unwrap()
873 .origin()
874 };
875 assert_eq!(position(&editor)[0], x);
876 assert!(
877 (position(&editor)[1] - expected).abs() < 0.002,
878 "({x}, {y}) after {text}: {:?}, expected {expected}",
879 position(&editor)
880 );
881 editor.select_all().unwrap();
882 editor.insert(&mut engine, "Header").unwrap();
883 assert!((position(&editor)[1] - expected).abs() < 0.002);
884 editor.undo(&mut engine).unwrap();
885 editor.undo(&mut engine).unwrap();
886 assert_eq!(position(&editor), [x, y]);
887 editor.redo(&mut engine).unwrap();
888 assert!((position(&editor)[1] - expected).abs() < 0.002);
889 }
890 for trigger in [true, false] {
891 let mut page = Page::from_document(&document, "Baseline anchors").unwrap();
892 let image_bytes = page
893 .objects
894 .iter()
895 .find_map(|object| match object {
896 PageObject::Image(image) => image.bytes.clone(),
897 _ => None,
898 })
899 .unwrap();
900 page.objects
901 .retain(|object| matches!(object, PageObject::Title(_)));
902 for (x, y, background) in [
903 (120.0, 90.0, !trigger),
904 (600.0, 150.0, false),
905 (600.0, 90.0, true),
906 (120.0, 60.0, false),
907 ] {
908 page.objects.push(PageObject::Image(Image {
909 id: crate::text::new_id().unwrap(),
910 bytes: Some(image_bytes.clone()),
911 layout: Layout {
912 x: Some(x),
913 y: Some(y),
914 max_width: Some(72.0),
915 max_height: Some(80.0),
916 ..Default::default()
917 },
918 alt: None,
919 background,
920 }));
921 }
922 let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap();
923 let images = |editor: &CanvasEditor| {
924 let mut primitives = Vec::new();
925 scene
926 .append_primitives_with::<SceneError>(
927 &mut primitives,
928 [0.0; 2],
929 Some(editor),
930 |_, _, _| Ok(()),
931 )
932 .unwrap();
933 primitives
934 .into_iter()
935 .filter_map(|primitive| match primitive {
936 Primitive::Image { image, rect } => Some((image.pixels.id(), rect)),
937 _ => None,
938 })
939 .collect::<Vec<_>>()
940 };
941 let before = images(&editor);
942 editor.select_all().unwrap();
943 editor.insert(&mut engine, "Header").unwrap();
944 editor.select_all().unwrap();
945 editor.insert(&mut engine, long).unwrap();
946 let after = images(&editor);
947 for (index, ((old_id, old), (new_id, new))) in before.iter().zip(&after).enumerate() {
948 assert_eq!(old_id, new_id);
949 let delta = if trigger && index < 2 { 26.18992 } else { 0.0 };
950 assert_eq!([old[0], old[2]], [new[0], new[2]]);
951 assert!((old[1] + delta - new[1]).abs() < 0.002);
952 assert!((old[3] + delta - new[3]).abs() < 0.002);
953 assert!(scene.content_bounds(&editor).any(|bounds| bounds == *new));
954 }
955 editor.undo(&mut engine).unwrap();
956 assert_eq!(images(&editor), before);
957 editor.redo(&mut engine).unwrap();
958 assert_eq!(images(&editor), after);
959 }
960 }
961
962 #[test]
963 fn title_exit_places_an_uncommitted_body_caret_below_the_date() {
964 use crate::{document::TextDocument, page::Title};
965 let mut engine = TextEngine::default();
966 let fields =
967 [("Header", 20.751953), ("Date\u{000b}Time", 12.207031)].map(|(text, line_spacing)| {
968 let document = TextDocument::new(vec![Paragraph::new(
969 text.into(),
970 onestore::document::Format {
971 font: Some("Arial".into()),
972 font_size: Some(8.0),
973 line_spacing: Some(line_spacing),
974 ..Default::default()
975 },
976 )])
977 .unwrap();
978 TextOutline::new(&mut engine, document, 468.0, [0.0; 2])
979 .unwrap()
980 .snapshot()
981 });
982 let mut fields = Vec::from(fields);
983 fields[0].title = true;
984 fields[0].min_width = Some(162.0);
985 let title_id = fields[0].id;
986 let page = Page {
987 created: None,
988 title: "Header".into(),
989 margin_origin: [36.0, 14.4],
990 definitions: BTreeMap::new(),
991 objects: vec![PageObject::Title(Title {
992 date: None,
993 id: ExGuid::default(),
994 layout: Layout::default(),
995 outlines: fields,
996 })],
997 };
998 let mut editor = CanvasEditor::from_page(page, &mut engine).unwrap();
999 let original = editor.active_outline().document().clone();
1000 editor.leave_title(&mut engine).unwrap();
1001 assert_eq!(editor.caret_outline().unwrap().origin(), [36.0, 86.4]);
1002 assert_eq!(editor.outlines().len(), 1);
1003 assert!(!editor.undo(&mut engine).unwrap());
1004 editor.insert(&mut engine, "body").unwrap();
1005 let body_id = editor.active_outline().id;
1006 editor.focus_outline(title_id).unwrap();
1007 editor.leave_title(&mut engine).unwrap();
1008 assert_eq!(editor.active_outline().id, body_id);
1009 assert!(editor.caret_outline().is_none());
1010 editor.undo(&mut engine).unwrap();
1011 assert_eq!(editor.outlines().len(), 1);
1012 editor.focus_outline(title_id).unwrap();
1013 editor.select_all().unwrap();
1014 editor
1015 .insert(&mut engine, "Header\u{000b}Second\u{000b}Third")
1016 .unwrap();
1017 assert_eq!(editor.active_outline().document().nodes().len(), 1);
1018 assert_eq!(
1019 editor
1020 .active_outline()
1021 .layouts()
1022 .next()
1023 .unwrap()
1024 .1
1025 .text
1026 .lines()
1027 .count(),
1028 3
1029 );
1030 editor.leave_title(&mut engine).unwrap();
1031 assert_eq!(editor.caret_outline().unwrap().origin(), [36.0, 140.4]);
1032 assert_eq!(editor.outlines().len(), 1);
1033 editor.undo(&mut engine).unwrap();
1034 assert_eq!(editor.active_outline().id, title_id);
1035 assert_eq!(editor.active_outline().document(), &original);
1036 }
1037
1038 #[test]
1039 fn reverse_hit_order_keeps_read_only_focus_indices() {
1040 let objects = [0.0, 20.0]
1041 .into_iter()
1042 .map(|position| {
1043 PageObject::Unsupported(crate::page::Unsupported {
1044 id: ExGuid {
1045 guid: [position as u8; 16],
1046 n: 1,
1047 },
1048 jcid: 0xdead,
1049 layout: Layout {
1050 x: Some(position),
1051 y: Some(position),
1052 max_width: Some(160.0),
1053 max_height: Some(80.0),
1054 ..Default::default()
1055 },
1056 })
1057 })
1058 .collect();
1059 let page = Page {
1060 created: None,
1061 title: String::new(),
1062 margin_origin: [0.0; 2],
1063 definitions: BTreeMap::new(),
1064 objects,
1065 };
1066 let scene = PageScene::new(page, &mut TextEngine::default()).unwrap();
1067 assert_eq!(
1068 scene.hit_test::<()>([30.0, 30.0], None, |_| unreachable!()),
1069 Some(SceneHit::ReadOnly(1))
1070 );
1071 assert_eq!(
1072 scene.hit_test::<()>([5.0, 5.0], None, |_| unreachable!()),
1073 Some(SceneHit::ReadOnly(0))
1074 );
1075 assert_eq!(
1076 scene.hit_test::<()>([300.0, 300.0], None, |_| unreachable!()),
1077 None
1078 );
1079 }
1080
1081 #[test]
1082 fn unsupported_tags_and_titles_leave_supported_outlines_editable() {
1083 use crate::{
1084 document::TextDocument,
1085 page::{Definition, Outline, Title},
1086 };
1087 use onestore::document::{Kind, Tag};
1088 for title in [false, true] {
1089 let definition = ExGuid {
1090 guid: [9; 16],
1091 n: 1,
1092 };
1093 let make_outline = |text: &str| {
1094 let document =
1095 TextDocument::new(vec![Paragraph::new(text.into(), Default::default())])
1096 .unwrap();
1097 Outline {
1098 title: false,
1099 min_width: None,
1100 id: ExGuid {
1101 guid: [u8::from(text == "Editable") + 1; 16],
1102 n: 1,
1103 },
1104 layout: Layout {
1105 max_width: Some(200.0),
1106 ..Default::default()
1107 },
1108 indents: vec![18.0, 0.0],
1109 paragraphs: document.nodes().to_vec(),
1110 unsupported: Vec::new(),
1111 }
1112 };
1113 let mut tagged = make_outline("Tagged");
1114 tagged.paragraphs[0].tags.push(Tag {
1115 definition: Some(definition),
1116 status: 1,
1117 action_type: None,
1118 created: None,
1119 completed: None,
1120 start: None,
1121 due: None,
1122 task_id: None,
1123 extra_set: 0,
1124 });
1125 let unsupported = if title {
1126 PageObject::Title(Title {
1127 date: None,
1128 id: ExGuid::default(),
1129 layout: Layout {
1130 x: Some(10.0),
1131 y: Some(20.0),
1132 ..Default::default()
1133 },
1134 outlines: vec![tagged],
1135 })
1136 } else {
1137 PageObject::Outline(tagged)
1138 };
1139 let page = Page {
1140 created: None,
1141 title: String::new(),
1142 margin_origin: [36.0, 14.0],
1143 objects: vec![unsupported, PageObject::Outline(make_outline("Editable"))],
1144 definitions: BTreeMap::from([(
1145 definition,
1146 Definition {
1147 kind: Kind::TagDefinition {
1148 shape: Some(999),
1149 label: Some("Unknown icon".into()),
1150 color: None,
1151 highlight: None,
1152 action_type: None,
1153 },
1154 format: Default::default(),
1155 },
1156 )]),
1157 };
1158 let mut engine = TextEngine::default();
1159 let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap();
1160 assert_eq!(editor.outlines().len(), 1);
1161 let readonly = scene.read_only(Some(&editor)).next().unwrap();
1162 assert_eq!(scene.read_only(Some(&editor)).count(), 1);
1163 if title {
1164 assert_eq!(&readonly.rect()[..2], &[46.0, 34.0]);
1165 }
1166 editor.insert(&mut engine, "Still ").unwrap();
1167 assert_eq!(
1168 editor
1169 .active_outline()
1170 .document()
1171 .paragraphs()
1172 .next()
1173 .unwrap()
1174 .text(),
1175 "Still Editable"
1176 );
1177 editor.undo(&mut engine).unwrap();
1178 assert_eq!(
1179 editor
1180 .active_outline()
1181 .document()
1182 .paragraphs()
1183 .next()
1184 .unwrap()
1185 .text(),
1186 "Editable"
1187 );
1188 }
1189 }
1190
1191 #[test]
1192 fn unsupported_sources_stay_owned_and_read_only_across_edit_and_undo() {
1193 use crate::{
1194 document::TextDocument,
1195 page::{Outline, Unsupported},
1196 };
1197 let document = TextDocument::new(vec![Paragraph::new(
1198 "preserved text".into(),
1199 Default::default(),
1200 )])
1201 .unwrap();
1202 let original = document.nodes().to_vec();
1203 let unknown = Unsupported {
1204 id: ExGuid {
1205 guid: [8; 16],
1206 n: 1,
1207 },
1208 jcid: 0xdead,
1209 layout: Layout::default(),
1210 };
1211 let page = Page {
1212 created: None,
1213 title: String::new(),
1214 margin_origin: [0.0; 2],
1215 definitions: BTreeMap::new(),
1216 objects: vec![
1217 PageObject::Outline(Outline {
1218 title: false,
1219 min_width: None,
1220 id: ExGuid {
1221 guid: [7; 16],
1222 n: 1,
1223 },
1224 layout: Layout {
1225 x: Some(50.0),
1226 y: Some(70.0),
1227 max_width: Some(200.0),
1228 ..Default::default()
1229 },
1230 indents: vec![18.0, 0.0],
1231 paragraphs: original.clone(),
1232 unsupported: vec![unknown.clone()],
1233 }),
1234 PageObject::Image(Image {
1235 id: ExGuid::default(),
1236 layout: Layout {
1237 y: Some(150.0),
1238 ..Default::default()
1239 },
1240 bytes: None,
1241 alt: Some("missing diagram".into()),
1242 background: false,
1243 }),
1244 PageObject::Unsupported(unknown.clone()),
1245 ],
1246 };
1247 let mut engine = TextEngine::default();
1248 let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap();
1249 assert_eq!(scene.read_only(Some(&editor)).count(), 3);
1250 assert!(editor.outlines().is_empty());
1251 assert!(editor.caret_outline().is_some());
1252 assert!(
1253 editor.active_outline().origin()[0]
1254 > scene
1255 .read_only(Some(&editor))
1256 .map(|object| object.rect()[2])
1257 .fold(0.0_f32, f32::max)
1258 );
1259 let labels: Vec<_> = scene
1260 .read_only(Some(&editor))
1261 .map(|object| object.label.id())
1262 .collect();
1263 for edit in [true, false] {
1264 if edit {
1265 editor.insert(&mut engine, "new annotation").unwrap();
1266 } else {
1267 editor.undo(&mut engine).unwrap();
1268 }
1269 assert_eq!(editor.caret_outline().is_none(), edit);
1270 assert_eq!(editor.outlines().len(), usize::from(edit));
1271 let readonly: Vec<_> = scene.read_only(Some(&editor)).collect();
1272 let PageObject::Outline(outline) = &readonly[0].source else {
1273 panic!()
1274 };
1275 assert_eq!(outline.paragraphs, original);
1276 assert_eq!(
1277 outline.unsupported.as_slice(),
1278 std::slice::from_ref(&unknown)
1279 );
1280 let PageObject::Image(image) = &readonly[1].source else {
1281 panic!()
1282 };
1283 assert_eq!(image.alt.as_deref(), Some("missing diagram"));
1284 assert!(image.bytes.is_none());
1285 let PageObject::Unsupported(source) = &readonly[2].source else {
1286 panic!()
1287 };
1288 assert_eq!(source, &unknown);
1289 assert_eq!(
1290 readonly
1291 .iter()
1292 .map(|object| object.label.id())
1293 .collect::<Vec<_>>(),
1294 labels
1295 );
1296 let primitives = paint_editor(&scene, &editor);
1297 assert_eq!(primitives.len(), 9);
1298 assert_eq!(
1299 primitives
1300 .iter()
1301 .filter(|p| matches!(p, Primitive::Text { .. }))
1302 .count(),
1303 3
1304 );
1305 }
1306 assert_eq!(
1307 editor
1308 .active_outline()
1309 .document()
1310 .paragraphs()
1311 .next()
1312 .unwrap()
1313 .text(),
1314 ""
1315 );
1316 }
1317
1318 fn paint_editor<'a>(scene: &'a PageScene, editor: &'a CanvasEditor) -> Vec<Primitive<'a>> {
1319 let mut primitives = Vec::new();
1320 scene
1321 .append_primitives_with(
1322 &mut primitives,
1323 [0.0; 2],
1324 Some(editor),
1325 |id, offset, primitives| {
1326 let outline = editor
1327 .outlines()
1328 .iter()
1329 .find(|outline| outline.id == id)
1330 .ok_or(SceneError::MissingOutline)?;
1331 for (_, paragraph) in outline.layouts() {
1332 primitives.push(Primitive::Text {
1333 clip: None,
1334 layout: &paragraph.text,
1335 origin: [
1336 outline.origin()[0] + paragraph.origin[0] + offset[0],
1337 outline.origin()[1] + paragraph.origin[1] + offset[1],
1338 ],
1339 });
1340 }
1341 Ok::<_, SceneError>(())
1342 },
1343 )
1344 .unwrap();
1345 primitives
1346 }
1347
1348 #[test]
1349 fn editable_outlines_keep_paint_order_and_images_across_edit_and_undo() {
1350 use crate::{document::TextDocument, page::Outline, text::Paragraph};
1351 let outline = |x, text: &str| {
1352 let document =
1353 TextDocument::new(vec![Paragraph::new(text.into(), Default::default())]).unwrap();
1354 PageObject::Outline(Outline {
1355 title: false,
1356 min_width: None,
1357 id: ExGuid {
1358 guid: [x as u8; 16],
1359 n: 1,
1360 },
1361 layout: Layout {
1362 x: Some(x),
1363 max_width: Some(180.0),
1364 width_set_by_user: Some(true),
1365 ..Default::default()
1366 },
1367 indents: vec![18.0, 0.0],
1368 paragraphs: document.nodes().to_vec(),
1369 unsupported: Vec::new(),
1370 })
1371 };
1372 let mut bytes = Vec::new();
1373 {
1374 let mut encoder = png::Encoder::new(&mut bytes, 1, 1);
1375 encoder.set_color(png::ColorType::Rgba);
1376 encoder.set_depth(png::BitDepth::Eight);
1377 encoder
1378 .write_header()
1379 .unwrap()
1380 .write_image_data(&[17, 34, 51, 255])
1381 .unwrap();
1382 }
1383 let page = || Page {
1384 created: None,
1385 title: String::new(),
1386 margin_origin: [0.0; 2],
1387 definitions: BTreeMap::new(),
1388 objects: vec![
1389 outline(2.0, "first"),
1390 PageObject::Image(Image {
1391 id: ExGuid::default(),
1392 layout: Layout {
1393 x: Some(5.0),
1394 y: Some(7.0),
1395 max_width: Some(40.0),
1396 max_height: Some(30.0),
1397 ..Default::default()
1398 },
1399 bytes: Some(Arc::from(bytes.clone())),
1400 alt: None,
1401 background: false,
1402 }),
1403 outline(50.0, "last"),
1404 ],
1405 };
1406 let mut engine = TextEngine::default();
1407 let reference = PageScene::new(page(), &mut engine).unwrap();
1408 let (scene, mut editor) = PageScene::from_page(page(), &mut engine).unwrap();
1409 let mut reference_primitives = Vec::new();
1410 reference
1411 .append_primitives(&mut reference_primitives, [0.0; 2])
1412 .unwrap();
1413 let image_id;
1414 {
1415 let actual = paint_editor(&scene, &editor);
1416 assert_eq!(actual.len(), 3);
1417 for (actual, expected) in actual.iter().zip(&reference_primitives) {
1418 match (actual, expected) {
1419 (
1420 Primitive::Text {
1421 clip: None,
1422 layout: a,
1423 origin: x,
1424 },
1425 Primitive::Text {
1426 clip: None,
1427 layout: b,
1428 origin: y,
1429 },
1430 ) => {
1431 assert_eq!(x, y);
1432 assert_eq!(
1433 a.lines().next().unwrap().0.metrics().advance,
1434 b.lines().next().unwrap().0.metrics().advance
1435 );
1436 }
1437 (
1438 Primitive::Image { image: a, rect: x },
1439 Primitive::Image { image: b, rect: y },
1440 ) => {
1441 assert_eq!(x, y);
1442 assert_eq!(a.pixels.as_ref(), b.pixels.as_ref());
1443 }
1444 _ => panic!("paint order changed"),
1445 }
1446 }
1447 let Primitive::Image { image, .. } = &actual[1] else {
1448 panic!()
1449 };
1450 image_id = image.pixels.id();
1451 }
1452 let original = editor.active_outline().document().clone();
1453 editor.insert(&mut engine, "new words ").unwrap();
1454 {
1455 let actual = paint_editor(&scene, &editor);
1456 let Primitive::Text { layout, .. } = &actual[0] else {
1457 panic!()
1458 };
1459 let Primitive::Text { layout: before, .. } = &reference_primitives[0] else {
1460 panic!()
1461 };
1462 assert!(
1463 layout.lines().next().unwrap().0.metrics().advance
1464 > before.lines().next().unwrap().0.metrics().advance
1465 );
1466 let Primitive::Image { image, .. } = &actual[1] else {
1467 panic!()
1468 };
1469 assert_eq!(image.pixels.id(), image_id);
1470 }
1471 editor.undo(&mut engine).unwrap();
1472 assert_eq!(editor.active_outline().document(), &original);
1473 let actual = paint_editor(&scene, &editor);
1474 let Primitive::Text { layout, .. } = &actual[0] else {
1475 panic!()
1476 };
1477 let Primitive::Text { layout: before, .. } = &reference_primitives[0] else {
1478 panic!()
1479 };
1480 assert_eq!(
1481 layout.lines().next().unwrap().0.metrics().advance,
1482 before.lines().next().unwrap().0.metrics().advance
1483 );
1484 assert!(matches!(
1485 scene.append_primitives(&mut Vec::new(), [0.0; 2]),
1486 Err(SceneError::MissingOutline)
1487 ));
1488 }
1489
1490 #[test]
1491 fn scene_owns_decoded_images_and_reuses_identity_across_translated_frames() {
1492 let mut encoded = Vec::new();
1493 {
1494 let mut encoder = png::Encoder::new(&mut encoded, 1, 1);
1495 encoder.set_color(png::ColorType::Rgba);
1496 encoder.set_depth(png::BitDepth::Eight);
1497 encoder
1498 .write_header()
1499 .unwrap()
1500 .write_image_data(&[255, 0, 0, 255])
1501 .unwrap();
1502 }
1503 let page = |width| Page {
1504 created: None,
1505 title: String::new(),
1506 margin_origin: [0.0; 2],
1507 definitions: BTreeMap::new(),
1508 objects: vec![PageObject::Image(Image {
1509 id: ExGuid::default(),
1510 layout: Layout {
1511 x: Some(2.0),
1512 y: Some(5.0),
1513 max_width: Some(width),
1514 max_height: Some(4.0),
1515 ..Default::default()
1516 },
1517 bytes: Some(Arc::from(encoded.clone())),
1518 alt: None,
1519 background: false,
1520 })],
1521 };
1522 let mut engine = TextEngine::default();
1523 let scene = PageScene::new(page(3.0), &mut engine).unwrap();
1524 for invalid in [f32::NAN, f32::INFINITY, -1.0, 0.0] {
1525 assert!(matches!(
1526 PageScene::new(page(invalid), &mut engine),
1527 Err(SceneError::InvalidGeometry)
1528 ));
1529 }
1530 let mut primitives = Vec::new();
1531 scene.append_primitives(&mut primitives, [0.0; 2]).unwrap();
1532 scene
1533 .append_primitives(&mut primitives, [10.0, -20.0])
1534 .unwrap();
1535 let [
1536 Primitive::Image {
1537 image: first,
1538 rect: a,
1539 },
1540 Primitive::Image {
1541 image: second,
1542 rect: b,
1543 },
1544 ] = primitives.as_slice()
1545 else {
1546 panic!()
1547 };
1548 assert_eq!(*a, [2.0, 5.0, 5.0, 9.0]);
1549 assert_eq!(*b, [12.0, -15.0, 15.0, -11.0]);
1550 assert_eq!(first.pixels.id(), second.pixels.id());
1551 assert_eq!(first.pixels.as_ref(), [255, 0, 0, 255]);
1552 }
1553}
crates/canvas/src/gpu/profile.rs created+151
...@@ -0,0 +1,151 @@
1use super::*;
2use crate::{layout::TextEngine, page::Page, text::Paragraph};
3use std::{
4 sync::Arc,
5 time::{Duration, Instant},
6};
7
8#[test]
9#[ignore = "manual release GPU timing and cache occupancy probe"]
10fn renderer_cost() {
11 let mut engine = TextEngine::default();
12 if let Some(font) = std::env::var_os("CANVAS_TEST_SUBSTITUTE") {
13 engine
14 .register_substitute(Blob::new(Arc::new(std::fs::read(font).unwrap())))
15 .unwrap();
16 }
17 let source = std::env::var_os("CANVAS_TEST_SECTION").map(|path| std::fs::read(path).unwrap());
18 let title = std::env::var("CANVAS_TEST_PAGE").unwrap_or_default();
19 let mut scene = source
20 .as_ref()
21 .map(|bytes| load_scene(bytes, &title, &mut engine));
22 let layout = source.is_none().then(|| {
23 engine
24 .layout(
25 &Paragraph::new(
26 "A paragraph with café, trees 🌳 and a few words.\n".repeat(2000),
27 Default::default(),
28 ),
29 360.0,
30 )
31 .unwrap()
32 });
33 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
34 let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
35 eprintln!("canvas_gpu_adapter\t{:?}", adapter.get_info());
36 let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
37 let size = [1386, 759];
38 let format = wgpu::TextureFormat::Rgba8UnormSrgb;
39 let target = device.create_texture(&wgpu::TextureDescriptor {
40 label: Some("Canvas profile target"),
41 size: wgpu::Extent3d {
42 width: size[0],
43 height: size[1],
44 depth_or_array_layers: 1,
45 },
46 mip_level_count: 1,
47 sample_count: 1,
48 dimension: wgpu::TextureDimension::D2,
49 format,
50 usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
51 view_formats: &[],
52 });
53 let view = target.create_view(&Default::default());
54 let mut renderer = Renderer::new(device, queue, format);
55 eprintln!(
56 "canvas_gpu_allocated_bytes\tatlas\t{}\tvertex_buffer\t{}\ttarget\t{}",
57 u64::from(renderer.atlas.width()) * u64::from(renderer.atlas.height()) * 4,
58 renderer.vertex_buffer.size(),
59 u64::from(size[0]) * u64::from(size[1]) * 4
60 );
61 for phase in [
62 "warm",
63 "pan",
64 "zoom",
65 "evict",
66 "offscreen_x",
67 "offscreen_y",
68 "reopen",
69 ] {
70 if phase == "reopen" && source.is_none() {
71 continue;
72 }
73 if phase.starts_with("offscreen") {
74 renderer.clear_glyph_cache();
75 renderer.images.clear();
76 }
77 for sample in 0..32 {
78 if phase == "evict" {
79 renderer.clear_glyph_cache();
80 renderer.images.clear();
81 }
82 let mut viewport = Viewport {
83 size,
84 scale: 96.0 / 72.0,
85 origin: [0.0; 2],
86 };
87 match phase {
88 "pan" => viewport.origin[1] = -(sample as f32 * 63.25),
89 "zoom" => viewport.scale *= [0.5, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0][sample % 8],
90 "offscreen_x" => viewport.origin[0] = 1_000_000.0,
91 "offscreen_y" => viewport.origin[1] = 1_000_000.0,
92 "reopen" => {
93 scene = Some(load_scene(source.as_ref().unwrap(), &title, &mut engine));
94 }
95 _ => {}
96 }
97 let mut primitives = Vec::new();
98 if let Some(scene) = &scene {
99 scene.append_primitives(&mut primitives, [0.0; 2]).unwrap();
100 } else {
101 primitives.push(Primitive::Text {
102 clip: None,
103 layout: layout.as_ref().unwrap(),
104 origin: [36.0, 90.0],
105 });
106 }
107 let start = Instant::now();
108 renderer.draw(&view, viewport, &primitives).unwrap();
109 let submitted = start.elapsed().as_nanos();
110 renderer
111 .device
112 .poll(wgpu::PollType::Wait {
113 submission_index: None,
114 timeout: Some(Duration::from_secs(10)),
115 })
116 .unwrap();
117 let completed = start.elapsed().as_nanos();
118 let image_bytes: u64 = renderer.images.values().map(|image| image.bytes).sum();
119 let atlas_texels: u64 = renderer
120 .glyphs
121 .values()
122 .flatten()
123 .map(|glyph| u64::from(glyph.width) * u64::from(glyph.height))
124 .sum();
125 assert!(renderer.glyphs.len() <= MAX_GLYPHS);
126 assert!(renderer.images.len() <= MAX_IMAGES && image_bytes <= MAX_IMAGE_BYTES);
127 assert!(renderer.vertices.len() <= MAX_VERTICES);
128 eprintln!(
129 "canvas_gpu_sample\t{phase}\t{sample}\t{submitted}\t{completed}\t{}\t{atlas_texels}\t{}\t{image_bytes}\t{}\t{}\t{}\t{}\t{}\t{}",
130 renderer.glyphs.len(),
131 renderer.images.len(),
132 renderer.vertices.len(),
133 renderer.batches.len(),
134 renderer.vertices.capacity() * size_of::<Vertex>(),
135 renderer.batches.capacity() * size_of::<Batch>(),
136 renderer.glyphs.capacity(),
137 renderer.images.capacity()
138 );
139 }
140 }
141}
142
143fn load_scene(bytes: &[u8], title: &str, engine: &mut TextEngine) -> page::PageScene {
144 let store = onestore::Store::parse(bytes).unwrap();
145 let index = onestore::RevisionIndex::parse(&store).unwrap();
146 page::PageScene::new(
147 Page::from_document(&onestore::document::Document::parse(&index).unwrap(), title).unwrap(),
148 engine,
149 )
150 .unwrap()
151}
crates/canvas/src/gpu/tags.rs created+132
...@@ -0,0 +1,132 @@
1use crate::outline::TagIcon;
2use swash::{
3 scale::image::{Content, Image},
4 zeno::{Mask, Placement, Transform, Vector},
5};
6
7pub(crate) fn rasterize(icon: TagIcon, size: f32, phase: [u8; 2]) -> Image {
8 let (source, overlay) = match icon {
9 TagIcon::CheckBox { checked } => (
10 include_str!("../../assets/tags/checkbox.svg"),
11 checked.then_some(include_str!("../../assets/tags/checkmark.svg")),
12 ),
13 TagIcon::Question => (include_str!("../../assets/tags/question.svg"), None),
14 TagIcon::Music => (include_str!("../../assets/tags/music.svg"), None),
15 };
16 let width = (size + 1.0).ceil() as u32;
17 let mut pixels = vec![[0.0_f32; 4]; (width * width) as usize];
18 for source in std::iter::once(source).chain(overlay) {
19 let svg = roxmltree::Document::parse(source).expect("Bundled tag SVG must be valid");
20 assert_eq!(svg.root_element().attribute("viewBox"), Some("0 0 16 16"));
21 for path in svg.descendants().filter(|node| node.has_tag_name("path")) {
22 let gradient = path
23 .attribute("fill")
24 .unwrap()
25 .strip_prefix("url(#")
26 .unwrap()
27 .strip_suffix(')')
28 .unwrap();
29 let gradient = svg
30 .descendants()
31 .find(|node| node.attribute("id") == Some(gradient))
32 .unwrap();
33 let mut stops = gradient
34 .children()
35 .filter(|node| node.has_tag_name("stop"))
36 .map(|node| {
37 let color = u32::from_str_radix(
38 node.attribute("stop-color")
39 .unwrap()
40 .strip_prefix('#')
41 .unwrap(),
42 16,
43 )
44 .unwrap();
45 let [_, r, g, b] = color.to_be_bytes();
46 super::colorref(u32::from_le_bytes([r, g, b, 0]))
47 });
48 let top = stops.next().unwrap();
49 let bottom = stops.next().unwrap();
50 assert!(stops.next().is_none());
51 let (mask, _) = Mask::new(path.attribute("d").unwrap())
52 .transform(Some(Transform::scale(size / 16.0, size / 16.0)))
53 .render_offset(Vector::new(phase[0] as f32 * 0.25, phase[1] as f32 * 0.25))
54 .size(width, width)
55 .render();
56 for (index, (pixel, coverage)) in pixels.iter_mut().zip(mask).enumerate() {
57 let alpha = f32::from(coverage) / 255.0;
58 let y = ((index / width as usize) as f32 / size).min(1.0);
59 for channel in 0..3 {
60 let color = top[channel] + (bottom[channel] - top[channel]) * y;
61 pixel[channel] = color * alpha + pixel[channel] * (1.0 - alpha);
62 }
63 pixel[3] = alpha + pixel[3] * (1.0 - alpha);
64 }
65 }
66 }
67 let data = pixels
68 .into_iter()
69 .flat_map(|pixel| {
70 let alpha = pixel[3];
71 let channel = |value: f32| {
72 let value = if alpha > 0.0 { value / alpha } else { 0.0 };
73 super::srgb_byte(value)
74 };
75 [
76 channel(pixel[0]),
77 channel(pixel[1]),
78 channel(pixel[2]),
79 (alpha * 255.0).round() as u8,
80 ]
81 })
82 .collect();
83 Image {
84 content: Content::Color,
85 placement: Placement {
86 left: 0,
87 top: 0,
88 width,
89 height: width,
90 },
91 data,
92 ..Image::default()
93 }
94}
95
96#[cfg(test)]
97mod tests {
98 use super::*;
99
100 #[test]
101 fn tag_masks_keep_transparency_and_checked_state_across_scales() {
102 for size in [8.0, 16.0, 32.0, 57.5] {
103 for phase in [[0, 0], [1, 2], [3, 3]] {
104 let mut masks = Vec::new();
105 for icon in [
106 TagIcon::CheckBox { checked: false },
107 TagIcon::CheckBox { checked: true },
108 TagIcon::Question,
109 TagIcon::Music,
110 ] {
111 let image = rasterize(icon, size, phase);
112 assert_eq!(
113 image.data.len(),
114 (image.placement.width * image.placement.height * 4) as usize
115 );
116 assert!(image.data.chunks_exact(4).any(|p| p[3] > 0));
117 assert!(image.data.chunks_exact(4).any(|p| p[3] < 255));
118 assert!(
119 image
120 .data
121 .chunks_exact(4)
122 .filter(|p| p[3] == 0)
123 .all(|p| p[..3] == [0; 3])
124 );
125 masks.push(image.data);
126 }
127 assert_ne!(masks[0], masks[1]);
128 assert!(masks[1].chunks_exact(4).any(|p| p[0] > p[2] && p[3] > 0));
129 }
130 }
131 }
132}
crates/canvas/src/layout.rs+177-24
...@@ -9,7 +9,10 @@ use std::{...@@ -9,7 +9,10 @@ use std::{
9 collections::BTreeSet,9 collections::BTreeSet,
10 fmt,10 fmt,
11 ops::Range,11 ops::Range,
12 sync::atomic::{AtomicU64, Ordering},12 sync::{
13 Arc,
14 atomic::{AtomicU64, Ordering},
15 },
13};16};
1417
15#[derive(Default)]18#[derive(Default)]
...@@ -33,9 +36,10 @@ pub struct LineBox {...@@ -33,9 +36,10 @@ pub struct LineBox {
33 pub height: f32,36 pub height: f32,
34}37}
3538
39#[derive(Clone)]
36pub struct TextLayout {40pub struct TextLayout {
37 id: u64,41 id: u64,
38 pub(crate) shaped: Layout<TextBrush>,42 pub(crate) shaped: Arc<Layout<TextBrush>>,
39 lines: Vec<LineBox>,43 lines: Vec<LineBox>,
40}44}
4145
...@@ -112,7 +116,11 @@ impl TextEngine {...@@ -112,7 +116,11 @@ impl TextEngine {
112 Ok(target)116 Ok(target)
113 }117 }
114118
115 pub fn layout(&mut self, paragraph: &Paragraph, width: f32) -> Result<TextLayout, LayoutError> {119 fn shape(
120 &mut self,
121 paragraph: &Paragraph,
122 width: f32,
123 ) -> Result<Layout<TextBrush>, LayoutError> {
116 if !width.is_finite() || width <= 0.0 {124 if !width.is_finite() || width <= 0.0 {
117 return Err(LayoutError::InvalidWidth);125 return Err(LayoutError::InvalidWidth);
118 }126 }
...@@ -121,6 +129,8 @@ impl TextEngine {...@@ -121,6 +129,8 @@ impl TextEngine {
121 .context129 .context
122 .ranged_builder(&mut self.fonts, text, 1.0, false);130 .ranged_builder(&mut self.fonts, text, 1.0, false);
123 builder.push_default(StyleProperty::OverflowWrap(OverflowWrap::BreakWord));131 builder.push_default(StyleProperty::OverflowWrap(OverflowWrap::BreakWord));
132 // OneNote 2010 uses unkerned advances for text and table widths.
133 builder.push_default(StyleProperty::FontFeatures(r#""kern" 0"#.into()));
124 let mut start = 0;134 let mut start = 0;
125 for (index, span) in paragraph.spans().iter().enumerate() {135 for (index, span) in paragraph.spans().iter().enumerate() {
126 let format = &span.format;136 let format = &span.format;
...@@ -161,6 +171,43 @@ impl TextEngine {...@@ -161,6 +171,43 @@ impl TextEngine {
161 let mut shaped = builder.build(text);171 let mut shaped = builder.build(text);
162 shaped.break_all_lines(Some(width));172 shaped.break_all_lines(Some(width));
163 shaped.align(Alignment::Start, AlignmentOptions::default());173 shaped.align(Alignment::Start, AlignmentOptions::default());
174 Ok(shaped)
175 }
176
177 fn font_extents(
178 &self,
179 run: parley::layout::Run<'_, TextBrush>,
180 ) -> Result<(f32, f32), LayoutError> {
181 let data = &run.font().font;
182 let font = FontRef::from_index(data.data.as_ref(), data.index)
183 .map_err(|_| LayoutError::InvalidFontMetrics)?;
184 Ok(if self.arial_substitutes.contains(&data.data.id()) {
185 // Arimo's hhea extents match Arial's Windows extents; omit hhea line gap.
186 let head = font.head().map_err(|_| LayoutError::InvalidFontMetrics)?;
187 let hhea = font.hhea().map_err(|_| LayoutError::InvalidFontMetrics)?;
188 let scale = run.font_size() / f32::from(head.units_per_em());
189 (
190 f32::from(hhea.ascender().to_i16()) * scale,
191 -f32::from(hhea.descender().to_i16()) * scale,
192 )
193 } else if let (Ok(head), Ok(os2)) = (font.head(), font.os2())
194 && head.units_per_em() != 0
195 && (os2.us_win_ascent() != 0 || os2.us_win_descent() != 0)
196 {
197 let scale = run.font_size() / f32::from(head.units_per_em());
198 (
199 f32::from(os2.us_win_ascent()) * scale,
200 f32::from(os2.us_win_descent()) * scale,
201 )
202 } else {
203 let metrics = run.font_metrics();
204 (metrics.ascent, metrics.descent)
205 })
206 }
207
208 pub fn layout(&mut self, paragraph: &Paragraph, width: f32) -> Result<TextLayout, LayoutError> {
209 let shaped = self.shape(paragraph, width)?;
210 let text = paragraph.text();
164 let mut lines = Vec::with_capacity(shaped.len());211 let mut lines = Vec::with_capacity(shaped.len());
165 let mut top = 0.0_f64;212 let mut top = 0.0_f64;
166 let mut source_end = 0;213 let mut source_end = 0;
...@@ -189,27 +236,25 @@ impl TextEngine {...@@ -189,27 +236,25 @@ impl TextEngine {
189 let data = &run.font().font;236 let data = &run.font().font;
190 let font = FontRef::from_index(data.data.as_ref(), data.index)237 let font = FontRef::from_index(data.data.as_ref(), data.index)
191 .map_err(|_| LayoutError::InvalidFontMetrics)?;238 .map_err(|_| LayoutError::InvalidFontMetrics)?;
192 let (a, d) = if self.arial_substitutes.contains(&data.data.id()) {239 // Color glyphs fit the source font's line box instead of shifting annotation rows.
193 // Arimo's hhea extents match Arial's Windows extents; omit hhea line gap.240 let (a, d) = if font.colr().is_ok() || font.sbix().is_ok() || font.cbdt().is_ok() {
194 let head = font.head().map_err(|_| LayoutError::InvalidFontMetrics)?;241 let format = &paragraph
195 let hhea = font.hhea().map_err(|_| LayoutError::InvalidFontMetrics)?;242 .spans()
196 let scale = run.font_size() / f32::from(head.units_per_em());243 .iter()
197 (244 .find(|span| span.end > run.text_range().start)
198 f32::from(hhea.ascender().to_i16()) * scale,245 .unwrap_or_else(|| paragraph.spans().last().unwrap())
199 -f32::from(hhea.descender().to_i16()) * scale,246 .format;
200 )247 let sample =
201 } else if let (Ok(head), Ok(os2)) = (font.head(), font.os2())248 self.shape(&Paragraph::new("Mg".into(), format.clone()), f32::MAX)?;
202 && head.units_per_em() != 0249 sample.lines().flat_map(|line| line.runs()).try_fold(
203 && (os2.us_win_ascent() != 0 || os2.us_win_descent() != 0)250 (0.0_f32, 0.0_f32),
204 {251 |(a, d), run| {
205 let scale = run.font_size() / f32::from(head.units_per_em());252 let (next_a, next_d) = self.font_extents(run)?;
206 (253 Ok::<_, LayoutError>((a.max(next_a), d.max(next_d)))
207 f32::from(os2.us_win_ascent()) * scale,254 },
208 f32::from(os2.us_win_descent()) * scale,255 )?
209 )
210 } else {256 } else {
211 let metrics = run.font_metrics();257 self.font_extents(run)?
212 (metrics.ascent, metrics.descent)
213 };258 };
214 if !a.is_finite() || !d.is_finite() || a < 0.0 || d < 0.0 {259 if !a.is_finite() || !d.is_finite() || a < 0.0 || d < 0.0 {
215 return Err(LayoutError::InvalidFontMetrics);260 return Err(LayoutError::InvalidFontMetrics);
...@@ -239,7 +284,11 @@ impl TextEngine {...@@ -239,7 +284,11 @@ impl TextEngine {
239 let id = NEXT_ID284 let id = NEXT_ID
240 .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |id| id.checked_add(1))285 .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |id| id.checked_add(1))
241 .expect("Text layout identities exhausted");286 .expect("Text layout identities exhausted");
242 Ok(TextLayout { id, shaped, lines })287 Ok(TextLayout {
288 id,
289 shaped: Arc::new(shaped),
290 lines,
291 })
243 }292 }
244}293}
245294
...@@ -303,6 +352,59 @@ impl TextLayout {...@@ -303,6 +352,59 @@ impl TextLayout {
303 Cursor::from_byte_index(&self.shaped, byte, affinity)352 Cursor::from_byte_index(&self.shaped, byte, affinity)
304 }353 }
305354
355 pub(crate) fn word_cursor(&self, cursor: Cursor, backward: bool) -> Cursor {
356 let mut current = cursor;
357 let mut visited = BTreeSet::new();
358 loop {
359 if !visited.insert((current.index(), current.affinity() == Affinity::Upstream)) {
360 // Parley visual cursors can cycle at soft-wrapped bidi boundaries.
361 let [left, right] = cursor.visual_clusters(&self.shaped);
362 let rtl = if backward {
363 left.or(right)
364 } else {
365 right.or(left)
366 }
367 .is_some_and(|cluster| cluster.is_rtl());
368 return if backward != rtl {
369 cursor.previous_logical_word(&self.shaped)
370 } else {
371 cursor.next_logical_word(&self.shaped)
372 };
373 }
374 let next = if backward {
375 current.previous_visual(&self.shaped)
376 } else {
377 current.next_visual(&self.shaped)
378 };
379 if next == current {
380 return current;
381 }
382 current = next;
383 let [Some(left), Some(right)] = current.visual_clusters(&self.shaped) else {
384 return current;
385 };
386 let boundary = if left.is_rtl() {
387 left.is_word_boundary()
388 && if backward {
389 left.is_space_or_nbsp()
390 || (right.is_word_boundary() && !right.is_space_or_nbsp())
391 } else {
392 !left.is_space_or_nbsp()
393 }
394 } else {
395 right.is_word_boundary()
396 && if backward {
397 !right.is_space_or_nbsp()
398 } else {
399 !left.is_space_or_nbsp()
400 }
401 };
402 if boundary {
403 return current;
404 }
405 }
406 }
407
306 pub fn hit_test(&self, x: f32, y: f32) -> Cursor {408 pub fn hit_test(&self, x: f32, y: f32) -> Cursor {
307 let index = self409 let index = self
308 .lines410 .lines
...@@ -348,6 +450,30 @@ mod tests {...@@ -348,6 +450,30 @@ mod tests {
348 use super::*;450 use super::*;
349 use onestore::document::Format;451 use onestore::document::Format;
350452
453 #[test]
454 #[ignore = "requires Arial and CANVAS_TEST_SUBSTITUTE pointing to Carlito"]
455 fn native_unkerned_advances() {
456 let mut engine = TextEngine::default();
457 engine
458 .register_substitute(parley::fontique::Blob::new(Arc::new(
459 std::fs::read(std::env::var_os("CANVAS_TEST_SUBSTITUTE").unwrap()).unwrap(),
460 )))
461 .unwrap();
462 for (font, expected) in [("Calibri", 149.63843), ("Arial", 184.58305)] {
463 let paragraph = Paragraph::new(
464 "AVATAR AVATAR SECOND OFFICE".into(),
465 onestore::document::Format {
466 font: Some(font.into()),
467 font_size: Some(11.0),
468 ..Default::default()
469 },
470 );
471 let layout = engine.layout(&paragraph, 400.0).unwrap();
472 assert_eq!(layout.lines().count(), 1);
473 assert!((layout.lines().next().unwrap().0.metrics().advance - expected).abs() < 0.001);
474 }
475 }
476
351 #[test]477 #[test]
352 fn rejected_substitute_leaves_font_selection_unchanged() {478 fn rejected_substitute_leaves_font_selection_unchanged() {
353 let mut engine = TextEngine::default();479 let mut engine = TextEngine::default();
...@@ -592,6 +718,33 @@ mod tests {...@@ -592,6 +718,33 @@ mod tests {
592 assert!(caret.height() > 0.0);718 assert!(caret.height() > 0.0);
593 }719 }
594720
721 #[cfg(target_os = "macos")]
722 #[test]
723 fn color_emoji_preserve_text_baselines_and_respect_explicit_font_size() {
724 let mut engine = TextEngine::default();
725 for size in [11.0, 22.0] {
726 let format = Format {
727 font_size: Some(size),
728 ..Default::default()
729 };
730 let base = engine
731 .layout(&Paragraph::new("Mg".into(), format.clone()), 1000.0)
732 .unwrap();
733 for text in ["🌳", "👩‍👩‍👧‍👦", "before 🌳 after", "Mg\n🌳\nMg"] {
734 let layout = engine
735 .layout(&Paragraph::new(text.into(), format.clone()), 1000.0)
736 .unwrap();
737 for (_, line) in layout.lines() {
738 assert_eq!(line.height, base.lines[0].height, "{text}");
739 assert!(
740 (line.baseline - line.top - base.lines[0].baseline).abs() < 0.0001,
741 "{text}"
742 );
743 }
744 }
745 }
746 }
747
595 #[test]748 #[test]
596 fn bidi_hit_testing_stays_on_grapheme_boundaries() {749 fn bidi_hit_testing_stays_on_grapheme_boundaries() {
597 let graphemes = [750 let graphemes = [
crates/canvas/src/lib.rs+4
...@@ -4,3 +4,7 @@ pub mod layout;...@@ -4,3 +4,7 @@ pub mod layout;
4pub mod outline;4pub mod outline;
5pub mod page;5pub mod page;
6pub mod text;6pub mod text;
7
8pub mod date;
9#[cfg(feature = "gpu")]
10pub mod gpu;
crates/canvas/src/outline.rs+603-69
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1use crate::{1use crate::{
2 document::{DocumentEdit, edited_nodes},
2 layout::{LayoutError, TextEngine, TextLayout},3 layout::{LayoutError, TextEngine, TextLayout},
3 page::{Definition, Outline, PageParagraph, Title},4 page::{Definition, Outline, PageParagraph, ParagraphContent, Table, Title},
4 text::{Paragraph, TextProjection},5 text::{Paragraph, TextProjection},
5};6};
6use onestore::{7use onestore::{
...@@ -9,11 +10,55 @@ use onestore::{...@@ -9,11 +10,55 @@ use onestore::{
9};10};
10use std::collections::{BTreeMap, BTreeSet};11use std::collections::{BTreeMap, BTreeSet};
1112
13pub(crate) const TITLE_WIDTH: f32 = 468.0;
14
15#[derive(Clone)]
12pub struct OutlineLayout {16pub struct OutlineLayout {
13 pub paragraphs: Vec<ParagraphLayout>,17 pub paragraphs: Vec<ParagraphLayout>,
18 pub tables: Vec<TableLayout>,
14 pub size: [f32; 2],19 pub size: [f32; 2],
15}20}
1621
22#[derive(Clone)]
23pub struct TableLayout {
24 pub id: ExGuid,
25 /// Cells are in paragraph order with disjoint visible ranges.
26 pub cells: Vec<CellLayout>,
27 pub borders: bool,
28}
29
30#[derive(Clone)]
31pub struct CellLayout {
32 pub id: ExGuid,
33 pub rect: [f32; 4],
34 /// Visible paragraph indices, including paragraphs in nested tables.
35 pub paragraphs: std::ops::Range<usize>,
36}
37
38impl CellLayout {
39 /// Native ink gutters extend beyond the cell borders.
40 pub fn text_bounds(&self) -> [f32; 4] {
41 [
42 self.rect[0] - 2.7,
43 self.rect[1],
44 self.rect[2] + 4.62,
45 self.rect[3],
46 ]
47 }
48
49 pub fn clip(&self, rect: parley::BoundingBox) -> Option<parley::BoundingBox> {
50 let [left, top, right, bottom] = self.text_bounds().map(f64::from);
51 let rect = parley::BoundingBox::new(
52 rect.x0.max(left),
53 rect.y0.max(top),
54 rect.x1.min(right),
55 rect.y1.min(bottom),
56 );
57 (rect.width() > 0.0 && rect.height() > 0.0).then_some(rect)
58 }
59}
60
61#[derive(Clone)]
17pub struct ParagraphLayout {62pub struct ParagraphLayout {
18 pub id: ExGuid,63 pub id: ExGuid,
19 pub origin: [f32; 2],64 pub origin: [f32; 2],
...@@ -44,7 +89,51 @@ impl ParagraphTag {...@@ -44,7 +89,51 @@ impl ParagraphTag {
44 pub const SIZE: f32 = 12.0;89 pub const SIZE: f32 = 12.0;
45}90}
4691
92pub(crate) fn indentation(level: u32, indents: &[f32], width: f32) -> Result<f32, LayoutError> {
93 if indents.len() < 2 || indents.iter().any(|v| !v.is_finite() || *v < 0.0) || level == 0 {
94 return Err(LayoutError::InvalidIndentation);
95 }
96 let known = (level as usize).min(indents.len() - 1);
97 let indent = (indents[1..=known]
98 .iter()
99 .map(|v| f64::from(*v))
100 .sum::<f64>()
101 + f64::from(level - known as u32) * f64::from(*indents.last().unwrap()))
102 as f32;
103 if !indent.is_finite() || indent >= width {
104 return Err(LayoutError::InvalidIndentation);
105 }
106 Ok(indent)
107}
108
109fn spacing(
110 bottom: &mut f64,
111 previous: &mut Option<f32>,
112 format: &Format,
113) -> Result<f32, LayoutError> {
114 let before = format.space_before.unwrap_or(0.0);
115 let after = format.space_after.unwrap_or(0.0);
116 if [before, after].iter().any(|v| !v.is_finite() || *v < 0.0) {
117 return Err(LayoutError::InvalidSpacing);
118 }
119 if let Some(previous) = previous {
120 *bottom += f64::from(previous.max(before));
121 }
122 *previous = Some(after);
123 Ok(*bottom as f32)
124}
125
47impl ParagraphLayout {126impl ParagraphLayout {
127 fn size(&self) -> [f32; 2] {
128 [
129 self.origin[0] + self.text.shaped.width(),
130 self.markers
131 .iter()
132 .map(|(layout, _)| layout.height())
133 .fold(self.text.height(), f32::max),
134 ]
135 }
136
48 pub(crate) fn shape(137 pub(crate) fn shape(
49 engine: &mut TextEngine,138 engine: &mut TextEngine,
50 paragraph: &PageParagraph,139 paragraph: &PageParagraph,
...@@ -55,27 +144,10 @@ impl ParagraphLayout {...@@ -55,27 +144,10 @@ impl ParagraphLayout {
55 if !width.is_finite() || width <= 0.0 {144 if !width.is_finite() || width <= 0.0 {
56 return Err(LayoutError::InvalidWidth);145 return Err(LayoutError::InvalidWidth);
57 }146 }
58 if indents.len() < 2147 let indent = indentation(paragraph.level, indents, width)?;
59 || indents.iter().any(|v| !v.is_finite() || *v < 0.0)148 let source = paragraph.text().ok_or(LayoutError::UnsupportedContent)?;
60 || paragraph.level == 0149 let projection = source
61 {150 .text
62 return Err(LayoutError::InvalidIndentation);
63 }
64 if !paragraph.unsupported.is_empty() {
65 return Err(LayoutError::UnsupportedContent);
66 }
67 let known = (paragraph.level as usize).min(indents.len() - 1);
68 let indent = (indents[1..=known]
69 .iter()
70 .map(|v| f64::from(*v))
71 .sum::<f64>()
72 + f64::from(paragraph.level - known as u32) * f64::from(*indents.last().unwrap()))
73 as f32;
74 if !indent.is_finite() || indent >= width {
75 return Err(LayoutError::InvalidIndentation);
76 }
77 let projection = paragraph
78 .combined_text()
79 .project()151 .project()
80 .map_err(|_| LayoutError::InvalidSourceRange)?;152 .map_err(|_| LayoutError::InvalidSourceRange)?;
81 let format = &projection.text().spans()[0].format;153 let format = &projection.text().spans()[0].format;
...@@ -122,11 +194,7 @@ impl ParagraphLayout {...@@ -122,11 +194,7 @@ impl ParagraphLayout {
122 }194 }
123 text.minimum_line_height(format.line_spacing.unwrap_or(0.0))?;195 text.minimum_line_height(format.line_spacing.unwrap_or(0.0))?;
124 let mut tags = Vec::new();196 let mut tags = Vec::new();
125 for tag in paragraph197 for tag in paragraph.tags.iter().chain(&source.tags) {
126 .tags
127 .iter()
128 .chain(paragraph.text.iter().flat_map(|text| &text.tags))
129 {
130 let Some(Definition {198 let Some(Definition {
131 kind:199 kind:
132 Kind::TagDefinition {200 Kind::TagDefinition {
...@@ -172,9 +240,11 @@ impl ParagraphLayout {...@@ -172,9 +240,11 @@ impl ParagraphLayout {
172 }240 }
173}241}
174242
175pub(crate) fn visible_paragraphs(nodes: &[PageParagraph]) -> impl Iterator<Item = &PageParagraph> {243pub(crate) fn visible_paragraphs<'a>(
244 nodes: impl Iterator<Item = &'a PageParagraph>,
245) -> impl Iterator<Item = &'a PageParagraph> {
176 let mut hidden = BTreeSet::new();246 let mut hidden = BTreeSet::new();
177 nodes.iter().filter(move |paragraph| {247 nodes.filter(move |paragraph| {
178 let invisible = paragraph248 let invisible = paragraph
179 .parent249 .parent
180 .is_some_and(|parent| hidden.contains(&parent));250 .is_some_and(|parent| hidden.contains(&parent));
...@@ -192,38 +262,17 @@ pub(crate) fn arrange<'a>(...@@ -192,38 +262,17 @@ pub(crate) fn arrange<'a>(
192) -> Result<(Vec<f32>, [f32; 2]), LayoutError> {262) -> Result<(Vec<f32>, [f32; 2]), LayoutError> {
193 let mut origins = Vec::new();263 let mut origins = Vec::new();
194 let mut bottom = 0.0_f64;264 let mut bottom = 0.0_f64;
195 let mut previous_after = 0.0_f32;265 let mut previous_after = None;
196 let mut content_width = 0.0_f32;266 let mut content_width = 0.0_f32;
197 for paragraph in paragraphs {267 for paragraph in paragraphs {
198 let format = &paragraph.projection.text().spans()[0].format;268 let format = &paragraph.projection.text().spans()[0].format;
199 let before = format.space_before.unwrap_or(0.0);269 origins.push(spacing(&mut bottom, &mut previous_after, format)?);
200 let after = format.space_after.unwrap_or(0.0);270 let size = paragraph.size();
201 if [before, after].iter().any(|v| !v.is_finite() || *v < 0.0) {271 bottom += f64::from(size[1]);
202 return Err(LayoutError::InvalidSpacing);
203 }
204 if !origins.is_empty() {
205 bottom += f64::from(previous_after.max(before));
206 }
207 origins.push(bottom as f32);
208 bottom += f64::from(
209 paragraph
210 .markers
211 .iter()
212 .map(|(m, _)| m.height())
213 .fold(paragraph.text.height(), f32::max),
214 );
215 previous_after = after;
216 if !(bottom as f32).is_finite() {272 if !(bottom as f32).is_finite() {
217 return Err(LayoutError::InvalidSpacing);273 return Err(LayoutError::InvalidSpacing);
218 }274 }
219 content_width = content_width.max(275 content_width = content_width.max(size[0]);
220 paragraph.origin[0]
221 + paragraph
222 .text
223 .lines()
224 .map(|(line, _)| line.metrics().advance)
225 .fold(0.0, f32::max),
226 );
227 }276 }
228 let height = bottom as f32;277 let height = bottom as f32;
229 if !height.is_finite() || !content_width.is_finite() {278 if !height.is_finite() || !content_width.is_finite() {
...@@ -243,6 +292,192 @@ pub(crate) fn arrange<'a>(...@@ -243,6 +292,192 @@ pub(crate) fn arrange<'a>(
243}292}
244293
245impl OutlineLayout {294impl OutlineLayout {
295 /// Innermost table cell containing a visible paragraph index.
296 pub fn paragraph_cell(&self, index: usize) -> Option<&CellLayout> {
297 self.tables.iter().rev().find_map(|table| {
298 let cell = table
299 .cells
300 .partition_point(|cell| cell.paragraphs.end <= index);
301 table
302 .cells
303 .get(cell)
304 .filter(|cell| cell.paragraphs.contains(&index))
305 })
306 }
307
308 fn append(&mut self, mut child: Self, origin: [f32; 2]) {
309 for paragraph in &mut child.paragraphs {
310 paragraph.origin[0] += origin[0];
311 paragraph.origin[1] += origin[1];
312 for (_, marker) in &mut paragraph.markers {
313 marker[0] += origin[0];
314 }
315 for tag in &mut paragraph.tags {
316 tag.origin[0] += origin[0];
317 }
318 }
319 for table in &mut child.tables {
320 for cell in &mut table.cells {
321 cell.paragraphs.start += self.paragraphs.len();
322 cell.paragraphs.end += self.paragraphs.len();
323 for (value, offset) in cell.rect.iter_mut().zip(origin.into_iter().cycle()) {
324 *value += offset;
325 }
326 }
327 }
328 self.paragraphs.extend(child.paragraphs);
329 self.tables.extend(child.tables);
330 }
331
332 pub(crate) fn flow<'a>(
333 nodes: impl Iterator<Item = &'a PageParagraph>,
334 indents: &[f32],
335 width: f32,
336 fixed_width: bool,
337 depth: usize,
338 edit: Option<&DocumentEdit>,
339 shape: &mut impl FnMut(&PageParagraph, f32, &[f32]) -> Result<ParagraphLayout, LayoutError>,
340 ) -> Result<Self, LayoutError> {
341 if !width.is_finite() || width <= 0.0 {
342 return Err(LayoutError::InvalidWidth);
343 }
344 if depth > 64 {
345 return Err(LayoutError::UnsupportedContent);
346 }
347 let mut result = Self {
348 paragraphs: Vec::new(),
349 tables: Vec::new(),
350 size: [36.0, 0.0],
351 };
352 let mut bottom = 0.0;
353 let mut previous = None;
354 for node in visible_paragraphs(nodes) {
355 match &node.content {
356 ParagraphContent::Text(_) => {
357 let mut paragraph = shape(node, width, indents)?;
358 let y = spacing(
359 &mut bottom,
360 &mut previous,
361 &paragraph.projection.text().spans()[0].format,
362 )?;
363 let size = paragraph.size();
364 paragraph.origin[1] = y;
365 result.paragraphs.push(paragraph);
366 result.size[0] = result.size[0].max(size[0]);
367 bottom += f64::from(size[1]);
368 }
369 ParagraphContent::Table(table) => {
370 let x = indentation(node.level, indents, width)?;
371 let y = spacing(&mut bottom, &mut previous, &node.format)?;
372 let child = Self::table(table, depth + 1, edit, shape)?;
373 result.size[0] = result.size[0].max(x + child.size[0]);
374 bottom += f64::from(child.size[1]);
375 result.append(child, [x, y]);
376 }
377 ParagraphContent::Unsupported(_) => return Err(LayoutError::UnsupportedContent),
378 }
379 if !(bottom as f32).is_finite() || !result.size[0].is_finite() {
380 return Err(LayoutError::InvalidSpacing);
381 }
382 }
383 result.size[1] = bottom as f32;
384 result.size[0] = if fixed_width { width } else { result.size[0] }.max(result.table_width());
385 Ok(result)
386 }
387
388 pub(crate) fn table_width(&self) -> f32 {
389 // Stored column widths extend 1.77pt past the final cell border.
390 self.tables
391 .iter()
392 .filter_map(|table| table.cells.last())
393 .map(|cell| cell.rect[2] + 1.77)
394 .fold(0.0, f32::max)
395 }
396
397 fn table(
398 table: &Table,
399 depth: usize,
400 edit: Option<&DocumentEdit>,
401 shape: &mut impl FnMut(&PageParagraph, f32, &[f32]) -> Result<ParagraphLayout, LayoutError>,
402 ) -> Result<Self, LayoutError> {
403 let widths = edit.and_then(|edit| edit.columns.get(&table.id));
404 if widths.is_some_and(|widths| widths.len() != table.columns.len()) {
405 return Err(LayoutError::InvalidWidth);
406 }
407 let width =
408 |index: usize| widths.map_or(table.columns[index].width, |widths| widths[index]);
409 if table.columns.is_empty()
410 || table.rows.is_empty()
411 || table
412 .columns
413 .iter()
414 .enumerate()
415 .any(|(index, _)| !width(index).is_finite() || width(index) < 36.0)
416 {
417 return Err(LayoutError::InvalidWidth);
418 }
419 if !table.tags.is_empty() {
420 return Err(LayoutError::UnsupportedContent);
421 }
422 let mut result = Self {
423 paragraphs: Vec::new(),
424 tables: vec![TableLayout {
425 id: table.id,
426 cells: Vec::new(),
427 borders: table.borders.unwrap_or(true),
428 }],
429 size: [
430 (0..table.columns.len())
431 .map(|index| width(index) + 4.98)
432 .sum::<f32>()
433 - 1.83,
434 3.54,
435 ],
436 };
437 let mut y = 0.0;
438 for row in &table.rows {
439 if row.cells.len() != table.columns.len() {
440 return Err(LayoutError::InvalidWidth);
441 }
442 let start = result.tables[0].cells.len();
443 let mut x = 0.0;
444 let mut height = 0.0_f32;
445 for (index, cell) in row.cells.iter().enumerate() {
446 let width = width(index);
447 if cell.paragraphs.is_empty() || !cell.unsupported.is_empty() {
448 return Err(LayoutError::UnsupportedContent);
449 }
450 let child = Self::flow(
451 edited_nodes(&cell.paragraphs, Some(cell.id), edit),
452 &cell.indents,
453 width,
454 false,
455 depth,
456 edit,
457 shape,
458 )?;
459 height = height.max(child.size[1]);
460 let paragraph_start = result.paragraphs.len();
461 result.append(child, [x, y + 3.54]);
462 result.tables[0].cells.push(CellLayout {
463 id: cell.id,
464 rect: [x - 3.6, y + 1.86, x + width + 1.38, 0.0],
465 paragraphs: paragraph_start..result.paragraphs.len(),
466 });
467 x += width + 4.98;
468 }
469 y += height + 4.98;
470 for cell in &mut result.tables[0].cells[start..] {
471 cell.rect[3] = y + 1.86;
472 }
473 }
474 result.size[1] += y;
475 if result.size.iter().any(|v| !v.is_finite()) {
476 return Err(LayoutError::InvalidSpacing);
477 }
478 Ok(result)
479 }
480
246 pub(crate) fn new(481 pub(crate) fn new(
247 mut paragraphs: Vec<ParagraphLayout>,482 mut paragraphs: Vec<ParagraphLayout>,
248 width: f32,483 width: f32,
...@@ -255,7 +490,11 @@ impl OutlineLayout {...@@ -255,7 +490,11 @@ impl OutlineLayout {
255 for (paragraph, y) in paragraphs.iter_mut().zip(origins) {490 for (paragraph, y) in paragraphs.iter_mut().zip(origins) {
256 paragraph.origin[1] = y;491 paragraph.origin[1] = y;
257 }492 }
258 Ok(Self { paragraphs, size })493 Ok(Self {
494 paragraphs,
495 tables: Vec::new(),
496 size,
497 })
259 }498 }
260}499}
261500
...@@ -273,7 +512,7 @@ impl Outline {...@@ -273,7 +512,7 @@ impl Outline {
273 self.layout_with_width(engine, definitions, width)512 self.layout_with_width(engine, definitions, width)
274 }513 }
275514
276 fn layout_with_width(515 pub(crate) fn layout_with_width(
277 &self,516 &self,
278 engine: &mut TextEngine,517 engine: &mut TextEngine,
279 definitions: &BTreeMap<ExGuid, Definition>,518 definitions: &BTreeMap<ExGuid, Definition>,
...@@ -285,7 +524,24 @@ impl Outline {...@@ -285,7 +524,24 @@ impl Outline {
285 if !self.unsupported.is_empty() {524 if !self.unsupported.is_empty() {
286 return Err(LayoutError::UnsupportedContent);525 return Err(LayoutError::UnsupportedContent);
287 }526 }
288 let paragraphs = visible_paragraphs(&self.paragraphs)527 if self
528 .paragraphs
529 .iter()
530 .any(|node| matches!(node.content, ParagraphContent::Table(_)))
531 {
532 return OutlineLayout::flow(
533 self.paragraphs.iter(),
534 &self.indents,
535 width,
536 self.layout.width_set_by_user == Some(true),
537 0,
538 None,
539 &mut |node, width, indents| {
540 ParagraphLayout::shape(engine, node, width, indents, definitions)
541 },
542 );
543 }
544 let paragraphs = visible_paragraphs(self.paragraphs.iter())
289 .map(|p| ParagraphLayout::shape(engine, p, width, &self.indents, definitions))545 .map(|p| ParagraphLayout::shape(engine, p, width, &self.indents, definitions))
290 .collect::<Result<_, _>>()?;546 .collect::<Result<_, _>>()?;
291 OutlineLayout::new(547 OutlineLayout::new(
...@@ -316,7 +572,7 @@ impl Title {...@@ -316,7 +572,7 @@ impl Title {
316 .layout572 .layout
317 .reserved_width573 .reserved_width
318 .or(outline.layout.max_width)574 .or(outline.layout.max_width)
319 .unwrap_or(f32::MAX);575 .unwrap_or(TITLE_WIDTH);
320 let layout = outline.layout_with_width(engine, definitions, width)?;576 let layout = outline.layout_with_width(engine, definitions, width)?;
321 let origin = [577 let origin = [
322 outline.layout.x.unwrap_or(0.0),578 outline.layout.x.unwrap_or(0.0),
...@@ -326,7 +582,7 @@ impl Title {...@@ -326,7 +582,7 @@ impl Title {
326 if origin.iter().any(|v| !v.is_finite()) || !height.is_finite() || height < 0.0 {582 if origin.iter().any(|v| !v.is_finite()) || !height.is_finite() || height < 0.0 {
327 return Err(LayoutError::InvalidSpacing);583 return Err(LayoutError::InvalidSpacing);
328 }584 }
329 bottom = origin[1] + layout.size[1].max(height);585 bottom = origin[1] + layout.size[1].max(height) + if outline.title { 3.6 } else { 0.0 };
330 if !bottom.is_finite() {586 if !bottom.is_finite() {
331 return Err(LayoutError::InvalidSpacing);587 return Err(LayoutError::InvalidSpacing);
332 }588 }
...@@ -354,18 +610,281 @@ mod tests {...@@ -354,18 +610,281 @@ mod tests {
354 }),610 }),
355 level,611 level,
356 format: Format::default(),612 format: Format::default(),
357 text: vec![TextObject {613 content: crate::page::ParagraphContent::Text(TextObject {
614 date_field: None,
358 id: ExGuid {615 id: ExGuid {
359 n: n + 100,616 n: n + 100,
360 ..ExGuid::default()617 ..ExGuid::default()
361 },618 },
362 text: Paragraph::new(text.into(), Format::default()),619 text: Paragraph::new(text.into(), Format::default()),
363 tags: Vec::new(),620 tags: Vec::new(),
364 }],621 }),
365 unsupported: Vec::new(),
366 lists: Vec::new(),622 lists: Vec::new(),
367 tags: Vec::new(),623 tags: Vec::new(),
368 collapsed: false,624 collapsed: false,
625 style: None,
626 }
627 }
628
629 fn table(rows: &[&[&str]], widths: &[f32], mut n: u32) -> PageParagraph {
630 use crate::page::{TableCell, TableColumn, TableRow};
631 let mut id = || {
632 n += 1;
633 ExGuid {
634 n,
635 ..ExGuid::default()
636 }
637 };
638 let mut node = paragraph(id().n, "", 1, None);
639 node.content = ParagraphContent::Table(Table {
640 id: id(),
641 columns: widths
642 .iter()
643 .map(|width| TableColumn {
644 width: *width,
645 locked: false,
646 })
647 .collect(),
648 rows: rows
649 .iter()
650 .map(|row| TableRow {
651 id: id(),
652 cells: row
653 .iter()
654 .map(|text| TableCell {
655 id: id(),
656 layout: Layout::default(),
657 indents: vec![18.0, 0.0, 27.0],
658 shading: None,
659 paragraphs: vec![paragraph(id().n, text, 1, None)],
660 unsupported: Vec::new(),
661 })
662 .collect(),
663 })
664 .collect(),
665 borders: Some(true),
666 layout: Layout::default(),
667 tags: Vec::new(),
668 });
669 node
670 }
671
672 #[test]
673 fn table_rows_align_cells_and_expand_for_wrapped_text() {
674 let mut engine = TextEngine::default();
675 let mut outline = Outline {
676 id: ExGuid::default(),
677 title: false,
678 min_width: None,
679 layout: Layout {
680 max_width: Some(300.0),
681 ..Layout::default()
682 },
683 indents: vec![18.0, 0.0, 27.0],
684 paragraphs: vec![
685 paragraph(1, "Before", 1, None),
686 table(
687 &[
688 &[
689 "A long paragraph that wraps inside a single table cell",
690 "B",
691 ],
692 &["C", "D"],
693 ],
694 &[72.0, 48.0],
695 1000,
696 ),
697 paragraph(2, "After", 1, None),
698 ],
699 unsupported: Vec::new(),
700 };
701 let definitions = BTreeMap::new();
702 let layout = outline.layout(&mut engine, &definitions).unwrap();
703 assert_eq!(layout.paragraphs.len(), 6);
704 assert_eq!(layout.tables.len(), 1);
705 let cells = &layout.tables[0].cells;
706 assert_eq!(cells.len(), 4);
707 assert_eq!(
708 cells
709 .iter()
710 .map(|cell| cell.paragraphs.clone())
711 .collect::<Vec<_>>(),
712 [1..2, 2..3, 3..4, 4..5]
713 );
714 assert!(layout.paragraph_cell(0).is_none());
715 assert_eq!(layout.paragraph_cell(4).unwrap().id, cells[3].id);
716 assert!(layout.paragraph_cell(5).is_none());
717 let paragraphs = &layout.paragraphs;
718 assert!(paragraphs[1].text.lines().count() > 1);
719 assert_eq!(paragraphs[2].text.lines().count(), 1);
720 assert_eq!(paragraphs[1].origin[1], paragraphs[2].origin[1]);
721 assert_eq!(paragraphs[3].origin[1], paragraphs[4].origin[1]);
722 assert_eq!(cells[0].rect[3], cells[1].rect[3]);
723 assert_eq!(cells[0].rect[3], cells[2].rect[1]);
724 assert!((cells[0].rect[2] - cells[1].rect[0]).abs() < 0.00001);
725 assert_eq!(paragraphs[1].origin[0], 0.0);
726 assert_eq!(paragraphs[2].origin[0], 76.98);
727 assert!(paragraphs[3].origin[1] > paragraphs[1].origin[1] + paragraphs[1].text.height());
728 assert!(paragraphs[5].origin[1] > cells[3].rect[3]);
729 assert_eq!(
730 layout.size[1],
731 paragraphs[5].origin[1] + paragraphs[5].text.height()
732 );
733 let ParagraphContent::Table(source) = &outline.paragraphs[1].content else {
734 panic!()
735 };
736 assert_eq!(layout.tables[0].id, source.id);
737 assert_eq!(
738 cells.iter().map(|c| c.id).collect::<Vec<_>>(),
739 source
740 .rows
741 .iter()
742 .flat_map(|row| row.cells.iter().map(|c| c.id))
743 .collect::<Vec<_>>()
744 );
745
746 outline.layout.width_set_by_user = Some(true);
747 let fixed = outline.layout(&mut engine, &definitions).unwrap();
748 assert_eq!(fixed.size, [300.0, layout.size[1]]);
749 assert_eq!(fixed.paragraphs[2].origin, paragraphs[2].origin);
750 let ParagraphContent::Table(source) = &mut outline.paragraphs[1].content else {
751 panic!()
752 };
753 source.columns[0].width = 160.0;
754 let widened = outline.layout(&mut engine, &definitions).unwrap();
755 assert!(widened.size[1] < fixed.size[1]);
756 assert_eq!(widened.paragraphs[2].origin[0], 164.98);
757 }
758
759 #[test]
760 fn nested_table_layout_translates_cells_and_respects_collapsed_children() {
761 let mut outer = table(&[&["Left", "Right"]], &[160.0, 72.0], 1000);
762 let mut inner = table(&[&["Nested", "Cell"]], &[48.0, 48.0], 2000);
763 inner.level = 2;
764 let ParagraphContent::Table(source) = &mut outer.content else {
765 panic!()
766 };
767 let hidden = paragraph(
768 42,
769 "Hidden descendant",
770 2,
771 Some(source.rows[0].cells[0].paragraphs[0].id.n),
772 );
773 source.rows[0].cells[0].paragraphs[0].collapsed = true;
774 source.rows[0].cells[0].paragraphs.push(hidden);
775 source.rows[0].cells[0].paragraphs.push(inner);
776 let mut engine = TextEngine::default();
777 let layout = OutlineLayout::flow(
778 [&outer].into_iter(),
779 &[18.0, 0.0, 27.0],
780 300.0,
781 false,
782 0,
783 None,
784 &mut |node, width, indents| {
785 ParagraphLayout::shape(&mut engine, node, width, indents, &BTreeMap::new())
786 },
787 )
788 .unwrap();
789 assert_eq!(layout.tables.len(), 2);
790 assert_eq!(layout.tables[0].cells[0].paragraphs, 0..3);
791 assert_eq!(layout.tables[0].cells[1].paragraphs, 3..4);
792 assert_eq!(layout.tables[1].cells[0].paragraphs, 1..2);
793 assert_eq!(layout.tables[1].cells[1].paragraphs, 2..3);
794 assert_eq!(
795 layout.paragraph_cell(1).unwrap().id,
796 layout.tables[1].cells[0].id
797 );
798 assert_eq!(
799 layout
800 .paragraphs
801 .iter()
802 .map(|p| p.projection.text().text())
803 .collect::<Vec<_>>(),
804 ["Left", "Nested", "Cell", "Right"]
805 );
806 assert_eq!(layout.paragraphs[1].origin[0], 27.0);
807 assert_eq!(layout.tables[1].cells[0].rect[0], 27.0 - 3.6);
808 assert!(layout.tables[1].cells[0].rect[1] > layout.tables[0].cells[0].rect[1]);
809 assert!(layout.tables[1].cells[0].rect[3] < layout.tables[0].cells[0].rect[3]);
810 assert_eq!(
811 layout.paragraphs[0].origin[1],
812 layout.paragraphs[3].origin[1]
813 );
814 }
815
816 #[test]
817 #[ignore = "requires CANVAS_TEST_SECTION native Tab capture and CANVAS_TEST_SUBSTITUTE Carlito font"]
818 fn native_table_layout() {
819 use crate::page::{Page, PageObject};
820 use onestore::{RevisionIndex, Store, document::Document};
821 let bytes = std::fs::read(std::env::var_os("CANVAS_TEST_SECTION").unwrap()).unwrap();
822 let store = Store::parse(&bytes).unwrap();
823 let index = RevisionIndex::parse(&store).unwrap();
824 let document = Document::parse(&index).unwrap();
825 let mut engine = TextEngine::default();
826 engine
827 .register_substitute(parley::fontique::Blob::new(std::sync::Arc::new(
828 std::fs::read(std::env::var_os("CANVAS_TEST_SUBSTITUTE").unwrap()).unwrap(),
829 )))
830 .unwrap();
831 let page = Page::from_document(&document, "rows").unwrap();
832 let outline = page
833 .objects
834 .iter()
835 .find_map(|object| match object {
836 PageObject::Outline(outline) => Some(outline),
837 _ => None,
838 })
839 .unwrap();
840 let layout = outline.layout(&mut engine, &page.definitions).unwrap();
841 assert!((layout.size[0] - 84.6).abs() < 0.001);
842 assert!((layout.size[1] - 58.76315).abs() < 0.001);
843 for (paragraph, expected) in layout.paragraphs.iter().zip([
844 [0.0, 3.54],
845 [44.34, 3.54],
846 [0.0, 21.947714],
847 [44.34, 21.947714],
848 [0.0, 40.355427],
849 [44.34, 40.355427],
850 ]) {
851 assert_eq!(paragraph.text.lines().count(), 1);
852 for (actual, expected) in paragraph.origin.into_iter().zip(expected) {
853 assert!((actual - expected).abs() < 0.001);
854 }
855 }
856 assert_eq!(layout.tables.len(), 1);
857 assert_eq!(layout.tables[0].cells.len(), 6);
858 for (title, size) in [
859 ("soft-break", [82.350006, 35.375435]),
860 ("fixed", [180.0, 21.947721]),
861 ] {
862 let page = Page::from_document(&document, title).unwrap();
863 let outline = page
864 .objects
865 .iter()
866 .find_map(|object| match object {
867 PageObject::Outline(outline) => Some(outline),
868 _ => None,
869 })
870 .unwrap();
871 let layout = outline.layout(&mut engine, &page.definitions).unwrap();
872 for (actual, expected) in layout.size.into_iter().zip(size) {
873 assert!(
874 (actual - expected).abs() < 0.001,
875 "{title}: {actual} != {expected}"
876 );
877 }
878 assert_eq!(layout.tables.len(), 1);
879 assert_eq!(layout.tables[0].cells.len(), 2);
880 assert_eq!(
881 layout
882 .paragraphs
883 .iter()
884 .map(|p| p.text.lines().count())
885 .max(),
886 Some(if title == "soft-break" { 2 } else { 1 })
887 );
369 }888 }
370 }889 }
371890
...@@ -382,7 +901,7 @@ mod tests {...@@ -382,7 +901,7 @@ mod tests {
382 let plain =901 let plain =
383 ParagraphLayout::shape(&mut engine, &node, 120.0, &[18.0, 0.0, 27.0], &definitions)902 ParagraphLayout::shape(&mut engine, &node, 120.0, &[18.0, 0.0, 27.0], &definitions)
384 .unwrap();903 .unwrap();
385 node.text[0].tags.push(Tag {904 node.text_mut().unwrap().tags.push(Tag {
386 definition: Some(id),905 definition: Some(id),
387 status: 3,906 status: 3,
388 action_type: None,907 action_type: None,
...@@ -465,14 +984,17 @@ mod tests {...@@ -465,14 +984,17 @@ mod tests {
465 }984 }
466985
467 #[test]986 #[test]
468 fn title_accepts_intrinsic_width_and_keeps_date_after_wrapped_title() {987 fn title_uses_a_default_wrap_limit_and_keeps_date_after_wrapped_title() {
469 let mut title = Title {988 let mut title = Title {
989 date: None,
470 id: ExGuid::default(),990 id: ExGuid::default(),
471 layout: Layout::default(),991 layout: Layout::default(),
472 outlines: ["A title with enough words to wrap", "A date"]992 outlines: ["A title with enough words to wrap", "A date"]
473 .into_iter()993 .into_iter()
474 .enumerate()994 .enumerate()
475 .map(|(index, text)| Outline {995 .map(|(index, text)| Outline {
996 title: index == 0,
997 min_width: None,
476 id: ExGuid {998 id: ExGuid {
477 n: index as u32,999 n: index as u32,
478 ..ExGuid::default()1000 ..ExGuid::default()
...@@ -495,11 +1017,19 @@ mod tests {...@@ -495,11 +1017,19 @@ mod tests {
495 ));1017 ));
496 let layouts = title.layout(&mut engine, &definitions).unwrap();1018 let layouts = title.layout(&mut engine, &definitions).unwrap();
497 assert_eq!(layouts[0].1.paragraphs[0].text.lines().count(), 1);1019 assert_eq!(layouts[0].1.paragraphs[0].text.lines().count(), 1);
498 assert_eq!(layouts[1].0, [0.0, 21.6]);1020 assert_eq!(layouts[1].0, [0.0, 21.6 + 3.6]);
1021 title.outlines[0].paragraphs[0].text_mut().unwrap().text = Paragraph::new(
1022 "A title with enough words to wrap ".repeat(12),
1023 Format::default(),
1024 );
1025 let layouts = title.layout(&mut engine, &definitions).unwrap();
1026 assert!(layouts[0].1.paragraphs[0].text.lines().count() > 1);
1027 assert!(layouts[0].1.size[0] <= 468.0);
1028 assert_eq!(layouts[1].0[1], layouts[0].1.size[1] + 3.6);
499 title.outlines[0].layout.max_width = Some(70.0);1029 title.outlines[0].layout.max_width = Some(70.0);
500 let layouts = title.layout(&mut engine, &definitions).unwrap();1030 let layouts = title.layout(&mut engine, &definitions).unwrap();
501 assert!(layouts[0].1.paragraphs[0].text.lines().count() > 1);1031 assert!(layouts[0].1.paragraphs[0].text.lines().count() > 1);
502 assert_eq!(layouts[1].0[1], layouts[0].1.size[1]);1032 assert_eq!(layouts[1].0[1], layouts[0].1.size[1] + 3.6);
503 for value in [f32::NAN, f32::INFINITY, -1.0] {1033 for value in [f32::NAN, f32::INFINITY, -1.0] {
504 title.outlines[0].layout.max_height = Some(value);1034 title.outlines[0].layout.max_height = Some(value);
505 assert!(matches!(1035 assert!(matches!(
...@@ -518,7 +1048,7 @@ mod tests {...@@ -518,7 +1048,7 @@ mod tests {
518 #[test]1048 #[test]
519 fn collapses_descendants_and_uses_the_larger_adjacent_spacing() {1049 fn collapses_descendants_and_uses_the_larger_adjacent_spacing() {
520 let mut first = paragraph(1, "First", 1, None);1050 let mut first = paragraph(1, "First", 1, None);
521 first.text[0].text = Paragraph::new(1051 first.text_mut().unwrap().text = Paragraph::new(
522 "First".into(),1052 "First".into(),
523 Format {1053 Format {
524 space_after: Some(4.0),1054 space_after: Some(4.0),
...@@ -526,7 +1056,7 @@ mod tests {...@@ -526,7 +1056,7 @@ mod tests {
526 },1056 },
527 );1057 );
528 let mut second = paragraph(2, "Second", 2, Some(1));1058 let mut second = paragraph(2, "Second", 2, Some(1));
529 second.text[0].text = Paragraph::new(1059 second.text_mut().unwrap().text = Paragraph::new(
530 "Second".into(),1060 "Second".into(),
531 Format {1061 Format {
532 space_before: Some(7.0),1062 space_before: Some(7.0),
...@@ -536,6 +1066,8 @@ mod tests {...@@ -536,6 +1066,8 @@ mod tests {
536 );1066 );
537 second.collapsed = true;1067 second.collapsed = true;
538 let mut outline = Outline {1068 let mut outline = Outline {
1069 title: false,
1070 min_width: None,
539 id: ExGuid::default(),1071 id: ExGuid::default(),
540 layout: Layout {1072 layout: Layout {
541 max_width: Some(300.0),1073 max_width: Some(300.0),
...@@ -591,6 +1123,8 @@ mod tests {...@@ -591,6 +1123,8 @@ mod tests {
591 );1123 );
592 long.lists.push(marker_id);1124 long.lists.push(marker_id);
593 let outline = Outline {1125 let outline = Outline {
1126 title: false,
1127 min_width: None,
594 id: ExGuid::default(),1128 id: ExGuid::default(),
595 layout: Layout {1129 layout: Layout {
596 max_width: Some(110.0),1130 max_width: Some(110.0),
crates/canvas/src/page.rs+852-180
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1use crate::date::DateField;
1use crate::text::Paragraph;2use crate::text::Paragraph;
2use onestore::{3use onestore::{
3 ExGuid,4 ExGuid,
...@@ -10,6 +11,8 @@ use std::{...@@ -10,6 +11,8 @@ use std::{
1011
11pub struct Page {12pub struct Page {
12 pub title: String,13 pub title: String,
14 /// FILETIME ticks from the page's TopologyCreationTimeStamp.
15 pub created: Option<u64>,
13 pub margin_origin: [f32; 2],16 pub margin_origin: [f32; 2],
14 pub objects: Vec<PageObject>,17 pub objects: Vec<PageObject>,
15 pub definitions: BTreeMap<ExGuid, Definition>,18 pub definitions: BTreeMap<ExGuid, Definition>,
...@@ -27,14 +30,46 @@ pub enum PageObject {...@@ -27,14 +30,46 @@ pub enum PageObject {
27 Unsupported(Unsupported),30 Unsupported(Unsupported),
28}31}
2932
33impl PageObject {
34 pub(crate) fn id(&self) -> ExGuid {
35 match self {
36 Self::Outline(value) => value.id,
37 Self::Title(value) => value.id,
38 Self::Image(value) => value.id,
39 Self::Unsupported(value) => value.id,
40 }
41 }
42 pub(crate) fn layout(&self) -> &Layout {
43 match self {
44 Self::Outline(value) => &value.layout,
45 Self::Title(value) => &value.layout,
46 Self::Image(value) => &value.layout,
47 Self::Unsupported(value) => &value.layout,
48 }
49 }
50
51 pub(crate) fn layout_mut(&mut self) -> &mut Layout {
52 match self {
53 Self::Outline(value) => &mut value.layout,
54 Self::Title(value) => &mut value.layout,
55 Self::Image(value) => &mut value.layout,
56 Self::Unsupported(value) => &mut value.layout,
57 }
58 }
59}
60
30pub struct Title {61pub struct Title {
31 pub id: ExGuid,62 pub id: ExGuid,
63 pub date: Option<ExGuid>,
32 pub layout: Layout,64 pub layout: Layout,
33 pub outlines: Vec<Outline>,65 pub outlines: Vec<Outline>,
34}66}
3567
68#[derive(Clone)]
36pub struct Outline {69pub struct Outline {
37 pub id: ExGuid,70 pub id: ExGuid,
71 pub title: bool,
72 pub min_width: Option<f32>,
38 pub layout: Layout,73 pub layout: Layout,
39 pub indents: Vec<f32>,74 pub indents: Vec<f32>,
40 pub paragraphs: Vec<PageParagraph>,75 pub paragraphs: Vec<PageParagraph>,
...@@ -46,17 +81,57 @@ pub struct PageParagraph {...@@ -46,17 +81,57 @@ pub struct PageParagraph {
46 pub id: ExGuid,81 pub id: ExGuid,
47 pub parent: Option<ExGuid>,82 pub parent: Option<ExGuid>,
48 pub level: u32,83 pub level: u32,
84 pub style: Option<ExGuid>,
49 pub format: Format,85 pub format: Format,
50 pub text: Vec<TextObject>,86 pub content: ParagraphContent,
51 pub unsupported: Vec<Unsupported>,
52 pub lists: Vec<ExGuid>,87 pub lists: Vec<ExGuid>,
53 pub tags: Vec<Tag>,88 pub tags: Vec<Tag>,
54 pub collapsed: bool,89 pub collapsed: bool,
55}90}
5691
92#[derive(Clone, Debug, PartialEq)]
93pub enum ParagraphContent {
94 Text(TextObject),
95 Table(Table),
96 Unsupported(Unsupported),
97}
98
99#[derive(Clone, Debug, PartialEq)]
100pub struct Table {
101 pub id: ExGuid,
102 pub columns: Vec<TableColumn>,
103 pub rows: Vec<TableRow>,
104 pub borders: Option<bool>,
105 pub layout: Layout,
106 pub tags: Vec<Tag>,
107}
108
109#[derive(Clone, Debug, PartialEq)]
110pub struct TableColumn {
111 pub width: f32,
112 pub locked: bool,
113}
114
115#[derive(Clone, Debug, PartialEq)]
116pub struct TableRow {
117 pub id: ExGuid,
118 pub cells: Vec<TableCell>,
119}
120
121#[derive(Clone, Debug, PartialEq)]
122pub struct TableCell {
123 pub id: ExGuid,
124 pub layout: Layout,
125 pub indents: Vec<f32>,
126 pub shading: Option<u32>,
127 pub paragraphs: Vec<PageParagraph>,
128 pub unsupported: Vec<Unsupported>,
129}
130
57#[derive(Clone, Debug, PartialEq)]131#[derive(Clone, Debug, PartialEq)]
58pub struct TextObject {132pub struct TextObject {
59 pub id: ExGuid,133 pub id: ExGuid,
134 pub date_field: Option<DateField>,
60 pub text: Paragraph,135 pub text: Paragraph,
61 pub tags: Vec<Tag>,136 pub tags: Vec<Tag>,
62}137}
...@@ -101,20 +176,6 @@ impl Page {...@@ -101,20 +176,6 @@ impl Page {
101176
102 pub fn from_revision(revision: &Revision<'_>, id: ExGuid) -> Result<Self, onestore::Error> {177 pub fn from_revision(revision: &Revision<'_>, id: ExGuid) -> Result<Self, onestore::Error> {
103 let invalid = |message| onestore::Error { offset: 0, message };178 let invalid = |message| onestore::Error { offset: 0, message };
104 let default_format = Format::default();
105 let style = |id: Option<ExGuid>| -> Result<&Format, onestore::Error> {
106 let Some(id) = id else {
107 return Ok(&default_format);
108 };
109 let node = revision
110 .nodes
111 .get(&id)
112 .ok_or_else(|| invalid("Missing canvas paragraph style"))?;
113 if !matches!(node.kind, Kind::Style { .. }) {
114 return Err(invalid("Canvas paragraph style has the wrong type"));
115 }
116 Ok(&node.format)
117 };
118 let root = revision179 let root = revision
119 .nodes180 .nodes
120 .get(&id)181 .get(&id)
...@@ -129,6 +190,22 @@ impl Page {...@@ -129,6 +190,22 @@ impl Page {
129 };190 };
130 let mut page = Self {191 let mut page = Self {
131 title: page_title(revision, id).unwrap_or_default().to_owned(),192 title: page_title(revision, id).unwrap_or_default().to_owned(),
193 created: revision
194 .roots
195 .get(&2)
196 .and_then(|id| revision.nodes.get(id))
197 .filter(|node| matches!(node.kind, Kind::Metadata { .. }))
198 .and_then(|node| node.extra.first())
199 .and_then(|fields| fields.iter().find(|field| field.id == 0x18001c65))
200 .map(|field| {
201 let onestore::document::FieldValue::Bytes(bytes) = field.value else {
202 return Err(invalid("The page creation date is damaged"));
203 };
204 Ok(u64::from_le_bytes(bytes.try_into().map_err(|_| {
205 invalid("The page creation date is damaged")
206 })?))
207 })
208 .transpose()?,
132 margin_origin: [209 margin_origin: [
133 margin_origin_x.unwrap_or(0.0),210 margin_origin_x.unwrap_or(0.0),
134 margin_origin_y.unwrap_or(0.0),211 margin_origin_y.unwrap_or(0.0),
...@@ -160,168 +237,64 @@ impl Page {...@@ -160,168 +237,64 @@ impl Page {
160 let index = page.objects.len();237 let index = page.objects.len();
161 page.objects.push(PageObject::Title(Title {238 page.objects.push(PageObject::Title(Title {
162 id,239 id,
240 date: None,
163 layout: node.layout.clone(),241 layout: node.layout.clone(),
164 outlines: Vec::new(),242 outlines: Vec::new(),
165 }));243 }));
166 roots.extend(node.children.iter().rev().map(|id| (*id, Some(index))));244 roots.extend(node.children.iter().rev().map(|id| (*id, Some(index))));
167 }245 }
168 Kind::Outline { indents } => {246 Kind::Outline { indents } => {
247 let fields = node.extra.first().map(Vec::as_slice).unwrap_or_default();
248 if let Some(index) = title_index
249 && fields.iter().any(|field| {
250 field.id == 0x88001cb5
251 && matches!(field.value, onestore::document::FieldValue::NoData)
252 })
253 {
254 let PageObject::Title(title) = &mut page.objects[index] else {
255 unreachable!()
256 };
257 if title.date.replace(id).is_some() {
258 return Err(invalid("The page has more than one date field"));
259 }
260 }
261 let min_width = fields
262 .iter()
263 .find(|field| field.id == 0x14001cec)
264 .map(|field| {
265 let onestore::document::FieldValue::Bytes(bytes) = field.value else {
266 return Err(invalid("Invalid canvas minimum outline width"));
267 };
268 let width =
269 f32::from_le_bytes(bytes.try_into().map_err(|_| {
270 invalid("Invalid canvas minimum outline width")
271 })?) * 36.0;
272 if !width.is_finite() || width <= 0.0 {
273 return Err(invalid("Invalid canvas minimum outline width"));
274 }
275 Ok(width)
276 })
277 .transpose()?;
169 let mut outline = Outline {278 let mut outline = Outline {
170 id,279 id,
280 title: fields.iter().any(|field| {
281 field.id == 0x88001cb4
282 && matches!(field.value, onestore::document::FieldValue::NoData)
283 }),
284 min_width,
171 layout: node.layout.clone(),285 layout: node.layout.clone(),
172 indents: indents.clone(),286 indents: indents.clone(),
173 paragraphs: Vec::new(),287 paragraphs: Vec::new(),
174 unsupported: Vec::new(),288 unsupported: Vec::new(),
175 };289 };
176 let mut pending: Vec<_> = node290 (outline.paragraphs, outline.unsupported) = read_paragraphs(
177 .children291 revision,
178 .iter()292 node,
179 .rev()293 &node.format,
180 .map(|id| {294 &mut page.definitions,
181 (295 &mut seen,
182 *id,296 0,
183 None,297 )?;
184 u32::from(node.child_level.unwrap_or(0)),
185 node.format.clone(),
186 )
187 })
188 .collect();
189 while let Some((id, parent, level, inherited)) = pending.pop() {
190 if !seen.insert(id) {
191 return Err(invalid("Repeated canvas outline object"));
192 }
193 let node = revision
194 .nodes
195 .get(&id)
196 .ok_or_else(|| invalid("Missing canvas outline object"))?;
197 let next_level = level
198 .checked_add(u32::from(node.child_level.unwrap_or(0)))
199 .ok_or_else(|| invalid("Canvas outline level overflow"))?;
200 match &node.kind {
201 Kind::Paragraph {
202 lists,
203 paragraph_style,
204 collapse_state,
205 } => {
206 let format = node
207 .format
208 .inherit(style(*paragraph_style)?)
209 .inherit(&inherited);
210 let mut text = Vec::new();
211 let mut unsupported = Vec::new();
212 for id in &node.content {
213 let content = revision.nodes.get(id).ok_or_else(|| {
214 invalid("Missing canvas paragraph content")
215 })?;
216 if let Kind::RichText {
217 paragraph_style, ..
218 } = &content.kind
219 {
220 let runs = revision.text_runs(*id)?;
221 let text_content = if runs.is_empty() {
222 Paragraph::new(
223 String::new(),
224 content
225 .format
226 .inherit(style(*paragraph_style)?)
227 .inherit(&format),
228 )
229 } else {
230 Paragraph::from_runs(runs.into_iter().map(|run| {
231 (run.text.to_owned(), run.format.inherit(&format))
232 }))
233 };
234 text.push(TextObject {
235 id: *id,
236 text: text_content,
237 tags: content.tags.clone(),
238 });
239 } else {
240 unsupported.push(Unsupported {
241 id: *id,
242 jcid: content.jcid,
243 layout: content.layout.clone(),
244 });
245 }
246 }
247 for (id, is_list) in lists.iter().map(|id| (id, true)).chain(
248 node.tags
249 .iter()
250 .chain(text.iter().flat_map(|t| &t.tags))
251 .filter_map(|tag| tag.definition.as_ref())
252 .map(|id| (id, false)),
253 ) {
254 let definition = revision.nodes.get(id).ok_or_else(|| {
255 invalid("Missing canvas list or tag definition")
256 })?;
257 let kind = match &definition.kind {
258 Kind::List {
259 font,
260 format,
261 restart,
262 bullet,
263 } if is_list => Kind::List {
264 font: font.clone(),
265 format: format.clone(),
266 restart: *restart,
267 bullet: *bullet,
268 },
269 Kind::TagDefinition {
270 label,
271 action_type,
272 shape,
273 color,
274 highlight,
275 } if !is_list => Kind::TagDefinition {
276 label: label.clone(),
277 action_type: *action_type,
278 shape: *shape,
279 color: *color,
280 highlight: *highlight,
281 },
282 _ => {
283 return Err(invalid(
284 "Canvas list or tag definition has the wrong type",
285 ));
286 }
287 };
288 page.definitions.insert(
289 *id,
290 Definition {
291 kind,
292 format: definition.format.clone(),
293 },
294 );
295 }
296 outline.paragraphs.push(PageParagraph {
297 id,
298 parent,
299 level,
300 format: format.clone(),
301 text,
302 unsupported,
303 lists: lists.clone(),
304 tags: node.tags.clone(),
305 collapsed: *collapse_state == Some(1),
306 });
307 pending.extend(
308 node.children.iter().rev().map(|child| {
309 (*child, Some(id), next_level, format.clone())
310 }),
311 );
312 }
313 Kind::OutlineGroup => {
314 pending.extend(node.children.iter().rev().map(|child| {
315 (*child, parent, next_level, node.format.inherit(&inherited))
316 }))
317 }
318 _ => outline.unsupported.push(Unsupported {
319 id,
320 jcid: node.jcid,
321 layout: node.layout.clone(),
322 }),
323 }
324 }
325 if let Some(index) = title_index {298 if let Some(index) = title_index {
326 let PageObject::Title(title) = &mut page.objects[index] else {299 let PageObject::Title(title) = &mut page.objects[index] else {
327 unreachable!()300 unreachable!()
...@@ -368,6 +341,335 @@ impl Page {...@@ -368,6 +341,335 @@ impl Page {
368 }341 }
369}342}
370343
344fn read_paragraphs(
345 revision: &Revision<'_>,
346 container: &onestore::document::Element<'_>,
347 inherited: &Format,
348 definitions: &mut BTreeMap<ExGuid, Definition>,
349 seen: &mut BTreeSet<ExGuid>,
350 depth: usize,
351) -> Result<(Vec<PageParagraph>, Vec<Unsupported>), onestore::Error> {
352 let invalid = |message| onestore::Error { offset: 0, message };
353 let default_format = Format::default();
354 let style = |id: Option<ExGuid>| -> Result<&Format, onestore::Error> {
355 let Some(id) = id else {
356 return Ok(&default_format);
357 };
358 let node = revision
359 .nodes
360 .get(&id)
361 .ok_or_else(|| invalid("Missing canvas paragraph style"))?;
362 if !matches!(node.kind, Kind::Style { .. }) {
363 return Err(invalid("Canvas paragraph style has the wrong type"));
364 }
365 Ok(&node.format)
366 };
367
368 let mut paragraphs = Vec::new();
369 let mut unsupported = Vec::new();
370 let mut pending: Vec<_> = container
371 .children
372 .iter()
373 .rev()
374 .map(|id| {
375 (
376 *id,
377 None,
378 u32::from(container.child_level.unwrap_or(0)),
379 inherited.clone(),
380 )
381 })
382 .collect();
383 while let Some((id, parent, level, inherited)) = pending.pop() {
384 if !seen.insert(id) {
385 return Err(invalid("Repeated canvas outline object"));
386 }
387 let node = revision
388 .nodes
389 .get(&id)
390 .ok_or_else(|| invalid("Missing canvas outline object"))?;
391 let next_level = level
392 .checked_add(u32::from(node.child_level.unwrap_or(0)))
393 .ok_or_else(|| invalid("Canvas outline level overflow"))?;
394 match &node.kind {
395 Kind::Paragraph {
396 lists,
397 paragraph_style,
398 collapse_state,
399 } => {
400 let format = node
401 .format
402 .inherit(style(*paragraph_style)?)
403 .inherit(&inherited);
404 let mut base_style = *paragraph_style;
405 let [content_id] = node.content.as_slice() else {
406 return Err(invalid("A paragraph must contain one content object"));
407 };
408 if !seen.insert(*content_id) {
409 return Err(invalid("Repeated canvas paragraph content"));
410 }
411 let content = revision
412 .nodes
413 .get(content_id)
414 .ok_or_else(|| invalid("Missing canvas paragraph content"))?;
415 let content = if let Kind::RichText {
416 paragraph_style, ..
417 } = &content.kind
418 {
419 base_style = paragraph_style.or(base_style);
420 let runs = revision.text_runs(*content_id)?;
421 let text_content = if runs.is_empty() {
422 Paragraph::new(
423 String::new(),
424 content
425 .format
426 .inherit(style(*paragraph_style)?)
427 .inherit(&format),
428 )
429 } else {
430 Paragraph::from_runs(
431 runs.into_iter()
432 .map(|run| (run.text.to_owned(), run.format.inherit(&format))),
433 )
434 };
435 ParagraphContent::Text(TextObject {
436 id: *content_id,
437 date_field: content
438 .extra
439 .first()
440 .map(Vec::as_slice)
441 .unwrap_or_default()
442 .iter()
443 .filter(|field| {
444 matches!(field.value, onestore::document::FieldValue::NoData)
445 })
446 .filter_map(|field| match field.id {
447 0x88001cb5 => Some(DateField::Date),
448 0x88001c87 => Some(DateField::Time),
449 _ => None,
450 })
451 .try_fold(None, |previous, field| {
452 if previous.is_some() {
453 Err(invalid("The page date field has conflicting roles"))
454 } else {
455 Ok(Some(field))
456 }
457 })?,
458 text: text_content,
459 tags: content.tags.clone(),
460 })
461 } else if matches!(content.kind, Kind::Table { .. }) {
462 ParagraphContent::Table(read_table(
463 revision,
464 *content_id,
465 &content.format.inherit(&format),
466 definitions,
467 seen,
468 depth + 1,
469 )?)
470 } else {
471 ParagraphContent::Unsupported(Unsupported {
472 id: *content_id,
473 jcid: content.jcid,
474 layout: content.layout.clone(),
475 })
476 };
477 for (id, is_list) in lists.iter().map(|id| (id, true)).chain(
478 node.tags
479 .iter()
480 .chain(match &content {
481 ParagraphContent::Text(text) => text.tags.as_slice(),
482 ParagraphContent::Table(table) => table.tags.as_slice(),
483 ParagraphContent::Unsupported(_) => &[],
484 })
485 .filter_map(|tag| tag.definition.as_ref())
486 .map(|id| (id, false)),
487 ) {
488 let definition = revision
489 .nodes
490 .get(id)
491 .ok_or_else(|| invalid("Missing canvas list or tag definition"))?;
492 let kind = match &definition.kind {
493 Kind::List {
494 font,
495 format,
496 restart,
497 bullet,
498 } if is_list => Kind::List {
499 font: font.clone(),
500 format: format.clone(),
501 restart: *restart,
502 bullet: *bullet,
503 },
504 Kind::TagDefinition {
505 label,
506 action_type,
507 shape,
508 color,
509 highlight,
510 } if !is_list => Kind::TagDefinition {
511 label: label.clone(),
512 action_type: *action_type,
513 shape: *shape,
514 color: *color,
515 highlight: *highlight,
516 },
517 _ => {
518 return Err(invalid(
519 "Canvas list or tag definition has the wrong type",
520 ));
521 }
522 };
523 definitions.insert(
524 *id,
525 Definition {
526 kind,
527 format: definition.format.clone(),
528 },
529 );
530 }
531 if let Some(id) = base_style {
532 let definition = revision
533 .nodes
534 .get(&id)
535 .ok_or_else(|| invalid("Missing canvas paragraph style"))?;
536 let Kind::Style { name } = &definition.kind else {
537 return Err(invalid("Canvas paragraph style has the wrong type"));
538 };
539 definitions.entry(id).or_insert_with(|| Definition {
540 kind: Kind::Style { name: name.clone() },
541 format: definition.format.clone(),
542 });
543 }
544 paragraphs.push(PageParagraph {
545 id,
546 parent,
547 level,
548 style: base_style,
549 format: format.clone(),
550 content,
551 lists: lists.clone(),
552 tags: node.tags.clone(),
553 collapsed: *collapse_state == Some(1),
554 });
555 pending.extend(
556 node.children
557 .iter()
558 .rev()
559 .map(|child| (*child, Some(id), next_level, format.clone())),
560 );
561 }
562 Kind::OutlineGroup => pending.extend(
563 node.children
564 .iter()
565 .rev()
566 .map(|child| (*child, parent, next_level, node.format.inherit(&inherited))),
567 ),
568 _ => unsupported.push(Unsupported {
569 id,
570 jcid: node.jcid,
571 layout: node.layout.clone(),
572 }),
573 }
574 }
575 Ok((paragraphs, unsupported))
576}
577
578fn read_table(
579 revision: &Revision<'_>,
580 id: ExGuid,
581 inherited: &Format,
582 definitions: &mut BTreeMap<ExGuid, Definition>,
583 seen: &mut BTreeSet<ExGuid>,
584 depth: usize,
585) -> Result<Table, onestore::Error> {
586 let invalid = |message| onestore::Error { offset: 0, message };
587 if depth > 64 {
588 return Err(invalid("Tables are nested too deeply"));
589 }
590 let node = &revision.nodes[&id];
591 let Kind::Table {
592 rows,
593 columns,
594 widths,
595 locked,
596 borders,
597 } = &node.kind
598 else {
599 unreachable!()
600 };
601 if rows.map(|n| n as usize) != Some(node.children.len())
602 || node.children.is_empty()
603 || columns.map(|n| n as usize) != Some(widths.len())
604 || widths.is_empty()
605 || (!locked.is_empty() && locked.len() != widths.len())
606 || widths.iter().any(|w| !w.is_finite() || *w < 36.0)
607 {
608 return Err(invalid("Table dimensions are inconsistent"));
609 }
610 let mut table_rows = Vec::new();
611 for row_id in &node.children {
612 if !seen.insert(*row_id) {
613 return Err(invalid("Repeated canvas table row"));
614 }
615 let row = revision
616 .nodes
617 .get(row_id)
618 .ok_or_else(|| invalid("Missing canvas table row"))?;
619 if !matches!(row.kind, Kind::Row) || row.children.len() != widths.len() {
620 return Err(invalid("Table row dimensions are inconsistent"));
621 }
622 let mut cells = Vec::new();
623 for cell_id in &row.children {
624 if !seen.insert(*cell_id) {
625 return Err(invalid("Repeated canvas table cell"));
626 }
627 let cell = revision
628 .nodes
629 .get(cell_id)
630 .ok_or_else(|| invalid("Missing canvas table cell"))?;
631 if cell.children.is_empty() {
632 return Err(invalid("A table cell has no paragraphs"));
633 }
634 let Kind::Cell { shading, indents } = &cell.kind else {
635 return Err(invalid("A table row contains an invalid cell"));
636 };
637 let (paragraphs, unsupported) = read_paragraphs(
638 revision,
639 cell,
640 &cell.format.inherit(inherited),
641 definitions,
642 seen,
643 depth,
644 )?;
645 cells.push(TableCell {
646 id: *cell_id,
647 layout: cell.layout.clone(),
648 indents: indents.clone(),
649 shading: *shading,
650 paragraphs,
651 unsupported,
652 });
653 }
654 table_rows.push(TableRow { id: *row_id, cells });
655 }
656 Ok(Table {
657 id,
658 columns: widths
659 .iter()
660 .enumerate()
661 .map(|(index, &width)| TableColumn {
662 width,
663 locked: locked.get(index).copied().unwrap_or(false),
664 })
665 .collect(),
666 rows: table_rows,
667 borders: *borders,
668 layout: node.layout.clone(),
669 tags: node.tags.clone(),
670 })
671}
672
371fn page_title<'a>(revision: &'a Revision<'_>, id: ExGuid) -> Option<&'a str> {673fn page_title<'a>(revision: &'a Revision<'_>, id: ExGuid) -> Option<&'a str> {
372 revision674 revision
373 .roots675 .roots
...@@ -393,18 +695,17 @@ fn page_title<'a>(revision: &'a Revision<'_>, id: ExGuid) -> Option<&'a str> {...@@ -393,18 +695,17 @@ fn page_title<'a>(revision: &'a Revision<'_>, id: ExGuid) -> Option<&'a str> {
393}695}
394696
395impl PageParagraph {697impl PageParagraph {
396 pub fn combined_text(&self) -> Paragraph {698 pub fn text(&self) -> Option<&TextObject> {
397 if self.text.is_empty() {699 match &self.content {
398 return Paragraph::new(String::new(), self.format.clone());700 ParagraphContent::Text(text) => Some(text),
701 _ => None,
702 }
703 }
704 pub fn text_mut(&mut self) -> Option<&mut TextObject> {
705 match &mut self.content {
706 ParagraphContent::Text(text) => Some(text),
707 _ => None,
399 }708 }
400 Paragraph::from_runs(self.text.iter().flat_map(|object| {
401 let mut start = 0;
402 object.text.spans().iter().map(move |span| {
403 let text = object.text.text()[start..span.end].to_owned();
404 start = span.end;
405 (text, span.format.clone())
406 })
407 }))
408 }709 }
409}710}
410711
...@@ -499,6 +800,278 @@ mod tests {...@@ -499,6 +800,278 @@ mod tests {
499 }800 }
500 }801 }
501802
803 fn table_revision() -> Revision<'static> {
804 let mut source = revision();
805 source.nodes.get_mut(&id(3)).unwrap().content = vec![id(7)];
806 let mut table = element(Kind::Table {
807 rows: Some(1),
808 columns: Some(2),
809 widths: vec![37.11, 99.0],
810 locked: vec![false, true],
811 borders: Some(false),
812 });
813 table.children = vec![id(8)];
814 let mut row = element(Kind::Row);
815 row.children = vec![id(9), id(10)];
816 source.nodes.insert(id(7), table);
817 source.nodes.insert(id(8), row);
818 for (cell, paragraph, text) in [(9, 11, 4), (10, 12, 13)] {
819 let mut node = element(Kind::Cell {
820 shading: Some(0x00ffff),
821 indents: vec![18.0, 0.0, 27.0, 27.0],
822 });
823 node.child_level = Some(1);
824 node.layout.max_width = Some(268.8);
825 node.children = vec![id(paragraph)];
826 if cell == 10 {
827 node.format.font_size = Some(13.0);
828 }
829 source.nodes.insert(id(cell), node);
830 let mut node = element(Kind::Paragraph {
831 lists: Vec::new(),
832 paragraph_style: None,
833 collapse_state: None,
834 });
835 node.content = vec![id(text)];
836 source.nodes.insert(id(paragraph), node);
837 }
838 source.nodes.insert(
839 id(13),
840 element(Kind::RichText {
841 text: "cd".into(),
842 runs: vec![TextRun {
843 start: 0,
844 end: 2,
845 format: None,
846 extra_set: None,
847 }],
848 paragraph_style: None,
849 boilerplate: false,
850 }),
851 );
852 source
853 }
854
855 #[test]
856 fn table_import_owns_cells_and_inherits_format_across_containers() {
857 let page = {
858 let source = table_revision();
859 let before = serde_json::to_vec(&source).unwrap();
860 let page = Page::from_revision(&source, id(1)).unwrap();
861 assert_eq!(serde_json::to_vec(&source).unwrap(), before);
862 page
863 };
864 let PageObject::Outline(outline) = &page.objects[0] else {
865 panic!()
866 };
867 let ParagraphContent::Table(table) = &outline.paragraphs[0].content else {
868 panic!()
869 };
870 assert_eq!(table.id, id(7));
871 assert_eq!(table.borders, Some(false));
872 assert_eq!(
873 table.columns,
874 [
875 TableColumn {
876 width: 37.11,
877 locked: false
878 },
879 TableColumn {
880 width: 99.0,
881 locked: true
882 }
883 ]
884 );
885 assert_eq!(table.rows[0].id, id(8));
886 for (index, cell) in table.rows[0].cells.iter().enumerate() {
887 assert_eq!(cell.id, id(9 + index as u32));
888 assert_eq!(cell.layout.max_width, Some(268.8));
889 assert_eq!(cell.indents, [18.0, 0.0, 27.0, 27.0]);
890 assert_eq!(cell.shading, Some(0x00ffff));
891 let paragraph = &cell.paragraphs[0];
892 assert_eq!(paragraph.parent, None);
893 assert_eq!(paragraph.level, 1);
894 assert_eq!(paragraph.id, id(11 + index as u32));
895 let text = paragraph.text().unwrap();
896 assert_eq!(text.id, if index == 0 { id(4) } else { id(13) });
897 assert_eq!(text.text.text(), if index == 0 { "ab" } else { "cd" });
898 assert_eq!(
899 text.text.spans()[0].format.font_size,
900 Some(if index == 0 { 12.0 } else { 13.0 })
901 );
902 assert_eq!(text.text.spans()[0].format.bold, Some(false));
903 }
904 }
905
906 #[test]
907 fn absent_column_locks_use_the_specified_unlocked_default() {
908 let mut source = table_revision();
909 let Kind::Table { locked, .. } = &mut source.nodes.get_mut(&id(7)).unwrap().kind else {
910 panic!()
911 };
912 locked.clear();
913 let page = Page::from_revision(&source, id(1)).unwrap();
914 let PageObject::Outline(outline) = &page.objects[0] else {
915 panic!()
916 };
917 let ParagraphContent::Table(table) = &outline.paragraphs[0].content else {
918 panic!()
919 };
920 assert!(table.columns.iter().all(|column| !column.locked));
921 }
922
923 #[test]
924 fn table_import_rejects_aliases_cycles_and_inconsistent_dimensions() {
925 for case in 0..7 {
926 let mut source = table_revision();
927 match case {
928 0 => source.nodes.get_mut(&id(8)).unwrap().children[1] = id(9),
929 1 => source.nodes.get_mut(&id(11)).unwrap().content = vec![id(7)],
930 2 => source.nodes.get_mut(&id(12)).unwrap().content = vec![id(4)],
931 3 => source.nodes.get_mut(&id(3)).unwrap().content.push(id(4)),
932 4 => source.nodes.get_mut(&id(9)).unwrap().children.clear(),
933 5 => {
934 let Kind::Table { columns, .. } =
935 &mut source.nodes.get_mut(&id(7)).unwrap().kind
936 else {
937 panic!()
938 };
939 *columns = Some(3);
940 }
941 _ => {
942 let Kind::Table { widths, .. } =
943 &mut source.nodes.get_mut(&id(7)).unwrap().kind
944 else {
945 panic!()
946 };
947 widths[0] = f32::INFINITY;
948 }
949 }
950 assert!(Page::from_revision(&source, id(1)).is_err(), "case {case}");
951 }
952 }
953
954 #[test]
955 fn nested_tables_import_with_a_bounded_depth() {
956 for levels in [1, 62, 65] {
957 let mut source = table_revision();
958 let mut paragraph = id(11);
959 for depth in 0..levels {
960 let base = 20 + depth * 4;
961 source.nodes.get_mut(&paragraph).unwrap().content = vec![id(base)];
962 let mut table = element(Kind::Table {
963 rows: Some(1),
964 columns: Some(1),
965 widths: vec![72.0],
966 locked: vec![true],
967 borders: Some(true),
968 });
969 table.children = vec![id(base + 1)];
970 let mut row = element(Kind::Row);
971 row.children = vec![id(base + 2)];
972 let mut cell = element(Kind::Cell {
973 shading: None,
974 indents: vec![18.0, 0.0, 27.0, 27.0],
975 });
976 cell.child_level = Some(1);
977 cell.children = vec![id(base + 3)];
978 let mut child = element(Kind::Paragraph {
979 lists: Vec::new(),
980 paragraph_style: None,
981 collapse_state: None,
982 });
983 child.content = vec![id(4)];
984 source.nodes.extend([
985 (id(base), table),
986 (id(base + 1), row),
987 (id(base + 2), cell),
988 (id(base + 3), child),
989 ]);
990 paragraph = id(base + 3);
991 }
992 assert_eq!(Page::from_revision(&source, id(1)).is_ok(), levels < 64);
993 }
994 }
995
996 #[test]
997 #[ignore = "requires CANVAS_TEST_SECTION pointing to the native Tab table capture"]
998 fn native_table_import() {
999 use onestore::{RevisionIndex, Store};
1000 let page = {
1001 let bytes = std::fs::read(std::env::var_os("CANVAS_TEST_SECTION").unwrap()).unwrap();
1002 let store = Store::parse(&bytes).unwrap();
1003 let index = RevisionIndex::parse(&store).unwrap();
1004 let document = Document::parse(&index).unwrap();
1005 for (space, page) in document.pages().unwrap() {
1006 let space = &document.spaces[&space];
1007 let revision = &space.revisions[&space.contexts[&ExGuid::default()]];
1008 Page::from_revision(revision, page).unwrap();
1009 }
1010 Page::from_document(&document, "rows").unwrap()
1011 };
1012 let table = page
1013 .objects
1014 .iter()
1015 .find_map(|object| match object {
1016 PageObject::Outline(outline) => {
1017 outline.paragraphs.iter().find_map(|p| match &p.content {
1018 ParagraphContent::Table(table) => Some(table),
1019 _ => None,
1020 })
1021 }
1022 _ => None,
1023 })
1024 .unwrap();
1025 assert_eq!(table.rows.len(), 3);
1026 assert_eq!(table.columns.len(), 2);
1027 assert!(table.columns.iter().all(|c| !c.locked));
1028 let cells: Vec<_> = table.rows.iter().flat_map(|row| &row.cells).collect();
1029 let text: Vec<_> = cells
1030 .iter()
1031 .map(|cell| cell.paragraphs[0].text().unwrap().text.text())
1032 .collect();
1033 assert_eq!(text, ["Alpha", "Beta", "Gamma", "Delta", "Epsilon", ""]);
1034 assert!(cells.iter().all(|c| c.indents == [18.0, 0.0, 27.0, 27.0]));
1035 use crate::document::{TextDocument, TextPosition};
1036 let outline = page
1037 .objects
1038 .iter()
1039 .find_map(|object| match object {
1040 PageObject::Outline(outline)
1041 if outline
1042 .paragraphs
1043 .iter()
1044 .any(|p| matches!(p.content, ParagraphContent::Table(_))) =>
1045 {
1046 Some(outline)
1047 }
1048 _ => None,
1049 })
1050 .unwrap();
1051 let mut document = TextDocument::from_nodes(outline.paragraphs.clone()).unwrap();
1052 let original = document.clone();
1053 for (index, cell) in cells.iter().enumerate() {
1054 let position = TextPosition {
1055 paragraph: index,
1056 offset: 0,
1057 };
1058 let edit = document
1059 .replace(
1060 position..position,
1061 vec![
1062 Paragraph::new("🧊".into(), Format::default()),
1063 Paragraph::new("text".into(), Format::default()),
1064 ],
1065 )
1066 .unwrap();
1067 assert_eq!(edit.container, Some(cell.id));
1068 let undo = document.apply(edit).unwrap();
1069 assert_eq!(document.paragraphs().nth(index).unwrap().text(), "🧊");
1070 document.apply(undo).unwrap();
1071 assert_eq!(document, original);
1072 }
1073 }
1074
502 #[test]1075 #[test]
503 fn owns_page_content_and_resolves_style_before_ancestor_defaults() {1076 fn owns_page_content_and_resolves_style_before_ancestor_defaults() {
504 let page = {1077 let page = {
...@@ -539,15 +1112,104 @@ mod tests {...@@ -539,15 +1112,104 @@ mod tests {
539 assert!(paragraph.collapsed);1112 assert!(paragraph.collapsed);
540 assert_eq!(paragraph.level, 1);1113 assert_eq!(paragraph.level, 1);
541 assert_eq!(paragraph.parent, None);1114 assert_eq!(paragraph.parent, None);
542 let text = paragraph.combined_text();1115 assert_eq!(paragraph.style, Some(id(5)));
1116 assert!(matches!(&page.definitions[&id(5)].kind,
1117 Kind::Style { name: Some(name) } if name == "Body"));
1118 assert_eq!(page.definitions[&id(5)].format.font_size, Some(12.0));
1119 let text = &paragraph.text().unwrap().text;
543 assert_eq!(text.text(), "ab");1120 assert_eq!(text.text(), "ab");
544 assert_eq!(text.spans()[0].format.font_size, Some(12.0));1121 assert_eq!(text.spans()[0].format.font_size, Some(12.0));
545 assert_eq!(text.spans()[0].format.bold, Some(false));1122 assert_eq!(text.spans()[0].format.bold, Some(false));
546 assert_eq!(paragraph.text[0].tags[0].created, Some(123));1123 assert_eq!(paragraph.text().unwrap().tags[0].created, Some(123));
547 assert!(matches!(&page.definitions[&id(7)].kind,1124 assert!(matches!(&page.definitions[&id(7)].kind,
548 Kind::TagDefinition { label: Some(label), shape: Some(3), .. } if label == "To Do"));1125 Kind::TagDefinition { label: Some(label), shape: Some(3), .. } if label == "To Do"));
549 }1126 }
5501127
1128 #[test]
1129 fn creation_time_comes_from_page_metadata_and_date_role_from_the_title_child() {
1130 use onestore::document::{Field, FieldValue};
1131 let mut source = revision();
1132 source.nodes.get_mut(&id(1)).unwrap().created = Some(7);
1133 let bytes = 134_333_468_649_123_456_u64.to_le_bytes();
1134 source.nodes.get_mut(&id(6)).unwrap().extra = vec![vec![Field {
1135 id: 0x18001c65,
1136 value: FieldValue::Bytes(&bytes),
1137 }]];
1138 source.nodes.get_mut(&id(1)).unwrap().children.clear();
1139 source.nodes.get_mut(&id(1)).unwrap().structure.push(id(7));
1140 let mut title = element(Kind::Title);
1141 title.children.push(id(2));
1142 source.nodes.insert(id(7), title);
1143 source.nodes.get_mut(&id(2)).unwrap().extra = vec![vec![Field {
1144 id: 0x88001cb5,
1145 value: FieldValue::NoData,
1146 }]];
1147 source.nodes.get_mut(&id(4)).unwrap().extra = vec![vec![Field {
1148 id: 0x88001c87,
1149 value: FieldValue::NoData,
1150 }]];
1151 let page = Page::from_revision(&source, id(1)).unwrap();
1152 assert_eq!(page.created, Some(134_333_468_649_123_456));
1153 let PageObject::Title(title) = &page.objects[0] else {
1154 panic!()
1155 };
1156 assert_eq!(title.date, Some(id(2)));
1157 assert_eq!(
1158 title.outlines[0].paragraphs[0].text().unwrap().date_field,
1159 Some(DateField::Time)
1160 );
1161 source.nodes.get_mut(&id(6)).unwrap().extra[0][0].value = FieldValue::Bytes(&bytes[..7]);
1162 assert!(Page::from_revision(&source, id(1)).is_err());
1163 source.nodes.get_mut(&id(6)).unwrap().extra.clear();
1164 assert_eq!(Page::from_revision(&source, id(1)).unwrap().created, None);
1165 }
1166
1167 #[test]
1168 fn title_role_and_minimum_width_come_from_root_properties() {
1169 use onestore::document::{Field, FieldValue};
1170 let mut source = revision();
1171 source.nodes.get_mut(&id(1)).unwrap().children.clear();
1172 source.nodes.get_mut(&id(1)).unwrap().structure.push(id(7));
1173 let mut title = element(Kind::Title);
1174 title.children.push(id(2));
1175 source.nodes.insert(id(7), title);
1176 source.nodes.get_mut(&id(2)).unwrap().extra = vec![vec![
1177 Field {
1178 id: 0x88001cb4,
1179 value: FieldValue::NoData,
1180 },
1181 Field {
1182 id: 0x14001cec,
1183 value: FieldValue::Bytes(&[0, 0, 0x90, 0x40]),
1184 },
1185 ]];
1186 let page = Page::from_revision(&source, id(1)).unwrap();
1187 let PageObject::Title(title) = &page.objects[0] else {
1188 panic!()
1189 };
1190 assert!(title.outlines[0].title);
1191 assert_eq!(title.outlines[0].min_width, Some(162.0));
1192 source.nodes.get_mut(&id(2)).unwrap().extra[0][1].value =
1193 FieldValue::Bytes(&[0, 0, 0x80, 0x7f]);
1194 assert!(Page::from_revision(&source, id(1)).is_err());
1195 source.nodes.get_mut(&id(2)).unwrap().extra = vec![
1196 vec![Field {
1197 id: 0x08001cb4,
1198 value: FieldValue::NoData,
1199 }],
1200 vec![Field {
1201 id: 0x88001cb4,
1202 value: FieldValue::NoData,
1203 }],
1204 ];
1205 let page = Page::from_revision(&source, id(1)).unwrap();
1206 let PageObject::Title(title) = &page.objects[0] else {
1207 panic!()
1208 };
1209 assert!(!title.outlines[0].title);
1210 assert_eq!(title.outlines[0].min_width, None);
1211 }
1212
551 #[test]1213 #[test]
552 fn preserves_paint_order_nested_parents_and_owned_image_payloads() {1214 fn preserves_paint_order_nested_parents_and_owned_image_payloads() {
553 let bytes = vec![1, 2, 3, 4];1215 let bytes = vec![1, 2, 3, 4];
...@@ -577,11 +1239,21 @@ mod tests {...@@ -577,11 +1239,21 @@ mod tests {
577 let mut group = element(Kind::OutlineGroup);1239 let mut group = element(Kind::OutlineGroup);
578 group.children.push(id(12));1240 group.children.push(id(12));
579 group.child_level = Some(1);1241 group.child_level = Some(1);
580 let child = element(Kind::Paragraph {1242 let mut child = element(Kind::Paragraph {
581 lists: Vec::new(),1243 lists: Vec::new(),
582 paragraph_style: None,1244 paragraph_style: None,
583 collapse_state: None,1245 collapse_state: None,
584 });1246 });
1247 child.content.push(id(13));
1248 source.nodes.insert(
1249 id(13),
1250 element(Kind::RichText {
1251 text: "".into(),
1252 runs: Vec::new(),
1253 paragraph_style: None,
1254 boilerplate: false,
1255 }),
1256 );
585 source.nodes.extend([1257 source.nodes.extend([
586 (id(7), image),1258 (id(7), image),
587 (id(8), file),1259 (id(8), file),
...@@ -651,7 +1323,7 @@ mod tests {...@@ -651,7 +1323,7 @@ mod tests {
651 panic!()1323 panic!()
652 };1324 };
653 assert_eq!(1325 assert_eq!(
654 outline.paragraphs[0].combined_text().spans()[0]1326 outline.paragraphs[0].text().unwrap().text.spans()[0]
655 .format1327 .format
656 .font_size,1328 .font_size,
657 Some(12.0)1329 Some(12.0)
crates/canvas/src/text.rs+24-1
...@@ -16,12 +16,14 @@ pub struct Paragraph {...@@ -16,12 +16,14 @@ pub struct Paragraph {
16 spans: Vec<Span>,16 spans: Vec<Span>,
17}17}
1818
19#[derive(Clone)]
19pub struct TextProjection {20pub struct TextProjection {
20 text: Paragraph,21 text: Paragraph,
21 spans: Vec<ProjectedSpan>,22 spans: Vec<ProjectedSpan>,
22 source_len: u32,23 source_len: u32,
23}24}
2425
26#[derive(Clone)]
25struct ProjectedSpan {27struct ProjectedSpan {
26 visible: Range<u32>,28 visible: Range<u32>,
27 source_start: u32,29 source_start: u32,
...@@ -148,7 +150,7 @@ impl Paragraph {...@@ -148,7 +150,7 @@ impl Paragraph {
148 .map_err(|_| EditError::TextTooLong)?;150 .map_err(|_| EditError::TextTooLong)?;
149 let source_end = source.checked_add(length).ok_or(EditError::TextTooLong)?;151 let source_end = source.checked_add(length).ok_or(EditError::TextTooLong)?;
150 if span.format.hidden != Some(true) {152 if span.format.hidden != Some(true) {
151 runs.push((fragment.to_owned(), span.format.clone()));153 runs.push((fragment.replace('\u{000b}', "\n"), span.format.clone()));
152 if length > 0 {154 if length > 0 {
153 if let Some(last) = spans.last_mut()155 if let Some(last) = spans.last_mut()
154 && last.source_start + (last.visible.end - last.visible.start) == source156 && last.source_start + (last.visible.end - last.visible.start) == source
...@@ -356,6 +358,27 @@ mod tests {...@@ -356,6 +358,27 @@ mod tests {
356 }358 }
357 }359 }
358360
361 #[test]
362 fn soft_breaks_keep_source_offsets_and_formatting() {
363 let source = Paragraph::from_runs([("A\u{000b}".into(), regular()), ("🌳".into(), bold())]);
364 let projection = source.project().unwrap();
365 assert_eq!(source.text(), "A\u{000b}🌳");
366 assert_eq!(projection.text().text(), "A\n🌳");
367 assert_eq!(projection.text().spans(), source.spans());
368 assert_eq!(
369 projection.source_boundaries().collect::<Vec<_>>(),
370 [(0, 0), (1, 1), (2, 2), (6, 4)]
371 );
372 for offset in [0, 1, 2, 4] {
373 assert_eq!(projection.visible_offset(offset), Ok(offset));
374 assert_eq!(
375 projection.source_offset(offset, Affinity::Downstream),
376 Ok(offset)
377 );
378 }
379 assert!(projection.visible_offset(3).is_err());
380 }
381
359 #[test]382 #[test]
360 fn hidden_fields_keep_source_offsets_at_both_sides_of_a_gap() {383 fn hidden_fields_keep_source_offsets_at_both_sides_of_a_gap() {
361 let hidden = Format {384 let hidden = Format {
crates/snowbound/Cargo.toml created+22
...@@ -0,0 +1,22 @@
1[package]
2name = "snowbound"
3version = "0.1.0"
4edition = "2024"
5publish = false
6
7[dependencies]
8canvas = { path = "../canvas", features = ["gpu"] }
9onestore = { path = "../onestore" }
10pollster = "0.4"
11wgpu.workspace = true
12parley.workspace = true
13winit = "0.30.13"
14arboard = { version = "3.6.1", default-features = false }
15accesskit = "0.25.0"
16accesskit_winit = { version = "0.34.0", default-features = false, features = ["rwh_06"] }
17objc2 = "0.5.2"
18objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSAlert", "NSButton", "NSControl", "NSView", "NSResponder", "NSWindow", "NSApplication", "NSEvent", "NSColor", "NSColorSpace", "NSDatePicker", "NSDatePickerCell"] }
19objc2-foundation = { version = "0.2.2", default-features = false, features = ["NSString", "NSThread", "NSAttributedString", "NSDate", "NSCalendar", "NSDateFormatter", "NSLocale", "NSTimeZone"] }
20
21[dev-dependencies]
22accesskit_consumer = "0.39.0"
crates/snowbound/src/accessibility.rs created+1467
...@@ -0,0 +1,1467 @@
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 text::EditError,
11};
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 };
370 let label = if tag.label.is_empty() {
371 fallback
372 } else {
373 &tag.label
374 };
375 let state = match tag.icon {
376 TagIcon::CheckBox { checked: true } => ", completed",
377 TagIcon::CheckBox { checked: false } => ", incomplete",
378 _ => "",
379 };
380 format!(
381 "{label}{state}{}",
382 if tag.disabled { ", disabled" } else { "" }
383 )
384 })
385 .collect::<Vec<_>>()
386 .join("; ");
387 runs[start].1.set_description(descriptions);
388 }
389 paragraph_ranges.insert(key, start..runs.len());
390 }
391 }
392 let mut runs: Vec<_> = runs
393 .into_iter()
394 .enumerate()
395 .map(|(index, (outline, mut node, positions))| {
396 node.clear_next_on_line();
397 node.clear_previous_on_line();
398 let id = match self.runs.get(index) {
399 Some(old)
400 if old.outline == outline
401 && old.positions == positions
402 && old.node.value() == node.value() =>
403 {
404 old.id
405 }
406 _ => self.allocate(),
407 };
408 Run {
409 outline,
410 id,
411 node,
412 positions,
413 }
414 })
415 .collect();
416 for index in 1..runs.len() {
417 if runs[index - 1].outline == runs[index].outline
418 && runs[index - 1].node.bounds().map(|r| r.y0)
419 == runs[index].node.bounds().map(|r| r.y0)
420 {
421 let (before, after) = runs.split_at_mut(index);
422 before[index - 1].node.set_next_on_line(after[0].id);
423 after[0].node.set_previous_on_line(before[index - 1].id);
424 }
425 }
426 self.runs = runs;
427 self.paragraphs = paragraph_ranges;
428 self.outlines = outlines;
429 let mut root = Node::new(Role::Window);
430 root.set_label(title);
431 root.set_children(self.outlines.iter().map(|(_, id)| *id).collect::<Vec<_>>());
432 root.set_bounds(Rect::new(
433 0.0,
434 0.0,
435 viewport.size[0] as f64,
436 viewport.size[1] as f64,
437 ));
438 let mut nodes = vec![(ROOT, root)];
439 let mut focus = ROOT;
440 for (index, (outline, (_, id))) in editor
441 .visible_outlines()
442 .chain(editor.caret_outline())
443 .zip(&self.outlines)
444 .enumerate()
445 {
446 let mut field = Node::new(Role::MultilineTextInput);
447 field.set_label(if outline.title {
448 "Page title".into()
449 } else if editor
450 .caret_outline()
451 .is_some_and(|caret| caret.id == outline.id)
452 {
453 "Text input".into()
454 } else {
455 format!("Text outline {}", index + 1)
456 });
457 field.add_action(Action::Focus);
458 field.add_action(Action::SetTextSelection);
459 field.add_action(Action::ReplaceSelectedText);
460 field.add_action(Action::SetValue);
461 let runs = self.runs.iter().filter(|run| run.outline == outline.id);
462 field.set_value(
463 runs.clone()
464 .filter_map(|run| run.node.value())
465 .collect::<String>(),
466 );
467 field.set_children(runs.map(|run| run.id).collect::<Vec<_>>());
468 let origin = preview
469 .filter(|(id, _)| *id == outline.id)
470 .map(|(_, origin)| origin)
471 .unwrap_or_else(|| outline.origin());
472 field.set_transform(Affine::new([
473 f64::from(viewport.scale),
474 0.0,
475 0.0,
476 f64::from(viewport.scale),
477 f64::from(viewport.origin[0]) + f64::from(origin[0]) * f64::from(viewport.scale),
478 f64::from(viewport.origin[1]) + f64::from(origin[1]) * f64::from(viewport.scale),
479 ]));
480 let bounds = outline.bounds();
481 field.set_bounds(Rect::new(0.0, 0.0, bounds.width(), bounds.height()));
482 if outline.id == editor.active_outline().id {
483 let Selection {
484 positions: [anchor, caret],
485 affinities,
486 } = editor.selection();
487 field.set_text_selection(TextSelection {
488 anchor: self.position(outline, anchor, affinities[0])?,
489 focus: self.position(outline, caret, affinities[1])?,
490 });
491 focus = *id;
492 }
493 nodes.push((*id, field));
494 }
495 nodes.extend(self.runs.iter().map(|run| (run.id, run.node.clone())));
496 Ok(TreeUpdate {
497 nodes,
498 tree: Some(TreeInfo::new(ROOT)),
499 tree_id: TreeId::ROOT,
500 focus,
501 })
502 }
503
504 fn position(
505 &self,
506 outline: &TextOutline,
507 mut position: TextPosition,
508 affinity: Affinity,
509 ) -> Result<accesskit::TextPosition, EditError> {
510 let shaped = outline.paragraph_layout(position.paragraph)?;
511 let projection = &shaped.projection;
512 let layout = &shaped.text;
513 let origin = shaped.origin;
514 let cursor = layout.cursor(
515 projection
516 .text()
517 .byte_offset(projection.visible_offset(position.offset)?)?,
518 affinity,
519 );
520 position.offset = projection.source_offset(
521 projection.text().utf16_offset(cursor.index())?,
522 Affinity::Downstream,
523 )?;
524 let caret = layout.caret(cursor, 0.0);
525 let mut positions = self
526 .runs
527 .iter()
528 .filter(|run| {
529 run.outline == outline.id
530 && run
531 .node
532 .bounds()
533 .is_some_and(|rect| rect.y0 == f64::from(caret.y0 as f32 + origin[1]))
534 })
535 .filter_map(|run| {
536 run.positions
537 .iter()
538 .position(|p| *p == position)
539 .map(|character_index| accesskit::TextPosition {
540 node: run.id,
541 character_index,
542 })
543 });
544 match affinity {
545 Affinity::Upstream => positions.next(),
546 Affinity::Downstream => positions.next_back(),
547 }
548 .ok_or(EditError::InvalidRange)
549 }
550
551 pub fn selection(
552 &self,
553 outline: onestore::ExGuid,
554 selection: TextSelection,
555 ) -> Result<Selection, EditError> {
556 let position = |position: accesskit::TextPosition| {
557 let run = self
558 .runs
559 .iter()
560 .find(|run| run.outline == outline && run.id == position.node)
561 .ok_or(EditError::InvalidRange)?;
562 let source = *run
563 .positions
564 .get(position.character_index)
565 .ok_or(EditError::InvalidRange)?;
566 let affinity = if position.character_index > 0
567 && position.character_index + 1 == run.positions.len()
568 {
569 Affinity::Upstream
570 } else {
571 Affinity::Downstream
572 };
573 Ok::<_, EditError>((source, affinity))
574 };
575 let (anchor, anchor_affinity) = position(selection.anchor)?;
576 let (focus, focus_affinity) = position(selection.focus)?;
577 Ok(Selection {
578 positions: [anchor, focus],
579 affinities: [anchor_affinity, focus_affinity],
580 })
581 }
582}
583
584#[cfg(test)]
585mod tests {
586 use super::*;
587 use canvas::{document::TextDocument, layout::TextEngine, text::Paragraph};
588 use onestore::document::Format;
589
590 #[test]
591 fn title_flow_updates_read_only_hit_and_accessibility_bounds() {
592 use canvas::page::{Page, PageObject, Title, Unsupported};
593 let mut engine = TextEngine::default();
594 let mut title = TextOutline::new(
595 &mut engine,
596 TextDocument::new(vec![Paragraph::new(
597 "Header".into(),
598 Format {
599 font: Some("Arial".into()),
600 font_size: Some(8.0),
601 line_spacing: Some(20.0),
602 ..Default::default()
603 },
604 )])
605 .unwrap(),
606 468.0,
607 [0.0; 2],
608 )
609 .unwrap()
610 .snapshot();
611 title.title = true;
612 let page = Page {
613 title: "Header".into(),
614 created: None,
615 margin_origin: [36.0, 14.4],
616 definitions: Default::default(),
617 objects: vec![
618 PageObject::Title(Title {
619 id: Default::default(),
620 date: None,
621 layout: Default::default(),
622 outlines: vec![title],
623 }),
624 PageObject::Unsupported(Unsupported {
625 id: Default::default(),
626 jcid: 0xdead,
627 layout: onestore::document::Layout {
628 x: Some(120.0),
629 y: Some(80.0),
630 max_width: Some(200.0),
631 max_height: Some(60.0),
632 ..Default::default()
633 },
634 }),
635 ],
636 };
637 let (scene, mut editor) =
638 canvas::gpu::page::PageScene::from_page(page, &mut engine).unwrap();
639 let scene = (scene, [30.0, 40.0]);
640 let viewport = Viewport {
641 size: [800, 600],
642 origin: [10.0, -20.0],
643 scale: 2.0,
644 };
645 let mut access = Accessibility::default();
646 let mut identity = None;
647 for phase in 0..4 {
648 match phase {
649 1 => {
650 editor.select_all().unwrap();
651 editor.insert(&mut engine, "Header\u{000b}Second\u{000b}Third\u{000b}Fourth\u{000b}Fifth\u{000b}Sixth").unwrap();
652 }
653 2 => {
654 editor.undo(&mut engine).unwrap();
655 }
656 3 => {
657 editor.redo(&mut engine).unwrap();
658 }
659 _ => {}
660 }
661 let mut update = access.update(&editor, viewport, "Test", None).unwrap();
662 access.append_page_fields(&mut update, Some(&scene), &editor, viewport, Some(0));
663 let (id, node) = update
664 .nodes
665 .iter()
666 .find(|(_, node)| node.role() == Role::Label)
667 .unwrap();
668 if let Some(identity) = identity {
669 assert_eq!(*id, identity);
670 }
671 identity = Some(*id);
672 let y = if phase % 2 == 0 { 80.0 } else { 145.92 };
673 let expected = Rect::new(
674 310.0,
675 f64::from((y + 40.0) * 2.0 - 20.0),
676 710.0,
677 f64::from((y + 100.0) * 2.0 - 20.0),
678 );
679 let bounds = node.bounds().unwrap();
680 assert_eq!(bounds.x0, expected.x0);
681 assert_eq!(bounds.x1, expected.x1);
682 assert!((bounds.y0 - expected.y0).abs() < 0.001);
683 assert!((bounds.y1 - expected.y1).abs() < 0.001);
684 assert_eq!(
685 crate::page_hit_test(&editor, Some(&scene), [155.0, y + 45.0], 1.0),
686 Some(crate::Hit::ReadOnly(0))
687 );
688 if phase % 2 == 1 {
689 assert_ne!(
690 crate::page_hit_test(&editor, Some(&scene), [155.0, 125.0], 1.0),
691 Some(crate::Hit::ReadOnly(0))
692 );
693 }
694 }
695 }
696
697 #[test]
698 fn read_only_objects_keep_accessibility_identity_through_edits_and_view_changes() {
699 use canvas::gpu::page::PageScene;
700 use canvas::page::{Page, PageObject, Unsupported};
701 let page = Page {
702 created: None,
703 title: String::new(),
704 margin_origin: [0.0; 2],
705 definitions: Default::default(),
706 objects: vec![PageObject::Unsupported(Unsupported {
707 id: Default::default(),
708 jcid: 0xdead,
709 layout: onestore::document::Layout {
710 x: Some(10.0),
711 y: Some(20.0),
712 max_width: Some(200.0),
713 max_height: Some(60.0),
714 ..Default::default()
715 },
716 })],
717 };
718 let mut engine = TextEngine::default();
719 let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap();
720 let scene = (scene, [30.0, 40.0]);
721 let mut access = Accessibility::default();
722 let mut identity = None;
723 for scale in [1.0, 2.0, 0.5] {
724 editor.insert(&mut engine, "annotation ").unwrap();
725 let viewport = Viewport {
726 size: [800, 600],
727 origin: [-200.0, 10.0],
728 scale,
729 };
730 let mut update = access.update(&editor, viewport, "Test", None).unwrap();
731 access.append_page_fields(&mut update, Some(&scene), &editor, viewport, None);
732 let (id, node) = update
733 .nodes
734 .iter()
735 .find(|(_, node)| node.role() == Role::Label)
736 .unwrap();
737 if let Some(identity) = identity {
738 assert_eq!(*id, identity);
739 }
740 identity = Some(*id);
741 assert_eq!(node.value(), Some("Unsupported content\nRead-only"));
742 assert!(node.is_read_only());
743 assert!(node.supports_action(Action::Focus));
744 for action in [
745 Action::SetValue,
746 Action::ReplaceSelectedText,
747 Action::SetTextSelection,
748 ] {
749 assert!(!node.supports_action(action));
750 }
751 assert_eq!(
752 node.bounds(),
753 Some(Rect::new(
754 f64::from(40.0 * scale - 200.0),
755 f64::from(60.0 * scale + 10.0),
756 f64::from(240.0 * scale - 200.0),
757 f64::from(120.0 * scale + 10.0)
758 ))
759 );
760 assert_eq!(access.outline_for_node(*id), None);
761 let tree = accesskit_consumer::Tree::new(update, true);
762 assert_eq!(
763 tree.state().focus().unwrap().document_range().text(),
764 "annotation "
765 );
766 let mut focused = access.update(&editor, viewport, "Test", None).unwrap();
767 access.append_page_fields(&mut focused, Some(&scene), &editor, viewport, Some(0));
768 assert_eq!(focused.focus, identity.unwrap());
769 assert_eq!(access.read_only_for_node(focused.focus), Some(0));
770 let tree = accesskit_consumer::Tree::new(focused, true);
771 assert_eq!(
772 tree.state().focus().unwrap().value().as_deref(),
773 Some("Unsupported content\nRead-only")
774 );
775 editor.undo(&mut engine).unwrap();
776 }
777 access.deactivate();
778 assert!(access.read_only.is_empty());
779 }
780
781 #[test]
782 fn tag_descriptions_preserve_plain_text_and_survive_edit_undo_and_cache_reuse() {
783 use canvas::page::{Definition, Outline};
784 use onestore::{
785 ExGuid,
786 document::{Kind, Layout, Tag},
787 };
788 use std::collections::BTreeMap;
789 let text = TextDocument::new(
790 ["task one", "task two", "question", "music"]
791 .into_iter()
792 .map(|text| Paragraph::new(text.into(), Format::default()))
793 .collect(),
794 )
795 .unwrap();
796 let mut nodes = text.nodes().to_vec();
797 let mut definitions = BTreeMap::new();
798 for (index, (shape, label, status)) in [
799 (3, Some("Rehearsal"), 0),
800 (3, Some("Rehearsal"), 1),
801 (15, None, 1),
802 (121, None, 3),
803 ]
804 .into_iter()
805 .enumerate()
806 {
807 let id = ExGuid {
808 n: index as u32 + 500,
809 ..ExGuid::default()
810 };
811 definitions.insert(
812 id,
813 Definition {
814 kind: Kind::TagDefinition {
815 label: label.map(str::to_owned),
816 action_type: None,
817 shape: Some(shape),
818 color: None,
819 highlight: None,
820 },
821 format: Format::default(),
822 },
823 );
824 nodes[index].text_mut().unwrap().tags.push(Tag {
825 definition: Some(id),
826 action_type: None,
827 status,
828 created: None,
829 completed: None,
830 start: None,
831 due: None,
832 task_id: None,
833 extra_set: 0,
834 });
835 }
836 let mut engine = TextEngine::default();
837 let mut editor = CanvasEditor::from_outlines(
838 &mut engine,
839 vec![Outline {
840 title: false,
841 min_width: None,
842 id: ExGuid {
843 n: 900,
844 ..ExGuid::default()
845 },
846 layout: Layout {
847 max_width: Some(120.0),
848 ..Layout::default()
849 },
850 indents: vec![18.0, 0.0],
851 paragraphs: nodes,
852 unsupported: Vec::new(),
853 }],
854 definitions,
855 )
856 .unwrap();
857 let mut access = Accessibility::default();
858 let viewport = Viewport {
859 size: [800, 600],
860 origin: [48.0; 2],
861 scale: 2.0,
862 };
863 for step in 0..4 {
864 if step == 2 {
865 editor.insert(&mut engine, "new ").unwrap();
866 }
867 if step == 3 {
868 editor.undo(&mut engine).unwrap();
869 }
870 let update = access.update(&editor, viewport, "Test", None).unwrap();
871 assert_eq!(
872 update
873 .nodes
874 .iter()
875 .filter_map(|(_, n)| n.description())
876 .collect::<Vec<_>>(),
877 [
878 "Rehearsal, incomplete",
879 "Rehearsal, completed",
880 "Question",
881 "Music, disabled"
882 ]
883 );
884 let tree = accesskit_consumer::Tree::new(update, true);
885 let text = tree.state().focus().unwrap().document_range().text();
886 assert_eq!(
887 text,
888 if step == 2 {
889 "new task one\ntask two\nquestion\nmusic"
890 } else {
891 "task one\ntask two\nquestion\nmusic"
892 }
893 );
894 }
895 }
896
897 #[test]
898 fn collapsed_paragraphs_keep_visible_accessibility_ranges_in_source_coordinates() {
899 let document = TextDocument::new(
900 [
901 "root",
902 "folded",
903 "hidden",
904 "last",
905 "folded tail",
906 "hidden tail",
907 ]
908 .into_iter()
909 .map(|text| Paragraph::new(text.into(), Format::default()))
910 .collect(),
911 )
912 .unwrap();
913 let mut nodes = document.nodes().to_vec();
914 for (child, parent, level) in [(1, 0, 2), (2, 1, 3), (4, 3, 2), (5, 4, 3)] {
915 nodes[child].parent = Some(nodes[parent].id);
916 nodes[child].level = level;
917 }
918 nodes[1].collapsed = true;
919 nodes[4].collapsed = true;
920 let mut engine = TextEngine::default();
921 let mut editor =
922 CanvasEditor::new(&mut engine, TextDocument::from_nodes(nodes).unwrap(), 180.0)
923 .unwrap();
924 let mut access = Accessibility::default();
925 let viewport = Viewport {
926 size: [900, 700],
927 scale: 2.0,
928 origin: [13.0, 19.0],
929 };
930 let update = access.update(&editor, viewport, "Test", None).unwrap();
931 let tree = accesskit_consumer::Tree::new(update, true);
932 let field = tree.state().focus().unwrap();
933 assert_eq!(
934 field.document_range().text(),
935 "root\nfolded\nlast\nfolded tail"
936 );
937 assert!(
938 access
939 .runs
940 .iter()
941 .flat_map(|run| &run.positions)
942 .all(|position| ![2, 5].contains(&position.paragraph))
943 );
944 let source = access
945 .selection(
946 editor.active_outline().id,
947 field.document_range().to_text_selection(),
948 )
949 .unwrap();
950 assert_eq!(
951 source.positions[0],
952 TextPosition {
953 paragraph: 0,
954 offset: 0
955 }
956 );
957 assert_eq!(
958 source.positions[1],
959 TextPosition {
960 paragraph: 4,
961 offset: 11
962 }
963 );
964 editor.select(source).unwrap();
965 let update = access.update(&editor, viewport, "Test", None).unwrap();
966 let tree = accesskit_consumer::Tree::new(update, true);
967 assert_eq!(
968 tree.state()
969 .focus()
970 .unwrap()
971 .text_selection()
972 .unwrap()
973 .text(),
974 "root\nfolded\nlast\nfolded tail"
975 );
976 editor
977 .select(
978 [TextPosition {
979 paragraph: 4,
980 offset: 0,
981 }; 2]
982 .into(),
983 )
984 .unwrap();
985 access.update(&editor, viewport, "Test", None).unwrap();
986 let caret = editor.caret(1.0).unwrap();
987 assert_eq!(caret.x0, 27.0);
988 let position = access
989 .position(
990 editor.active_outline(),
991 editor.selection().positions[1],
992 Affinity::Downstream,
993 )
994 .unwrap();
995 let run = access
996 .runs
997 .iter()
998 .find(|run| run.id == position.node)
999 .unwrap();
1000 assert_eq!(run.node.bounds().unwrap().x0, 27.0);
1001 assert_eq!(run.node.bounds().unwrap().y0, caret.y0);
1002 }
1003
1004 #[test]
1005 fn consumer_reads_unicode_blank_paragraphs_and_hidden_fields() {
1006 let cases = [
1007 "",
1008 "a\n\nשלום\nend",
1009 "café e\u{301} 🌳 👩‍👩‍👧‍👦 words wrap here",
1010 "\n\n",
1011 "a\nb\n",
1012 ];
1013 let mut engine = TextEngine::default();
1014 for text in cases
1015 .into_iter()
1016 .map(str::to_owned)
1017 .chain([format!("a{}", "\u{301}".repeat(300)), "x".repeat(600)])
1018 {
1019 for width in [24.0, 480.0, 10000.0] {
1020 let document = TextDocument::new(
1021 text.split('\n')
1022 .map(|line| {
1023 Paragraph::from_runs([
1024 (
1025 String::from("secret"),
1026 Format {
1027 hidden: Some(true),
1028 ..Format::default()
1029 },
1030 ),
1031 (line.to_owned(), Format::default()),
1032 ])
1033 })
1034 .collect(),
1035 )
1036 .unwrap();
1037 let mut editor = CanvasEditor::new(&mut engine, document, width).unwrap();
1038 let mut access = Accessibility::default();
1039 let viewport = Viewport {
1040 size: [800, 600],
1041 origin: [48.0; 2],
1042 scale: 2.0,
1043 };
1044 let update = access.update(&editor, viewport, "Test", None).unwrap();
1045 for (_, node) in &update.nodes {
1046 if node.role() == Role::TextRun {
1047 assert_eq!(
1048 node.character_lengths()
1049 .iter()
1050 .map(|n| *n as usize)
1051 .sum::<usize>(),
1052 node.value().unwrap().len()
1053 );
1054 if let Some(positions) = node.character_positions() {
1055 assert_eq!(positions.len(), node.character_lengths().len());
1056 }
1057 if let Some(widths) = node.character_widths() {
1058 assert_eq!(widths.len(), node.character_lengths().len());
1059 }
1060 }
1061 }
1062 let tree = accesskit_consumer::Tree::new(update, true);
1063 let field = tree.state().focus().unwrap();
1064 assert_eq!(field.document_range().text(), text, "width {width}");
1065 let selection = field.document_range().to_text_selection();
1066 let source_selection = access
1067 .selection(editor.active_outline().id, selection)
1068 .unwrap();
1069 editor.select(source_selection).unwrap();
1070 let selected = access.update(&editor, viewport, "Test", None).unwrap();
1071 let tree = accesskit_consumer::Tree::new(selected, true);
1072 assert_eq!(
1073 tree.state()
1074 .focus()
1075 .unwrap()
1076 .text_selection()
1077 .unwrap()
1078 .text(),
1079 text
1080 );
1081 let stable_ids: Vec<_> = access.runs.iter().map(|run| run.id).collect();
1082 access
1083 .update(
1084 &editor,
1085 Viewport {
1086 scale: 3.0,
1087 ..viewport
1088 },
1089 "Test",
1090 None,
1091 )
1092 .unwrap();
1093 assert_eq!(
1094 stable_ids,
1095 access.runs.iter().map(|run| run.id).collect::<Vec<_>>()
1096 );
1097 access.deactivate();
1098 access.update(&editor, viewport, "Test", None).unwrap();
1099 assert!(
1100 access
1101 .selection(editor.active_outline().id, selection)
1102 .is_err()
1103 );
1104 let selection = access
1105 .update(&editor, viewport, "Test", None)
1106 .unwrap()
1107 .nodes[1]
1108 .1
1109 .text_selection()
1110 .copied()
1111 .unwrap();
1112 editor.insert(&mut engine, "replacement").unwrap();
1113 access.update(&editor, viewport, "Test", None).unwrap();
1114 assert!(
1115 access
1116 .selection(editor.active_outline().id, selection)
1117 .is_err()
1118 );
1119 editor.undo(&mut engine).unwrap();
1120 let update = access.update(&editor, viewport, "Test", None).unwrap();
1121 let tree = accesskit_consumer::Tree::new(update, true);
1122 assert_eq!(tree.state().focus().unwrap().document_range().text(), text);
1123 }
1124 }
1125 }
1126
1127 #[test]
1128 fn caret_geometry_uses_visible_offsets_and_canvas_line_metrics() {
1129 let mut engine = TextEngine::default();
1130 let document = TextDocument::new(vec![
1131 Paragraph::from_runs([
1132 ("abc ".into(), Format::default()),
1133 (
1134 "hidden field".into(),
1135 Format {
1136 hidden: Some(true),
1137 ..Format::default()
1138 },
1139 ),
1140 ("e\u{301} 🌳 words wrap here".into(), Format::default()),
1141 ]),
1142 Paragraph::new("".into(), Format::default()),
1143 Paragraph::new("abc שלום def عالم end".into(), Format::default()),
1144 Paragraph::new("last\nline\n".into(), Format::default()),
1145 ])
1146 .unwrap();
1147 let positions: Vec<_> = document
1148 .paragraphs()
1149 .enumerate()
1150 .flat_map(|(paragraph, text)| {
1151 text.text()
1152 .char_indices()
1153 .map(|(byte, _)| text.utf16_offset(byte).unwrap())
1154 .chain(std::iter::once(
1155 text.utf16_offset(text.text().len()).unwrap(),
1156 ))
1157 .map(move |offset| TextPosition { paragraph, offset })
1158 })
1159 .collect();
1160 let mut editor = CanvasEditor::new(&mut engine, document, 48.0).unwrap();
1161 let mut access = Accessibility::default();
1162 let viewport = Viewport {
1163 size: [800, 600],
1164 origin: [31.0, -57.0],
1165 scale: 2.5,
1166 };
1167 for (position, affinity) in positions.into_iter().flat_map(|position| {
1168 [Affinity::Upstream, Affinity::Downstream].map(|affinity| (position, affinity))
1169 }) {
1170 editor
1171 .select(Selection {
1172 positions: [position; 2],
1173 affinities: [affinity; 2],
1174 })
1175 .unwrap();
1176 let caret = editor.caret(0.0).unwrap();
1177 let update = access.update(&editor, viewport, "Test", None).unwrap();
1178 let tree = accesskit_consumer::Tree::new(update, true);
1179 let field = tree.state().focus().unwrap();
1180 assert_eq!(
1181 field.document_range().text(),
1182 "abc e\u{301} 🌳 words wrap here\n\nabc שלום def عالم end\nlast\nline\n"
1183 );
1184 let rects = field.text_selection().unwrap().bounding_boxes();
1185 assert_eq!(rects.len(), 1, "{position:?}");
1186 let rect = rects[0];
1187 for (actual, expected) in [
1188 (rect.x0, caret.x0 * 2.5 + 31.0),
1189 (rect.y0, caret.y0 * 2.5 - 57.0),
1190 (rect.y1, caret.y1 * 2.5 - 57.0),
1191 ] {
1192 assert!(
1193 (actual - expected).abs() < 0.0001,
1194 "{position:?} {affinity:?}: {rect:?} vs {caret:?}"
1195 );
1196 }
1197 let selection = access
1198 .selection(
1199 editor.active_outline().id,
1200 field.text_selection().unwrap().to_text_selection(),
1201 )
1202 .unwrap();
1203 editor.select(selection).unwrap();
1204 assert_eq!(
1205 editor.caret(0.0).unwrap(),
1206 caret,
1207 "{position:?} {affinity:?}: AX selection moved the caret"
1208 );
1209 }
1210 }
1211 #[test]
1212 fn keyboard_and_accessibility_preserve_visual_caret_at_wraps() {
1213 use canvas::editor::Movement;
1214 let mut engine = TextEngine::default();
1215 let document = TextDocument::new(vec![
1216 Paragraph::new(
1217 "first words wrap here and continue".into(),
1218 Format::default(),
1219 ),
1220 Paragraph::new("abc שלום def عالم end".into(), Format::default()),
1221 Paragraph::new("last\nline".into(), Format::default()),
1222 ])
1223 .unwrap();
1224 let mut editor = CanvasEditor::new(&mut engine, document, 72.0).unwrap();
1225 let mut access = Accessibility::default();
1226 let viewport = Viewport {
1227 size: [800, 600],
1228 origin: [0.0; 2],
1229 scale: 1.0,
1230 };
1231 for movement in [
1232 Movement::LineEnd,
1233 Movement::Right,
1234 Movement::Left,
1235 Movement::Down,
1236 Movement::LineStart,
1237 Movement::LineEnd,
1238 Movement::Down,
1239 Movement::Down,
1240 Movement::Down,
1241 Movement::LineEnd,
1242 Movement::Left,
1243 Movement::Left,
1244 Movement::Right,
1245 Movement::LineStart,
1246 Movement::Up,
1247 Movement::Right,
1248 Movement::LineEnd,
1249 ] {
1250 editor.move_selection(&mut engine, movement, false).unwrap();
1251 let caret = editor.caret(0.0).unwrap();
1252 let tree = accesskit_consumer::Tree::new(
1253 access.update(&editor, viewport, "Test", None).unwrap(),
1254 true,
1255 );
1256 let field = tree.state().focus().unwrap();
1257 let selection = field.text_selection().unwrap();
1258 let rects = selection.bounding_boxes();
1259 assert_eq!(rects.len(), 1);
1260 let rect = rects[0];
1261 for (actual, expected) in [
1262 (rect.x0, caret.x0),
1263 (rect.y0, caret.y0),
1264 (rect.y1, caret.y1),
1265 ] {
1266 assert!(
1267 (actual - expected).abs() < 0.0001,
1268 "{movement:?}: {rect:?} vs {caret:?}"
1269 );
1270 }
1271 let source_selection = access
1272 .selection(editor.active_outline().id, selection.to_text_selection())
1273 .unwrap();
1274 editor.select(source_selection).unwrap();
1275 let restored = editor.caret(0.0).unwrap();
1276 assert_eq!(
1277 restored, caret,
1278 "{movement:?}: AX selection moved the caret"
1279 );
1280 }
1281 }
1282 #[test]
1283 fn cached_runs_match_reconstruction_through_editing_and_view_changes() {
1284 let mut engine = TextEngine::default();
1285 let document = TextDocument::new(
1286 [
1287 "one two three",
1288 "",
1289 "אבג 日本 👩🏽‍💻 e\u{301}",
1290 "last paragraph",
1291 ]
1292 .into_iter()
1293 .map(|text| Paragraph::new(text.into(), Format::default()))
1294 .collect(),
1295 )
1296 .unwrap();
1297 let mut editor = CanvasEditor::new(&mut engine, document, 72.0).unwrap();
1298 let mut cached = Accessibility::default();
1299 let mut rebuilt = Accessibility::default();
1300 let mut compare = |editor: &CanvasEditor, step: usize| {
1301 let viewport = Viewport {
1302 size: [800, 600],
1303 origin: [step as f32 * 0.125, -17.25],
1304 scale: 0.75 + (step % 5) as f32 * 0.5,
1305 };
1306 let preview = step
1307 .is_multiple_of(2)
1308 .then_some((editor.active_outline().id, [13.5, -7.25]));
1309 for repeat in 0..2 {
1310 rebuilt.paragraphs.clear();
1311 let actual = cached.update(editor, viewport, "Test", preview).unwrap();
1312 let expected = rebuilt.update(editor, viewport, "Test", preview).unwrap();
1313 assert_eq!(actual.nodes, expected.nodes, "step {step}, repeat {repeat}");
1314 assert_eq!(actual.focus, expected.focus);
1315 let tree = accesskit_consumer::Tree::new(actual, true);
1316 let field = tree.state().focus().unwrap();
1317 let selection = field.document_range().to_text_selection();
1318 assert_eq!(
1319 cached
1320 .selection(editor.active_outline().id, selection)
1321 .unwrap(),
1322 rebuilt
1323 .selection(editor.active_outline().id, selection)
1324 .unwrap()
1325 );
1326 let count = editor.outlines().iter().map(|o| o.layouts().count()).sum();
1327 assert_eq!(cached.paragraphs.len(), count);
1328 }
1329 };
1330 compare(&editor, 0);
1331 let mut seed = 0x923e_u64;
1332 let mut next = || {
1333 seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
1334 (seed >> 32) as usize
1335 };
1336 for step in 1..=160 {
1337 let id = editor.outlines()[next() % editor.outlines().len()].id;
1338 editor.focus_outline(id).unwrap();
1339 let positions: Vec<_> = editor
1340 .active_outline()
1341 .document()
1342 .paragraphs()
1343 .enumerate()
1344 .flat_map(|(paragraph, text)| {
1345 (0..=text.text().len()).filter_map(move |byte| {
1346 text.utf16_offset(byte)
1347 .ok()
1348 .map(|offset| TextPosition { paragraph, offset })
1349 })
1350 })
1351 .collect();
1352 let anchor = positions[next() % positions.len()];
1353 let focus = if step % 3 == 0 {
1354 positions[next() % positions.len()]
1355 } else {
1356 anchor
1357 };
1358 editor.select([anchor, focus].into()).unwrap();
1359 match step % 10 {
1360 0 => editor.insert(&mut engine, "\n").unwrap(),
1361 1 => editor.insert(&mut engine, "abc 👩🏽‍💻 שלום e\u{301}").unwrap(),
1362 2 => {
1363 editor.delete(&mut engine, true).unwrap();
1364 }
1365 3 => editor
1366 .resize(&mut engine, 48.0 + (step % 7) as f32 * 31.25)
1367 .unwrap(),
1368 4 => {
1369 editor
1370 .create_outline(&mut engine, [step as f32, -5.0], 97.0)
1371 .unwrap();
1372 editor.insert(&mut engine, "annotation").unwrap();
1373 }
1374 5 => {
1375 editor.undo(&mut engine).unwrap();
1376 }
1377 6 => {
1378 editor.redo(&mut engine).unwrap();
1379 }
1380 7 => editor.move_outline(id, [17.5, step as f32]).unwrap(),
1381 8 => {
1382 editor.compose(&mut engine, "仮\n名".into(), 1..1).unwrap();
1383 compare(&editor, step);
1384 editor.cancel_composition(&mut engine).unwrap();
1385 }
1386 _ => {}
1387 }
1388 compare(&editor, step);
1389 }
1390 while editor.undo(&mut engine).unwrap() {
1391 compare(&editor, 161);
1392 }
1393 while editor.redo(&mut engine).unwrap() {
1394 compare(&editor, 162);
1395 }
1396 }
1397 #[test]
1398 fn outlines_have_independent_accessibility_identity_and_selection() {
1399 let mut engine = TextEngine::default();
1400 let document =
1401 TextDocument::new(vec![Paragraph::new("lyrics".into(), Format::default())]).unwrap();
1402 let mut editor = CanvasEditor::new(&mut engine, document, 240.0).unwrap();
1403 let first = editor.active_outline().id;
1404 let second = editor
1405 .create_outline(&mut engine, [300.0, 40.0], 120.0)
1406 .unwrap();
1407 editor.insert(&mut engine, "annotation 🌳").unwrap();
1408 let mut access = Accessibility::default();
1409 let viewport = Viewport {
1410 size: [1000, 720],
1411 scale: 2.0,
1412 origin: [48.0; 2],
1413 };
1414 let update = access.update(&editor, viewport, "Test", None).unwrap();
1415 let fields: Vec<_> = update
1416 .nodes
1417 .iter()
1418 .filter(|(_, node)| node.role() == Role::MultilineTextInput)
1419 .map(|(id, node)| (*id, node.value().unwrap().to_owned()))
1420 .collect();
1421 assert_eq!(
1422 fields
1423 .iter()
1424 .map(|(_, text)| text.as_str())
1425 .collect::<Vec<_>>(),
1426 ["lyrics", "annotation 🌳"]
1427 );
1428 assert_ne!(fields[0].0, fields[1].0);
1429 assert_eq!(access.outline_for_node(fields[0].0), Some(first));
1430 assert_eq!(access.outline_for_node(fields[1].0), Some(second));
1431 let tree = accesskit_consumer::Tree::new(update, true);
1432 let field = tree.state().focus().unwrap();
1433 assert_eq!(field.document_range().text(), "annotation 🌳");
1434 let selection = field.document_range().to_text_selection();
1435 assert!(access.selection(first, selection).is_err());
1436 let source_selection = access.selection(second, selection).unwrap();
1437 editor.select(source_selection).unwrap();
1438 let preview = access
1439 .update(&editor, viewport, "Test", Some((second, [400.0, 50.0])))
1440 .unwrap();
1441 let preview_tree = accesskit_consumer::Tree::new(preview, true);
1442 let preview_field = preview_tree.state().focus().unwrap();
1443 let rect = preview_field.document_range().bounding_boxes()[0];
1444 assert_eq!(rect.x0, 848.0);
1445 assert_eq!(rect.y0, 148.0);
1446 assert_eq!(editor.active_outline().origin(), [300.0, 40.0]);
1447 editor.move_outline(second, [400.0, 50.0]).unwrap();
1448 access.update(&editor, viewport, "Test", None).unwrap();
1449 assert_eq!(access.outline_for_node(fields[1].0), Some(second));
1450 assert!(access.selection(second, selection).is_ok());
1451 editor.undo(&mut engine).unwrap();
1452 editor.undo(&mut engine).unwrap();
1453 editor.undo(&mut engine).unwrap();
1454 let update = access.update(&editor, viewport, "Test", None).unwrap();
1455 assert_eq!(
1456 update
1457 .nodes
1458 .iter()
1459 .filter(|(_, node)| node.role() == Role::MultilineTextInput)
1460 .count(),
1461 1
1462 );
1463 assert_eq!(access.outline_for_node(fields[1].0), None);
1464 assert!(access.selection(second, selection).is_err());
1465 assert_eq!(access.outline_for_node(fields[0].0), Some(first));
1466 }
1467}
crates/snowbound/src/macos.rs created+377
...@@ -0,0 +1,377 @@
1use canvas::date::DateField;
2use objc2::{
3 ClassType, DeclaredClass,
4 declare::ClassBuilder,
5 declare_class, msg_send, msg_send_id, mutability,
6 rc::{Allocated, Retained},
7 runtime::{AnyClass, AnyObject, Sel},
8 sel,
9};
10use objc2_app_kit::{
11 NSAlert, NSAlertFirstButtonReturn, NSAlertSecondButtonReturn, NSApplication, NSColor,
12 NSColorSpace, NSDatePicker, NSDatePickerElementFlags, NSDatePickerStyle, NSEvent, NSEventType,
13};
14use objc2_foundation::{
15 MainThreadMarker, NSAttributedString, NSCalendar, NSCalendarUnit, NSDate, NSDateFormatter,
16 NSDateFormatterStyle, NSRange, NSString,
17};
18use std::{cell::Cell, sync::OnceLock};
19use winit::{
20 error::EventLoopError,
21 event_loop::{EventLoop, EventLoopProxy},
22 raw_window_handle::{HasWindowHandle, RawWindowHandle},
23 window::Window,
24};
25
26static QUIT: OnceLock<EventLoopProxy<crate::UserEvent>> = OnceLock::new();
27static INPUT_CLASS: OnceLock<&'static AnyClass> = OnceLock::new();
28thread_local! { static IN_KEY_DOWN: Cell<bool> = const { Cell::new(false) }; }
29
30unsafe extern "C" fn key_down(view: &AnyObject, _: Sel, event: &NSEvent) {
31 let previous = IN_KEY_DOWN.replace(true);
32 unsafe {
33 let superclass = INPUT_CLASS.get().unwrap().superclass().unwrap();
34 let _: () = msg_send![super(view, superclass), keyDown: event];
35 }
36 IN_KEY_DOWN.set(previous);
37}
38
39unsafe extern "C" fn insert_text(view: &AnyObject, _: Sel, text: &AnyObject, range: NSRange) {
40 unsafe {
41 let marked: bool = msg_send![view, hasMarkedText];
42 if IN_KEY_DOWN.get() || marked {
43 let superclass = INPUT_CLASS.get().unwrap().superclass().unwrap();
44 let _: () =
45 msg_send![super(view, superclass), insertText: text replacementRange: range];
46 } else {
47 // NSTextInputClient supplies either NSString or NSAttributedString.
48 let attributed: bool = msg_send![text, isKindOfClass: NSAttributedString::class()];
49 let string: Retained<NSString> = if attributed {
50 msg_send_id![text, string]
51 } else {
52 msg_send_id![text, copy]
53 };
54 if let Some(proxy) = QUIT.get() {
55 let _ = proxy.send_event(crate::UserEvent::InsertText(string.to_string()));
56 }
57 }
58 }
59}
60
61/// Install before AccessKit subclasses the same view, preserving its restoration chain.
62pub fn install_text_input(window: &Window) {
63 MainThreadMarker::new().expect("Text input belongs to the main thread");
64 let RawWindowHandle::AppKit(handle) =
65 window.window_handle().expect("Live AppKit window").as_raw()
66 else {
67 unreachable!()
68 };
69 unsafe {
70 let view = &*handle.ns_view.as_ptr().cast::<AnyObject>();
71 let class = INPUT_CLASS.get_or_init(|| {
72 let mut class = ClassBuilder::new("SnowboundTextInputView", view.class())
73 .expect("Unique text input class");
74 class.add_method(sel!(keyDown:), key_down as unsafe extern "C" fn(_, _, _));
75 class.add_method(
76 sel!(insertText:replacementRange:),
77 insert_text as unsafe extern "C" fn(_, _, _, _),
78 );
79 class.register()
80 });
81 assert_eq!(class.superclass(), Some(view.class()));
82 assert_eq!(class.instance_size(), view.class().instance_size());
83 // The subclass adds no ivars, so the existing allocation remains valid.
84 AnyObject::set_class(view, class);
85 }
86}
87
88pub fn double_click_interval() -> std::time::Duration {
89 std::time::Duration::from_secs_f64(unsafe { NSEvent::doubleClickInterval() })
90}
91
92pub fn show_character_palette() {
93 let mtm = MainThreadMarker::new().expect("Text input belongs to the main thread");
94 NSApplication::sharedApplication(mtm).orderFrontCharacterPalette(None);
95}
96
97pub fn edit_date(
98 timestamp: u64,
99 field: DateField,
100) -> Result<Option<(u64, [String; 2])>, &'static str> {
101 let mtm = MainThreadMarker::new().expect("Date controls belong to the main thread");
102 unsafe {
103 let calendar = NSCalendar::currentCalendar();
104 let picker = NSDatePicker::new(mtm);
105 picker.setDatePickerStyle(NSDatePickerStyle::TextFieldAndStepper);
106 picker.setDatePickerElements(match field {
107 DateField::Date => NSDatePickerElementFlags::NSDatePickerElementFlagYearMonthDay,
108 DateField::Time => NSDatePickerElementFlags::NSDatePickerElementFlagHourMinute,
109 });
110 picker.setPresentsCalendarOverlay(true);
111 picker.setCalendar(Some(&calendar));
112 picker.setTimeZone(Some(&calendar.timeZone()));
113 picker.setDateValue(&NSDate::dateWithTimeIntervalSince1970(
114 (timestamp / 10_000_000) as f64 - 11_644_473_600.0,
115 ));
116 picker.setMinDate(Some(&NSDate::dateWithTimeIntervalSince1970(
117 -11_644_473_600.0,
118 )));
119 let label = NSString::from_str(crate::DATE_LABELS[field as usize]);
120 let _: () = msg_send![&picker, setAccessibilityLabel: &*label];
121 picker.sizeToFit();
122 let alert = NSAlert::new(mtm);
123 alert.setMessageText(&NSString::from_str(match field {
124 DateField::Date => "Change Page Date",
125 DateField::Time => "Change Page Time",
126 }));
127 alert.setAccessoryView(Some(&picker));
128 alert.addButtonWithTitle(&NSString::from_str("Apply"));
129 alert.addButtonWithTitle(&NSString::from_str("Cancel"));
130 alert.layout();
131 alert.window().makeFirstResponder(Some(&picker));
132 if alert.runModal() != NSAlertFirstButtonReturn {
133 return Ok(None);
134 }
135 alert.window().makeFirstResponder(None);
136 merge_date(timestamp, &picker.dateValue(), field, &calendar).map(Some)
137 }
138}
139
140fn merge_date(
141 timestamp: u64,
142 selected: &NSDate,
143 field: DateField,
144 calendar: &NSCalendar,
145) -> Result<(u64, [String; 2]), &'static str> {
146 unsafe {
147 let original = NSDate::dateWithTimeIntervalSince1970(
148 (timestamp / 10_000_000) as f64 - 11_644_473_600.0,
149 );
150 let units = NSCalendarUnit::Era
151 | NSCalendarUnit::Year
152 | NSCalendarUnit::Month
153 | NSCalendarUnit::Day
154 | NSCalendarUnit::Hour
155 | NSCalendarUnit::Minute
156 | NSCalendarUnit::Second;
157 let components = calendar.components_fromDate(units, &original);
158 let changed = calendar.components_fromDate(units, selected);
159 match field {
160 DateField::Date => {
161 components.setEra(changed.era());
162 components.setYear(changed.year());
163 components.setMonth(changed.month());
164 components.setDay(changed.day());
165 }
166 DateField::Time => {
167 components.setHour(changed.hour());
168 components.setMinute(changed.minute());
169 }
170 }
171 let date = calendar
172 .dateFromComponents(&components)
173 .ok_or("Choose another date or time.")?;
174 let seconds = date.timeIntervalSince1970() + 11_644_473_600.0;
175 let outside_range = "This date is outside the notebook's supported range.";
176 if !seconds.is_finite() || seconds < 0.0 || seconds > (u64::MAX / 10_000_000) as f64 {
177 return Err(outside_range);
178 }
179 // NSDate cannot retain FILETIME's subsecond precision at this epoch distance.
180 let updated = (seconds.round() as u64)
181 .checked_mul(10_000_000)
182 .and_then(|value| value.checked_add(timestamp % 10_000_000))
183 .ok_or(outside_range)?;
184 let formatter = NSDateFormatter::new();
185 formatter.setCalendar(Some(calendar));
186 formatter.setTimeZone(Some(&calendar.timeZone()));
187 formatter.setDateStyle(NSDateFormatterStyle::NSDateFormatterFullStyle);
188 let date_text = formatter.stringFromDate(&date).to_string();
189 formatter.setDateStyle(NSDateFormatterStyle::NSDateFormatterNoStyle);
190 formatter.setTimeStyle(NSDateFormatterStyle::NSDateFormatterShortStyle);
191 Ok((
192 updated,
193 [date_text, formatter.stringFromDate(&date).to_string()],
194 ))
195 }
196}
197
198#[cfg(test)]
199mod date_tests {
200 use super::*;
201 use objc2_foundation::{NSCalendarIdentifierGregorian, NSTimeZone};
202
203 #[test]
204 fn calendar_changes_preserve_hidden_components_and_filetime_fraction() {
205 unsafe {
206 let calendar = NSCalendar::initWithCalendarIdentifier(
207 NSCalendar::alloc(),
208 NSCalendarIdentifierGregorian,
209 )
210 .unwrap();
211 calendar.setTimeZone(
212 &NSTimeZone::timeZoneWithName(&NSString::from_str("America/Los_Angeles")).unwrap(),
213 );
214 for (original, selected, field, expected) in [
215 (
216 1_788_786_864_u64,
217 1_789_023_599.0,
218 DateField::Date,
219 1_788_959_664_u64,
220 ),
221 (1_788_786_864, 978_413_702.0, DateField::Time, 1_788_842_124),
222 (
223 1_709_259_645,
224 1_740_772_800.0,
225 DateField::Date,
226 1_740_795_645,
227 ),
228 (
229 1_772_879_424,
230 1_772_996_400.0,
231 DateField::Date,
232 1_772_965_824,
233 ),
234 ] {
235 let ticks = (original + 11_644_473_600) * 10_000_000 + 9_123_456;
236 let (updated, text) = merge_date(
237 ticks,
238 &NSDate::dateWithTimeIntervalSince1970(selected),
239 field,
240 &calendar,
241 )
242 .unwrap();
243 assert_eq!(
244 updated,
245 (expected + 11_644_473_600) * 10_000_000 + 9_123_456
246 );
247 assert!(text.iter().all(|value| !value.is_empty()));
248 }
249 }
250 }
251}
252
253pub fn configure_presentation(surface: &wgpu::Surface<'_>) {
254 MainThreadMarker::new().expect("Layer presentation belongs to the main thread");
255 // Retain wgpu's surface ownership while synchronizing presentation with AppKit resize transactions.
256 if let Some(surface) = unsafe { surface.as_hal::<wgpu::hal::api::Metal>() } {
257 surface
258 .render_layer()
259 .lock()
260 .setPresentsWithTransaction(true);
261 }
262}
263
264pub fn text_colors() -> [[f32; 4]; 2] {
265 unsafe {
266 let space = NSColorSpace::sRGBColorSpace();
267 [
268 NSColor::textInsertionPointColor(),
269 NSColor::selectedTextBackgroundColor(),
270 ]
271 .map(|color| {
272 let color = color
273 .colorUsingColorSpace(&space)
274 .expect("System text colors convert to sRGB");
275 let [r, g, b] = [
276 color.redComponent(),
277 color.greenComponent(),
278 color.blueComponent(),
279 ]
280 .map(|value| {
281 let value = value as f32;
282 if value <= 0.04045 {
283 value / 12.92
284 } else {
285 ((value + 0.055) / 1.055).powf(2.4)
286 }
287 });
288 [r, g, b, color.alphaComponent() as f32]
289 })
290 }
291}
292
293declare_class!(
294 struct CanvasApplication;
295
296 unsafe impl ClassType for CanvasApplication {
297 type Super = NSApplication;
298 type Mutability = mutability::MainThreadOnly;
299 const NAME: &'static str = "SnowboundApplication";
300 }
301
302 impl DeclaredClass for CanvasApplication {
303 type Ivars = ();
304 }
305
306 unsafe impl CanvasApplication {
307 #[method_id(init)]
308 fn init(this: Allocated<Self>) -> Retained<Self> {
309 let this = this.set_ivars(());
310 unsafe { msg_send_id![super(this), init] }
311 }
312
313 #[method(terminate:)]
314 fn terminate(&self, _sender: Option<&AnyObject>) {
315 if let Some(proxy) = QUIT.get() { let _ = proxy.send_event(crate::UserEvent::Quit); }
316 }
317
318 // NSAlert consumes Escape before it reaches cancelOperation:.
319 #[method(sendEvent:)]
320 fn send_event(&self, event: &NSEvent) {
321 unsafe {
322 if event.r#type() == NSEventType::KeyDown
323 && self.modalWindow().is_some()
324 && event.charactersIgnoringModifiers().is_some_and(|text| text.to_string() == "\u{1b}")
325 {
326 self.stopModal();
327 } else {
328 let _: () = msg_send![super(self), sendEvent: event];
329 }
330 }
331 }
332 }
333);
334
335pub fn event_loop() -> Result<EventLoop<crate::UserEvent>, EventLoopError> {
336 MainThreadMarker::new().expect("AppKit must start on the main thread");
337 // Create the application's own subclass before Winit obtains the singleton.
338 let _: Retained<CanvasApplication> =
339 unsafe { msg_send_id![CanvasApplication::class(), sharedApplication] };
340 let event_loop = EventLoop::with_user_event().build()?;
341 QUIT.set(event_loop.create_proxy())
342 .expect("Only one application event loop is created");
343 Ok(event_loop)
344}
345
346pub fn discard_changes() -> bool {
347 let mtm = MainThreadMarker::new().expect("Window events run on the main thread");
348 // The alert and its strings stay on AppKit's main thread for the modal call.
349 unsafe {
350 let alert = NSAlert::new(mtm);
351 alert.setMessageText(&NSString::from_str("Discard this page?"));
352 alert.setInformativeText(&NSString::from_str(
353 "This temporary page has no saved copy. Closing it will discard your edits.",
354 ));
355 alert.addButtonWithTitle(&NSString::from_str("Keep Editing"));
356 alert.addButtonWithTitle(&NSString::from_str("Discard Changes"));
357 alert.runModal() == NSAlertSecondButtonReturn
358 }
359}
360
361/// End AppKit preedit after a canvas action commits or cancels the composition.
362pub fn clear_marked_text(window: &Window) {
363 MainThreadMarker::new().expect("Text input belongs to the main thread");
364 let RawWindowHandle::AppKit(handle) =
365 window.window_handle().expect("Live AppKit window").as_raw()
366 else {
367 unreachable!()
368 };
369 // Winit owns this view and implements the NSTextInputClient selectors.
370 unsafe {
371 let view = &*handle.ns_view.as_ptr().cast::<AnyObject>();
372 let marked: bool = msg_send![view, hasMarkedText];
373 if marked {
374 let _: () = msg_send![view, unmarkText];
375 }
376 }
377}
crates/snowbound/src/main.rs created+3126
...@@ -0,0 +1,3126 @@
1mod accessibility;
2mod macos;
3#[cfg(test)]
4mod profile;
5mod scroll;
6
7use canvas::gpu::{Primitive, Renderer, Stroke, Viewport, page::PageScene};
8use canvas::{
9 date::DateField,
10 document::TextDocument,
11 editor::{
12 CanvasEditor, DEFAULT_OUTLINE_WIDTH, Movement, Selection, SelectionUnit, TextOutline,
13 },
14 layout::TextEngine,
15 page::Page,
16 text::Paragraph,
17};
18use onestore::document::Format;
19use std::{
20 error::Error,
21 path::PathBuf,
22 sync::Arc,
23 time::{Duration, Instant},
24};
25use winit::{
26 application::ApplicationHandler,
27 dpi::{LogicalSize, PhysicalPosition, PhysicalSize},
28 event::{ElementState, Ime, MouseButton, MouseScrollDelta, WindowEvent},
29 event_loop::{ActiveEventLoop, ControlFlow, EventLoopProxy},
30 keyboard::{Key, ModifiersState, NamedKey},
31 window::{Window, WindowId},
32};
33
34const HANDLE_HEIGHT: f32 = 6.75;
35const DATE_LABELS: [&str; 2] = ["Page date", "Page time"];
36
37#[derive(Debug)]
38enum UserEvent {
39 Quit,
40 InsertText(String),
41 Accessibility(accesskit_winit::Event),
42}
43
44impl From<accesskit_winit::Event> for UserEvent {
45 fn from(event: accesskit_winit::Event) -> Self {
46 Self::Accessibility(event)
47 }
48}
49
50fn trace_input(event: &impl std::fmt::Debug) {
51 if std::env::var_os("SNOWBOUND_TRACE_INPUT").is_some() {
52 eprintln!("Input {:?}: {event:?}", std::time::SystemTime::now());
53 }
54}
55
56fn read_only_shortcut(key: &Key, modifiers: ModifiersState) -> bool {
57 key == &Key::Named(NamedKey::Escape)
58 || (modifiers.control_key() && key == &Key::Named(NamedKey::Tab))
59 || (modifiers.super_key()
60 && matches!(key, Key::Character(value) if matches!(value.as_str(), "+" | "=" | "-" | "0") || (modifiers.shift_key() && value.eq_ignore_ascii_case("n"))))
61}
62
63struct App {
64 proxy: EventLoopProxy<UserEvent>,
65 input: Option<Input>,
66 substitutes: Vec<PathBuf>,
67 state: Option<State>,
68 startup_error: Option<Box<dyn Error>>,
69}
70
71enum Input {
72 Notes {
73 document: TextDocument,
74 width: f32,
75 reference: Option<Page>,
76 },
77 Page(Page),
78}
79
80enum Drag {
81 Text {
82 anchor: Selection,
83 unit: SelectionUnit,
84 },
85 Scrollbar {
86 axis: usize,
87 grab: f32,
88 },
89 Resize {
90 outline: Option<Box<TextOutline>>,
91 grab: f32,
92 },
93 Outline {
94 id: onestore::ExGuid,
95 grab: [f32; 2],
96 pending_press: Option<[f32; 2]>,
97 },
98}
99
100#[derive(Clone, Copy)]
101enum PointerFeedback<'a> {
102 Hover(onestore::ExGuid),
103 Move(onestore::ExGuid, [f32; 2]),
104 Resize(&'a TextOutline),
105}
106
107struct State {
108 window: Arc<Window>,
109 instance: wgpu::Instance,
110 surface: wgpu::Surface<'static>,
111 config: wgpu::SurfaceConfiguration,
112 renderer: Renderer,
113 engine: TextEngine,
114 editor: CanvasEditor,
115 initial: Vec<(onestore::ExGuid, TextDocument)>,
116 initial_layouts: Vec<(onestore::ExGuid, onestore::document::Layout)>,
117 initial_date: Option<u64>,
118 scene: Option<(PageScene, [f32; 2])>,
119 viewport: Viewport,
120 display_scale: f32,
121 pointer: [f32; 2],
122 pointer_inside: bool,
123 last_click: Option<(Instant, [f32; 2], u8)>,
124 drag: Option<Drag>,
125 read_only_focus: Option<usize>,
126 modifiers: ModifiersState,
127 focused: bool,
128 occluded: bool,
129 caret: bool,
130 blink_at: Instant,
131 clipboard: arboard::Clipboard,
132 access_adapter: accesskit_winit::Adapter,
133 accessibility: accessibility::Accessibility,
134}
135
136impl State {
137 fn edit_date(&mut self, field: DateField) -> Result<(), Box<dyn Error>> {
138 let Some(date) = self.editor.date() else {
139 return Ok(());
140 };
141 let timestamp = date.timestamp();
142 self.editor.finish_composition();
143 macos::clear_marked_text(&self.window);
144 self.drag = None;
145 if let Some((timestamp, text)) = macos::edit_date(timestamp, field)? {
146 self.editor.change_date(&mut self.engine, timestamp, text)?;
147 }
148 self.changed()
149 }
150
151 async fn new(
152 event_loop: &ActiveEventLoop,
153 proxy: EventLoopProxy<UserEvent>,
154 input: Input,
155 substitutes: &[PathBuf],
156 ) -> Result<Self, Box<dyn Error>> {
157 let window = Arc::new(
158 event_loop.create_window(
159 Window::default_attributes()
160 .with_visible(false)
161 .with_title(match &input {
162 Input::Notes {
163 reference: Some(page),
164 ..
165 } => format!("{} · Reference and temporary notes", page.title),
166 Input::Page(page) => format!("{} · Temporary page", page.title),
167 Input::Notes {
168 reference: None, ..
169 } => "Untitled · Temporary page".into(),
170 })
171 .with_inner_size(LogicalSize::new(1000.0, 720.0)),
172 )?,
173 );
174 macos::install_text_input(&window);
175 let access_adapter =
176 accesskit_winit::Adapter::with_event_loop_proxy(event_loop, &window, proxy);
177 window.set_visible(true);
178 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_with_display_handle(
179 Box::new(window.clone()),
180 ));
181 let surface = instance.create_surface(window.clone())?;
182 macos::configure_presentation(&surface);
183 let adapter = instance
184 .request_adapter(&wgpu::RequestAdapterOptions {
185 compatible_surface: Some(&surface),
186 ..Default::default()
187 })
188 .await?;
189 let (device, queue) = adapter.request_device(&Default::default()).await?;
190 let size = window.inner_size();
191 let config = surface
192 .get_default_config(&adapter, size.width, size.height)
193 .ok_or("No supported canvas surface")?;
194 surface.configure(&device, &config);
195 let renderer = Renderer::new(device, queue, config.format);
196 let mut engine = TextEngine::default();
197 for path in substitutes {
198 let target = engine
199 .register_substitute(parley::fontique::Blob::new(Arc::new(std::fs::read(path)?)))?;
200 eprintln!("Using {} for {target}", path.display());
201 }
202 let (editor, scene) = match input {
203 Input::Notes {
204 document,
205 width,
206 reference,
207 } => {
208 let editor = CanvasEditor::new(&mut engine, document, width)?;
209 let scene = reference
210 .map(|page| {
211 PageScene::new(page, &mut engine).map(|scene| (scene, [width + 24.0, 0.0]))
212 })
213 .transpose()?;
214 (editor, scene)
215 }
216 Input::Page(page) => {
217 let (scene, editor) = PageScene::from_page(page, &mut engine)?;
218 (editor, Some((scene, [0.0; 2])))
219 }
220 };
221 let initial_date = editor.date().map(|date| date.timestamp());
222 let initial_layouts = editor
223 .object_layouts()
224 .map(|(id, layout)| (id, layout.clone()))
225 .collect();
226 let initial = editor
227 .outlines()
228 .iter()
229 .map(|outline| (outline.id, outline.document().clone()))
230 .collect();
231 let dpr = window.scale_factor() as f32;
232 window.set_ime_allowed(true);
233 window.set_cursor(winit::window::CursorIcon::Text);
234 window.request_redraw();
235 eprintln!("Canvas GPU: {:?}; scale factor {dpr}", adapter.get_info());
236 Ok(Self {
237 window,
238 instance,
239 surface,
240 config,
241 renderer,
242 engine,
243 editor,
244 initial,
245 initial_date,
246 initial_layouts,
247 scene,
248 viewport: Viewport {
249 size: [size.width, size.height],
250 scale: dpr * 96.0 / 72.0,
251 origin: [48.0 * dpr; 2],
252 },
253 display_scale: dpr,
254 pointer: [0.0; 2],
255 pointer_inside: false,
256 last_click: None,
257 drag: None,
258 read_only_focus: None,
259 modifiers: ModifiersState::empty(),
260 focused: true,
261 occluded: false,
262 caret: true,
263 blink_at: Instant::now() + Duration::from_millis(500),
264 clipboard: arboard::Clipboard::new()?,
265 access_adapter,
266 accessibility: accessibility::Accessibility::default(),
267 })
268 }
269
270 fn preview(&self) -> Option<(onestore::ExGuid, [f32; 2])> {
271 let Drag::Outline {
272 id,
273 grab,
274 pending_press,
275 } = self.drag.as_ref()?
276 else {
277 return None;
278 };
279 let origin = self
280 .editor
281 .outlines()
282 .iter()
283 .find(|outline| outline.id == *id)?
284 .origin();
285 if pending_press.is_some() {
286 return Some((*id, origin));
287 }
288 let point = self.viewport.document_point(self.pointer);
289 let position = [point[0] - grab[0], point[1] - grab[1]];
290 Some((
291 *id,
292 if self.modifiers.alt_key() {
293 position
294 } else {
295 snap_to_grid(position)
296 },
297 ))
298 }
299
300 fn set_read_only_focus(&mut self, index: Option<usize>) {
301 if self.read_only_focus != index {
302 self.editor.finish_composition();
303 self.drag = None;
304 self.read_only_focus = index;
305 self.window.set_ime_allowed(index.is_none());
306 }
307 }
308
309 fn changed(&mut self) -> Result<(), Box<dyn Error>> {
310 self.scroll().clamp(&mut self.viewport);
311 self.caret = true;
312 self.blink_at = Instant::now() + Duration::from_millis(500);
313 let mut rect = self.editor.caret(1.0)?;
314 let origin = self
315 .preview()
316 .map(|(_, origin)| origin)
317 .unwrap_or_else(|| self.editor.active_outline().origin());
318 rect.x0 += f64::from(origin[0]);
319 rect.y0 += f64::from(origin[1]);
320 rect.x1 += f64::from(origin[0]);
321 rect.y1 += f64::from(origin[1]);
322 let v = self.viewport;
323 self.window.set_ime_cursor_area(
324 PhysicalPosition::new(
325 rect.x0 * f64::from(v.scale) + f64::from(v.origin[0]),
326 rect.y0 * f64::from(v.scale) + f64::from(v.origin[1]),
327 ),
328 PhysicalSize::new(
329 (rect.width() * f64::from(v.scale)).max(1.0),
330 (rect.height() * f64::from(v.scale)).max(1.0),
331 ),
332 );
333 self.update_accessibility()?;
334 if self.editor.active_outline().title {
335 let title = self
336 .editor
337 .active_outline()
338 .document()
339 .paragraphs()
340 .next()
341 .unwrap()
342 .text()
343 .split(['\u{000b}', '\n', '\r'])
344 .next()
345 .unwrap();
346 let title = if title.trim().is_empty() {
347 "Untitled"
348 } else {
349 title
350 };
351 let title = format!("{title} · Temporary page");
352 if self.window.title() != title {
353 self.window.set_title(&title);
354 }
355 }
356 self.window.request_redraw();
357 Ok(())
358 }
359
360 fn update_accessibility(&mut self) -> Result<(), Box<dyn Error>> {
361 let mut error = None;
362 let preview = self.preview();
363 self.access_adapter.update_if_active(|| {
364 match self.accessibility.update(
365 &self.editor,
366 self.viewport,
367 &self.window.title(),
368 preview,
369 ) {
370 Ok(mut update) => {
371 self.accessibility.append_page_fields(
372 &mut update,
373 self.scene.as_ref(),
374 &self.editor,
375 self.viewport,
376 self.read_only_focus,
377 );
378 update
379 }
380 Err(failure) => {
381 error = Some(failure);
382 self.accessibility.deactivate();
383 let mut root = accesskit::Node::new(accesskit::Role::Window);
384 root.set_label(self.window.title());
385 accesskit::TreeUpdate {
386 nodes: vec![(accessibility::ROOT, root)],
387 tree: Some(accesskit::TreeInfo::new(accessibility::ROOT)),
388 tree_id: accesskit::TreeId::ROOT,
389 focus: accessibility::ROOT,
390 }
391 }
392 }
393 });
394 if let Some(error) = error {
395 return Err(error.into());
396 }
397 Ok(())
398 }
399
400 fn access_action(&mut self, request: accesskit::ActionRequest) -> Result<(), Box<dyn Error>> {
401 trace_input(&request);
402 use accesskit::{Action, ActionData};
403 if request.target_tree != accesskit::TreeId::ROOT {
404 return Ok(());
405 }
406 if let Some(field) = self.accessibility.date_for_node(request.target_node) {
407 if request.action == Action::Click {
408 self.edit_date(field)?;
409 }
410 return Ok(());
411 }
412 if let Some(index) = self.accessibility.read_only_for_node(request.target_node) {
413 if request.action == Action::Focus {
414 self.set_read_only_focus(Some(index));
415 self.window.focus_window();
416 self.reveal_focus()?;
417 self.changed()?;
418 }
419 return Ok(());
420 }
421 let Some(outline) = self.accessibility.outline_for_node(request.target_node) else {
422 return Ok(());
423 };
424 match (request.action, request.data) {
425 (Action::Focus, _) => {
426 self.editor.focus_outline(outline)?;
427 self.window.focus_window();
428 }
429 (Action::SetTextSelection, Some(ActionData::SetTextSelection(selection))) => {
430 let selection = self.accessibility.selection(outline, selection)?;
431 self.editor.focus_outline(outline)?;
432 self.editor.select(selection)?;
433 trace_input(&self.editor.selection());
434 }
435 (Action::SetValue, Some(ActionData::Value(text))) => {
436 self.editor.focus_outline(outline)?;
437 self.editor.select_all()?;
438 self.editor.commit_text(&mut self.engine, text.into())?;
439 }
440 (Action::ReplaceSelectedText, Some(ActionData::Value(text))) => {
441 self.editor.focus_outline(outline)?;
442 self.editor.commit_text(&mut self.engine, text.into())?;
443 }
444 _ => return Ok(()),
445 }
446 self.set_read_only_focus(None);
447 self.reveal_focus()?;
448 self.changed()
449 }
450
451 fn reveal_focus(&mut self) -> Result<(), Box<dyn Error>> {
452 if self.viewport.size.contains(&0) {
453 return Ok(());
454 }
455 let rect = if let Some(index) = self.read_only_focus {
456 let (scene, offset) = self.scene.as_ref().unwrap();
457 let [x0, y0, x1, y1] = scene
458 .read_only(Some(&self.editor))
459 .nth(index)
460 .unwrap()
461 .rect();
462 parley::BoundingBox {
463 x0: f64::from(x0 + offset[0]),
464 y0: f64::from(y0 + offset[1]),
465 x1: f64::from(x1 + offset[0]),
466 y1: f64::from(y1 + offset[1]),
467 }
468 } else {
469 let mut rect = self.editor.caret(1.0)?;
470 let origin = self.editor.active_outline().origin();
471 rect.x0 += f64::from(origin[0]);
472 rect.x1 += f64::from(origin[0]);
473 rect.y0 += f64::from(origin[1]);
474 rect.y1 += f64::from(origin[1]);
475 rect
476 };
477 let outline = self.editor.active_outline().bounds();
478 for (axis, (mut start, mut end)) in [(rect.x0, rect.x1), (rect.y0, rect.y1)]
479 .into_iter()
480 .enumerate()
481 {
482 let size = f64::from(self.viewport.size[axis]);
483 let margin = f64::from(16.0 * self.display_scale).min(size * 0.25);
484 let (outline_start, outline_end) =
485 [(outline.x0, outline.x1), (outline.y0, outline.y1)][axis];
486 let outline_start = outline_start.min(start);
487 let outline_end = outline_end.max(end);
488 if self.read_only_focus.is_none()
489 && (outline_end - outline_start) * f64::from(self.viewport.scale)
490 <= size - margin * 2.0
491 {
492 start = outline_start;
493 end = outline_end;
494 }
495 let start =
496 start * f64::from(self.viewport.scale) + f64::from(self.viewport.origin[axis]);
497 let end = end * f64::from(self.viewport.scale) + f64::from(self.viewport.origin[axis]);
498 let shift = if start < margin {
499 margin - start
500 } else if end > size - margin {
501 size - margin - end
502 } else {
503 0.0
504 };
505 self.viewport.origin[axis] += shift as f32;
506 }
507 Ok(())
508 }
509
510 fn zoom(&mut self, factor: f32) {
511 let point = self.viewport.document_point(self.pointer);
512 let dpr = self.window.scale_factor() as f32;
513 self.viewport.scale = (self.viewport.scale * factor).clamp(dpr / 3.0, dpr * 16.0 / 3.0);
514 self.viewport.origin = [
515 self.pointer[0] - point[0] * self.viewport.scale,
516 self.pointer[1] - point[1] * self.viewport.scale,
517 ];
518 }
519
520 fn scroll(&self) -> scroll::Scroll {
521 let editable = self
522 .editor
523 .visible_outlines()
524 .chain(self.editor.caret_outline())
525 .map(|outline| {
526 let rect = outline.bounds();
527 let offset = self
528 .scene
529 .as_ref()
530 .filter(|_| self.editor.has_page_outline(outline.id))
531 .map(|(_, offset)| *offset)
532 .unwrap_or([0.0; 2]);
533 [
534 rect.x0 as f32 + offset[0],
535 rect.y0 as f32 + offset[1],
536 rect.x1 as f32 + offset[0],
537 rect.y1 as f32 + offset[1],
538 ]
539 });
540 let fixed = self.scene.iter().flat_map(|(scene, offset)| {
541 scene.content_bounds(&self.editor).map(|rect| {
542 [
543 rect[0] + offset[0],
544 rect[1] + offset[1],
545 rect[2] + offset[0],
546 rect[3] + offset[1],
547 ]
548 })
549 });
550 scroll::Scroll::new(self.viewport, editable.chain(fixed))
551 }
552
553 fn draw(&mut self) -> Result<(), Box<dyn Error>> {
554 trace_input(&("Draw", self.viewport.origin, self.viewport.scale));
555 if self.occluded || self.viewport.size.contains(&0) {
556 return Ok(());
557 }
558 let mut reconfigure = false;
559 let frame = match self.surface.get_current_texture() {
560 wgpu::CurrentSurfaceTexture::Success(frame) => frame,
561 wgpu::CurrentSurfaceTexture::Suboptimal(frame) => {
562 reconfigure = true;
563 frame
564 }
565 wgpu::CurrentSurfaceTexture::Outdated => {
566 self.surface.configure(&self.renderer.device, &self.config);
567 self.window.request_redraw();
568 return Ok(());
569 }
570 wgpu::CurrentSurfaceTexture::Lost => {
571 self.surface = self.instance.create_surface(self.window.clone())?;
572 macos::configure_presentation(&self.surface);
573 self.surface.configure(&self.renderer.device, &self.config);
574 self.window.request_redraw();
575 return Ok(());
576 }
577 wgpu::CurrentSurfaceTexture::Timeout => {
578 trace_input(&"Surface timeout");
579 return Ok(());
580 }
581 wgpu::CurrentSurfaceTexture::Occluded => {
582 trace_input(&"Surface occluded");
583 return Ok(());
584 }
585 wgpu::CurrentSurfaceTexture::Validation => {
586 return Err("Canvas surface validation failed".into());
587 }
588 };
589 let mut primitives = page_primitives(
590 &self.editor,
591 self.scene.as_ref(),
592 match &self.drag {
593 Some(Drag::Resize {
594 outline: Some(outline),
595 ..
596 }) => Some(PointerFeedback::Resize(outline)),
597 _ => self
598 .preview()
599 .map(|(id, origin)| PointerFeedback::Move(id, origin))
600 .or_else(|| {
601 if !self.pointer_inside {
602 return None;
603 }
604 match page_hit_test(
605 &self.editor,
606 self.scene.as_ref(),
607 self.viewport.document_point(self.pointer),
608 self.display_scale / self.viewport.scale,
609 ) {
610 Some(
611 Hit::Text { id, .. }
612 | Hit::Handle { id, .. }
613 | Hit::Resize { id, .. },
614 ) => Some(PointerFeedback::Hover(id)),
615 _ => None,
616 }
617 }),
618 },
619 self.read_only_focus,
620 self.caret
621 && self.focused
622 && !matches!(self.drag, Some(Drag::Outline { .. } | Drag::Resize { .. })),
623 self.viewport.scale,
624 self.display_scale,
625 )?;
626 self.scroll()
627 .append(self.viewport, self.display_scale, &mut primitives);
628 self.renderer
629 .draw(
630 &frame.texture.create_view(&Default::default()),
631 self.viewport,
632 &primitives,
633 )
634 .map_err(|error| format!("Canvas drawing failed: {error:?}"))?;
635 self.window.pre_present_notify();
636 self.renderer.queue.present(frame);
637 trace_input(&"Present submitted");
638 if reconfigure {
639 self.surface.configure(&self.renderer.device, &self.config);
640 }
641 Ok(())
642 }
643
644 fn key(&mut self, key: &Key, text: Option<&str>) -> Result<(), Box<dyn Error>> {
645 let shift = self.modifiers.shift_key();
646 let command = self.modifiers.super_key();
647 let option = self.modifiers.alt_key();
648 if self.read_only_focus.is_some() {
649 if !read_only_shortcut(key, self.modifiers) {
650 return Ok(());
651 }
652 if key == &Key::Named(NamedKey::Escape) {
653 self.set_read_only_focus(None);
654 self.reveal_focus()?;
655 return self.changed();
656 }
657 }
658 if command && self.modifiers.control_key() && key == &Key::Named(NamedKey::Space) {
659 macos::show_character_palette();
660 return Ok(());
661 }
662 if matches!(self.drag, Some(Drag::Outline { .. } | Drag::Resize { .. })) {
663 if matches!(
664 key,
665 Key::Named(
666 NamedKey::Alt
667 | NamedKey::AltGraph
668 | NamedKey::Control
669 | NamedKey::Shift
670 | NamedKey::Super
671 | NamedKey::Meta
672 )
673 ) {
674 return Ok(());
675 }
676 self.drag = None;
677 if key == &Key::Named(NamedKey::Escape) {
678 return self.changed();
679 }
680 }
681 if command && option {
682 let delta = match key {
683 Key::Named(NamedKey::ArrowLeft) => Some([-1.0, 0.0]),
684 Key::Named(NamedKey::ArrowRight) => Some([1.0, 0.0]),
685 Key::Named(NamedKey::ArrowUp) => Some([0.0, -1.0]),
686 Key::Named(NamedKey::ArrowDown) => Some([0.0, 1.0]),
687 _ => None,
688 };
689 if let Some(delta) = delta {
690 let outline = self.editor.active_outline();
691 let origin = outline.origin();
692 let step = if shift { 10.0 } else { 1.0 };
693 self.editor.move_outline(
694 outline.id,
695 [origin[0] + delta[0] * step, origin[1] + delta[1] * step],
696 )?;
697 self.reveal_focus()?;
698 return self.changed();
699 }
700 }
701 if self.modifiers.control_key() && key == &Key::Named(NamedKey::Tab) {
702 let outlines = self.editor.outlines();
703 let count = outlines.len()
704 + self
705 .scene
706 .as_ref()
707 .map_or(0, |(scene, _)| scene.read_only(Some(&self.editor)).count());
708 if count == 0 {
709 return self.changed();
710 }
711 let index = self.read_only_focus.map_or_else(
712 || {
713 outlines
714 .iter()
715 .position(|outline| outline.id == self.editor.active_outline().id)
716 },
717 |index| Some(outlines.len() + index),
718 );
719 let next = index.map_or(if shift { count - 1 } else { 0 }, |index| {
720 if shift {
721 (index + count - 1) % count
722 } else {
723 (index + 1) % count
724 }
725 });
726 if next < outlines.len() {
727 self.editor.focus_outline(outlines[next].id)?;
728 self.set_read_only_focus(None);
729 } else {
730 self.set_read_only_focus(Some(next - outlines.len()));
731 }
732 self.reveal_focus()?;
733 return self.changed();
734 }
735 if command && let Key::Character(key) = key {
736 match key.to_lowercase().as_str() {
737 "n" if shift => {
738 let position = if let Some(index) = self.read_only_focus {
739 let (scene, offset) = self.scene.as_ref().unwrap();
740 let rect = scene
741 .read_only(Some(&self.editor))
742 .nth(index)
743 .unwrap()
744 .rect();
745 [rect[2] + offset[0] + 24.0, rect[1] + offset[1]]
746 } else {
747 let bounds = self.editor.active_outline().bounds();
748 [bounds.x1 as f32 + 24.0, bounds.y0 as f32]
749 };
750 self.editor.place_caret(
751 &mut self.engine,
752 snap_to_grid(position),
753 DEFAULT_OUTLINE_WIDTH,
754 )?;
755 self.set_read_only_focus(None);
756 }
757 "a" => self.editor.select_all()?,
758 "z" => {
759 if shift {
760 self.editor.redo(&mut self.engine)?;
761 } else {
762 self.editor.undo(&mut self.engine)?;
763 }
764 }
765 "c" | "x" => {
766 let [anchor, focus] = self.editor.selection().positions;
767 let selected = self
768 .editor
769 .active_outline()
770 .document()
771 .slice(anchor.min(focus)..anchor.max(focus))?;
772 let text = selected
773 .iter()
774 .map(|paragraph| {
775 paragraph
776 .project()
777 .map(|projection| projection.text().text().to_owned())
778 })
779 .collect::<Result<Vec<_>, _>>()?
780 .join("\n");
781 if !text.is_empty() {
782 self.clipboard.set_text(text)?;
783 if key.eq_ignore_ascii_case("x") {
784 self.editor.insert(&mut self.engine, "")?;
785 }
786 }
787 }
788 "v" => {
789 let text = self.clipboard.get_text()?;
790 self.editor.commit_text(&mut self.engine, text)?;
791 }
792 "+" | "=" => {
793 self.zoom(1.1);
794 return self.changed();
795 }
796 "-" => {
797 self.zoom(1.0 / 1.1);
798 return self.changed();
799 }
800 "0" => {
801 self.zoom((self.display_scale * 96.0 / 72.0) / self.viewport.scale);
802 return self.changed();
803 }
804 _ => return Ok(()),
805 }
806 } else {
807 let movement = match key {
808 Key::Named(NamedKey::ArrowLeft) => Some(if command {
809 Movement::LineStart
810 } else if option {
811 Movement::WordLeft
812 } else {
813 Movement::Left
814 }),
815 Key::Named(NamedKey::ArrowRight) => Some(if command {
816 Movement::LineEnd
817 } else if option {
818 Movement::WordRight
819 } else {
820 Movement::Right
821 }),
822 Key::Named(NamedKey::ArrowUp) => Some(if command {
823 Movement::DocumentStart
824 } else if option {
825 Movement::ParagraphStart
826 } else {
827 Movement::Up
828 }),
829 Key::Named(NamedKey::ArrowDown) => Some(if command {
830 Movement::DocumentEnd
831 } else if option {
832 Movement::ParagraphEnd
833 } else {
834 Movement::Down
835 }),
836 Key::Named(NamedKey::Home) if shift => Some(Movement::DocumentStart),
837 Key::Named(NamedKey::End) if shift => Some(Movement::DocumentEnd),
838 Key::Character(key) if self.modifiers.control_key() && !option => {
839 match key.as_str() {
840 "a" => Some(Movement::LineStart),
841 "e" => Some(Movement::LineEnd),
842 "b" => Some(Movement::Left),
843 "f" => Some(Movement::Right),
844 "p" => Some(Movement::Up),
845 "n" => Some(Movement::Down),
846 _ => None,
847 }
848 }
849 _ => None,
850 };
851 if let Some(movement) = movement {
852 self.editor
853 .move_selection(&mut self.engine, movement, shift)?;
854 } else if self.editor.marked_range().is_none() {
855 match key {
856 Key::Named(NamedKey::Backspace) if command || option => {
857 self.editor.delete_to(
858 &mut self.engine,
859 if command {
860 Movement::LineStart
861 } else {
862 Movement::WordLeft
863 },
864 )?;
865 }
866 Key::Named(NamedKey::Delete) if command || option => {
867 self.editor.delete_to(
868 &mut self.engine,
869 if command {
870 Movement::LineEnd
871 } else {
872 Movement::WordRight
873 },
874 )?;
875 }
876 Key::Named(NamedKey::Backspace) => {
877 self.editor.delete(&mut self.engine, true)?;
878 }
879 Key::Named(NamedKey::Delete) => {
880 self.editor.delete(&mut self.engine, false)?;
881 }
882 Key::Named(NamedKey::Home | NamedKey::End) => {
883 let limits = self.scroll();
884 self.viewport.origin[1] = -if key == &Key::Named(NamedKey::Home) {
885 limits.min[1]
886 } else {
887 limits.max[1]
888 };
889 return self.changed();
890 }
891 Key::Character(key) if self.modifiers.control_key() && !option => {
892 match key.as_str() {
893 "h" => {
894 self.editor.delete(&mut self.engine, true)?;
895 }
896 "d" => {
897 self.editor.delete(&mut self.engine, false)?;
898 }
899 "k" if !self
900 .editor
901 .delete_to(&mut self.engine, Movement::LineEnd)? =>
902 {
903 self.editor.delete(&mut self.engine, false)?;
904 }
905 _ => {}
906 }
907 }
908 Key::Named(NamedKey::Enter) => {
909 self.editor.enter(&mut self.engine, shift)?;
910 }
911 Key::Named(NamedKey::Tab) => {
912 self.editor.tab(&mut self.engine, shift)?;
913 }
914 _ if !command && !self.modifiers.control_key() => {
915 if let Some(text) = text
916 .filter(|text| !text.is_empty() && !text.chars().any(char::is_control))
917 {
918 self.editor.insert(&mut self.engine, text)?;
919 }
920 }
921 _ => {}
922 }
923 } else if key == &Key::Named(NamedKey::Escape) {
924 self.editor.cancel_composition(&mut self.engine)?;
925 }
926 }
927 self.reveal_focus()?;
928 self.changed()
929 }
930}
931
932impl App {
933 fn close(&self, event_loop: &ActiveEventLoop) {
934 if self.state.as_ref().is_none_or(|state| {
935 state
936 .editor
937 .caret_outline()
938 .is_none_or(TextOutline::is_empty)
939 && state.initial_date == state.editor.date().map(|date| date.timestamp())
940 && state
941 .initial_layouts
942 .iter()
943 .map(|(id, layout)| (*id, layout))
944 .eq(state.editor.object_layouts())
945 && state
946 .initial
947 .iter()
948 .map(|(id, document)| (id, document))
949 .eq(state
950 .editor
951 .outlines()
952 .iter()
953 .map(|outline| (&outline.id, outline.document())))
954 }) || macos::discard_changes()
955 {
956 event_loop.exit();
957 }
958 }
959}
960
961impl ApplicationHandler<UserEvent> for App {
962 fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) {
963 let event = match event {
964 UserEvent::InsertText(text) => {
965 if let Some(state) = &mut self.state
966 && state.read_only_focus.is_none()
967 && !text.is_empty()
968 && !text.chars().any(char::is_control)
969 {
970 let result = (|| -> Result<(), Box<dyn Error>> {
971 state.editor.commit_text(&mut state.engine, text)?;
972 state.reveal_focus()?;
973 state.changed()
974 })();
975 if let Err(error) = result {
976 eprintln!("{error}");
977 }
978 }
979 return;
980 }
981 UserEvent::Quit => {
982 self.close(event_loop);
983 return;
984 }
985 UserEvent::Accessibility(event) => event,
986 };
987 let Some(state) = &mut self.state else {
988 return;
989 };
990 if event.window_id != state.window.id() {
991 return;
992 }
993 let was_marked = state.editor.marked_range().is_some();
994 let result = match event.window_event {
995 accesskit_winit::WindowEvent::InitialTreeRequested => state.update_accessibility(),
996 accesskit_winit::WindowEvent::ActionRequested(request) => state.access_action(request),
997 accesskit_winit::WindowEvent::AccessibilityDeactivated => {
998 state.accessibility.deactivate();
999 Ok(())
1000 }
1001 };
1002 if was_marked && state.editor.marked_range().is_none() {
1003 macos::clear_marked_text(&state.window);
1004 }
1005 if let Err(error) = result {
1006 eprintln!("{error}");
1007 }
1008 }
1009
1010 fn resumed(&mut self, event_loop: &ActiveEventLoop) {
1011 if self.state.is_some() {
1012 return;
1013 }
1014 match pollster::block_on(State::new(
1015 event_loop,
1016 self.proxy.clone(),
1017 self.input.take().unwrap(),
1018 &self.substitutes,
1019 )) {
1020 Ok(state) => self.state = Some(state),
1021 Err(error) => {
1022 self.startup_error = Some(error);
1023 event_loop.exit();
1024 }
1025 }
1026 }
1027
1028 fn window_event(&mut self, event_loop: &ActiveEventLoop, _: WindowId, event: WindowEvent) {
1029 if matches!(
1030 event,
1031 WindowEvent::KeyboardInput { .. }
1032 | WindowEvent::Ime(_)
1033 | WindowEvent::MouseInput { .. }
1034 | WindowEvent::CursorMoved { .. }
1035 | WindowEvent::ModifiersChanged(_)
1036 | WindowEvent::Focused(_)
1037 ) {
1038 trace_input(&event);
1039 }
1040 if let Some(state) = &mut self.state {
1041 state.access_adapter.process_event(&state.window, &event);
1042 }
1043 if matches!(event, WindowEvent::CloseRequested) {
1044 self.close(event_loop);
1045 return;
1046 }
1047 let Some(state) = &mut self.state else {
1048 return;
1049 };
1050 let was_marked =
1051 !matches!(event, WindowEvent::Ime(_)) && state.editor.marked_range().is_some();
1052 let result = (|| -> Result<(), Box<dyn Error>> {
1053 match event {
1054 WindowEvent::RedrawRequested => state.draw()?,
1055 WindowEvent::Resized(size) => {
1056 state.viewport.size = [size.width, size.height];
1057 if size.width > 0 && size.height > 0 {
1058 state.config.width = size.width;
1059 state.config.height = size.height;
1060 state
1061 .surface
1062 .configure(&state.renderer.device, &state.config);
1063 state.changed()?;
1064 }
1065 }
1066 WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
1067 let scale = scale_factor as f32;
1068 let ratio = scale / state.display_scale;
1069 state.viewport.scale *= ratio;
1070 state.viewport.origin[0] *= ratio;
1071 state.viewport.origin[1] *= ratio;
1072 state.display_scale = scale;
1073 state.renderer.clear_glyph_cache();
1074 state.changed()?;
1075 }
1076 WindowEvent::Focused(focused) => {
1077 state.focused = focused;
1078 if !focused {
1079 state.drag = None;
1080 }
1081 state.changed()?;
1082 }
1083 WindowEvent::Occluded(occluded) => {
1084 trace_input(&("Window occluded", occluded));
1085 state.occluded = occluded;
1086 if !occluded {
1087 state.changed()?;
1088 }
1089 }
1090 WindowEvent::ModifiersChanged(modifiers) => {
1091 state.modifiers = modifiers.state();
1092 if matches!(state.drag, Some(Drag::Outline { .. })) {
1093 state.changed()?;
1094 }
1095 }
1096 WindowEvent::CursorLeft { .. } => {
1097 state.pointer_inside = false;
1098 state.window.request_redraw();
1099 }
1100 WindowEvent::CursorMoved { position, .. } => {
1101 state.pointer_inside = true;
1102 state.pointer = [position.x as f32, position.y as f32];
1103 let hit = page_hit_test(
1104 &state.editor,
1105 state.scene.as_ref(),
1106 state.viewport.document_point(state.pointer),
1107 state.display_scale / state.viewport.scale,
1108 );
1109 let scrollbar = state
1110 .scroll()
1111 .hit_test(state.viewport, state.display_scale, state.pointer)
1112 .is_some();
1113 state.window.set_cursor(match (&state.drag, hit) {
1114 (Some(Drag::Scrollbar { .. }), _) => winit::window::CursorIcon::Default,
1115 (None, _) if scrollbar => winit::window::CursorIcon::Default,
1116 (Some(Drag::Resize { .. }), _) | (None, Some(Hit::Resize { .. })) => {
1117 winit::window::CursorIcon::EwResize
1118 }
1119 (Some(Drag::Outline { .. }), _) | (None, Some(Hit::Handle { .. })) => {
1120 winit::window::CursorIcon::Move
1121 }
1122 (None, Some(Hit::Date(_))) => winit::window::CursorIcon::Pointer,
1123 (None, Some(Hit::ReadOnly(_))) => winit::window::CursorIcon::Default,
1124 _ => winit::window::CursorIcon::Text,
1125 });
1126 match &mut state.drag {
1127 Some(Drag::Scrollbar { axis, grab }) => {
1128 let (axis, grab) = (*axis, *grab);
1129 state.scroll().drag(
1130 &mut state.viewport,
1131 state.display_scale,
1132 axis,
1133 state.pointer[axis],
1134 grab,
1135 );
1136 state.changed()?;
1137 }
1138 Some(Drag::Text { anchor, unit }) => {
1139 let point = state.viewport.document_point(state.pointer);
1140 let origin = state.editor.active_outline().origin();
1141 let target = state.editor.selection_at(
1142 point[0] - origin[0],
1143 point[1] - origin[1],
1144 *unit,
1145 )?;
1146 state
1147 .editor
1148 .select(drag_selection(*anchor, target, *unit))?;
1149 state.changed()?;
1150 }
1151 Some(Drag::Outline { pending_press, .. }) => {
1152 if pending_press.is_some_and(|press| {
1153 (0..2).any(|axis| {
1154 (state.pointer[axis] - press[axis]).abs()
1155 > 2.0 * state.display_scale
1156 })
1157 }) {
1158 *pending_press = None;
1159 }
1160 state.changed()?;
1161 }
1162 Some(Drag::Resize { outline, grab }) => {
1163 let point = state.viewport.document_point(state.pointer);
1164 let width =
1165 (point[0] - state.editor.active_outline().origin()[0] - *grab)
1166 .max(36.0);
1167 if outline.is_some()
1168 || (width - state.editor.active_outline().bounds().width() as f32)
1169 .abs()
1170 * state.viewport.scale
1171 > 2.0 * state.display_scale
1172 {
1173 *outline = Some(Box::new(
1174 state.editor.preview_resize(&mut state.engine, width)?,
1175 ));
1176 state.changed()?;
1177 }
1178 }
1179 None => state.window.request_redraw(),
1180 }
1181 }
1182 WindowEvent::MouseInput {
1183 state: button_state,
1184 button: MouseButton::Left,
1185 ..
1186 } => {
1187 let point = state.viewport.document_point(state.pointer);
1188 if button_state == ElementState::Pressed {
1189 let now = Instant::now();
1190 let count = state
1191 .last_click
1192 .filter(|(time, point, _)| {
1193 now.duration_since(*time) <= macos::double_click_interval()
1194 && (0..2).all(|axis| {
1195 (point[axis] - state.pointer[axis]).abs()
1196 <= 4.0 * state.display_scale
1197 })
1198 })
1199 .map_or(1, |(_, _, count)| (count % 3) + 1);
1200 state.last_click = Some((now, state.pointer, count));
1201 let scrollbar = state
1202 .scroll()
1203 .hit_test(state.viewport, state.display_scale, state.pointer)
1204 .map(|(axis, grab)| Drag::Scrollbar { axis, grab });
1205 if scrollbar.is_some() {
1206 state.drag = scrollbar;
1207 } else {
1208 match page_hit_test(
1209 &state.editor,
1210 state.scene.as_ref(),
1211 point,
1212 state.display_scale / state.viewport.scale,
1213 ) {
1214 Some(Hit::Date(field)) => state.edit_date(field)?,
1215 Some(Hit::ReadOnly(index)) => {
1216 state.set_read_only_focus(Some(index))
1217 }
1218 Some(Hit::Handle { id, grab }) => {
1219 state.set_read_only_focus(None);
1220 state.editor.focus_outline(id)?;
1221 state.drag = Some(Drag::Outline {
1222 id,
1223 grab,
1224 pending_press: Some(state.pointer),
1225 });
1226 }
1227 Some(Hit::Resize { id, grab }) => {
1228 state.set_read_only_focus(None);
1229 state.editor.focus_outline(id)?;
1230 state.drag = Some(Drag::Resize {
1231 outline: None,
1232 grab,
1233 });
1234 }
1235 Some(Hit::Text { id, point }) => {
1236 let extend = state.modifiers.shift_key()
1237 && state.read_only_focus.is_none()
1238 && id == state.editor.active_outline().id;
1239 state.set_read_only_focus(None);
1240 state.editor.focus_outline(id)?;
1241 let previous = state.editor.selection();
1242 state.editor.select_below(&mut state.engine, id, point)?;
1243 let unit = match count {
1244 2 => SelectionUnit::Word,
1245 3 => SelectionUnit::Paragraph,
1246 _ => SelectionUnit::Grapheme,
1247 };
1248 let selection =
1249 state.editor.selection_at(point[0], point[1], unit)?;
1250 let selection = if extend {
1251 Selection {
1252 positions: [
1253 previous.positions[0],
1254 selection.positions[1],
1255 ],
1256 affinities: [
1257 previous.affinities[0],
1258 selection.affinities[1],
1259 ],
1260 }
1261 } else {
1262 selection
1263 };
1264 state.editor.select(selection)?;
1265 state.drag = Some(Drag::Text {
1266 anchor: selection,
1267 unit,
1268 });
1269 }
1270 None => {
1271 let position = [
1272 point[0],
1273 point[1] - 7.0 * state.display_scale / state.viewport.scale,
1274 ];
1275 let position = if state.modifiers.alt_key() {
1276 position
1277 } else {
1278 snap_to_grid(position)
1279 };
1280 state.editor.place_caret(
1281 &mut state.engine,
1282 position,
1283 DEFAULT_OUTLINE_WIDTH,
1284 )?;
1285 state.set_read_only_focus(None);
1286 state.drag = Some(Drag::Text {
1287 anchor: state.editor.selection(),
1288 unit: SelectionUnit::Grapheme,
1289 });
1290 }
1291 }
1292 }
1293 } else {
1294 let preview = state.preview();
1295 if let Some(Drag::Resize {
1296 outline: Some(outline),
1297 ..
1298 }) = state.drag.take()
1299 {
1300 state
1301 .editor
1302 .resize(&mut state.engine, outline.bounds().width() as f32)?;
1303 }
1304 if let Some((id, origin)) = preview {
1305 state.editor.move_outline(id, origin)?;
1306 }
1307 }
1308 state.changed()?;
1309 }
1310 WindowEvent::MouseWheel { delta, .. } => {
1311 let dpr = state.window.scale_factor() as f32;
1312 let delta = match delta {
1313 MouseScrollDelta::LineDelta(x, y) => [x * 32.0 * dpr, y * 32.0 * dpr],
1314 MouseScrollDelta::PixelDelta(p) => [p.x as f32, p.y as f32],
1315 };
1316 if state.modifiers.super_key() {
1317 state.zoom((delta[1] * 0.005).exp());
1318 } else {
1319 state.viewport.origin[0] += delta[0];
1320 state.viewport.origin[1] += delta[1];
1321 }
1322 state.changed()?;
1323 }
1324 WindowEvent::KeyboardInput { event, .. }
1325 if event.state == ElementState::Pressed =>
1326 {
1327 state.key(&event.logical_key, event.text.as_deref())?;
1328 }
1329 WindowEvent::Ime(_) if state.read_only_focus.is_some() => {}
1330 WindowEvent::Ime(Ime::Preedit(text, cursor)) => {
1331 if text.is_empty() {
1332 state.editor.cancel_composition(&mut state.engine)?;
1333 } else {
1334 let (start, end) = cursor.unwrap_or((text.len(), text.len()));
1335 let start: u32 = text
1336 .get(..start)
1337 .ok_or("IME range splits UTF-8")?
1338 .encode_utf16()
1339 .count()
1340 .try_into()?;
1341 let end: u32 = text
1342 .get(..end)
1343 .ok_or("IME range splits UTF-8")?
1344 .encode_utf16()
1345 .count()
1346 .try_into()?;
1347 state.editor.compose(&mut state.engine, text, start..end)?;
1348 }
1349 state.reveal_focus()?;
1350 state.changed()?;
1351 }
1352 WindowEvent::Ime(Ime::Commit(text)) => {
1353 state.editor.commit_text(&mut state.engine, text)?;
1354 state.reveal_focus()?;
1355 state.changed()?;
1356 }
1357 WindowEvent::Ime(Ime::Disabled) => {
1358 state.editor.cancel_composition(&mut state.engine)?;
1359 state.reveal_focus()?;
1360 state.changed()?;
1361 }
1362 _ => {}
1363 }
1364 Ok(())
1365 })();
1366 if was_marked && state.editor.marked_range().is_none() {
1367 macos::clear_marked_text(&state.window);
1368 }
1369 if let Err(error) = result {
1370 eprintln!("{error}");
1371 }
1372 }
1373
1374 fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
1375 let Some(state) = &mut self.state else {
1376 return;
1377 };
1378 let [anchor, focus] = state.editor.selection().positions;
1379 if state.focused && !state.occluded && state.read_only_focus.is_none() && anchor == focus {
1380 let now = Instant::now();
1381 if now >= state.blink_at {
1382 state.caret = !state.caret;
1383 state.blink_at = now + Duration::from_millis(500);
1384 state.window.request_redraw();
1385 }
1386 event_loop.set_control_flow(ControlFlow::WaitUntil(state.blink_at));
1387 } else {
1388 event_loop.set_control_flow(ControlFlow::Wait);
1389 }
1390 }
1391}
1392
1393#[derive(Debug, PartialEq)]
1394enum Hit {
1395 Date(DateField),
1396 Resize {
1397 id: onestore::ExGuid,
1398 grab: f32,
1399 },
1400 Text {
1401 id: onestore::ExGuid,
1402 point: [f32; 2],
1403 },
1404 Handle {
1405 id: onestore::ExGuid,
1406 grab: [f32; 2],
1407 },
1408 ReadOnly(usize),
1409}
1410
1411fn page_hit_test(
1412 editor: &CanvasEditor,
1413 scene: Option<&(PageScene, [f32; 2])>,
1414 point: [f32; 2],
1415 pixel: f32,
1416) -> Option<Hit> {
1417 let hit = |outline: &TextOutline, offset: [f32; 2], below: bool| {
1418 if below {
1419 let outline = editor
1420 .outlines()
1421 .iter()
1422 .find(|source| source.id == outline.id)?;
1423 let local = [
1424 point[0] - offset[0] - outline.origin()[0],
1425 point[1] - offset[1] - outline.origin()[1],
1426 ];
1427 return outline.contains_extension(local).then_some(Hit::Text {
1428 id: outline.id,
1429 point: local,
1430 });
1431 }
1432 let (bounds, body_top) = outline_chrome(outline, pixel);
1433 let local = [point[0] - offset[0], point[1] - offset[1]];
1434 let [x, y] = local;
1435 if x >= bounds[0] && x <= bounds[2] && y >= bounds[1] && y < body_top {
1436 if x >= bounds[2] - 9.0 * pixel {
1437 return Some(Hit::Resize {
1438 id: outline.id,
1439 grab: point[0] - outline.bounds().x1 as f32,
1440 });
1441 }
1442 return Some(Hit::Handle {
1443 id: outline.id,
1444 grab: [
1445 point[0] - outline.origin()[0],
1446 point[1] - outline.origin()[1],
1447 ],
1448 });
1449 }
1450 if (x >= bounds[0] && x <= bounds[2] && y >= body_top && y <= bounds[3])
1451 || outline.layouts().any(|(_, paragraph)| {
1452 paragraph.tags.iter().any(|tag| {
1453 let origin = outline.origin();
1454 let x = origin[0] + tag.origin[0];
1455 let y = origin[1] + paragraph.origin[1] + tag.origin[1];
1456 let size = canvas::outline::ParagraphTag::SIZE;
1457 (x..=x + size).contains(&local[0]) && (y..=y + size).contains(&local[1])
1458 })
1459 })
1460 {
1461 return Some(Hit::Text {
1462 id: outline.id,
1463 point: [
1464 local[0] - outline.origin()[0],
1465 local[1] - outline.origin()[1],
1466 ],
1467 });
1468 }
1469 None
1470 };
1471 let pass = |below| {
1472 if let Some(hit) = editor
1473 .visible_outlines()
1474 .rev()
1475 .filter(|outline| scene.is_none() || !editor.has_page_outline(outline.id))
1476 .find_map(|outline| hit(outline, [0.0; 2], below))
1477 {
1478 return Some(hit);
1479 }
1480 let (scene, offset) = scene?;
1481 match scene.hit_test(
1482 [point[0] - offset[0], point[1] - offset[1]],
1483 Some(editor),
1484 |id| {
1485 editor
1486 .visible_outlines()
1487 .find(|outline| outline.id == id)
1488 .and_then(|outline| hit(outline, *offset, below))
1489 },
1490 )? {
1491 canvas::gpu::page::SceneHit::Outline(hit) => Some(hit),
1492 canvas::gpu::page::SceneHit::Date(field) => Some(Hit::Date(field)),
1493 canvas::gpu::page::SceneHit::ReadOnly(index) => Some(Hit::ReadOnly(index)),
1494 }
1495 };
1496 pass(false).or_else(|| pass(true))
1497}
1498
1499fn page_primitives<'a>(
1500 editor: &'a CanvasEditor,
1501 scene: Option<&'a (PageScene, [f32; 2])>,
1502 preview: Option<PointerFeedback<'a>>,
1503 read_only_focus: Option<usize>,
1504 show_caret: bool,
1505 scale: f32,
1506 display_scale: f32,
1507) -> Result<Vec<Primitive<'a>>, Box<dyn Error>> {
1508 let mut primitives = Vec::new();
1509 let paint = |id, offset: [f32; 2], primitives: &mut Vec<_>| {
1510 let outline = editor
1511 .visible_outlines()
1512 .find(|outline| outline.id == id)
1513 .ok_or(canvas::gpu::page::SceneError::MissingOutline)?;
1514 let outline = match preview {
1515 Some(PointerFeedback::Resize(resized)) if resized.id == outline.id => resized,
1516 _ => outline,
1517 };
1518 let origin = match preview {
1519 Some(PointerFeedback::Move(id, origin)) if id == outline.id => origin,
1520 _ => outline.origin(),
1521 };
1522 if (read_only_focus.is_none() && outline.id == editor.active_outline().id)
1523 || matches!(preview, Some(PointerFeedback::Hover(id) | PointerFeedback::Move(id, _)) if id == outline.id)
1524 || matches!(preview, Some(PointerFeedback::Resize(resized)) if resized.id == outline.id)
1525 {
1526 append_outline_chrome(
1527 outline,
1528 [origin[0] + offset[0], origin[1] + offset[1]],
1529 display_scale / scale,
1530 primitives,
1531 );
1532 }
1533 append_outline(
1534 (read_only_focus.is_none()
1535 && outline.id == editor.active_outline().id
1536 && !matches!(preview, Some(PointerFeedback::Resize(_))))
1537 .then_some(editor),
1538 outline,
1539 [origin[0] + offset[0], origin[1] + offset[1]],
1540 show_caret,
1541 scale,
1542 display_scale / scale,
1543 primitives,
1544 )
1545 };
1546 if let Some((scene, origin)) = scene {
1547 scene.append_primitives_with(&mut primitives, *origin, Some(editor), &paint)?;
1548 }
1549 for outline in editor
1550 .visible_outlines()
1551 .filter(|outline| scene.is_none() || !editor.has_page_outline(outline.id))
1552 {
1553 paint(outline.id, [0.0; 2], &mut primitives)?;
1554 }
1555 if read_only_focus.is_none()
1556 && let Some(outline) = editor.caret_outline()
1557 {
1558 append_outline(
1559 Some(editor),
1560 outline,
1561 outline.origin(),
1562 show_caret,
1563 scale,
1564 display_scale / scale,
1565 &mut primitives,
1566 )?;
1567 }
1568 if let Some(index) = read_only_focus {
1569 let (scene, offset) = scene.unwrap();
1570 let [x0, y0, x1, y1] = scene.read_only(Some(editor)).nth(index).unwrap().rect();
1571 let [x0, y0, x1, y1] = [
1572 x0 + offset[0],
1573 y0 + offset[1],
1574 x1 + offset[0],
1575 y1 + offset[1],
1576 ];
1577 let border = 2.0 / scale;
1578 for rect in [
1579 [x0, y0, x1, y0 + border],
1580 [x0, y1 - border, x1, y1],
1581 [x0, y0, x0 + border, y1],
1582 [x1 - border, y0, x1, y1],
1583 ] {
1584 primitives.push(Primitive::Rect {
1585 rect,
1586 color: [0.25, 0.45, 0.7, 1.0],
1587 });
1588 }
1589 }
1590 Ok(primitives)
1591}
1592
1593fn drag_selection(anchor: Selection, target: Selection, unit: SelectionUnit) -> Selection {
1594 if unit == SelectionUnit::Grapheme {
1595 return Selection {
1596 positions: [anchor.positions[0], target.positions[1]],
1597 affinities: [anchor.affinities[0], target.affinities[1]],
1598 };
1599 }
1600 let backwards = target.positions[0] < anchor.positions[0].min(anchor.positions[1]);
1601 let start = usize::from((anchor.positions[0] > anchor.positions[1]) != backwards);
1602 let end = usize::from((target.positions[0] > target.positions[1]) == backwards);
1603 Selection {
1604 positions: [anchor.positions[start], target.positions[end]],
1605 affinities: [anchor.affinities[start], target.affinities[end]],
1606 }
1607}
1608
1609fn snap_to_grid(point: [f32; 2]) -> [f32; 2] {
1610 std::array::from_fn(|axis| {
1611 let offset = [0.0, 14.4][axis];
1612 let cell = (point[axis] - offset) / 18.0;
1613 let nearest = cell.round();
1614 // Native midpoints remain free; account for the source coordinate's float precision.
1615 if ((cell - nearest).abs() - 0.5).abs() <= f32::EPSILON * cell.abs().max(1.0) {
1616 point[axis]
1617 } else {
1618 nearest * 18.0 + offset
1619 }
1620 })
1621}
1622
1623fn outline_chrome(outline: &TextOutline, pixel: f32) -> ([f32; 4], f32) {
1624 let bounds = outline.bounds();
1625 // Native chrome combines page-scaled gutters with a fixed screen inset.
1626 let inset = 5.0 * pixel;
1627 let body_top = bounds.y0 as f32 - inset;
1628 if outline.title {
1629 let (_, paragraph) = outline.layouts().last().unwrap();
1630 let bottom = outline.origin()[1] + paragraph.origin[1] + paragraph.text.height();
1631 return (
1632 [
1633 bounds.x0 as f32 - inset - 6.0 * pixel,
1634 body_top,
1635 bounds.x1 as f32 + inset - 2.0 * pixel,
1636 bottom + inset,
1637 ],
1638 body_top,
1639 );
1640 }
1641 (
1642 [
1643 bounds.x0 as f32 - 7.5 - inset,
1644 body_top - HANDLE_HEIGHT,
1645 bounds.x1 as f32 + inset,
1646 bounds.y1 as f32 + HANDLE_HEIGHT + inset,
1647 ],
1648 body_top,
1649 )
1650}
1651
1652fn append_outline_chrome(
1653 outline: &TextOutline,
1654 origin: [f32; 2],
1655 pixel: f32,
1656 primitives: &mut Vec<Primitive<'_>>,
1657) {
1658 let [x, y] = origin;
1659 let (bounds, body_top) = outline_chrome(outline, pixel);
1660 let [dx, dy] = [x - outline.origin()[0], y - outline.origin()[1]];
1661 let [left, top, right, bottom] = [
1662 bounds[0] + dx,
1663 bounds[1] + dy,
1664 bounds[2] + dx,
1665 bounds[3] + dy,
1666 ];
1667 let body_top = body_top + dy;
1668 if outline.title {
1669 primitives.push(Primitive::RoundedRect {
1670 rect: [left, top, right, bottom],
1671 radius: [6.0 * pixel, (bottom - top) * 0.5],
1672 stroke: Some(Stroke::Dashed(pixel)),
1673 color: canvas::gpu::colorref(0x007f7f7f),
1674 });
1675 return;
1676 }
1677 primitives.push(Primitive::RoundedRect {
1678 rect: [left, top, right, body_top],
1679 radius: [3.0 * pixel; 2],
1680 stroke: None,
1681 color: canvas::gpu::colorref(0x00e8ebed),
1682 });
1683 primitives.push(Primitive::RoundedRect {
1684 rect: [right - 9.0 * pixel, top, right, body_top],
1685 radius: [3.0 * pixel; 2],
1686 stroke: None,
1687 color: canvas::gpu::colorref(0x00e5dee7),
1688 });
1689 primitives.push(Primitive::RoundedRect {
1690 rect: [left, top, right, bottom],
1691 radius: [3.0 * pixel; 2],
1692 stroke: Some(Stroke::Solid(pixel)),
1693 color: canvas::gpu::colorref(0x00d9cfd8),
1694 });
1695 let middle = (top + body_top) * 0.5;
1696 for offset in [-3.0, 0.0, 3.0] {
1697 let center = (left + right) * 0.5 + offset * pixel;
1698 primitives.push(Primitive::RoundedRect {
1699 rect: [
1700 center - pixel * 0.5,
1701 middle - pixel * 0.5,
1702 center + pixel * 0.5,
1703 middle + pixel * 0.5,
1704 ],
1705 radius: [pixel * 0.5; 2],
1706 stroke: None,
1707 color: canvas::gpu::colorref(0x00b4a5b4),
1708 });
1709 }
1710 for column in [0.0, 1.0, 2.0] {
1711 let half = (column + 0.5) * pixel;
1712 for x in [
1713 right - (8.0 - column) * pixel,
1714 right - (2.0 + column) * pixel,
1715 ] {
1716 primitives.push(Primitive::Rect {
1717 rect: [x, middle - half, x + pixel, middle + half],
1718 color: canvas::gpu::colorref(0x00b4a5b4),
1719 });
1720 }
1721 }
1722}
1723
1724fn append_outline<'a>(
1725 editor: Option<&'a CanvasEditor>,
1726 outline: &'a TextOutline,
1727 origin: [f32; 2],
1728 show_caret: bool,
1729 scale: f32,
1730 pixel: f32,
1731 primitives: &mut Vec<Primitive<'a>>,
1732) -> Result<(), Box<dyn Error>> {
1733 let [x, y] = origin;
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 let [_, selection_color] = macos::text_colors();
1740 for rect in editor.selection_rects()? {
1741 primitives.push(Primitive::Rect {
1742 rect: [
1743 rect.x0 as f32 + x,
1744 rect.y0 as f32 + y,
1745 rect.x1 as f32 + x,
1746 rect.y1 as f32 + y,
1747 ],
1748 color: selection_color,
1749 });
1750 }
1751 }
1752 for (index, (_, paragraph)) in outline.layouts().enumerate() {
1753 let layout = &paragraph.text;
1754 let paragraph_origin = paragraph.origin;
1755 let clip = outline.shaped().paragraph_cell(index).map(|cell| {
1756 let [left, top, right, bottom] = cell.text_bounds();
1757 [left + x, top + y, right + x, bottom + y]
1758 });
1759 primitives.push(Primitive::Text {
1760 clip,
1761 layout,
1762 origin: [x + paragraph_origin[0], y + paragraph_origin[1]],
1763 });
1764 for (layout, origin) in &paragraph.markers {
1765 primitives.push(Primitive::Text {
1766 clip,
1767 layout,
1768 origin: [x + origin[0], y + (origin[1] + paragraph_origin[1])],
1769 });
1770 }
1771 for tag in &paragraph.tags {
1772 primitives.push(Primitive::Tag {
1773 tag,
1774 origin: [x, y + paragraph_origin[1]],
1775 });
1776 }
1777 }
1778 if let Some(editor) = editor {
1779 for rect in editor.marked_rects()? {
1780 primitives.push(Primitive::Rect {
1781 rect: [
1782 rect.x0 as f32 + x,
1783 rect.y1 as f32 + y - 1.0 / scale,
1784 rect.x1 as f32 + x,
1785 rect.y1 as f32 + y,
1786 ],
1787 color: [0.0, 0.0, 0.0, 1.0],
1788 });
1789 }
1790 let [anchor, focus] = editor.selection().positions;
1791 if show_caret && anchor == focus {
1792 let rect = editor.caret(2.0 * pixel)?;
1793 primitives.push(Primitive::RoundedRect {
1794 rect: [
1795 rect.x0 as f32 + x,
1796 rect.y0 as f32 + y,
1797 rect.x1 as f32 + x,
1798 rect.y1 as f32 + y,
1799 ],
1800 radius: [pixel; 2],
1801 stroke: None,
1802 color: macos::text_colors()[0],
1803 });
1804 }
1805 }
1806 Ok(())
1807}
1808
1809fn main() -> Result<(), Box<dyn Error>> {
1810 let mut args = std::env::args_os().skip(1);
1811 let mut positional = Vec::new();
1812 let mut substitutes = Vec::new();
1813 let mut reference = None;
1814 let mut editable = false;
1815 while let Some(arg) = args.next() {
1816 if arg == "--substitute-font" {
1817 substitutes.push(PathBuf::from(
1818 args.next()
1819 .ok_or("Provide a font file after --substitute-font.")?,
1820 ));
1821 } else if arg == "--reference" || arg == "--page" {
1822 if reference.is_some() {
1823 return Err("Only one page can be opened.".into());
1824 }
1825 editable = arg == "--page";
1826 let path = args
1827 .next()
1828 .ok_or("Provide a section file and page title after the page option.")?;
1829 let title = args
1830 .next()
1831 .ok_or("Provide a page title after the section file.")?;
1832 let bytes = std::fs::read(path)?;
1833 let store = onestore::Store::parse(&bytes)?;
1834 let index = onestore::RevisionIndex::parse(&store)?;
1835 reference = Some(Page::from_document(
1836 &onestore::document::Document::parse(&index)?,
1837 title
1838 .to_str()
1839 .ok_or("The page title must be valid Unicode.")?,
1840 )?);
1841 } else {
1842 positional.push(arg);
1843 }
1844 }
1845 if editable && !positional.is_empty() {
1846 return Err(
1847 "Use --page with a section file and page title, without a text file or width.".into(),
1848 );
1849 }
1850 if positional.len() > 2 {
1851 return Err(
1852 "Usage: snowbound [TEXT_FILE] [WIDTH_POINTS] [--reference SECTION PAGE_TITLE | --page SECTION PAGE_TITLE] [--substitute-font FONT_FILE]..."
1853 .into(),
1854 );
1855 }
1856 let text = positional
1857 .first()
1858 .map(std::fs::read_to_string)
1859 .transpose()?
1860 .unwrap_or_default();
1861 let width = positional
1862 .get(1)
1863 .map(|value| value.to_string_lossy().parse::<f32>())
1864 .transpose()?
1865 .unwrap_or(if reference.is_some() {
1866 DEFAULT_OUTLINE_WIDTH
1867 } else {
1868 480.0
1869 });
1870 let input = if editable {
1871 Input::Page(reference.unwrap())
1872 } else {
1873 Input::Notes {
1874 document: TextDocument::new(
1875 text.split('\n')
1876 .map(|line| Paragraph::new(line.to_owned(), Format::default()))
1877 .collect(),
1878 )?,
1879 width,
1880 reference,
1881 }
1882 };
1883 let event_loop = macos::event_loop()?;
1884 let mut app = App {
1885 proxy: event_loop.create_proxy(),
1886 input: Some(input),
1887 substitutes,
1888 state: None,
1889 startup_error: None,
1890 };
1891 event_loop.run_app(&mut app)?;
1892 app.startup_error.map_or(Ok(()), Err)
1893}
1894
1895#[cfg(test)]
1896mod tests {
1897 use super::*;
1898
1899 #[test]
1900 fn date_buttons_keep_accessibility_identity_and_match_mouse_hits_after_reflow() {
1901 let mut engine = TextEngine::default();
1902 let mut fields = [vec!["Header"], vec!["Monday", "6:14 AM"]].map(|text| {
1903 TextOutline::new(
1904 &mut engine,
1905 TextDocument::new(
1906 text.into_iter()
1907 .map(|text| Paragraph::new(text.into(), Default::default()))
1908 .collect(),
1909 )
1910 .unwrap(),
1911 468.0,
1912 [0.0; 2],
1913 )
1914 .unwrap()
1915 .snapshot()
1916 });
1917 fields[0].title = true;
1918 let page = Page {
1919 title: "Header".into(),
1920 created: Some(1),
1921 margin_origin: [36.0, 14.4],
1922 definitions: Default::default(),
1923 objects: vec![canvas::page::PageObject::Title(canvas::page::Title {
1924 id: onestore::ExGuid::default(),
1925 date: Some(fields[1].id),
1926 layout: Default::default(),
1927 outlines: fields.into(),
1928 })],
1929 };
1930 let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap();
1931 let scene = (scene, [20.0, 40.0]);
1932 let viewport = Viewport {
1933 size: [1000, 700],
1934 scale: 2.0,
1935 origin: [-30.0, -50.0],
1936 };
1937 let mut access = accessibility::Accessibility::default();
1938 let mut identities = Vec::new();
1939 for phase in 0..3 {
1940 if phase == 1 {
1941 editor
1942 .insert(&mut engine, &"A wrapped title ".repeat(25))
1943 .unwrap();
1944 }
1945 if phase == 2 {
1946 editor
1947 .change_date(&mut engine, 2, ["Wednesday".into(), "8:40 AM".into()])
1948 .unwrap();
1949 }
1950 let mut update = access.update(&editor, viewport, "Header", None).unwrap();
1951 access.append_page_fields(&mut update, Some(&scene), &editor, viewport, None);
1952 let buttons: Vec<_> = update
1953 .nodes
1954 .iter()
1955 .filter(|(_, node)| node.role() == accesskit::Role::Button)
1956 .collect();
1957 assert_eq!(buttons.len(), 2);
1958 for (index, (id, node)) in buttons.iter().enumerate() {
1959 let field = [DateField::Date, DateField::Time][index];
1960 assert_eq!(access.date_for_node(*id), Some(field));
1961 assert!(node.supports_action(accesskit::Action::Click));
1962 let rect = node.bounds().unwrap();
1963 let point = viewport.document_point([
1964 ((rect.x0 + rect.x1) * 0.5) as f32,
1965 ((rect.y0 + rect.y1) * 0.5) as f32,
1966 ]);
1967 assert_eq!(
1968 page_hit_test(&editor, Some(&scene), point, 0.5),
1969 Some(Hit::Date(field))
1970 );
1971 if phase == 0 {
1972 identities.push(*id);
1973 } else {
1974 assert_eq!(*id, identities[index]);
1975 }
1976 }
1977 if phase == 2 {
1978 assert_eq!(buttons[1].1.value(), Some("8:40 AM"));
1979 }
1980 accesskit_consumer::Tree::new(update, true);
1981 }
1982 }
1983
1984 #[test]
1985 fn extension_hits_yield_to_objects_and_keep_their_source_coordinate_frame() {
1986 let mut engine = TextEngine::default();
1987 let upper = TextOutline::new(
1988 &mut engine,
1989 TextDocument::new(vec![Paragraph::new("Upper".into(), Default::default())]).unwrap(),
1990 240.0,
1991 [36.0, 90.0],
1992 )
1993 .unwrap();
1994 let bottom = upper.bounds().y1 as f32;
1995 let id = upper.id;
1996 let lower = TextOutline::new(
1997 &mut engine,
1998 TextDocument::new(vec![Paragraph::new("Lower".into(), Default::default())]).unwrap(),
1999 240.0,
2000 [36.0, bottom + 20.0],
2001 )
2002 .unwrap();
2003 let lower_id = lower.id;
2004 let source = upper.snapshot();
2005 let mut editor =
2006 CanvasEditor::from_text_outlines(vec![lower, upper], Default::default(), None).unwrap();
2007 assert!(
2008 matches!(page_hit_test(&editor, None, [60.0, bottom + 22.0], 0.75), Some(Hit::Text { id, .. }) if id == lower_id)
2009 );
2010 editor.move_outline(lower_id, [600.0, 500.0]).unwrap();
2011 for pixel in [0.375, 0.75, 1.5] {
2012 let hit = page_hit_test(&editor, None, [60.0, bottom + 26.0], pixel).unwrap();
2013 assert!(
2014 matches!(hit, Hit::Text { id: hit_id, point } if hit_id == id && (point[1] - (bottom + 26.0 - 90.0)).abs() < 0.001)
2015 );
2016 assert_eq!(
2017 page_hit_test(&editor, None, [60.0, bottom + 30.0], pixel),
2018 None
2019 );
2020 }
2021 let page = canvas::page::Page {
2022 title: String::new(),
2023 created: None,
2024 margin_origin: [0.0; 2],
2025 definitions: Default::default(),
2026 objects: vec![canvas::page::PageObject::Outline(source)],
2027 };
2028 let (scene, editor) = PageScene::from_page(page, &mut engine).unwrap();
2029 let offset = [50.0, 300.0];
2030 assert!(
2031 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)
2032 );
2033 }
2034
2035 #[test]
2036 fn title_chrome_selects_text_and_exposes_a_named_editable_field() {
2037 let mut engine = TextEngine::default();
2038 let mut source = TextOutline::new(
2039 &mut engine,
2040 TextDocument::new(vec![Paragraph::new(
2041 "Header".into(),
2042 Format {
2043 font_size: Some(8.0),
2044 line_spacing: Some(20.751953),
2045 ..Default::default()
2046 },
2047 )])
2048 .unwrap(),
2049 468.0,
2050 [36.0, 14.4],
2051 )
2052 .unwrap()
2053 .snapshot();
2054 source.title = true;
2055 source.min_width = Some(162.0);
2056 source.layout.max_width = None;
2057 source.layout.width_set_by_user = None;
2058 source.layout.max_height = Some(21.6);
2059 let outline = TextOutline::from_outline(&mut engine, &source, &Default::default()).unwrap();
2060 let editor =
2061 CanvasEditor::from_text_outlines(vec![outline], Default::default(), None).unwrap();
2062 assert_eq!(editor.active_outline().bounds().width(), 162.0);
2063 assert_eq!(editor.active_outline().snapshot().min_width, Some(162.0));
2064 let (native_rect, _) = outline_chrome(editor.active_outline(), 0.75);
2065 for (axis, (actual, expected)) in native_rect
2066 .iter()
2067 .zip([85.0, 98.0, 315.0, 134.0])
2068 .enumerate()
2069 {
2070 let origin = if axis % 2 == 0 { 48.0 } else { 83.0 };
2071 assert!((actual / 0.75 + origin - expected).abs() < 1.5);
2072 }
2073 let id = editor.active_outline().id;
2074 let (rect, _) = outline_chrome(editor.active_outline(), 1.0);
2075 for point in [
2076 [rect[0] + 1.0, rect[1] + 1.0],
2077 [rect[2] - 1.0, rect[1] + 1.0],
2078 [40.0, 20.0],
2079 ] {
2080 assert!(
2081 matches!(page_hit_test(&editor, None, point, 1.0), Some(Hit::Text { id: hit, .. }) if hit == id)
2082 );
2083 }
2084 let primitives = page_primitives(&editor, None, None, None, false, 1.0, 1.0).unwrap();
2085 let borders = primitives
2086 .iter()
2087 .filter_map(|p| match p {
2088 Primitive::RoundedRect { stroke, .. } => Some(*stroke),
2089 _ => None,
2090 })
2091 .collect::<Vec<_>>();
2092 assert_eq!(borders, [Some(Stroke::Dashed(1.0))]);
2093 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])));
2094 let mut access = accessibility::Accessibility::default();
2095 let update = access
2096 .update(
2097 &editor,
2098 Viewport {
2099 size: [800, 600],
2100 scale: 1.0,
2101 origin: [0.0; 2],
2102 },
2103 "Header",
2104 None,
2105 )
2106 .unwrap();
2107 assert!(update.nodes.iter().any(|(_, node)| node.role()
2108 == accesskit::Role::MultilineTextInput
2109 && node.label() == Some("Page title")));
2110 }
2111
2112 #[test]
2113 fn provisional_lines_share_one_drawn_hit_tested_and_accessible_outline() {
2114 let mut engine = TextEngine::default();
2115 let mut editor = CanvasEditor::new(
2116 &mut engine,
2117 TextDocument::new(vec![Paragraph::new("body".into(), Format::default())]).unwrap(),
2118 240.0,
2119 )
2120 .unwrap();
2121 let id = editor.active_outline().id;
2122 for _ in 0..2 {
2123 editor
2124 .move_selection(&mut engine, Movement::Down, false)
2125 .unwrap();
2126 }
2127 let primitives = page_primitives(&editor, None, None, None, true, 1.0, 1.0).unwrap();
2128 assert_eq!(
2129 primitives
2130 .iter()
2131 .filter(|p| matches!(p, Primitive::Text { .. }))
2132 .count(),
2133 3
2134 );
2135 let caret = editor.caret(1.0).unwrap();
2136 assert!(
2137 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)
2138 );
2139 let mut access = accessibility::Accessibility::default();
2140 let update = access
2141 .update(
2142 &editor,
2143 Viewport {
2144 size: [800, 600],
2145 scale: 1.0,
2146 origin: [0.0; 2],
2147 },
2148 "Test",
2149 None,
2150 )
2151 .unwrap();
2152 let fields = update
2153 .nodes
2154 .iter()
2155 .filter(|(_, node)| node.role() == accesskit::Role::MultilineTextInput)
2156 .collect::<Vec<_>>();
2157 assert_eq!(fields.len(), 1);
2158 assert_eq!(fields[0].1.value(), Some("body\n\n"));
2159 assert_eq!(editor.outlines()[0].document().nodes().len(), 1);
2160 editor
2161 .place_caret(&mut engine, [300.0, 200.0], 240.0)
2162 .unwrap();
2163 assert_eq!(
2164 editor
2165 .visible_outlines()
2166 .next()
2167 .unwrap()
2168 .document()
2169 .nodes()
2170 .len(),
2171 1
2172 );
2173 }
2174
2175 #[test]
2176 fn chrome_follows_focus_hover_and_drag_without_changing_hit_geometry() {
2177 let mut engine = TextEngine::default();
2178 let outlines = [[0.0, 0.0], [220.0, 0.0]]
2179 .into_iter()
2180 .map(|origin| {
2181 TextOutline::new(
2182 &mut engine,
2183 TextDocument::new(vec![Paragraph::new("text".into(), Format::default())])
2184 .unwrap(),
2185 100.0,
2186 origin,
2187 )
2188 .unwrap()
2189 })
2190 .collect();
2191 let editor = CanvasEditor::from_text_outlines(outlines, Default::default(), None).unwrap();
2192 let second = editor.outlines()[1].id;
2193 let borders = |feedback| {
2194 page_primitives(&editor, None, feedback, None, false, 1.0, 1.0)
2195 .unwrap()
2196 .into_iter()
2197 .filter_map(|p| match p {
2198 Primitive::RoundedRect {
2199 rect,
2200 stroke: Some(Stroke::Solid(1.0)),
2201 ..
2202 } => Some(rect),
2203 _ => None,
2204 })
2205 .collect::<Vec<_>>()
2206 };
2207 assert_eq!(borders(None).len(), 1);
2208 let hovered = borders(Some(PointerFeedback::Hover(second)));
2209 assert_eq!(hovered.len(), 2);
2210 let moved = borders(Some(PointerFeedback::Move(second, [240.0, 50.0])));
2211 assert_eq!(moved[1][0] - hovered[1][0], 20.0);
2212 assert_eq!(moved[1][1] - hovered[1][1], 50.0);
2213 assert!(
2214 matches!(page_hit_test(&editor, None, [225.0, 5.0], 1.0), Some(Hit::Text { id, .. }) if id == second)
2215 );
2216 }
2217
2218 #[test]
2219 fn grouped_drag_keeps_the_initial_word_when_reversing_direction() {
2220 let selection = |start, end| {
2221 Selection::from([start, end].map(|offset| canvas::document::TextPosition {
2222 paragraph: 0,
2223 offset,
2224 }))
2225 };
2226 let anchor = selection(6, 10);
2227 assert_eq!(
2228 drag_selection(anchor, selection(11, 16), SelectionUnit::Word).positions,
2229 selection(6, 16).positions
2230 );
2231 assert_eq!(
2232 drag_selection(anchor, selection(0, 5), SelectionUnit::Word).positions,
2233 selection(10, 0).positions
2234 );
2235 assert_eq!(
2236 drag_selection(anchor, anchor, SelectionUnit::Word).positions,
2237 anchor.positions
2238 );
2239 let anchor = selection(10, 3);
2240 assert_eq!(
2241 drag_selection(anchor, selection(7, 7), SelectionUnit::Grapheme).positions,
2242 selection(10, 7).positions
2243 );
2244 }
2245
2246 #[test]
2247 fn native_grid_matches_drag_offsets_midpoints_and_zoom() {
2248 for (pixels, points) in [
2249 (5.0, 0.0),
2250 (6.0, 0.0),
2251 (7.0, 0.0),
2252 (8.0, 0.0),
2253 (11.0, 0.0),
2254 (12.0, 9.0),
2255 (13.0, 18.0),
2256 (17.0, 18.0),
2257 (18.0, 18.0),
2258 (19.0, 18.0),
2259 (25.0, 18.0),
2260 (37.0, 36.0),
2261 (-6.0, 0.0),
2262 (-7.0, 0.0),
2263 (-12.0, -9.0),
2264 (-13.0, -18.0),
2265 (-19.0, -18.0),
2266 ] {
2267 let origin = [486.0, 230.40001];
2268 let proposed = origin.map(|v| v + pixels * 72.0 / 96.0);
2269 let result = snap_to_grid(proposed);
2270 for axis in 0..2 {
2271 assert!((result[axis] - origin[axis] - points).abs() < 0.00004);
2272 }
2273 }
2274 for pixels in [6.0, 7.0, 12.0, 13.0, 19.0] {
2275 let result = snap_to_grid([491.25 + pixels * 0.75, 235.65 + pixels * 0.75]);
2276 assert!((result[0] - 504.0).abs() < 0.00004);
2277 assert!((result[1] - 248.4).abs() < 0.00004);
2278 }
2279 for (scale, delta, expected) in [
2280 (96.0 / 72.0, [27.0, 31.0], [54.0, 104.4]),
2281 (96.0 / 72.0, [13.0, 17.0], [54.0, 104.4]),
2282 (192.0 / 72.0, [13.0, 17.0], [36.0, 104.4]),
2283 ] {
2284 for dpr in [1.0, 2.0] {
2285 let origin = [36.0, 90.0];
2286 let proposed =
2287 std::array::from_fn(|axis| origin[axis] + delta[axis] * dpr / (scale * dpr));
2288 let result = snap_to_grid(proposed);
2289 for axis in 0..2 {
2290 assert!((result[axis] - expected[axis]).abs() < 0.00004);
2291 }
2292 }
2293 }
2294 for point in [[9.0, 5.4], [-9.0, -12.6], [423.0, 239.4]] {
2295 assert_eq!(snap_to_grid(point), point);
2296 }
2297 }
2298
2299 #[test]
2300 fn native_fresh_click_grid_uses_screen_offset_at_both_zoom_levels() {
2301 for (x, y, scale, expected) in [
2302 (900.0, 500.0, 96.0 / 72.0, [639.0, 302.4]),
2303 (901.0, 500.0, 96.0 / 72.0, [648.0, 302.4]),
2304 (906.0, 500.0, 96.0 / 72.0, [648.0, 302.4]),
2305 (913.0, 500.0, 96.0 / 72.0, [648.0, 302.4]),
2306 (900.0, 501.0, 96.0 / 72.0, [639.0, 302.4]),
2307 (900.0, 506.0, 96.0 / 72.0, [639.0, 320.4]),
2308 (900.0, 520.0, 96.0 / 72.0, [639.0, 320.4]),
2309 (900.0, 500.0, 192.0 / 72.0, [324.0, 158.4]),
2310 ] {
2311 for dpr in [1.0, 2.0] {
2312 let viewport = Viewport {
2313 size: [(1600.0 * dpr) as u32, (900.0 * dpr) as u32],
2314 origin: [48.0 * dpr, 83.0 * dpr],
2315 scale: scale * dpr,
2316 };
2317 let point = viewport.document_point([x * dpr, y * dpr]);
2318 let result = snap_to_grid([point[0], point[1] - 7.0 * dpr / viewport.scale]);
2319 for axis in 0..2 {
2320 assert!((result[axis] - expected[axis]).abs() < 0.00004);
2321 }
2322 }
2323 }
2324 }
2325
2326 #[test]
2327 fn blank_caret_and_composition_have_accessible_text_without_outline_chrome() {
2328 let mut engine = TextEngine::default();
2329 let mut editor = CanvasEditor::new(
2330 &mut engine,
2331 TextDocument::new(vec![Paragraph::new(String::new(), Default::default())]).unwrap(),
2332 240.0,
2333 )
2334 .unwrap();
2335 editor
2336 .place_caret(&mut engine, [120.0, 100.0], 240.0)
2337 .unwrap();
2338 let mut access = accessibility::Accessibility::default();
2339 let viewport = Viewport {
2340 size: [1000, 720],
2341 scale: 96.0 / 72.0,
2342 origin: [48.0; 2],
2343 };
2344 let initial = access.update(&editor, viewport, "Page", None).unwrap();
2345 let id = editor.active_outline().id;
2346 assert_eq!(access.outline_for_node(initial.focus), Some(id));
2347 let rectangles = |editor: &CanvasEditor| {
2348 page_primitives(editor, None, None, None, true, viewport.scale, 1.0)
2349 .unwrap()
2350 .into_iter()
2351 .filter_map(|p| match p {
2352 Primitive::Rect { rect, color }
2353 | Primitive::RoundedRect { rect, color, .. } => Some((rect, color)),
2354 _ => None,
2355 })
2356 .collect::<Vec<_>>()
2357 };
2358 assert_eq!(rectangles(&editor).len(), 1);
2359 assert_eq!(
2360 page_hit_test(&editor, None, [120.0, 100.0], 1.0 / viewport.scale),
2361 None
2362 );
2363 editor.compose(&mut engine, "に".into(), 1..1).unwrap();
2364 assert!(editor.outlines().is_empty());
2365 assert!(
2366 rectangles(&editor)
2367 .iter()
2368 .all(|(_, color)| *color == [0.0, 0.0, 0.0, 1.0]
2369 || *color == macos::text_colors()[0])
2370 );
2371 let preedit = access.update(&editor, viewport, "Page", None).unwrap();
2372 assert_eq!(preedit.focus, initial.focus);
2373 editor.commit_text(&mut engine, "日本".into()).unwrap();
2374 let committed = access.update(&editor, viewport, "Page", None).unwrap();
2375 assert_eq!(committed.focus, initial.focus);
2376 assert!(rectangles(&editor).len() > 1);
2377 editor.undo(&mut engine).unwrap();
2378 assert!(editor.outlines().is_empty());
2379 assert_eq!(rectangles(&editor).len(), 1);
2380 let empty = access.update(&editor, viewport, "Page", None).unwrap();
2381 assert_eq!(empty.focus, committed.focus);
2382 assert_eq!(access.outline_for_node(committed.focus), Some(id));
2383 editor.redo(&mut engine).unwrap();
2384 editor.select_all().unwrap();
2385 editor.delete(&mut engine, true).unwrap();
2386 assert!(editor.outlines().is_empty());
2387 assert_eq!(rectangles(&editor).len(), 1);
2388 let retired = access.update(&editor, viewport, "Page", None).unwrap();
2389 assert_eq!(retired.focus, committed.focus);
2390 assert_eq!(access.outline_for_node(retired.focus), Some(id));
2391 editor.undo(&mut engine).unwrap();
2392 assert!(rectangles(&editor).len() > 1);
2393 let restored = access.update(&editor, viewport, "Page", None).unwrap();
2394 assert_eq!(restored.focus, committed.focus);
2395 editor.redo(&mut engine).unwrap();
2396 assert_eq!(rectangles(&editor).len(), 1);
2397 assert!(editor.caret_outline().unwrap().is_empty());
2398 editor.insert(&mut engine, " ").unwrap();
2399 assert!(editor.caret_outline().unwrap().is_empty());
2400 editor.compose(&mut engine, "に".into(), 1..1).unwrap();
2401 assert!(!editor.caret_outline().unwrap().is_empty());
2402 }
2403
2404 #[test]
2405 fn table_glyphs_highlights_and_selection_share_cell_paint_bounds() {
2406 let mut engine = TextEngine::default();
2407 let mut editor = CanvasEditor::new(
2408 &mut engine,
2409 TextDocument::new(vec![Paragraph::new(
2410 "M".into(),
2411 Format {
2412 font_size: Some(130.0),
2413 highlight: Some(0xffff),
2414 ..Default::default()
2415 },
2416 )])
2417 .unwrap(),
2418 180.0,
2419 )
2420 .unwrap();
2421 editor
2422 .move_selection(&mut engine, Movement::LineEnd, false)
2423 .unwrap();
2424 editor.tab(&mut engine, false).unwrap();
2425 editor.insert(&mut engine, "R").unwrap();
2426 editor.tab(&mut engine, true).unwrap();
2427 let mut primitives = Vec::new();
2428 append_outline(
2429 Some(&editor),
2430 editor.active_outline(),
2431 [24.0, 48.0],
2432 false,
2433 1.0,
2434 1.0,
2435 &mut primitives,
2436 )
2437 .unwrap();
2438 let bounds = editor.active_outline().shaped().tables[0]
2439 .cells
2440 .iter()
2441 .map(|cell| {
2442 let [left, top, right, bottom] = cell.text_bounds();
2443 [left + 24.0, top + 48.0, right + 24.0, bottom + 48.0]
2444 })
2445 .collect::<Vec<_>>();
2446 let text = primitives
2447 .iter()
2448 .filter_map(|primitive| match primitive {
2449 Primitive::Text { clip, .. } => Some(clip.unwrap()),
2450 _ => None,
2451 })
2452 .collect::<Vec<_>>();
2453 assert_eq!(text, bounds);
2454 let highlights = primitives
2455 .iter()
2456 .filter_map(|primitive| match primitive {
2457 Primitive::Rect { rect, color } if *color == canvas::gpu::colorref(0xffff) => {
2458 Some(*rect)
2459 }
2460 _ => None,
2461 })
2462 .collect::<Vec<_>>();
2463 assert_eq!(highlights.len(), 2);
2464 for (rect, bounds) in highlights.iter().zip(&bounds) {
2465 assert_eq!(rect[2], bounds[2]);
2466 assert!(rect[0] >= bounds[0] && rect[1] >= bounds[1] && rect[3] <= bounds[3]);
2467 }
2468 let selection = primitives
2469 .iter()
2470 .find_map(|primitive| match primitive {
2471 Primitive::Rect { rect, color } if *color == macos::text_colors()[1] => Some(*rect),
2472 _ => None,
2473 })
2474 .unwrap();
2475 assert_eq!(selection[2], bounds[0][2]);
2476 assert!(editor.caret(1.0).unwrap().x0 + 24.0 > f64::from(bounds[0][2]));
2477 }
2478
2479 #[test]
2480 fn editable_tables_paint_borders_before_selection_and_cell_text() {
2481 let mut engine = TextEngine::default();
2482 let mut editor = CanvasEditor::new(
2483 &mut engine,
2484 TextDocument::new(vec![Paragraph::new("Left".into(), Format::default())]).unwrap(),
2485 180.0,
2486 )
2487 .unwrap();
2488 editor
2489 .move_selection(&mut engine, Movement::LineEnd, false)
2490 .unwrap();
2491 editor.tab(&mut engine, false).unwrap();
2492 editor.insert(&mut engine, "Right").unwrap();
2493 editor.tab(&mut engine, true).unwrap();
2494 let mut primitives = Vec::new();
2495 append_outline(
2496 Some(&editor),
2497 editor.active_outline(),
2498 [24.0, 48.0],
2499 false,
2500 1.0,
2501 1.0,
2502 &mut primitives,
2503 )
2504 .unwrap();
2505 let color = canvas::gpu::colorref(0x00a3a3a3);
2506 assert!(matches!(primitives[0], Primitive::RoundedRect {
2507 stroke: Some(canvas::gpu::Stroke::Solid(0.75)), color: actual, ..
2508 } if actual == color));
2509 assert!(matches!(primitives[1], Primitive::Rect { color: actual, .. } if actual == color));
2510 assert!(
2511 matches!(primitives[2], Primitive::Rect { color, .. } if color == macos::text_colors()[1])
2512 );
2513 let painted = primitives
2514 .iter()
2515 .filter_map(|primitive| match primitive {
2516 Primitive::Text { layout, origin, .. } => Some((layout.id(), *origin)),
2517 _ => None,
2518 })
2519 .collect::<Vec<_>>();
2520 let expected = editor
2521 .active_outline()
2522 .layouts()
2523 .map(|(_, paragraph)| {
2524 (
2525 paragraph.text.id(),
2526 [paragraph.origin[0] + 24.0, paragraph.origin[1] + 48.0],
2527 )
2528 })
2529 .collect::<Vec<_>>();
2530 assert_eq!(painted, expected);
2531 assert_eq!(painted.len(), 2);
2532 }
2533
2534 #[test]
2535 fn resize_preview_paints_reflowed_text_without_changing_the_editor() {
2536 let mut engine = TextEngine::default();
2537 let editor = CanvasEditor::new(
2538 &mut engine,
2539 TextDocument::new(vec![Paragraph::new(
2540 "A paragraph with enough words to wrap when the resize handle moves inward.".into(),
2541 Format::default(),
2542 )])
2543 .unwrap(),
2544 240.0,
2545 )
2546 .unwrap();
2547 let original = editor.active_outline().snapshot();
2548 let original_layout = editor
2549 .active_outline()
2550 .paragraph_layout(0)
2551 .unwrap()
2552 .text
2553 .id();
2554 let resized = editor.preview_resize(&mut engine, 72.0).unwrap();
2555 let primitives = page_primitives(
2556 &editor,
2557 None,
2558 Some(PointerFeedback::Resize(&resized)),
2559 None,
2560 false,
2561 96.0 / 72.0,
2562 1.0,
2563 )
2564 .unwrap();
2565 let painted = primitives
2566 .iter()
2567 .find_map(|primitive| match primitive {
2568 Primitive::Text { layout, .. } => Some(*layout),
2569 _ => None,
2570 })
2571 .unwrap();
2572 assert_eq!(painted.id(), resized.paragraph_layout(0).unwrap().text.id());
2573 assert!(
2574 painted.lines().count()
2575 > editor
2576 .active_outline()
2577 .paragraph_layout(0)
2578 .unwrap()
2579 .text
2580 .lines()
2581 .count()
2582 );
2583 assert_eq!(editor.active_outline().layout(), &original.layout);
2584 assert_eq!(
2585 editor
2586 .active_outline()
2587 .paragraph_layout(0)
2588 .unwrap()
2589 .text
2590 .id(),
2591 original_layout
2592 );
2593 assert!(primitives.iter().any(
2594 |primitive| matches!(primitive, Primitive::RoundedRect { stroke, .. } if stroke.is_some())
2595 ));
2596 }
2597
2598 #[test]
2599 fn native_chrome_geometry_preserves_text_and_matches_hit_regions_across_zoom_and_dpi() {
2600 let mut engine = TextEngine::default();
2601 let format = Format {
2602 font: Some("Arial".into()),
2603 font_size: Some(11.0),
2604 ..Default::default()
2605 };
2606 let mut editor = CanvasEditor::new(&mut engine, TextDocument::new(vec![
2607 Paragraph::new("HAMBURGEFONTS abcdefghijklmnopqrstuvwxyz 0123456789 HAMBURGEFONTS abcdefghijklmnopqrstuvwxyz 0123456789 HAMBURGEFONTS abcdefghijklmnopqrstuvwxyz 0123456789".into(), format.clone()),
2608 Paragraph::new("Second paragraph abcdefghijklmnopqrstuvwxyz.".into(), format),
2609 ]).unwrap(), 220.0).unwrap();
2610 let id = editor.active_outline().id;
2611 editor.move_outline(id, [36.0, 90.0]).unwrap();
2612 let document = editor.active_outline().document().clone();
2613 let layout_ids: Vec<_> = editor
2614 .active_outline()
2615 .layouts()
2616 .map(|(_, p)| p.text.id())
2617 .collect();
2618 for (zoom, expected) in [
2619 (0.5, [62.0, 134.0, 223.0, 219.0]),
2620 (1.0, [81.0, 189.0, 394.0, 348.0]),
2621 (1.5, [101.0, 245.0, 565.0, 479.0]),
2622 (2.0, [120.0, 300.0, 736.0, 608.0]),
2623 ] {
2624 for dpr in [1.0, 2.0] {
2625 let scale = zoom * (96.0 / 72.0) * dpr;
2626 let pixel = dpr / scale;
2627 let (frame, body_top) = outline_chrome(editor.active_outline(), pixel);
2628 for (index, coordinate) in frame.iter().enumerate() {
2629 let origin = if index % 2 == 0 { 48.0 } else { 83.0 };
2630 let screen = coordinate * scale / dpr + origin;
2631 assert!(
2632 (screen - expected[index]).abs() <= 1.5,
2633 "zoom={zoom} dpr={dpr} edge={index}: {screen} vs {}",
2634 expected[index]
2635 );
2636 }
2637 let header = [36.0, (frame[1] + body_top) / 2.0];
2638 assert!(
2639 matches!(page_hit_test(&editor, None, header, pixel), Some(Hit::Handle {id: hit, ..}) if hit == id)
2640 );
2641 assert!(matches!(
2642 page_hit_test(&editor, None, [frame[2] - 4.0 * pixel, header[1]], pixel),
2643 Some(Hit::Resize { id: hit, .. }) if hit == id
2644 ));
2645 let padding = [frame[0] + pixel, 90.0];
2646 assert!(
2647 matches!(page_hit_test(&editor, None, padding, pixel), Some(Hit::Text {id: hit, ..}) if hit == id)
2648 );
2649 assert_eq!(
2650 page_hit_test(&editor, None, [frame[0] - pixel, 90.0], pixel),
2651 None
2652 );
2653 let primitives =
2654 page_primitives(&editor, None, None, None, false, scale, dpr).unwrap();
2655 assert!(
2656 matches!(&primitives[0], Primitive::RoundedRect {rect, ..} if *rect == [frame[0], frame[1], frame[2], body_top])
2657 );
2658 assert!(
2659 primitives.iter().any(
2660 |p| matches!(p, Primitive::Text {origin, ..} if *origin == [36.0, 90.0])
2661 )
2662 );
2663 }
2664 }
2665 assert_eq!(editor.active_outline().document(), &document);
2666 assert_eq!(
2667 editor
2668 .active_outline()
2669 .layouts()
2670 .map(|(_, p)| p.text.id())
2671 .collect::<Vec<_>>(),
2672 layout_ids
2673 );
2674 }
2675
2676 #[test]
2677 fn a_later_text_body_takes_priority_over_an_earlier_drag_handle() {
2678 let mut engine = TextEngine::default();
2679 let mut editor = CanvasEditor::new(
2680 &mut engine,
2681 TextDocument::new(vec![Paragraph::new("first".into(), Default::default())]).unwrap(),
2682 240.0,
2683 )
2684 .unwrap();
2685 let first = editor.active_outline().id;
2686 editor.move_outline(first, [0.0, 20.0]).unwrap();
2687 assert_eq!(
2688 page_hit_test(&editor, None, [5.0, 12.0], 1.0),
2689 Some(Hit::Handle {
2690 id: first,
2691 grab: [5.0, -8.0]
2692 })
2693 );
2694 let second = editor
2695 .create_outline(&mut engine, [0.0, 10.0], 240.0)
2696 .unwrap();
2697 assert_eq!(
2698 page_hit_test(&editor, None, [5.0, 12.0], 1.0),
2699 Some(Hit::Text {
2700 id: second,
2701 point: [5.0, 2.0]
2702 })
2703 );
2704 editor.undo(&mut engine).unwrap();
2705 assert_eq!(
2706 page_hit_test(&editor, None, [5.0, 12.0], 1.0),
2707 Some(Hit::Handle {
2708 id: first,
2709 grab: [5.0, -8.0]
2710 })
2711 );
2712 }
2713
2714 #[test]
2715 fn overlapping_objects_follow_paint_order_through_creation_movement_and_undo() {
2716 use canvas::page::{Outline, PageObject, Unsupported};
2717 for readonly_on_top in [false, true] {
2718 let mut engine = TextEngine::default();
2719 let document = TextDocument::new(vec![Paragraph::new(
2720 "Imported text".into(),
2721 Default::default(),
2722 )])
2723 .unwrap();
2724 let id = onestore::ExGuid {
2725 guid: [5; 16],
2726 n: 1,
2727 };
2728 let mut objects = vec![
2729 PageObject::Outline(Outline {
2730 id,
2731 title: false,
2732 min_width: None,
2733 layout: onestore::document::Layout {
2734 x: Some(30.0),
2735 y: Some(40.0),
2736 max_width: Some(180.0),
2737 ..Default::default()
2738 },
2739 indents: vec![18.0, 0.0],
2740 paragraphs: document.nodes().to_vec(),
2741 unsupported: Vec::new(),
2742 }),
2743 PageObject::Unsupported(Unsupported {
2744 id: Default::default(),
2745 jcid: 0xdead,
2746 layout: onestore::document::Layout {
2747 x: Some(20.0),
2748 y: Some(20.0),
2749 max_width: Some(200.0),
2750 max_height: Some(100.0),
2751 ..Default::default()
2752 },
2753 }),
2754 ];
2755 if !readonly_on_top {
2756 objects.reverse();
2757 }
2758 let page = Page {
2759 created: None,
2760 title: String::new(),
2761 margin_origin: [0.0; 2],
2762 definitions: Default::default(),
2763 objects,
2764 };
2765 let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap();
2766 let scene = (scene, [0.0; 2]);
2767 let imported_layout = editor
2768 .active_outline()
2769 .layouts()
2770 .next()
2771 .unwrap()
2772 .1
2773 .text
2774 .id();
2775 let texts: Vec<_> = page_primitives(&editor, Some(&scene), None, None, false, 1.0, 1.0)
2776 .unwrap()
2777 .into_iter()
2778 .filter_map(|p| match p {
2779 Primitive::Text { layout, .. } => Some(layout.id()),
2780 _ => None,
2781 })
2782 .collect();
2783 assert_eq!(texts.len(), 2);
2784 assert_eq!(texts[usize::from(!readonly_on_top)], imported_layout);
2785 let expected = if readonly_on_top {
2786 Hit::ReadOnly(0)
2787 } else {
2788 Hit::Text {
2789 id,
2790 point: [10.0, 5.0],
2791 }
2792 };
2793 assert_eq!(
2794 page_hit_test(&editor, Some(&scene), [40.0, 45.0], 1.0),
2795 Some(expected)
2796 );
2797 let header = page_hit_test(&editor, Some(&scene), [40.0, 32.0], 1.0);
2798 if readonly_on_top {
2799 assert_eq!(header, Some(Hit::ReadOnly(0)));
2800 } else {
2801 assert_eq!(
2802 header,
2803 Some(Hit::Handle {
2804 id,
2805 grab: [10.0, -8.0]
2806 })
2807 );
2808 }
2809 let annotation = editor
2810 .create_outline(&mut engine, [30.0, 40.0], 180.0)
2811 .unwrap();
2812 assert_eq!(
2813 page_hit_test(&editor, Some(&scene), [40.0, 45.0], 1.0),
2814 Some(Hit::Text {
2815 id: annotation,
2816 point: [10.0, 5.0]
2817 })
2818 );
2819 assert_eq!(
2820 page_hit_test(&editor, Some(&scene), [40.0, 32.0], 1.0),
2821 Some(Hit::Handle {
2822 id: annotation,
2823 grab: [10.0, -8.0]
2824 })
2825 );
2826 let texts: Vec<_> = page_primitives(&editor, Some(&scene), None, None, false, 1.0, 1.0)
2827 .unwrap()
2828 .into_iter()
2829 .filter_map(|p| match p {
2830 Primitive::Text { layout, .. } => Some(layout.id()),
2831 _ => None,
2832 })
2833 .collect();
2834 assert_eq!(
2835 *texts.last().unwrap(),
2836 editor
2837 .active_outline()
2838 .layouts()
2839 .next()
2840 .unwrap()
2841 .1
2842 .text
2843 .id()
2844 );
2845 editor.move_outline(annotation, [500.0, 500.0]).unwrap();
2846 assert!(
2847 !matches!(page_hit_test(&editor, Some(&scene), [40.0,45.0], 1.0), Some(Hit::Text { id, .. }) if id == annotation)
2848 );
2849 editor.undo(&mut engine).unwrap();
2850 assert_eq!(
2851 page_hit_test(&editor, Some(&scene), [40.0, 45.0], 1.0),
2852 Some(Hit::Text {
2853 id: annotation,
2854 point: [10.0, 5.0]
2855 })
2856 );
2857 editor.undo(&mut engine).unwrap();
2858 assert_eq!(editor.outlines().len(), 1);
2859 assert_eq!(editor.active_outline().document(), &document);
2860 assert_eq!(
2861 page_hit_test(&editor, Some(&scene), [10.0, 10.0], 1.0),
2862 None
2863 );
2864 }
2865 }
2866
2867 #[test]
2868 fn read_only_focus_accepts_navigation_without_edit_shortcuts() {
2869 for modifiers in [
2870 ModifiersState::empty(),
2871 ModifiersState::SHIFT,
2872 ModifiersState::ALT,
2873 ModifiersState::SUPER,
2874 ModifiersState::SUPER | ModifiersState::SHIFT,
2875 ModifiersState::SUPER | ModifiersState::ALT,
2876 ] {
2877 for key in [
2878 Key::Character("a".into()),
2879 Key::Character("v".into()),
2880 Key::Character("c".into()),
2881 Key::Character("x".into()),
2882 Key::Character("z".into()),
2883 Key::Character("🌳".into()),
2884 Key::Named(NamedKey::Backspace),
2885 Key::Named(NamedKey::Delete),
2886 Key::Named(NamedKey::Enter),
2887 Key::Named(NamedKey::ArrowLeft),
2888 Key::Named(NamedKey::ArrowRight),
2889 Key::Named(NamedKey::ArrowUp),
2890 Key::Named(NamedKey::ArrowDown),
2891 ] {
2892 assert!(
2893 !read_only_shortcut(&key, modifiers),
2894 "{key:?} {modifiers:?}"
2895 );
2896 }
2897 }
2898 for (key, modifiers) in [
2899 (Key::Named(NamedKey::Escape), ModifiersState::empty()),
2900 (Key::Named(NamedKey::Tab), ModifiersState::CONTROL),
2901 (
2902 Key::Named(NamedKey::Tab),
2903 ModifiersState::CONTROL | ModifiersState::SHIFT,
2904 ),
2905 (
2906 Key::Character("N".into()),
2907 ModifiersState::SUPER | ModifiersState::SHIFT,
2908 ),
2909 (Key::Character("+".into()), ModifiersState::SUPER),
2910 (Key::Character("0".into()), ModifiersState::SUPER),
2911 (Key::Character("-".into()), ModifiersState::SUPER),
2912 ] {
2913 assert!(read_only_shortcut(&key, modifiers));
2914 }
2915 assert!(!read_only_shortcut(
2916 &Key::Character("n".into()),
2917 ModifiersState::SUPER
2918 ));
2919 assert!(!read_only_shortcut(
2920 &Key::Named(NamedKey::Tab),
2921 ModifiersState::empty()
2922 ));
2923 }
2924
2925 #[test]
2926 fn read_only_focus_retires_text_overlays_and_draws_a_scaled_focus_border() {
2927 use canvas::page::{PageObject, Unsupported};
2928 let mut engine = TextEngine::default();
2929 let mut editor = CanvasEditor::new(
2930 &mut engine,
2931 TextDocument::new(vec![Paragraph::new("Draft".into(), Default::default())]).unwrap(),
2932 240.0,
2933 )
2934 .unwrap();
2935 let page = Page {
2936 created: None,
2937 title: String::new(),
2938 margin_origin: [0.0; 2],
2939 definitions: Default::default(),
2940 objects: vec![PageObject::Unsupported(Unsupported {
2941 id: Default::default(),
2942 jcid: 0xdead,
2943 layout: onestore::document::Layout {
2944 x: Some(100.0),
2945 y: Some(80.0),
2946 max_width: Some(180.0),
2947 max_height: Some(80.0),
2948 ..Default::default()
2949 },
2950 })],
2951 };
2952 let scene = (PageScene::new(page, &mut engine).unwrap(), [20.0, 30.0]);
2953 let original = editor.active_outline().document().clone();
2954 let rectangles = |primitives: Vec<Primitive<'_>>| {
2955 primitives
2956 .into_iter()
2957 .filter_map(|p| match p {
2958 Primitive::Rect { rect, color }
2959 | Primitive::RoundedRect { rect, color, .. } => Some((rect, color)),
2960 _ => None,
2961 })
2962 .collect::<Vec<_>>()
2963 };
2964 for scale in [1.0, 2.0] {
2965 let text = rectangles(
2966 page_primitives(&editor, Some(&scene), None, None, true, scale, 1.0).unwrap(),
2967 );
2968 assert!(
2969 text.iter()
2970 .any(|(_, color)| *color == macos::text_colors()[0])
2971 );
2972 let selected = rectangles(
2973 page_primitives(&editor, Some(&scene), None, Some(0), true, scale, 1.0).unwrap(),
2974 );
2975 assert!(
2976 !selected
2977 .iter()
2978 .any(|(_, color)| *color == macos::text_colors()[0])
2979 );
2980 assert_eq!(
2981 selected,
2982 rectangles(
2983 page_primitives(&editor, Some(&scene), None, Some(0), false, scale, 1.0)
2984 .unwrap()
2985 )
2986 );
2987 assert_eq!(
2988 selected.last().unwrap().0,
2989 [300.0 - 2.0 / scale, 110.0, 300.0, 190.0]
2990 );
2991 }
2992 editor.select_all().unwrap();
2993 let text = rectangles(
2994 page_primitives(&editor, Some(&scene), None, None, false, 1.0, 1.0).unwrap(),
2995 );
2996 assert!(
2997 text.iter()
2998 .any(|(_, color)| *color == macos::text_colors()[1])
2999 );
3000 let selected = rectangles(
3001 page_primitives(&editor, Some(&scene), None, Some(0), true, 1.0, 1.0).unwrap(),
3002 );
3003 assert!(
3004 !selected
3005 .iter()
3006 .any(|(_, color)| *color == macos::text_colors()[1])
3007 );
3008 assert_eq!(editor.active_outline().document(), &original);
3009 }
3010
3011 #[test]
3012 #[ignore = "requires CANVAS_TEST_SECTION and CANVAS_TEST_PAGE private fixture inputs"]
3013 fn imported_page_widgets_and_accessibility_follow_edits() {
3014 let bytes = std::fs::read(std::env::var_os("CANVAS_TEST_SECTION").unwrap()).unwrap();
3015 let page = {
3016 let store = onestore::Store::parse(&bytes).unwrap();
3017 let index = onestore::RevisionIndex::parse(&store).unwrap();
3018 Page::from_document(
3019 &onestore::document::Document::parse(&index).unwrap(),
3020 &std::env::var("CANVAS_TEST_PAGE").unwrap(),
3021 )
3022 .unwrap()
3023 };
3024 drop(bytes);
3025 let mut engine = TextEngine::default();
3026 if let Some(font) = std::env::var_os("CANVAS_TEST_SUBSTITUTE") {
3027 engine
3028 .register_substitute(parley::fontique::Blob::new(Arc::new(
3029 std::fs::read(font).unwrap(),
3030 )))
3031 .unwrap();
3032 }
3033 let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap();
3034 let scene = (scene, [0.0; 2]);
3035 let viewport = Viewport {
3036 size: [1000, 720],
3037 scale: 96.0 / 72.0,
3038 origin: [48.0; 2],
3039 };
3040 let mut tag_hits = 0;
3041 for outline in editor.outlines() {
3042 for (_, paragraph) in outline.layouts() {
3043 for tag in &paragraph.tags {
3044 let point = [
3045 outline.origin()[0]
3046 + tag.origin[0]
3047 + canvas::outline::ParagraphTag::SIZE / 2.0,
3048 outline.origin()[1]
3049 + paragraph.origin[1]
3050 + tag.origin[1]
3051 + canvas::outline::ParagraphTag::SIZE / 2.0,
3052 ];
3053 assert!(
3054 matches!(page_hit_test(&editor, Some(&scene), point, 1.0), Some(Hit::Text { id, .. }) if id == outline.id),
3055 "Tag center {point:?} did not target outline {:?}",
3056 outline.id
3057 );
3058 tag_hits += 1;
3059 }
3060 }
3061 }
3062 eprintln!("Verified {tag_hits} imported tag-gutter targets");
3063 let mut access = accessibility::Accessibility::default();
3064 let tags = |editor: &CanvasEditor| {
3065 page_primitives(editor, Some(&scene), None, None, true, viewport.scale, 1.0)
3066 .unwrap()
3067 .into_iter()
3068 .filter_map(|primitive| match primitive {
3069 Primitive::Tag { tag, origin } => Some((tag.clone(), origin)),
3070 _ => None,
3071 })
3072 .collect::<Vec<_>>()
3073 };
3074 let original_tags = tags(&editor);
3075 let ids: Vec<_> = editor.outlines().iter().map(|outline| outline.id).collect();
3076 for id in ids {
3077 editor.focus_outline(id).unwrap();
3078 let original = editor.active_outline().document().clone();
3079 editor
3080 .select(
3081 [canvas::document::TextPosition {
3082 paragraph: 0,
3083 offset: 0,
3084 }; 2]
3085 .into(),
3086 )
3087 .unwrap();
3088 let before = access.update(&editor, viewport, "Test", None).unwrap();
3089 let before = accesskit_consumer::Tree::new(before, true);
3090 let before = before.state().focus().unwrap().document_range().text();
3091 editor.insert(&mut engine, "CANVAS CHECK ").unwrap();
3092 {
3093 let update = access.update(&editor, viewport, "Test", None).unwrap();
3094 let tree = accesskit_consumer::Tree::new(update, true);
3095 assert_eq!(
3096 tree.state().focus().unwrap().document_range().text(),
3097 format!("CANVAS CHECK {before}")
3098 );
3099 let primitives =
3100 page_primitives(&editor, Some(&scene), None, None, true, viewport.scale, 1.0)
3101 .unwrap();
3102 assert!(!primitives.is_empty());
3103 let edited_tags = tags(&editor);
3104 assert_eq!(edited_tags.len(), original_tags.len());
3105 for ((edited, _), (original, _)) in edited_tags.iter().zip(&original_tags) {
3106 assert_eq!(edited.icon, original.icon);
3107 assert_eq!(edited.label, original.label);
3108 assert_eq!(edited.disabled, original.disabled);
3109 }
3110 }
3111 editor.undo(&mut engine).unwrap();
3112 assert_eq!(editor.active_outline().document(), &original);
3113 assert_eq!(tags(&editor), original_tags);
3114 let update = access.update(&editor, viewport, "Test", None).unwrap();
3115 let tree = accesskit_consumer::Tree::new(update, true);
3116 assert_eq!(
3117 tree.state().focus().unwrap().document_range().text(),
3118 before
3119 );
3120 }
3121 eprintln!(
3122 "Rendered and restored {} imported outline widgets and accessibility fields",
3123 editor.outlines().len()
3124 );
3125 }
3126}
crates/snowbound/src/profile.rs created+192
...@@ -0,0 +1,192 @@
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 created+133
...@@ -0,0 +1,133 @@
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 for axis in 0..2 {
19 min[axis] = (min[axis] - 36.0) * viewport.scale;
20 max[axis] =
21 ((max[axis] + 36.0) * viewport.scale - viewport.size[axis] as f32).max(min[axis]);
22 }
23 Self { min, max }
24 }
25
26 pub fn clamp(&self, viewport: &mut Viewport) {
27 for axis in 0..2 {
28 viewport.origin[axis] = -(-viewport.origin[axis]).clamp(self.min[axis], self.max[axis]);
29 }
30 }
31
32 pub fn thumb(&self, viewport: Viewport, dpr: f32, axis: usize) -> Option<[f32; 4]> {
33 let range = self.max[axis] - self.min[axis];
34 let length = viewport.size[axis] as f32;
35 let track = length - 20.0 * dpr;
36 if range <= 0.0 || track <= 0.0 {
37 return None;
38 }
39 let size = (track * length / (length + range))
40 .max(28.0 * dpr)
41 .min(track);
42 let position = (-viewport.origin[axis] - self.min[axis]) / range;
43 let start = 4.0 * dpr + position.clamp(0.0, 1.0) * (track - size);
44 let cross = viewport.size[1 - axis] as f32 - 4.0 * dpr;
45 Some(if axis == 0 {
46 [start, cross - 6.0 * dpr, start + size, cross]
47 } else {
48 [cross - 6.0 * dpr, start, cross, start + size]
49 })
50 }
51
52 pub fn drag(&self, viewport: &mut Viewport, dpr: f32, axis: usize, pointer: f32, grab: f32) {
53 let Some(rect) = self.thumb(*viewport, dpr, axis) else {
54 return;
55 };
56 let travel = viewport.size[axis] as f32 - 20.0 * dpr - (rect[axis + 2] - rect[axis]);
57 if travel > 0.0 {
58 let fraction = ((pointer - grab - 4.0 * dpr) / travel).clamp(0.0, 1.0);
59 viewport.origin[axis] =
60 -(self.min[axis] + fraction * (self.max[axis] - self.min[axis]));
61 }
62 }
63
64 pub fn hit_test(&self, viewport: Viewport, dpr: f32, point: [f32; 2]) -> Option<(usize, f32)> {
65 (0..2).find_map(|axis| {
66 let rect = self.thumb(viewport, dpr, axis)?;
67 let inset = 3.0 * dpr;
68 ((rect[0] - inset..=rect[2] + inset).contains(&point[0])
69 && (rect[1] - inset..=rect[3] + inset).contains(&point[1]))
70 .then_some((axis, point[axis] - rect[axis]))
71 })
72 }
73
74 pub fn append(&self, viewport: Viewport, dpr: f32, primitives: &mut Vec<Primitive<'_>>) {
75 for axis in 0..2 {
76 if let Some(rect) = self.thumb(viewport, dpr, axis) {
77 let start = viewport.document_point([rect[0], rect[1]]);
78 let end = viewport.document_point([rect[2], rect[3]]);
79 primitives.push(Primitive::RoundedRect {
80 rect: [start[0], start[1], end[0], end[1]],
81 radius: [3.0 * dpr / viewport.scale; 2],
82 stroke: None,
83 color: [0.18, 0.18, 0.18, 0.45],
84 });
85 }
86 }
87 }
88}
89
90#[cfg(test)]
91mod tests {
92 use super::*;
93
94 #[test]
95 fn bounds_include_negative_objects_and_clamp_both_ends() {
96 let mut viewport = Viewport {
97 size: [800, 600],
98 scale: 2.0,
99 origin: [9999.0; 2],
100 };
101 let scroll = Scroll::new(viewport, [[-80.0, -20.0, 1000.0, 1200.0]].into_iter());
102 scroll.clamp(&mut viewport);
103 assert_eq!(viewport.origin, [232.0, 112.0]);
104 viewport.origin = [-9999.0; 2];
105 scroll.clamp(&mut viewport);
106 assert_eq!(viewport.origin, [-1272.0, -1872.0]);
107 }
108
109 #[test]
110 fn small_page_has_no_scrollbars_and_thumb_drag_reaches_endpoints() {
111 for dpr in [1.0, 2.0] {
112 let mut viewport = Viewport {
113 size: [(800.0 * dpr) as u32, (600.0 * dpr) as u32],
114 scale: dpr * 4.0 / 3.0,
115 origin: [48.0 * dpr; 2],
116 };
117 let scroll = Scroll::new(viewport, [[0.0, 0.0, 72.0, 14.0]].into_iter());
118 assert!(scroll.thumb(viewport, dpr, 0).is_none());
119 assert!(scroll.thumb(viewport, dpr, 1).is_none());
120 let scroll = Scroll::new(viewport, [[0.0, 0.0, 2000.0, 2000.0]].into_iter());
121 for axis in 0..2 {
122 scroll.drag(&mut viewport, dpr, axis, -1000.0, 3.0 * dpr);
123 assert_eq!(-viewport.origin[axis], scroll.min[axis]);
124 let rect = scroll.thumb(viewport, dpr, axis).unwrap();
125 assert!((rect[axis] - 4.0 * dpr).abs() < 0.001);
126 scroll.drag(&mut viewport, dpr, axis, 10000.0, 3.0 * dpr);
127 assert_eq!(-viewport.origin[axis], scroll.max[axis]);
128 let rect = scroll.thumb(viewport, dpr, axis).unwrap();
129 assert!((rect[axis + 2] - (viewport.size[axis] as f32 - 16.0 * dpr)).abs() < 0.001);
130 }
131 }
132 }
133}
fuzz/Cargo.lock+13-13
...@@ -77,6 +77,18 @@ dependencies = [...@@ -77,6 +77,18 @@ dependencies = [
77 "syn 3.0.5",77 "syn 3.0.5",
78]78]
7979
80[[package]]
81name = "canvas"
82version = "0.1.0"
83dependencies = [
84 "getrandom",
85 "onestore",
86 "parley",
87 "serde",
88 "serde_json",
89 "skrifa",
90]
91
80[[package]]92[[package]]
81name = "cbc"93name = "cbc"
82version = "0.2.1"94version = "0.2.1"
...@@ -616,18 +628,6 @@ version = "1.21.4"...@@ -616,18 +628,6 @@ version = "1.21.4"
616source = "registry+https://github.com/rust-lang/crates.io-index"628source = "registry+https://github.com/rust-lang/crates.io-index"
617checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"629checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
618630
619[[package]]
620name = "one-canvas"
621version = "0.1.0"
622dependencies = [
623 "getrandom",
624 "onestore",
625 "parley",
626 "serde",
627 "serde_json",
628 "skrifa",
629]
630
631[[package]]631[[package]]
632name = "onestore"632name = "onestore"
633version = "0.1.0"633version = "0.1.0"
...@@ -649,9 +649,9 @@ dependencies = [...@@ -649,9 +649,9 @@ dependencies = [
649name = "onestore-fuzz"649name = "onestore-fuzz"
650version = "0.0.0"650version = "0.0.0"
651dependencies = [651dependencies = [
652 "canvas",
652 "libfuzzer-sys",653 "libfuzzer-sys",
653 "md5",654 "md5",
654 "one-canvas",
655 "onestore",655 "onestore",
656 "onestore-offline",656 "onestore-offline",
657 "serde_json",657 "serde_json",
fuzz/Cargo.toml+1-1
...@@ -11,7 +11,7 @@ cargo-fuzz = true...@@ -11,7 +11,7 @@ cargo-fuzz = true
11libfuzzer-sys = "0.4"11libfuzzer-sys = "0.4"
12onestore = { path = "../crates/onestore", features = ["protected"] }12onestore = { path = "../crates/onestore", features = ["protected"] }
13onestore-offline = { path = "../crates/onestore-offline" }13onestore-offline = { path = "../crates/onestore-offline" }
14one-canvas = { path = "../crates/canvas" }14canvas = { path = "../crates/canvas" }
15tempfile = "3"15tempfile = "3"
16md5 = "0.8.1"16md5 = "0.8.1"
17serde_json = "1"17serde_json = "1"
fuzz/fuzz_targets/canvas_editor.rs+484-21
...@@ -1,13 +1,14 @@...@@ -1,13 +1,14 @@
1#![no_main]1#![no_main]
2use libfuzzer_sys::fuzz_target;2use canvas::{
3use one_canvas::{3 date::PageDate,
4 document::{TextDocument, TextPosition},4 document::{TextDocument, TextPosition},
5 editor::{CanvasEditor, TextOutline},5 editor::{CanvasEditor, Movement, SelectionUnit, TextOutline},
6 layout::TextEngine,6 layout::TextEngine,
7 text::Paragraph,7 text::Paragraph,
8};8};
9use libfuzzer_sys::fuzz_target;
9use onestore::document::Format;10use onestore::document::Format;
10use std::cell::RefCell;11use std::{cell::RefCell, collections::BTreeMap};
1112
12thread_local! {13thread_local! {
13 static ENGINE: RefCell<TextEngine> = RefCell::new(TextEngine::default());14 static ENGINE: RefCell<TextEngine> = RefCell::new(TextEngine::default());
...@@ -15,18 +16,244 @@ thread_local! {...@@ -15,18 +16,244 @@ thread_local! {
1516
16fuzz_target!(|input: &[u8]| {17fuzz_target!(|input: &[u8]| {
17 ENGINE.with_borrow_mut(|engine| {18 ENGINE.with_borrow_mut(|engine| {
18 let mut width =19 if input.first().is_some_and(|byte| byte & 32 != 0) {
19 [1.0, 10.0, 72.0, 240.0][usize::from(input.first().copied().unwrap_or(0)) % 4];20 let document = TextDocument::new(vec![Paragraph::new(
21 "ALPHA".into(),
22 Format {
23 font_size: (input[0] & 64 != 0).then_some(130.0),
24 ..Default::default()
25 },
26 )])
27 .unwrap();
28 let mut editor = CanvasEditor::new(engine, document, 180.0).unwrap();
29 for step in input.chunks_exact(4).take(24) {
30 let positions = editor
31 .active_outline()
32 .document()
33 .paragraphs()
34 .enumerate()
35 .flat_map(|(paragraph, text)| {
36 text.text()
37 .char_indices()
38 .map(move |(byte, _)| TextPosition {
39 paragraph,
40 offset: text.utf16_offset(byte).unwrap(),
41 })
42 .chain(std::iter::once(TextPosition {
43 paragraph,
44 offset: text.utf16_offset(text.text().len()).unwrap(),
45 }))
46 })
47 .collect::<Vec<_>>();
48 let focus = positions[usize::from(step[0]) % positions.len()];
49 let anchor = if step[3] & 16 == 0 {
50 focus
51 } else {
52 positions[usize::from(step[3]) % positions.len()]
53 };
54 editor.select([anchor, focus].into()).unwrap();
55 let before = editor.active_outline().document().clone();
56 let stored = editor
57 .outlines()
58 .iter()
59 .map(|outline| (outline.id, outline.document().clone()))
60 .collect::<Vec<_>>();
61 let selection = editor.selection();
62 let result = match step[1] % 10 {
63 0 => editor.tab(engine, false),
64 1 => editor.tab(engine, true),
65 2 => editor.enter(engine, false),
66 3 => editor.enter(engine, true),
67 4 => editor.insert(
68 engine,
69 ["x", "🧊", "left\nright", "العربية"][usize::from(step[2]) % 4],
70 ),
71 5 => editor.delete(engine, step[2] & 1 == 0).map(|_| ()),
72 6 => editor.indent(engine, step[2] & 1 == 0).map(|_| ()),
73 7 => editor
74 .move_selection(
75 engine,
76 [
77 Movement::Left,
78 Movement::Right,
79 Movement::Up,
80 Movement::Down,
81 ][usize::from(step[2]) % 4],
82 step[3] & 32 != 0,
83 )
84 .inspect_err(|error| {
85 assert!(!matches!(
86 error,
87 canvas::editor::EditorError::Edit(
88 canvas::text::EditError::InvalidStructure
89 )
90 ));
91 }),
92 8 => {
93 let unit = [
94 SelectionUnit::Grapheme,
95 SelectionUnit::Word,
96 SelectionUnit::Paragraph,
97 ][usize::from(step[2]) % 3];
98 editor
99 .selection_at(
100 f32::from(step[2]) - 32.0,
101 f32::from(step[3]) - 32.0,
102 unit,
103 )
104 .and_then(|selection| editor.select(selection))
105 .map_err(Into::into)
106 }
107 _ => editor.compose(engine, "🧊\n中".into(), 4..4).and_then(|_| {
108 if step[3] & 64 != 0 {
109 editor.cancel_composition(engine).map(|_| ())
110 } else {
111 editor.commit_text(engine, "done".into())
112 }
113 }),
114 };
115 if result.is_err() {
116 assert_eq!(editor.active_outline().document(), &before);
117 assert_eq!(editor.selection(), selection);
118 assert_eq!(
119 editor
120 .outlines()
121 .iter()
122 .map(|outline| (outline.id, outline.document().clone()))
123 .collect::<Vec<_>>(),
124 stored
125 );
126 continue;
127 }
128 let after = editor.active_outline().document().clone();
129 let after_selection = editor.selection();
130 let committed = editor
131 .outlines()
132 .iter()
133 .map(|outline| (outline.id, outline.document().clone()))
134 .collect::<Vec<_>>();
135 if committed != stored {
136 assert!(editor.undo(engine).unwrap());
137 assert_eq!(
138 editor
139 .outlines()
140 .iter()
141 .map(|outline| (outline.id, outline.document().clone()))
142 .collect::<Vec<_>>(),
143 stored
144 );
145 assert_eq!(editor.active_outline().document(), &before);
146 assert_eq!(editor.selection(), selection);
147 assert!(editor.redo(engine).unwrap());
148 assert_eq!(
149 editor
150 .outlines()
151 .iter()
152 .map(|outline| (outline.id, outline.document().clone()))
153 .collect::<Vec<_>>(),
154 committed
155 );
156 assert_eq!(editor.active_outline().document(), &after);
157 assert_eq!(editor.selection(), after_selection);
158 }
159 }
160 return;
161 }
162 let width = [1.0, 10.0, 72.0, 240.0][usize::from(input.first().copied().unwrap_or(0)) % 4];
20 let document = TextDocument::new(163 let document = TextDocument::new(
21 ["office e\u{301} 👩🏽‍💻", "العربية 日本"]164 ["office e\u{301} 👩🏽‍💻", "العربية 日本"]
22 .map(|text| Paragraph::new(text.into(), Format::default()))165 .map(|text| Paragraph::new(text.into(), Format::default()))
23 .into(),166 .into(),
24 )167 )
25 .unwrap();168 .unwrap();
26 let mut editor = CanvasEditor::new(engine, document, width).unwrap();169 let position = [
170 [0.0, 179.9, 180.0, 270.0][usize::from(input.first().copied().unwrap_or(0) >> 2) % 4],
171 158.4,
172 ];
173 let mut outline = TextOutline::new(engine, document, width, position).unwrap();
174 if input.first().is_some_and(|byte| byte & 128 != 0) {
175 let mut source = outline.snapshot();
176 source.title = true;
177 source.min_width = Some(162.0);
178 source.layout.max_width = None;
179 source.layout.width_set_by_user = None;
180 outline = TextOutline::from_outline(engine, &source, &BTreeMap::new()).unwrap();
181 }
182 let date = if input.first().is_some_and(|byte| byte & 64 != 0) {
183 let document = TextDocument::new(
184 ["Monday", "6:14 AM"]
185 .map(|text| Paragraph::new(text.into(), Format::default()))
186 .into(),
187 )
188 .unwrap();
189 let source = TextOutline::new(engine, document, 468.0, [0.0; 2])
190 .unwrap()
191 .snapshot();
192 Some(PageDate::new(100, source, engine, &BTreeMap::new()).unwrap())
193 } else {
194 None
195 };
196 let mut editor = if outline.title {
197 let mut fields = vec![outline.snapshot()];
198 let date_id = date.as_ref().map(|date| date.source().id);
199 if let Some(date) = &date {
200 fields.push(date.source().clone());
201 }
202 let mut objects = vec![canvas::page::PageObject::Title(canvas::page::Title {
203 id: onestore::ExGuid::default(),
204 layout: Default::default(),
205 date: date_id,
206 outlines: fields,
207 })];
208 for (index, (x, y, background)) in [
209 (120.0, 70.0, false),
210 (600.0, 180.0, false),
211 (120.0, 70.0, true),
212 ]
213 .into_iter()
214 .enumerate()
215 {
216 objects.push(canvas::page::PageObject::Image(canvas::page::Image {
217 id: onestore::ExGuid {
218 n: index.try_into().unwrap(),
219 ..Default::default()
220 },
221 layout: onestore::document::Layout {
222 x: Some(x),
223 y: Some(position[1] + y),
224 max_width: Some(72.0),
225 max_height: Some(40.0),
226 ..Default::default()
227 },
228 bytes: Some(std::sync::Arc::from(&b"deferred decoding"[..])),
229 background,
230 alt: None,
231 }));
232 }
233 CanvasEditor::from_page(
234 canvas::page::Page {
235 title: String::new(),
236 created: date.map(|date| date.timestamp()),
237 margin_origin: [0.0; 2],
238 definitions: BTreeMap::new(),
239 objects,
240 },
241 engine,
242 )
243 .unwrap()
244 } else {
245 CanvasEditor::from_text_outlines(vec![outline], BTreeMap::new(), date).unwrap()
246 };
247 let layouts = |editor: &CanvasEditor| {
248 editor
249 .object_layouts()
250 .map(|(id, layout)| (id, layout.clone()))
251 .collect::<Vec<_>>()
252 };
27 for step in input.chunks_exact(8).take(32) {253 for step in input.chunks_exact(8).take(32) {
254 let width = editor.active_outline().wrap_width();
28 if step[6] & 128 != 0 {255 if step[6] & 128 != 0 {
29 editor.place_caret(engine, [0.0; 2], width).unwrap();256 editor.place_caret(engine, position, width).unwrap();
30 }257 }
31 let mut positions = Vec::new();258 let mut positions = Vec::new();
32 let mut source = String::new();259 let mut source = String::new();
...@@ -47,6 +274,7 @@ fuzz_target!(|input: &[u8]| {...@@ -47,6 +274,7 @@ fuzz_target!(|input: &[u8]| {
47 let (focus, b) = positions[usize::from(step[1]) % positions.len()];274 let (focus, b) = positions[usize::from(step[1]) % positions.len()];
48 editor.select([anchor, focus].into()).unwrap();275 editor.select([anchor, focus].into()).unwrap();
49 let before = editor.active_outline().document().clone();276 let before = editor.active_outline().document().clone();
277 let layouts_before = layouts(&editor);
50 let selection = editor.selection();278 let selection = editor.selection();
51 let text = [279 let text = [
52 "",280 "",
...@@ -54,7 +282,7 @@ fuzz_target!(|input: &[u8]| {...@@ -54,7 +282,7 @@ fuzz_target!(|input: &[u8]| {
54 "e\u{301}",282 "e\u{301}",
55 "👩🏽‍💻",283 "👩🏽‍💻",
56 "\n",284 "\n",
57 "left\nright",285 "left\nright\u{000b}soft",
58 "العربية",286 "العربية",
59 "日本",287 "日本",
60 ][usize::from(step[2]) % 8];288 ][usize::from(step[2]) % 8];
...@@ -69,6 +297,7 @@ fuzz_target!(|input: &[u8]| {...@@ -69,6 +297,7 @@ fuzz_target!(|input: &[u8]| {
69 assert!(editor.cancel_composition(engine).unwrap());297 assert!(editor.cancel_composition(engine).unwrap());
70 assert_eq!(editor.active_outline().document(), &before);298 assert_eq!(editor.active_outline().document(), &before);
71 assert_eq!(editor.selection(), selection);299 assert_eq!(editor.selection(), selection);
300 assert_eq!(layouts(&editor), layouts_before);
72 }301 }
73 let format = Format {302 let format = Format {
74 font_size: Some(10.0 + f32::from(step[4] % 8)),303 font_size: Some(10.0 + f32::from(step[4] % 8)),
...@@ -96,14 +325,20 @@ fuzz_target!(|input: &[u8]| {...@@ -96,14 +325,20 @@ fuzz_target!(|input: &[u8]| {
96 source325 source
97 );326 );
98 let after = editor.active_outline().document().clone();327 let after = editor.active_outline().document().clone();
328 let layouts_after = layouts(&editor);
99 let after_selection = editor.selection();329 let after_selection = editor.selection();
100 assert!(editor.undo(engine).unwrap());330 assert!(editor.undo(engine).unwrap());
101 assert_eq!(editor.active_outline().document(), &before);331 assert_eq!(editor.active_outline().document(), &before);
102 assert_eq!(editor.selection(), selection);332 assert_eq!(editor.selection(), selection);
333 assert_eq!(layouts(&editor), layouts_before);
103 assert!(editor.redo(engine).unwrap());334 assert!(editor.redo(engine).unwrap());
104 assert_eq!(editor.active_outline().document(), &after);335 assert_eq!(editor.active_outline().document(), &after);
105 assert_eq!(editor.selection(), after_selection);336 assert_eq!(editor.selection(), after_selection);
106 if step[5] & 128 != 0 && editor.caret_outline().is_none() {337 assert_eq!(layouts(&editor), layouts_after);
338 if step[5] & 128 != 0
339 && editor.caret_outline().is_none()
340 && !editor.active_outline().title
341 {
107 let outlines = editor342 let outlines = editor
108 .outlines()343 .outlines()
109 .iter()344 .iter()
...@@ -144,11 +379,245 @@ fuzz_target!(|input: &[u8]| {...@@ -144,11 +379,245 @@ fuzz_target!(|input: &[u8]| {
144 }379 }
145 assert!(editor.undo(engine).unwrap());380 assert!(editor.undo(engine).unwrap());
146 }381 }
147 if step[6] & 1 != 0 {382 if step[6] & 1 != 0 && !editor.active_outline().title {
148 width = [1.0, 10.0, 72.0, 240.0][usize::from(step[7]) % 4];383 let width = [1.0, 10.0, 72.0, 240.0][usize::from(step[7]) % 4];
384 let before = editor.active_outline().layout().clone();
385 let document = editor.active_outline().document().clone();
386 let selection = editor.selection();
387 let preview = editor.preview_resize(engine, width).unwrap();
388 assert_eq!(editor.active_outline().layout(), &before);
389 assert_eq!(editor.active_outline().document(), &document);
149 editor.resize(engine, width).unwrap();390 editor.resize(engine, width).unwrap();
391 assert_eq!(editor.active_outline().layout(), preview.layout());
392 if before != *preview.layout() && editor.caret_outline().is_none() {
393 editor.undo(engine).unwrap();
394 assert_eq!(editor.active_outline().layout(), &before);
395 assert_eq!(editor.active_outline().document(), &document);
396 assert_eq!(editor.selection(), selection);
397 editor.redo(engine).unwrap();
398 assert_eq!(editor.active_outline().layout(), preview.layout());
399 }
150 }400 }
151 let rebuilt = CanvasEditor::new(engine, after, width).unwrap();401 if step[6] & 2 != 0 {
402 let before = editor
403 .outlines()
404 .iter()
405 .map(|outline| (outline.id, outline.document().clone()))
406 .collect::<Vec<_>>();
407 let movement = [
408 Movement::Left,
409 Movement::Right,
410 Movement::Up,
411 Movement::Down,
412 Movement::WordLeft,
413 Movement::WordRight,
414 Movement::LineStart,
415 Movement::LineEnd,
416 Movement::ParagraphStart,
417 Movement::ParagraphEnd,
418 Movement::DocumentStart,
419 Movement::DocumentEnd,
420 ][usize::from(step[7] >> 2) % 12];
421 editor
422 .move_selection(engine, movement, step[6] & 4 != 0)
423 .unwrap();
424 assert_eq!(
425 editor
426 .outlines()
427 .iter()
428 .map(|outline| (outline.id, outline.document().clone()))
429 .collect::<Vec<_>>(),
430 before
431 );
432 let caret = editor.caret(1.0).unwrap();
433 for unit in [
434 SelectionUnit::Grapheme,
435 SelectionUnit::Word,
436 SelectionUnit::Paragraph,
437 ] {
438 let selection = editor
439 .selection_at(caret.x0 as f32, ((caret.y0 + caret.y1) * 0.5) as f32, unit)
440 .unwrap();
441 editor.select(selection).unwrap();
442 }
443 }
444 if step[5] & 32 != 0 {
445 let id = editor.active_outline().id;
446 let point = editor
447 .outlines()
448 .iter()
449 .find(|outline| outline.id == id)
450 .map(|outline| {
451 [
452 outline.bounds().width() as f32 * 0.5,
453 outline.bounds().height() as f32
454 + [1.0, 20.0, 26.0, 28.0][usize::from(step[0]) % 4],
455 ]
456 })
457 .unwrap_or([0.0; 2]);
458 let stored = editor
459 .outlines()
460 .iter()
461 .map(|outline| {
462 (
463 outline.id,
464 outline.document().clone(),
465 outline.layout().clone(),
466 )
467 })
468 .collect::<Vec<_>>();
469 let before = editor.active_outline().document().clone();
470 let selection = editor.selection();
471 let extended = editor.select_below(engine, id, point).unwrap();
472 assert_eq!(
473 editor
474 .outlines()
475 .iter()
476 .map(|outline| (
477 outline.id,
478 outline.document().clone(),
479 outline.layout().clone()
480 ))
481 .collect::<Vec<_>>(),
482 stored
483 );
484 if !extended {
485 assert_eq!(editor.active_outline().document(), &before);
486 assert_eq!(editor.selection(), selection);
487 }
488 }
489 if editor
490 .outlines()
491 .iter()
492 .find(|outline| outline.id == editor.active_outline().id)
493 .is_some_and(|outline| outline.document() != editor.active_outline().document())
494 {
495 let pending = editor.active_outline().document().clone();
496 let stored = editor
497 .outlines()
498 .iter()
499 .map(|outline| (outline.id, outline.document().clone()))
500 .collect::<Vec<_>>();
501 editor
502 .move_selection(engine, Movement::DocumentEnd, false)
503 .unwrap();
504 let selection = editor.selection();
505 let mut expected = pending
506 .paragraphs()
507 .map(|paragraph| paragraph.text())
508 .collect::<Vec<_>>()
509 .join("\n");
510 expected.push('x');
511 editor.insert(engine, "x").unwrap();
512 assert_eq!(
513 editor
514 .active_outline()
515 .document()
516 .paragraphs()
517 .map(|paragraph| paragraph.text())
518 .collect::<Vec<_>>()
519 .join("\n"),
520 expected
521 );
522 assert!(editor.undo(engine).unwrap());
523 assert_eq!(editor.active_outline().document(), &pending);
524 assert_eq!(editor.selection(), selection);
525 assert_eq!(
526 editor
527 .outlines()
528 .iter()
529 .map(|outline| (outline.id, outline.document().clone()))
530 .collect::<Vec<_>>(),
531 stored
532 );
533 editor.place_caret(engine, [0.0; 2], width).unwrap();
534 }
535 if step[6] & 8 != 0 && editor.caret_outline().is_none() {
536 let before = editor.active_outline().document().clone();
537 let selection = editor.selection();
538 match editor.indent(engine, step[6] & 16 != 0) {
539 Ok(true) => {
540 let indented = editor.active_outline().document().clone();
541 assert!(editor.undo(engine).unwrap());
542 assert_eq!(editor.active_outline().document(), &before);
543 assert_eq!(editor.selection(), selection);
544 assert!(editor.redo(engine).unwrap());
545 assert_eq!(editor.active_outline().document(), &indented);
546 assert!(editor.undo(engine).unwrap());
547 }
548 Ok(false) | Err(_) => {
549 assert_eq!(editor.active_outline().document(), &before);
550 assert_eq!(editor.selection(), selection);
551 }
552 }
553 }
554 if step[6] & 32 != 0 {
555 let provisional = editor.caret_outline().is_some();
556 let stored = editor
557 .outlines()
558 .iter()
559 .map(|outline| (outline.id, outline.document().clone()))
560 .collect::<Vec<_>>();
561 let before = editor.active_outline().document().clone();
562 let selection = editor.selection();
563 let movement = [
564 Movement::WordLeft,
565 Movement::WordRight,
566 Movement::LineStart,
567 Movement::LineEnd,
568 ][usize::from(step[7]) % 4];
569 if editor.delete_to(engine, movement).unwrap() {
570 if provisional && editor.active_outline().is_empty() {
571 assert_eq!(
572 editor
573 .outlines()
574 .iter()
575 .map(|outline| (outline.id, outline.document().clone()))
576 .collect::<Vec<_>>(),
577 stored
578 );
579 } else {
580 assert!(editor.undo(engine).unwrap());
581 assert_eq!(editor.active_outline().document(), &before);
582 assert_eq!(editor.selection(), selection);
583 }
584 } else {
585 assert_eq!(editor.active_outline().document(), &before);
586 assert_eq!(editor.selection(), selection);
587 }
588 }
589 if step[5] & 64 != 0
590 && let Some(date) = editor.date()
591 {
592 let before = date.source().paragraphs.clone();
593 let timestamp = date.timestamp();
594 let selection = editor.selection();
595 let updated = timestamp.wrapping_add(1 + u64::from(step[0]));
596 assert!(
597 editor
598 .change_date(engine, updated, [text.into(), "7:15 AM".into()])
599 .unwrap()
600 );
601 let after = editor.date().unwrap().source().paragraphs.clone();
602 assert_eq!(editor.selection(), selection);
603 assert!(editor.undo(engine).unwrap());
604 assert_eq!(editor.date().unwrap().source().paragraphs, before);
605 assert_eq!(editor.date().unwrap().timestamp(), timestamp);
606 assert!(editor.redo(engine).unwrap());
607 assert_eq!(editor.date().unwrap().source().paragraphs, after);
608 assert_eq!(editor.date().unwrap().timestamp(), updated);
609 let date = editor.date().unwrap();
610 let rebuilt =
611 PageDate::new(updated, date.source().clone(), engine, &BTreeMap::new())
612 .unwrap();
613 assert_eq!(date.layout().size, rebuilt.layout().size);
614 }
615 let rebuilt = TextOutline::from_outline(
616 engine,
617 &editor.active_outline().snapshot(),
618 &BTreeMap::new(),
619 )
620 .unwrap();
152 let geometry = |outline: &TextOutline| {621 let geometry = |outline: &TextOutline| {
153 outline622 outline
154 .layouts()623 .layouts()
...@@ -173,14 +642,8 @@ fuzz_target!(|input: &[u8]| {...@@ -173,14 +642,8 @@ fuzz_target!(|input: &[u8]| {
173 })642 })
174 .collect::<Vec<_>>()643 .collect::<Vec<_>>()
175 };644 };
176 assert_eq!(645 assert_eq!(geometry(editor.active_outline()), geometry(&rebuilt));
177 geometry(editor.active_outline()),646 assert_eq!(editor.active_outline().bounds(), rebuilt.bounds());
178 geometry(rebuilt.active_outline())
179 );
180 assert_eq!(
181 editor.active_outline().bounds(),
182 rebuilt.active_outline().bounds()
183 );
184 let caret = editor.caret(1.0).unwrap();647 let caret = editor.caret(1.0).unwrap();
185 assert!(648 assert!(
186 [caret.x0, caret.y0, caret.x1, caret.y1]649 [caret.x0, caret.y0, caret.x1, caret.y1]
tools/canvas/README.md+38-36
...@@ -8,32 +8,34 @@ The host opens nonempty UTF-8 text as paragraphs in one temporary text outline....@@ -8,32 +8,34 @@ The host opens nonempty UTF-8 text as paragraphs in one temporary text outline.
88
9```sh9```sh
10python3 tools/canvas/build_macos.py10python3 tools/canvas/build_macos.py
11"target/One Canvas.app/Contents/MacOS/OneCanvas"11"target/Snowbound.app/Contents/MacOS/Snowbound"
12cargo test -p one-canvas12cargo test -p canvas
13cargo test -p one-canvas-gpu -- --ignored13cargo test -p canvas --features gpu gpu:: -- --ignored
14```14```
1515
16The builder ad-hoc signs and verifies the local bundle. To preserve an existing app during review, provide a new bundle path and a distinct identifier together:16The builder ad-hoc signs and verifies the local bundle. To preserve an existing app during review, provide a new bundle path and a distinct identifier together:
1717
18```sh18```sh
19python3 tools/canvas/build_macos.py --release --output '/PATH/One Canvas Review.app' --bundle-id dev.onecanvas.review19python3 tools/canvas/build_macos.py --release --output '/PATH/Snowbound Review.app' --bundle-id dev.snowbound.review
20```20```
2121
22The explicit output path must not exist. Its filename supplies the bundle's display name; the default build continues to update `target/One Canvas.app`. Local ad-hoc signing does not provide a Developer ID or notarization.22The explicit output path must not exist. Its filename supplies the bundle's display name; the default build continues to update `target/Snowbound.app`. Local ad-hoc signing does not provide a Developer ID or notarization.
2323
24Omit both arguments for a blank page with provisional caret placement, or provide a UTF-8 file and optional document width (default 480 pt). Typing, drag selection, arrow/word/line navigation, Backspace/Delete, clipboard shortcuts, Cmd-Z/Shift-Cmd-Z and IME use the shared editor. Scrolling pans; Cmd-wheel or Cmd-plus/minus zooms without changing document width, and Cmd-0 returns to 100%. The GPU test requires an available native adapter and compares readback pixels before/after repaint and forced glyph/image-cache eviction.24Control-Command-Space opens the macOS character picker for the active text field.
25
26Omit both arguments for a blank page with provisional caret placement, or provide a UTF-8 file and optional document width (default 480 pt). Typing, single/double/triple-click selection, drag selection, arrow/word/line/paragraph/document navigation, Backspace/Delete, word/line deletion, indentation, clipboard shortcuts, Cmd-Z/Shift-Cmd-Z and IME use the shared editor. Tab/Shift-Tab indent plain paragraphs; Enter retains their indentation. Cmd-Up/Down reach document boundaries, Option-Up/Down reach paragraph boundaries, and Home/End scroll without moving the caret. Mid-text Tab currently indents; OneNote’s table creation is not implemented. Scrolling is bounded by page objects, including negative coordinates, with draggable overlay indicators; Cmd-wheel or Cmd-plus/minus zooms without changing document width, and Cmd-0 returns to 100%. The GPU test requires an available native adapter and compares readback pixels before/after repaint and forced glyph/image-cache eviction.
2527
26```text28```text
27onestore::Format29onestore::Format
28 ↓30 ↓
29one-canvas text edits · inverse history · composition · paragraph layout · structural edits31canvas text edits · inverse history · composition · paragraph layout · structural edits
30 ↓32 ↓
31one-canvas-gpu Swash glyph rasterization · custom wgpu quads · bounded glyph/image caches33canvas::gpu Swash glyph rasterization · custom wgpu quads · bounded glyph/image caches
32 ↓34 ↓
33one-canvas-app Winit window/input · AppKit quit guard · clipboard · AccessKit35snowbound Winit window/input · AppKit quit guard · clipboard · AccessKit
34```36```
3537
36The renderer allocates a 2048² RGBA glyph atlas and a 2 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.38The 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.
3739
38Drawing accepts a flat sequence of positioned text layouts, tag icons, rectangles and immutable raster images. Consecutive primitives with the same texture share a batch without changing paint order. The host emits selection, composition and caret rectangles through that same interface; repainting multiple placements reuses the retained layout and glyph cache.40Drawing accepts a flat sequence of positioned text layouts, tag icons, rectangles and immutable raster images. Consecutive primitives with the same texture share a batch without changing paint order. The host emits selection, composition and caret rectangles through that same interface; repainting multiple placements reuses the retained layout and glyph cache.
3941
...@@ -52,11 +54,11 @@ Checkboxes display completion; noncheckable tags ignore the completion bit requi...@@ -52,11 +54,11 @@ Checkboxes display completion; noncheckable tags ignore the completion bit requi
52The host and `layout-probe` accept repeated `--substitute-font FONT_FILE` options. Supply Arimo regular/italic variable fonts or Carlito regular/bold/italic/bold-italic files. Each file is registered only in this process, as Arial or Calibri respectively; this explicitly replaces that family even when a system copy exists. Stored `Format.font` values remain unchanged. No font files are installed or bundled by this option.54The host and `layout-probe` accept repeated `--substitute-font FONT_FILE` options. Supply Arimo regular/italic variable fonts or Carlito regular/bold/italic/bold-italic files. Each file is registered only in this process, as Arial or Calibri respectively; this explicitly replaces that family even when a system copy exists. Stored `Format.font` values remain unchanged. No font files are installed or bundled by this option.
5355
54```sh56```sh
55"target/One Canvas.app/Contents/MacOS/OneCanvas" TEXT_FILE 240 --substitute-font ARIMO_FILE57"target/Snowbound.app/Contents/MacOS/Snowbound" TEXT_FILE 240 --substitute-font ARIMO_FILE
56cargo run -p one-canvas --bin layout-probe -- resources/canvas/text-cases.json --substitute-font ARIMO_FILE --substitute-font CARLITO_FILE58cargo run -p canvas --bin layout-probe -- resources/canvas/text-cases.json --substitute-font ARIMO_FILE --substitute-font CARLITO_FILE
57```59```
5860
59Arimo's horizontal-header ascent/descent match the measured Arial Windows extents; its larger OS/2 Windows extents do not. The substitute uses those horizontal-header extents without line gap, scoped to the registered font data. Carlito uses its OS/2 Windows extents. Other fonts selected for missing glyphs retain their own metrics, including emoji. The comparator reports actual `canvas_height` and `canvas_height_residual` separately from the raw Windows-table hypothesis.61Arimo's horizontal-header ascent/descent match the measured Arial Windows extents; its larger OS/2 Windows extents do not. The substitute uses those horizontal-header extents without line gap, scoped to the registered font data. Carlito uses its OS/2 Windows extents. Other text fallbacks retain their own metrics. Color emoji use the paragraph’s text baseline and scale within its line box; an explicit font-size change still changes line height. The comparator reports actual `canvas_height` and `canvas_height_residual` separately from the raw Windows-table hypothesis.
6062
61Arimo 1.341 and Carlito 1.104 match all 14 recoverable native ASCII wrap controls, with maximum outline-height residual 0.000054 pt. This does not establish matching coverage or advances for every script: the mixed Hebrew/Arabic control rewraps under substitution and resolves Arabic to Geeza Pro on this Mac. The 500-case Unicode source-coverage test passes with these substitutes across 1804 visual lines. Font files, hashes, native comparisons and host paste/undo captures remain in the external evidence bundle.63Arimo 1.341 and Carlito 1.104 match all 14 recoverable native ASCII wrap controls, with maximum outline-height residual 0.000054 pt. This does not establish matching coverage or advances for every script: the mixed Hebrew/Arabic control rewraps under substitution and resolves Arabic to Geeza Pro on this Mac. The 500-case Unicode source-coverage test passes with these substitutes across 1804 visual lines. Font files, hashes, native comparisons and host paste/undo captures remain in the external evidence bundle.
6264
...@@ -64,9 +66,9 @@ Arimo 1.341 and Carlito 1.104 match all 14 recoverable native ASCII wrap control...@@ -64,9 +66,9 @@ Arimo 1.341 and Carlito 1.104 match all 14 recoverable native ASCII wrap control
6466
65`TextDocument` owns a nonempty sequence of `PageParagraph` nodes, each with one rich-text object. Paragraph and rich-text identities survive edits; split/join preserves surviving identities and allocates new ones only for new objects. Construction and replacement reject duplicate identities, missing or forward parent links, and children at the same or shallower indentation level as their parent. In-paragraph replacement retains modeled hierarchy, lists, tags, collapse state and paragraph formatting. Structural replacement requires flat, untagged content. Document changes are internal to the canvas crate; callers edit through `CanvasEditor`.67`TextDocument` owns a nonempty sequence of `PageParagraph` nodes, each with one rich-text object. Paragraph and rich-text identities survive edits; split/join preserves surviving identities and allocates new ones only for new objects. Construction and replacement reject duplicate identities, missing or forward parent links, and children at the same or shallower indentation level as their parent. In-paragraph replacement retains modeled hierarchy, lists, tags, collapse state and paragraph formatting. Structural replacement requires flat, untagged content. Document changes are internal to the canvas crate; callers edit through `CanvasEditor`.
6668
67`CanvasEditor` owns independently positioned text outlines and one undo history, retaining one visible projection and layout per paragraph, with source UTF-16 selections and affinity at wrap boundaries. `CanvasEditor::from_outlines` takes owned outlines and their list/tag definitions, preserving source coordinates, widths, indentation and identities. Rendering and editing share `ParagraphLayout` shaping and outline arrangement, including list-marker height and adjacent spacing. Paragraph spacing contributes only between visible paragraphs; leading and trailing spacing properties remain stored without expanding the outline boundary. Collapsed descendants retain source nodes while navigation, hit-testing and selection geometry use visible paragraphs. LF insertion splits flat paragraphs; boundary Backspace/Delete joins them. Replacements stage every affected paragraph's layout before publishing the edit. Undo restores full affected nodes, including their identities, empty styles and reversed selections; untouched paragraphs retain their layouts. Resizing keeps source selection and history while recomputing geometry at the new width.69`CanvasEditor::from_page` owns the working page: text outlines, date fields, image payloads and positions, and retained unsupported objects. GPU support is optional. Text has one visible projection and layout per paragraph, with source UTF-16 selections and affinity at wrap boundaries. Title growth shifts eligible body objects across columns in the same undo transaction; shrinking keeps their positions, while undo and IME cancellation restore them. Background images remain fixed. `CanvasEditor::from_outlines` takes owned outlines and their list/tag definitions, preserving source coordinates, widths, indentation and identities. Rendering and editing share `ParagraphLayout` shaping and outline arrangement, including list-marker height and adjacent spacing. Paragraph spacing contributes only between visible paragraphs; leading and trailing spacing properties remain stored without expanding the outline boundary. Collapsed descendants retain source nodes while navigation, hit-testing and selection geometry use visible paragraphs. LF insertion splits flat paragraphs; boundary Backspace/Delete joins them. Replacements stage every affected paragraph's layout before publishing the edit. Undo restores full affected nodes, including their identities, empty styles and reversed selections; untouched paragraphs retain their layouts. Resizing keeps source selection and history while recomputing geometry at the new width.
6870
69The macOS host uses this same editor for typing, pointer/keyboard selection, clipboard text, IME, split/join and undo. Click blank canvas or press Cmd-Shift-N to place a provisional caret with a 240 pt text constraint; the keyboard command places it beside the focused outline or read-only object. The first committed input creates an outline in one undo step; placement alone preserves history and does not dirty the page. Drag an outline's top bar to move it on the default page grid. Hold Option for free pointer placement or dragging; Escape cancels a drag. Use Cmd-Option-arrows (1 pt; Shift makes it 10 pt). Ctrl-Tab and Ctrl-Shift-Tab cycle text outlines and read-only placeholders. A drag previews placement and commits one undo step on release; Escape or loss of window focus cancels the preview. Creation, movement and text edits share one history, while untouched outlines retain their text layouts. Composition can provisionally replace a selection spanning paragraphs; cancellation restores the original paragraphs, selection and redo, while commitment creates one undo step. Vertical navigation retains its preferred column across short or empty paragraphs. Horizontal navigation enters the neighboring paragraph at its visual edge, including when text direction changes.71The macOS host uses this same editor for typing, pointer/keyboard selection, clipboard text, IME, split/join and undo. Click blank canvas or press Cmd-Shift-N to place a provisional caret with an automatic width capped at 468 pt; the keyboard command places it beside the focused outline or read-only object. The first committed input creates an outline in one undo step; placement alone preserves history and does not dirty the page. Drag an outline's top bar to move it on the default page grid. Hold Option for free pointer placement or dragging; Escape cancels a drag. Use Cmd-Option-arrows (1 pt; Shift makes it 10 pt). Ctrl-Tab and Ctrl-Shift-Tab cycle text outlines and read-only placeholders. A drag previews placement and commits one undo step on release; Escape or loss of window focus cancels the preview. Creation, movement and text edits share one history, while untouched outlines retain their text layouts. Composition can provisionally replace a selection spanning paragraphs; cancellation restores the original paragraphs, selection and redo, while commitment creates one undo step. Vertical navigation retains its preferred column across short or empty paragraphs. Horizontal navigation enters the neighboring paragraph at its visual edge, including when text direction changes.
7072
71Core tests compare 220 Unicode range/replacement combinations with plain-text replacement, and compare incremental layout with full recomputation through a 64-edit structural history and an 80-step multi-outline history followed by complete undo/redo. They also exercise hidden-field mapping, paragraph-separator selection, grapheme deletion, multiline IME and failed-layout atomicity. Live macOS captures verify Enter, Backspace join, undo/redo, Option-E dead-key commit and the close guard. Native Pinyin input verifies Escape cancellation over single- and cross-paragraph selections: original text, selection and redo survive. Accepting a candidate across paragraphs commits one undo step. Input-event logs distinguish empty preedit cancellation from candidate commitment. Escape commits the spacing acute accent in both this host and a native TextEdit control; that dead-key case is not evidence of composition cancellation.73Core tests compare 220 Unicode range/replacement combinations with plain-text replacement, and compare incremental layout with full recomputation through a 64-edit structural history and an 80-step multi-outline history followed by complete undo/redo. They also exercise hidden-field mapping, paragraph-separator selection, grapheme deletion, multiline IME and failed-layout atomicity. Live macOS captures verify Enter, Backspace join, undo/redo, Option-E dead-key commit and the close guard. Native Pinyin input verifies Escape cancellation over single- and cross-paragraph selections: original text, selection and redo survive. Accepting a candidate across paragraphs commits one undo step. Input-event logs distinguish empty preedit cancellation from candidate commitment. Escape commits the spacing acute accent in both this host and a native TextEdit control; that dead-key case is not evidence of composition cancellation.
7274
...@@ -75,13 +77,13 @@ Core tests compare 220 Unicode range/replacement combinations with plain-text re...@@ -75,13 +77,13 @@ Core tests compare 220 Unicode range/replacement combinations with plain-text re
75The private import check edits and restores each body and title paragraph after dropping the source file bytes. It compares complete modeled nodes before/after undo and redo, including metadata on nested and tagged content. It exercises the document boundary without launching or modifying the live app:77The private import check edits and restores each body and title paragraph after dropping the source file bytes. It compares complete modeled nodes before/after undo and redo, including metadata on nested and tagged content. It exercises the document boundary without launching or modifying the live app:
7678
77```sh79```sh
78CANVAS_TEST_SECTION=PRIVATE_SECTION_COPY CANVAS_TEST_PAGE=EXACT_PAGE_TITLE cargo test -p one-canvas imported_nodes_preserve_identity_through_edit_and_undo -- --ignored --nocapture80CANVAS_TEST_SECTION=PRIVATE_SECTION_COPY CANVAS_TEST_PAGE=EXACT_PAGE_TITLE cargo test -p canvas imported_nodes_preserve_identity_through_edit_and_undo -- --ignored --nocapture
79```81```
8082
81The editor integration check additionally edits every visible body paragraph, compares incremental geometry with fresh outline layout, and requires undo/redo to restore the original/edited geometry exactly. All 269 body paragraphs in the three frozen references pass. Set `CANVAS_TEST_SUBSTITUTE` to the optional Carlito font file when reproducing that check:83The editor integration check additionally edits every visible body paragraph, compares incremental geometry with fresh outline layout, and requires undo/redo to restore the original/edited geometry exactly. All 269 body paragraphs in the three frozen references pass. Set `CANVAS_TEST_SUBSTITUTE` to the optional Carlito font file when reproducing that check:
8284
83```sh85```sh
84CANVAS_TEST_SECTION=PRIVATE_SECTION_COPY CANVAS_TEST_PAGE=EXACT_PAGE_TITLE CANVAS_TEST_SUBSTITUTE=CARLITO_REGULAR_FILE cargo test -p one-canvas imported_editor_reflows_and_restores_native_outline_geometry -- --ignored --nocapture86CANVAS_TEST_SECTION=PRIVATE_SECTION_COPY CANVAS_TEST_PAGE=EXACT_PAGE_TITLE CANVAS_TEST_SUBSTITUTE=CARLITO_REGULAR_FILE cargo test -p canvas imported_editor_reflows_and_restores_native_outline_geometry -- --ignored --nocapture
85```87```
8688
87## Native text accessibility89## Native text accessibility
...@@ -89,8 +91,8 @@ CANVAS_TEST_SECTION=PRIVATE_SECTION_COPY CANVAS_TEST_PAGE=EXACT_PAGE_TITLE CANVA...@@ -89,8 +91,8 @@ CANVAS_TEST_SECTION=PRIVATE_SECTION_COPY CANVAS_TEST_PAGE=EXACT_PAGE_TITLE CANVA
89AccessKit 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.91AccessKit 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.
9092
91```sh93```sh
92cargo test -p one-canvas-app94cargo test -p snowbound
93ONE_CANVAS_TRACE_INPUT=1 "target/One Canvas.app/Contents/MacOS/OneCanvas" SYNTHETIC_TEXT_FILE95SNOWBOUND_TRACE_INPUT=1 "target/Snowbound.app/Contents/MacOS/Snowbound" SYNTHETIC_TEXT_FILE
94```96```
9597
96Input tracing logs Winit keyboard/IME, pointer, modifier and focus events, AccessKit action payloads and applied accessibility selections to stderr, including text. Consumer tests check Unicode/hidden-field mappings, stale actions, undo, exact document text and transformed caret geometry in both affinities at wraps and bidirectional boundaries; native captures exercise AX value/selection setters. Those checks do not substitute for a VoiceOver walkthrough.98Input tracing logs Winit keyboard/IME, pointer, modifier and focus events, AccessKit action payloads and applied accessibility selections to stderr, including text. Consumer tests check Unicode/hidden-field mappings, stale actions, undo, exact document text and transformed caret geometry in both affinities at wraps and bidirectional boundaries; native captures exercise AX value/selection setters. Those checks do not substitute for a VoiceOver walkthrough.
...@@ -98,49 +100,49 @@ Input tracing logs Winit keyboard/IME, pointer, modifier and focus events, Acces...@@ -98,49 +100,49 @@ Input tracing logs Winit keyboard/IME, pointer, modifier and focus events, Acces
98## Reference pages in the macOS host100## Reference pages in the macOS host
99101
100```sh102```sh
101"target/One Canvas.app/Contents/MacOS/OneCanvas" --reference PRIVATE_SECTION_COPY EXACT_PAGE_TITLE --substitute-font CARLITO_REGULAR_FILE103"target/Snowbound.app/Contents/MacOS/Snowbound" --reference PRIVATE_SECTION_COPY EXACT_PAGE_TITLE --substitute-font CARLITO_REGULAR_FILE
102```104```
103105
104This integration mode keeps imported objects read-only and opens a separate editable canvas to their left. The initial notes width defaults to 240 pt in this mode, and the reference starts 24 pt to its right. Existing text-file and width arguments also work. Typing, outline creation/movement, paste and undo operate on the temporary notes. The close guard protects those notes. Reference text is rendered but is not exposed through the host’s editable accessibility fields.106This integration mode keeps imported objects read-only and opens a separate editable canvas to their left. The initial notes width defaults to 240 pt in this mode, and the reference starts 24 pt to its right. Existing text-file and width arguments also work. Typing, outline creation/movement, paste and undo operate on the temporary notes. The close guard protects those notes. Reference text is rendered but is not exposed through the host’s editable accessibility fields.
105107
106The host and offscreen renderer both use `one_canvas_gpu::page::PageScene`. Layout and image decoding happen when the scene is built; drawing reuses that data. Scene coordinates stay in source document points, while the comparison example applies its captured margin normalization through the viewport. All three shared-scene outputs are pixel-identical to their preceding title-rendering captures. Decoded image residency is capped at 64 MiB per scene, in addition to the renderer’s GPU cache budget; encoded source payloads and temporary decoder allocations are separate.108The host and offscreen renderer both use `canvas::gpu::page::PageScene`. The live scene retains decoded image assets and reads current geometry from `CanvasEditor`; the static reference scene owns its imported content. Layout and image decoding happen on construction; drawing reuses those caches. Scene coordinates stay in source document points, while the comparison example applies its captured margin normalization through the viewport. All three shared-scene outputs are pixel-identical to their preceding title-rendering captures. Decoded image residency is capped at 64 MiB per scene, in addition to the renderer’s GPU cache budget; encoded source payloads and temporary decoder allocations are separate.
107109
108The host sleeps its caret timer while macOS reports the window occluded and requests a redraw when visibility returns. Input tracing includes draw attempts, surface occlusion/timeouts and submitted presents; submission is not a measurement of display completion. Startup failures propagate a nonzero exit code after the event loop exits.110The host sleeps its caret timer while macOS reports the window occluded and requests a redraw when visibility returns. Input tracing includes draw attempts, surface occlusion/timeouts and submitted presents; submission is not a measurement of display completion. Startup failures propagate a nonzero exit code after the event loop exits.
109111
110## Editing an imported page112## Editing an imported page
111113
112```sh114```sh
113"target/One Canvas.app/Contents/MacOS/OneCanvas" --page PRIVATE_SECTION_COPY EXACT_PAGE_TITLE --substitute-font CARLITO_REGULAR_FILE115"target/Snowbound.app/Contents/MacOS/Snowbound" --page PRIVATE_SECTION_COPY EXACT_PAGE_TITLE --substitute-font CARLITO_REGULAR_FILE
114```116```
115117
116`--page` opens the imported body outlines as temporary editable objects in their source positions. It accepts a section copy and exact page title, without the separate text-file/width arguments. Titles, dates and images remain fixed. Character editing, selection, IME and undo use the same outline model as new pages. Existing structured or tagged paragraphs accept in-paragraph edits; their structural split/join remains restricted. Newly created flat outlines support the full structural workflow. The app never writes the section file.118`--page` opens supported body outlines and title text as temporary editable objects in their source positions. It accepts a section copy and exact page title, without the separate text-file/width arguments. Title text supports editing, wrapping, IME and undo, and remains present when empty. Date text follows its height; clicking the date or time opens the macOS date control and commits into the same undo history. Image editing is not exposed. Character editing, selection, IME and undo use the same outline model as new pages. Existing structured or tagged paragraphs accept in-paragraph edits; their structural split/join remains restricted. Newly created flat outlines support the full structural workflow. The app never writes the section file.
117119
118`PageScene::from_page` transfers body outlines and definitions to `CanvasEditor`, retaining static drawing data and ordered outline identities in the scene. Each dynamic slot invokes the host's outline painter; moving or editing an outline changes its drawing without re-decoding images or duplicating its paragraph layout. New outlines draw after the imported page. The close guard compares every outline's identity, layout and document against the opening state, so changing any imported outline or creating one requires confirmation before discarding.120`PageScene::from_page` constructs the editor-owned page and decodes its image assets. Imported paint order stays with the editor; each text slot invokes the host’s outline painter. Movement reads the current model geometry without decoding images again. New outlines draw after the imported page. The close guard compares all object layouts, editable text and the creation timestamp with the opening state, including displacement that remains after a title is shortened.
119121
120The app's private integration test exercises the actual outline painter and accessibility consumer for each imported outline, then verifies text restoration through undo:122The app's private integration test exercises the actual outline painter and accessibility consumer for each imported outline, then verifies text restoration through undo:
121123
122```sh124```sh
123CANVAS_TEST_SECTION=PRIVATE_SECTION_COPY CANVAS_TEST_PAGE=EXACT_PAGE_TITLE CANVAS_TEST_SUBSTITUTE=CARLITO_REGULAR_FILE cargo test -p one-canvas-app imported_page_widgets_and_accessibility_follow_edits -- --ignored --nocapture125CANVAS_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
124```126```
125127
126## Unsupported page content128## Unsupported page content
127129
128Unsupported top-level objects, outlines or titles render as read-only placeholders in source order. Missing image payloads or dimensions produce an image-unavailable placeholder. The scene owns the imported source values for those objects; the importer’s `Unsupported` record contains identity, class and layout metadata, not an opaque copy of unparsed notebook bytes. Supported body outlines still enter the editor. If none exist, the page receives a separate empty annotation outline to the right of the placeholders.130Unsupported top-level objects, outlines or titles render as read-only placeholders in source order. Missing image payloads or dimensions produce an image-unavailable placeholder. The editor owns the imported source values for those objects; the importer’s `Unsupported` record contains identity, class and layout metadata, not an opaque copy of unparsed notebook bytes. Supported body outlines still enter the editor. If none exist, a provisional caret appears to the right of the placeholders; typing creates an annotation outline.
129131
130Placeholders use source positions and at least 160 pt width, expanding their height to contain the status text. This is an explicit substitute for unavailable rendering, not a geometric reconstruction. Pointer presses focus placeholders, hide the previous text caret/selection and retire its IME composition. Typing, deletion, paste, text-selection commands, undo/redo and outline movement are suppressed while a placeholder has focus. Escape restores the previous text focus; Ctrl-Tab cycles through text outlines and placeholders. Zoom remains available, and Cmd-Shift-N creates an editable annotation beside the focused object. AccessKit exposes placeholder focus, status and bounds without text-edit actions; identities persist across editor updates and viewport changes. Focus reveals offscreen placeholders and suspends caret-blink scheduling. Hit-testing follows paint order for overlapping editable outlines and placeholders: new annotations draw last and receive hits first, while imported objects resolve from front to back. Each outline’s header, body and tag gutters participate at its own position in that order; static titles and decoded images have no editable hit target. Invalid geometry, malformed image data and resource-budget failures remain errors.132Placeholders use source positions and at least 160 pt width, expanding their height to contain the status text. This is an explicit substitute for unavailable rendering, not a geometric reconstruction. Pointer presses focus placeholders, hide the previous text caret/selection and retire its IME composition. Typing, deletion, paste, text-selection commands, undo/redo and outline movement are suppressed while a placeholder has focus. Escape restores the previous text focus; Ctrl-Tab cycles through text outlines and placeholders. Zoom remains available, and Cmd-Shift-N creates an editable annotation beside the focused object. AccessKit exposes placeholder focus, status and bounds without text-edit actions; identities persist across editor updates and viewport changes. Focus reveals offscreen placeholders and suspends caret-blink scheduling. Hit-testing follows paint order for overlapping editable outlines and placeholders: new annotations draw last and receive hits first, while imported objects resolve from front to back. Each body outline’s header, text and tag gutters participate at its own position in that order. Supported titles expose editable text without move/resize handles; decoded images have no editable hit target. Invalid geometry, malformed image data and resource-budget failures remain errors.
131133
132`TextOutline::from_outline` shapes supported content once before the scene transfers the prepared outline into `CanvasEditor::from_text_outlines`. The document and layout fields are read-only outside the editor, keeping their retained geometry tied to editor operations. The source-based `CanvasEditor::from_outlines` constructor uses the same path.134Document and layout values are read-only outside the editor; edits update their retained geometry together. `CanvasEditor::from_outlines` remains available for text-only construction.
133135
134## Owned page import136## Owned page import
135137
136`Page::from_revision` copies one page's text, outline hierarchy, list/tag definitions and image payloads into canvas-owned values. The source revision and file bytes can then be dropped. Hidden text has a separate visible projection with UTF-16 mappings on both sides of a hidden field; the full source paragraph remains available. This import path does not write a notebook.138`Page::from_revision` copies one page's text, outline hierarchy, list/tag definitions and image payloads into canvas-owned values. The source revision and file bytes can then be dropped. Hidden text has a separate visible projection with UTF-16 mappings on both sides of a hidden field; the full source paragraph remains available. This import path does not write a notebook.
137139
138```sh140```sh
139cargo run -p one-canvas --bin page-probe -- PRIVATE_SECTION_COPY EXACT_PAGE_TITLE > PRIVATE_PAGE_JSON141cargo run -p canvas --bin page-probe -- PRIVATE_SECTION_COPY EXACT_PAGE_TITLE > PRIVATE_PAGE_JSON
140python3 tools/canvas/compare_page.py PRIVATE_PAGE_JSON NATIVE_PAGE_XML > PRIVATE_COMPARISON_JSON142python3 tools/canvas/compare_page.py PRIVATE_PAGE_JSON NATIVE_PAGE_XML > PRIVATE_COMPARISON_JSON
141```143```
142144
143The page probe uses retained outline layout: cumulative indentation, collapsed descendants, adjacent paragraph spacing, minimum line spacing and bullet layout. Title containers retain their IDs, parent geometry and ordered child outlines. Children use intrinsic text width when no width is stored; each following child starts after the larger of the preceding child’s content height and stored suggested height. The probe emits title parent geometry once in `title_areas`, linked to the flattened outline diagnostics by ID. The comparator identifies outlines by their complete paragraph text and rejects ambiguous matches. `--allow-native-empty-nbsp` explicitly records the observed native conversion of a sole nonbreaking space to an empty paragraph; it does not report those paragraphs as exact text matches.145The page probe uses retained outline layout: cumulative indentation, collapsed descendants, adjacent paragraph spacing, minimum line spacing and bullet layout. Title containers retain their IDs, parent geometry and ordered child outlines. Children wrap at 468 pt when no width is stored; each following child starts after the larger of the preceding child’s content height and stored suggested height. The probe emits title parent geometry once in `title_areas`, linked to the flattened outline diagnostics by ID. The comparator identifies outlines by their complete paragraph text and rejects ambiguous matches. `--allow-native-empty-nbsp` explicitly records the observed native conversion of a sole nonbreaking space to an empty paragraph; it does not report those paragraphs as exact text matches.
144146
145The PDF companion verifies the matching native XML first, then recovers paragraph wraps from its adjacent PDF export:147The PDF companion verifies the matching native XML first, then recovers paragraph wraps from its adjacent PDF export:
146148
...@@ -155,7 +157,7 @@ Complete-outline text identifies repeated paragraphs. Otherwise, a paragraph nee...@@ -155,7 +157,7 @@ Complete-outline text identifies repeated paragraphs. Otherwise, a paragraph nee
155All three private reference pages pass owned import after the source document is dropped. All 15 body outline heights agree with native XML within 0.002 pt. Album's Courier New bullet height participates in the whole paragraph box; expanding the first text line instead incorrectly adds space to wrapped paragraphs. The importer retains note tags attached to rich-text objects as well as paragraph nodes. These are total-height measurements, not proof of matching line breaks or first baselines. Full reports remain in the external evidence bundle.157All three private reference pages pass owned import after the source document is dropped. All 15 body outline heights agree with native XML within 0.002 pt. Album's Courier New bullet height participates in the whole paragraph box; expanding the first text line instead incorrectly adds space to wrapped paragraphs. The importer retains note tags attached to rich-text objects as well as paragraph nodes. These are total-height measurements, not proof of matching line breaks or first baselines. Full reports remain in the external evidence bundle.
156158
157```sh159```sh
158cargo run -p one-canvas-gpu --example render_page -- PRIVATE_SECTION_COPY EXACT_PAGE_TITLE NEW_PNG_PATH160cargo run -p canvas --features gpu --example render_page -- PRIVATE_SECTION_COPY EXACT_PAGE_TITLE NEW_PNG_PATH
159```161```
160162
161This comparison renderer draws body text, title text, date/time fields, bullet glyphs, supported tag icons, highlights and images in source object order at 100% / 96 DPI into the frozen viewport size, writing only a new PNG. It normalizes the three measured page margin origins to 36 pt / 14.4 pt. The title container is placed relative to that normalized margin. The optional fourth argument is the physical Y position of the document origin (default zero). Image-region comparison against the frozen viewport finds native translations of +6 px for Video and +30 px for Lore. Those are measured capture transforms, not paragraph-baseline corrections or a general scrolling rule; the native screenshots and geometry reports accompany these images.163This comparison renderer draws body text, title text, date/time fields, bullet glyphs, supported tag icons, highlights and images in source object order at 100% / 96 DPI into the frozen viewport size, writing only a new PNG. It normalizes the three measured page margin origins to 36 pt / 14.4 pt. The title container is placed relative to that normalized margin. The optional fourth argument is the physical Y position of the document origin (default zero). Image-region comparison against the frozen viewport finds native translations of +6 px for Video and +30 px for Lore. Those are measured capture transforms, not paragraph-baseline corrections or a general scrolling rule; the native screenshots and geometry reports accompany these images.
...@@ -165,7 +167,7 @@ This comparison renderer draws body text, title text, date/time fields, bullet g...@@ -165,7 +167,7 @@ This comparison renderer draws body text, title text, date/time fields, bullet g
165`resources/canvas/text-cases.json` is the input shared by the Rust/Parley probe, the Swift/Core Text probe and the synthetic native-page generator. Probe coordinates are logical points, without pixel quantization. Both probes accept additional font-file paths after the JSON path and register those fonts only within the process.167`resources/canvas/text-cases.json` is the input shared by the Rust/Parley probe, the Swift/Core Text probe and the synthetic native-page generator. Probe coordinates are logical points, without pixel quantization. Both probes accept additional font-file paths after the JSON path and register those fonts only within the process.
166168
167```sh169```sh
168cargo run -p one-canvas --bin layout-probe -- resources/canvas/text-cases.json > PARLEY_JSON170cargo run -p canvas --bin layout-probe -- resources/canvas/text-cases.json > PARLEY_JSON
169swift tools/canvas/core_text_probe.swift resources/canvas/text-cases.json > CORE_TEXT_JSON171swift tools/canvas/core_text_probe.swift resources/canvas/text-cases.json > CORE_TEXT_JSON
170python3 tools/canvas/native_fixture.py resources/canvas/text-cases.json NEW_NOTEBOOK_DIRECTORY172python3 tools/canvas/native_fixture.py resources/canvas/text-cases.json NEW_NOTEBOOK_DIRECTORY
171python3 tools/native_runner.py NEW_NOTEBOOK_DIRECTORY NEW_CAPTURE_DIRECTORY --author tools/native/pages.ps1 --pdf --screenshots173python3 tools/native_runner.py NEW_NOTEBOOK_DIRECTORY NEW_CAPTURE_DIRECTORY --author tools/native/pages.ps1 --pdf --screenshots
...@@ -208,8 +210,8 @@ Full native captures, candidate font binaries/licenses and probe outputs remain...@@ -208,8 +210,8 @@ Full native captures, candidate font binaries/licenses and probe outputs remain
208The 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.210The 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.
209211
210```sh212```sh
211cargo test --release -p one-canvas-app canvas_pipeline_cost -- --ignored --nocapture --test-threads=1213cargo test --release -p snowbound canvas_pipeline_cost -- --ignored --nocapture --test-threads=1
212CANVAS_TEST_SECTION=PRIVATE_SECTION_COPY CANVAS_TEST_PAGE=EXACT_PAGE_TITLE CANVAS_TEST_SUBSTITUTE=CARLITO_REGULAR_FILE cargo test --release -p one-canvas-app canvas_pipeline_cost -- --ignored --nocapture --test-threads=1214CANVAS_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
213```215```
214216
215Private-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.217Private-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.
...@@ -217,8 +219,8 @@ Private-page mode reads the section once and measures 16 parse/open/close cycles...@@ -217,8 +219,8 @@ Private-page mode reads the section once and measures 16 parse/open/close cycles
217The GPU probe uses a 1386×759 offscreen target and runs 32 samples each for repeated drawing, vertical pan, eight zoom levels, forced eviction, horizontal/vertical offscreen placement, and native page reconstruction. Without private inputs it draws 2,000 synthetic text lines. The first repeated-draw sample includes initial pipeline and atlas work; later samples reuse the renderer. Native reconstruction preserves the text engine and renderer while replacing the decoded scene. Parsing, layout, primitive collection and logging are outside the measured draw interval.219The GPU probe uses a 1386×759 offscreen target and runs 32 samples each for repeated drawing, vertical pan, eight zoom levels, forced eviction, horizontal/vertical offscreen placement, and native page reconstruction. Without private inputs it draws 2,000 synthetic text lines. The first repeated-draw sample includes initial pipeline and atlas work; later samples reuse the renderer. Native reconstruction preserves the text engine and renderer while replacing the decoded scene. Parsing, layout, primitive collection and logging are outside the measured draw interval.
218220
219```sh221```sh
220cargo test --release -p one-canvas-gpu renderer_cost -- --ignored --nocapture222cargo test --release -p canvas --features gpu renderer_cost -- --ignored --nocapture
221CANVAS_TEST_SECTION=PRIVATE_SECTION_COPY CANVAS_TEST_PAGE=EXACT_PAGE_TITLE CANVAS_TEST_SUBSTITUTE=CARLITO_FILE cargo test --release -p one-canvas-gpu renderer_cost -- --ignored --nocapture223CANVAS_TEST_SECTION=PRIVATE_SECTION_COPY CANVAS_TEST_PAGE=EXACT_PAGE_TITLE CANVAS_TEST_SUBSTITUTE=CARLITO_FILE cargo test --release -p canvas --features gpu renderer_cost -- --ignored --nocapture
222```224```
223225
224Each `canvas_gpu_sample` row contains phase, sample, draw/submit nanoseconds, elapsed nanoseconds through queue completion, glyph/tag entries, occupied atlas texels, image entries, image texture bytes, vertices, batches, CPU vertex capacity bytes, CPU batch capacity bytes, and glyph/image table capacities. GPU resources are reported at their requested sizes; driver overhead, staging buffers, allocator overhead and the font scaler's internal cache are excluded. Queue completion includes CPU preparation and waiting; it is neither GPU execution time nor input-to-display latency. Each sample waits for the queue, so this experiment does not measure display frame pacing. Assertions enforce the existing entry, vertex and image-byte budgets throughout every phase.226Each `canvas_gpu_sample` row contains phase, sample, draw/submit nanoseconds, elapsed nanoseconds through queue completion, glyph/tag entries, occupied atlas texels, image entries, image texture bytes, vertices, batches, CPU vertex capacity bytes, CPU batch capacity bytes, and glyph/image table capacities. GPU resources are reported at their requested sizes; driver overhead, staging buffers, allocator overhead and the font scaler's internal cache are excluded. Queue completion includes CPU preparation and waiting; it is neither GPU execution time nor input-to-display latency. Each sample waits for the queue, so this experiment does not measure display frame pacing. Assertions enforce the existing entry, vertex and image-byte budgets throughout every phase.
tools/canvas/build_macos.py+5-5
...@@ -18,16 +18,16 @@ if bool(args.output) != bool(args.bundle_id):...@@ -18,16 +18,16 @@ if bool(args.output) != bool(args.bundle_id):
18if args.output and (args.output.suffix != '.app' or args.output.exists()):18if args.output and (args.output.suffix != '.app' or args.output.exists()):
19 parser.error('Choose a new output path ending in .app.')19 parser.error('Choose a new output path ending in .app.')
20root = Path(__file__).resolve().parents[2]20root = Path(__file__).resolve().parents[2]
21subprocess.run(['cargo', 'build', '-p', 'one-canvas-app'] + (['--release'] if args.release else []), cwd=root, check=True)21subprocess.run(['cargo', 'build', '-p', 'snowbound'] + (['--release'] if args.release else []), cwd=root, check=True)
22metadata = json.loads(subprocess.check_output(['cargo', 'metadata', '--format-version=1', '--no-deps'], cwd=root))22metadata = json.loads(subprocess.check_output(['cargo', 'metadata', '--format-version=1', '--no-deps'], cwd=root))
23target = Path(metadata['target_directory'])23target = Path(metadata['target_directory'])
24bundle = args.output.resolve() if args.output else target / 'One Canvas.app'24bundle = args.output.resolve() if args.output else target / 'Snowbound.app'
25if args.output:25if args.output:
26 bundle.mkdir(parents=True, exist_ok=False)26 bundle.mkdir(parents=True, exist_ok=False)
27binary = bundle / 'Contents/MacOS/OneCanvas'27binary = bundle / 'Contents/MacOS/Snowbound'
28binary.parent.mkdir(parents=True, exist_ok=True)28binary.parent.mkdir(parents=True, exist_ok=True)
29pending = binary.with_suffix('.next')29pending = binary.with_suffix('.next')
30shutil.copy2(target / ('release' if args.release else 'debug') / 'one-canvas-app', pending)30shutil.copy2(target / ('release' if args.release else 'debug') / 'snowbound', pending)
31pending.replace(binary)31pending.replace(binary)
32icon = 'Snowbound-Tahoe' if int(platform.mac_ver()[0].split('.')[0] or 0) >= 26 else 'Snowbound-Sequoia'32icon = 'Snowbound-Tahoe' if int(platform.mac_ver()[0].split('.')[0] or 0) >= 26 else 'Snowbound-Sequoia'
33resources = bundle / 'Contents/Resources'33resources = bundle / 'Contents/Resources'
...@@ -35,7 +35,7 @@ resources.mkdir(exist_ok=True)...@@ -35,7 +35,7 @@ resources.mkdir(exist_ok=True)
35shutil.copy2(root / 'crates/snowbound/assets/icon' / f'{icon}.icns', resources / 'Snowbound.icns')35shutil.copy2(root / 'crates/snowbound/assets/icon' / f'{icon}.icns', resources / 'Snowbound.icns')
36(bundle / 'Contents/Info.plist').write_bytes(plistlib.dumps({36(bundle / 'Contents/Info.plist').write_bytes(plistlib.dumps({
37 'CFBundleExecutable': binary.name,37 'CFBundleExecutable': binary.name,
38 'CFBundleIdentifier': args.bundle_id or 'dev.onecanvas.app',38 'CFBundleIdentifier': args.bundle_id or 'dev.snowbound.app',
39 'CFBundleName': bundle.stem,39 'CFBundleName': bundle.stem,
40 'CFBundleDisplayName': bundle.stem,40 'CFBundleDisplayName': bundle.stem,
41 'CFBundlePackageType': 'APPL',41 'CFBundlePackageType': 'APPL',