diff --git a/Cargo.lock b/Cargo.lock index 6951eb5a196f0692308a260b2badff91aecf79c5..eaf14dc49906654a7c04daa9ec40a2582f4b7966 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -389,6 +389,25 @@ dependencies = [ "wayland-client", ] +[[package]] +name = "canvas" +version = "0.1.0" +dependencies = [ + "bytemuck", + "getrandom 0.4.3", + "image", + "onestore", + "parley", + "png", + "pollster", + "roxmltree", + "serde", + "serde_json", + "skrifa", + "swash", + "wgpu", +] + [[package]] name = "cbc" version = "0.2.1" @@ -1887,53 +1906,6 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -[[package]] -name = "one-canvas" -version = "0.1.0" -dependencies = [ - "getrandom 0.4.3", - "onestore", - "parley", - "serde", - "serde_json", - "skrifa", -] - -[[package]] -name = "one-canvas-app" -version = "0.1.0" -dependencies = [ - "accesskit", - "accesskit_consumer", - "accesskit_winit", - "arboard", - "objc2 0.5.2", - "objc2-app-kit 0.2.2", - "objc2-foundation 0.2.2", - "one-canvas", - "one-canvas-gpu", - "onestore", - "parley", - "pollster", - "wgpu", - "winit", -] - -[[package]] -name = "one-canvas-gpu" -version = "0.1.0" -dependencies = [ - "bytemuck", - "image", - "one-canvas", - "onestore", - "parley", - "png", - "pollster", - "swash", - "wgpu", -] - [[package]] name = "onestore" version = "0.1.0" @@ -2665,6 +2637,25 @@ dependencies = [ "serde", ] +[[package]] +name = "snowbound" +version = "0.1.0" +dependencies = [ + "accesskit", + "accesskit_consumer", + "accesskit_winit", + "arboard", + "canvas", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "onestore", + "parley", + "pollster", + "wgpu", + "winit", +] + [[package]] name = "socket2" version = "0.6.5" diff --git a/crates/canvas-app/Cargo.toml b/crates/canvas-app/Cargo.toml deleted file mode 100644 index 3d7ecd43ec99bc56d8f1f3ac9b8fa7dc62e5bba2..0000000000000000000000000000000000000000 --- a/crates/canvas-app/Cargo.toml +++ /dev/null @@ -1,23 +0,0 @@ -[package] -name = "one-canvas-app" -version = "0.1.0" -edition = "2024" -publish = false - -[dependencies] -one-canvas = { path = "../canvas" } -one-canvas-gpu = { path = "../canvas-gpu" } -onestore = { path = "../onestore" } -pollster = "0.4" -wgpu.workspace = true -parley.workspace = true -winit = "0.30.13" -arboard = { version = "3.6.1", default-features = false } -accesskit = "0.25.0" -accesskit_winit = { version = "0.34.0", default-features = false, features = ["rwh_06"] } -objc2 = "0.5.2" -objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSAlert", "NSButton", "NSControl", "NSView", "NSResponder", "NSWindow", "NSApplication", "NSEvent"] } -objc2-foundation = { version = "0.2.2", default-features = false, features = ["NSString", "NSThread"] } - -[dev-dependencies] -accesskit_consumer = "0.39.0" diff --git a/crates/canvas-app/src/accessibility.rs b/crates/canvas-app/src/accessibility.rs deleted file mode 100644 index 7451d4321ca953048b23ad5395c446eb80fb8490..0000000000000000000000000000000000000000 --- a/crates/canvas-app/src/accessibility.rs +++ /dev/null @@ -1,1312 +0,0 @@ -use accesskit::{ - Action, Affine, Node, NodeId, Rect, Role, TextDirection, TextSelection, TreeId, TreeInfo, - TreeUpdate, -}; -use one_canvas::{ - document::TextPosition, - editor::{CanvasEditor, Selection, TextOutline}, - text::EditError, -}; -use one_canvas_gpu::Viewport; -use parley::Affinity; -use std::{collections::HashMap, ops::Range}; - -pub const ROOT: NodeId = NodeId(0); - -struct Run { - outline: onestore::ExGuid, - id: NodeId, - node: Node, - positions: Vec, -} - -#[derive(Hash, PartialEq, Eq)] -struct ParagraphKey { - layout: u64, - source: usize, - next: Option, - origin: [u32; 2], -} - -pub struct Accessibility { - outlines: Vec<(onestore::ExGuid, NodeId)>, - runs: Vec, - paragraphs: HashMap>, - read_only: Vec, - next_id: u64, -} - -impl Default for Accessibility { - fn default() -> Self { - Self { - outlines: Vec::new(), - runs: Vec::new(), - paragraphs: HashMap::new(), - read_only: Vec::new(), - next_id: 1, - } - } -} - -impl Accessibility { - /// Retire published runs without recycling their IDs. - pub fn deactivate(&mut self) { - self.runs.clear(); - self.paragraphs.clear(); - self.outlines.clear(); - self.read_only.clear(); - } - - pub fn append_read_only( - &mut self, - update: &mut TreeUpdate, - scene: Option<&(one_canvas_gpu::page::PageScene, [f32; 2])>, - viewport: Viewport, - focus: Option, - ) { - let mut count = 0; - if let Some((scene, offset)) = scene { - for (index, object) in scene.read_only().enumerate() { - if index == self.read_only.len() { - let id = self.allocate(); - self.read_only.push(id); - } - let id = self.read_only[index]; - let mut node = Node::new(Role::Label); - node.set_value(object.message); - node.set_read_only(); - node.add_action(Action::Focus); - if focus == Some(index) { - update.focus = id; - } - let [x0, y0, x1, y1] = object.rect; - let x = |v| f64::from((v + offset[0]) * viewport.scale + viewport.origin[0]); - let y = |v| f64::from((v + offset[1]) * viewport.scale + viewport.origin[1]); - node.set_bounds(Rect::new(x(x0), y(y0), x(x1), y(y1))); - update - .nodes - .iter_mut() - .find(|(id, _)| *id == ROOT) - .unwrap() - .1 - .push_child(id); - update.nodes.push((id, node)); - count += 1; - } - } - self.read_only.truncate(count); - } - - fn allocate(&mut self) -> NodeId { - let id = NodeId(self.next_id); - self.next_id = self - .next_id - .checked_add(1) - .expect("Accessibility node IDs exhausted"); - id - } - - pub fn read_only_for_node(&self, node: NodeId) -> Option { - self.read_only.iter().position(|id| *id == node) - } - - pub fn outline_for_node(&self, node: NodeId) -> Option { - self.outlines - .iter() - .find(|(_, id)| *id == node) - .map(|(outline, _)| *outline) - } - - pub fn update( - &mut self, - editor: &CanvasEditor, - viewport: Viewport, - title: &str, - preview: Option<(onestore::ExGuid, [f32; 2])>, - ) -> Result { - let mut runs = Vec::new(); - let mut outlines = Vec::new(); - let mut paragraph_ranges = HashMap::new(); - for outline in editor.outlines().iter().chain(editor.caret_outline()) { - let field = self - .outlines - .iter() - .find(|(id, _)| *id == outline.id) - .map(|(_, node)| *node) - .unwrap_or_else(|| self.allocate()); - outlines.push((outline.id, field)); - let mut paragraphs = outline.layouts().peekable(); - while let Some((paragraph, shaped)) = paragraphs.next() { - let layout = &shaped.text; - let origin = shaped.origin; - let key = ParagraphKey { - layout: layout.id(), - source: paragraph, - next: paragraphs.peek().map(|(next, _)| *next), - origin: origin.map(f32::to_bits), - }; - let start = runs.len(); - if let Some(range) = self.paragraphs.get(&key) { - runs.extend( - self.runs[range.clone()] - .iter() - .map(|run| (run.outline, run.node.clone(), run.positions.clone())), - ); - paragraph_ranges.insert(key, start..runs.len()); - continue; - } - let projection = &shaped.projection; - let text = projection.text().text(); - let boundaries: Vec<_> = projection.source_boundaries().collect(); - let source_position = |byte| -> Result { - let index = boundaries - .binary_search_by_key(&byte, |(byte, _)| *byte) - .map_err(|_| EditError::InvalidRange)?; - Ok(TextPosition { - paragraph, - offset: boundaries[index].1, - }) - }; - let line_count = layout.lines().count(); - for (line_index, (line, bounds)) in layout.lines().enumerate() { - let line_start = runs.len(); - let mut x = line.metrics().offset; - let mut logical_runs = Vec::new(); - for run in line.runs() { - let advance = run.advance(); - logical_runs.push((run, x)); - x += advance; - } - logical_runs.sort_by_key(|(run, _)| run.text_range().start); - for (run, x) in logical_runs { - // AccessKit stores each character's byte length in a u8. - let mut characters = Vec::new(); - for cluster in run.clusters() { - let range = cluster.text_range(); - if range.is_empty() || range.start == text.len() { - continue; - } - let cluster_text = - text.get(range.clone()).ok_or(EditError::InvalidRange)?; - if range.len() <= u8::MAX as usize { - characters.push(( - range, - cluster.advance(), - cluster.is_word_boundary(), - )); - } else { - for (index, (offset, ch)) in cluster_text.char_indices().enumerate() - { - characters.push(( - range.start + offset..range.start + offset + ch.len_utf8(), - if index == 0 { cluster.advance() } else { 0.0 }, - index == 0 && cluster.is_word_boundary(), - )); - } - } - } - let mut advance = 0.0; - for chunk in characters.chunks(256) { - let width: f32 = chunk.iter().map(|(_, width, _)| width).sum(); - let left = x + if run.is_rtl() { - run.advance() - advance - width - } else { - advance - }; - let mut node = Node::new(Role::TextRun); - node.set_bounds(Rect::new( - f64::from(left + origin[0]), - f64::from(bounds.top + origin[1]), - f64::from(left + width + origin[0]), - f64::from(bounds.top + bounds.height + origin[1]), - )); - node.set_text_direction(if run.is_rtl() { - TextDirection::RightToLeft - } else { - TextDirection::LeftToRight - }); - node.set_font_size(run.font_size()); - let start = chunk[0].0.start; - let end = chunk.last().unwrap().0.end; - node.set_value(text.get(start..end).ok_or(EditError::InvalidRange)?); - node.set_character_lengths( - chunk - .iter() - .map(|(range, _, _)| u8::try_from(range.len()).unwrap()) - .collect::>(), - ); - let mut offset = 0.0; - node.set_character_positions( - chunk - .iter() - .map(|(_, width, _)| { - let position = offset; - offset += width; - position - }) - .collect::>(), - ); - node.set_character_widths( - chunk.iter().map(|(_, width, _)| *width).collect::>(), - ); - node.set_word_starts( - chunk - .iter() - .enumerate() - .filter_map(|(index, (_, _, word))| word.then_some(index as u8)) - .collect::>(), - ); - let positions = chunk - .iter() - .map(|(range, _, _)| source_position(range.start)) - .chain(std::iter::once(source_position(end))) - .collect::, _>>()?; - runs.push((outline.id, node, positions)); - advance += width; - } - } - // Empty lines still need a text position for selection and insertion. - if runs.len() == line_start { - let mut node = Node::new(Role::TextRun); - node.set_value(""); - node.set_character_lengths(Vec::::new()); - node.set_character_positions(Vec::::new()); - node.set_character_widths(Vec::::new()); - node.set_text_direction(TextDirection::LeftToRight); - node.set_bounds(Rect::new( - f64::from(origin[0]), - f64::from(bounds.top + origin[1]), - f64::from(origin[0]), - f64::from(bounds.top + bounds.height + origin[1]), - )); - runs.push(( - outline.id, - node, - vec![source_position(bounds.source.start)?], - )); - } - if line_index + 1 == line_count - && let Some((next, _)) = paragraphs.peek() - { - let (_, node, source_positions) = runs.last_mut().unwrap(); - let mut value = node.value().unwrap().to_owned(); - value.push('\n'); - node.set_value(value); - let mut lengths = node.character_lengths().to_vec(); - lengths.push(1); - node.set_character_lengths(lengths); - if let Some(rect) = node.bounds() { - let mut positions = - node.character_positions().unwrap_or_default().to_vec(); - positions.push(rect.width() as f32); - node.set_character_positions(positions); - let mut widths = node.character_widths().unwrap_or_default().to_vec(); - widths.push(4.0); - node.set_character_widths(widths); - } - source_positions.push(TextPosition { - paragraph: *next, - offset: 0, - }); - } - } - if !shaped.tags.is_empty() { - let descriptions = shaped - .tags - .iter() - .map(|tag| { - use one_canvas::outline::TagIcon; - let fallback = match tag.icon { - TagIcon::CheckBox { .. } => "To do", - TagIcon::Question => "Question", - TagIcon::Music => "Music", - }; - let label = if tag.label.is_empty() { - fallback - } else { - &tag.label - }; - let state = match tag.icon { - TagIcon::CheckBox { checked: true } => ", completed", - TagIcon::CheckBox { checked: false } => ", incomplete", - _ => "", - }; - format!( - "{label}{state}{}", - if tag.disabled { ", disabled" } else { "" } - ) - }) - .collect::>() - .join("; "); - runs[start].1.set_description(descriptions); - } - paragraph_ranges.insert(key, start..runs.len()); - } - } - let mut runs: Vec<_> = runs - .into_iter() - .enumerate() - .map(|(index, (outline, mut node, positions))| { - node.clear_next_on_line(); - node.clear_previous_on_line(); - let id = match self.runs.get(index) { - Some(old) - if old.outline == outline - && old.positions == positions - && old.node.value() == node.value() => - { - old.id - } - _ => self.allocate(), - }; - Run { - outline, - id, - node, - positions, - } - }) - .collect(); - for index in 1..runs.len() { - if runs[index - 1].outline == runs[index].outline - && runs[index - 1].node.bounds().map(|r| r.y0) - == runs[index].node.bounds().map(|r| r.y0) - { - let (before, after) = runs.split_at_mut(index); - before[index - 1].node.set_next_on_line(after[0].id); - after[0].node.set_previous_on_line(before[index - 1].id); - } - } - self.runs = runs; - self.paragraphs = paragraph_ranges; - self.outlines = outlines; - let mut root = Node::new(Role::Window); - root.set_label(title); - root.set_children(self.outlines.iter().map(|(_, id)| *id).collect::>()); - root.set_bounds(Rect::new( - 0.0, - 0.0, - viewport.size[0] as f64, - viewport.size[1] as f64, - )); - let mut nodes = vec![(ROOT, root)]; - let mut focus = ROOT; - for (index, (outline, (_, id))) in editor - .outlines() - .iter() - .chain(editor.caret_outline()) - .zip(&self.outlines) - .enumerate() - { - let mut field = Node::new(Role::MultilineTextInput); - field.set_label( - if editor - .caret_outline() - .is_some_and(|caret| caret.id == outline.id) - { - "Text input".into() - } else { - format!("Text outline {}", index + 1) - }, - ); - field.add_action(Action::Focus); - field.add_action(Action::SetTextSelection); - field.add_action(Action::ReplaceSelectedText); - field.add_action(Action::SetValue); - let runs = self.runs.iter().filter(|run| run.outline == outline.id); - field.set_value( - runs.clone() - .filter_map(|run| run.node.value()) - .collect::(), - ); - field.set_children(runs.map(|run| run.id).collect::>()); - let origin = preview - .filter(|(id, _)| *id == outline.id) - .map(|(_, origin)| origin) - .unwrap_or_else(|| outline.origin()); - field.set_transform(Affine::new([ - f64::from(viewport.scale), - 0.0, - 0.0, - f64::from(viewport.scale), - f64::from(viewport.origin[0]) + f64::from(origin[0]) * f64::from(viewport.scale), - f64::from(viewport.origin[1]) + f64::from(origin[1]) * f64::from(viewport.scale), - ])); - let bounds = outline.bounds(); - field.set_bounds(Rect::new(0.0, 0.0, bounds.width(), bounds.height())); - if outline.id == editor.active_outline().id { - let Selection { - positions: [anchor, caret], - affinities, - } = editor.selection(); - field.set_text_selection(TextSelection { - anchor: self.position(outline, anchor, affinities[0])?, - focus: self.position(outline, caret, affinities[1])?, - }); - focus = *id; - } - nodes.push((*id, field)); - } - nodes.extend(self.runs.iter().map(|run| (run.id, run.node.clone()))); - Ok(TreeUpdate { - nodes, - tree: Some(TreeInfo::new(ROOT)), - tree_id: TreeId::ROOT, - focus, - }) - } - - fn position( - &self, - outline: &TextOutline, - mut position: TextPosition, - affinity: Affinity, - ) -> Result { - let shaped = outline.paragraph_layout(position.paragraph)?; - let projection = &shaped.projection; - let layout = &shaped.text; - let origin = shaped.origin; - let cursor = layout.cursor( - projection - .text() - .byte_offset(projection.visible_offset(position.offset)?)?, - affinity, - ); - position.offset = projection.source_offset( - projection.text().utf16_offset(cursor.index())?, - Affinity::Downstream, - )?; - let caret = layout.caret(cursor, 0.0); - let mut positions = self - .runs - .iter() - .filter(|run| { - run.outline == outline.id - && run - .node - .bounds() - .is_some_and(|rect| rect.y0 == f64::from(caret.y0 as f32 + origin[1])) - }) - .filter_map(|run| { - run.positions - .iter() - .position(|p| *p == position) - .map(|character_index| accesskit::TextPosition { - node: run.id, - character_index, - }) - }); - match affinity { - Affinity::Upstream => positions.next(), - Affinity::Downstream => positions.next_back(), - } - .ok_or(EditError::InvalidRange) - } - - pub fn selection( - &self, - outline: onestore::ExGuid, - selection: TextSelection, - ) -> Result { - let position = |position: accesskit::TextPosition| { - let run = self - .runs - .iter() - .find(|run| run.outline == outline && run.id == position.node) - .ok_or(EditError::InvalidRange)?; - let source = *run - .positions - .get(position.character_index) - .ok_or(EditError::InvalidRange)?; - let affinity = if position.character_index > 0 - && position.character_index + 1 == run.positions.len() - { - Affinity::Upstream - } else { - Affinity::Downstream - }; - Ok::<_, EditError>((source, affinity)) - }; - let (anchor, anchor_affinity) = position(selection.anchor)?; - let (focus, focus_affinity) = position(selection.focus)?; - Ok(Selection { - positions: [anchor, focus], - affinities: [anchor_affinity, focus_affinity], - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use one_canvas::{document::TextDocument, layout::TextEngine, text::Paragraph}; - use onestore::document::Format; - - #[test] - fn read_only_objects_keep_accessibility_identity_through_edits_and_view_changes() { - use one_canvas::page::{Page, PageObject, Unsupported}; - use one_canvas_gpu::page::PageScene; - let page = Page { - title: String::new(), - margin_origin: [0.0; 2], - definitions: Default::default(), - objects: vec![PageObject::Unsupported(Unsupported { - id: Default::default(), - jcid: 0xdead, - layout: onestore::document::Layout { - x: Some(10.0), - y: Some(20.0), - max_width: Some(200.0), - max_height: Some(60.0), - ..Default::default() - }, - })], - }; - let mut engine = TextEngine::default(); - let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap(); - let scene = (scene, [30.0, 40.0]); - let mut access = Accessibility::default(); - let mut identity = None; - for scale in [1.0, 2.0, 0.5] { - editor.insert(&mut engine, "annotation ").unwrap(); - let viewport = Viewport { - size: [800, 600], - origin: [-200.0, 10.0], - scale, - }; - let mut update = access.update(&editor, viewport, "Test", None).unwrap(); - access.append_read_only(&mut update, Some(&scene), viewport, None); - let (id, node) = update - .nodes - .iter() - .find(|(_, node)| node.role() == Role::Label) - .unwrap(); - if let Some(identity) = identity { - assert_eq!(*id, identity); - } - identity = Some(*id); - assert_eq!(node.value(), Some("Unsupported content\nRead-only")); - assert!(node.is_read_only()); - assert!(node.supports_action(Action::Focus)); - for action in [ - Action::SetValue, - Action::ReplaceSelectedText, - Action::SetTextSelection, - ] { - assert!(!node.supports_action(action)); - } - assert_eq!( - node.bounds(), - Some(Rect::new( - f64::from(40.0 * scale - 200.0), - f64::from(60.0 * scale + 10.0), - f64::from(240.0 * scale - 200.0), - f64::from(120.0 * scale + 10.0) - )) - ); - assert_eq!(access.outline_for_node(*id), None); - let tree = accesskit_consumer::Tree::new(update, true); - assert_eq!( - tree.state().focus().unwrap().document_range().text(), - "annotation " - ); - let mut focused = access.update(&editor, viewport, "Test", None).unwrap(); - access.append_read_only(&mut focused, Some(&scene), viewport, Some(0)); - assert_eq!(focused.focus, identity.unwrap()); - assert_eq!(access.read_only_for_node(focused.focus), Some(0)); - let tree = accesskit_consumer::Tree::new(focused, true); - assert_eq!( - tree.state().focus().unwrap().value().as_deref(), - Some("Unsupported content\nRead-only") - ); - editor.undo(&mut engine).unwrap(); - } - access.deactivate(); - assert!(access.read_only.is_empty()); - } - - #[test] - fn tag_descriptions_preserve_plain_text_and_survive_edit_undo_and_cache_reuse() { - use one_canvas::page::{Definition, Outline}; - use onestore::{ - ExGuid, - document::{Kind, Layout, Tag}, - }; - use std::collections::BTreeMap; - let text = TextDocument::new( - ["task one", "task two", "question", "music"] - .into_iter() - .map(|text| Paragraph::new(text.into(), Format::default())) - .collect(), - ) - .unwrap(); - let mut nodes = text.nodes().to_vec(); - let mut definitions = BTreeMap::new(); - for (index, (shape, label, status)) in [ - (3, Some("Rehearsal"), 0), - (3, Some("Rehearsal"), 1), - (15, None, 1), - (121, None, 3), - ] - .into_iter() - .enumerate() - { - let id = ExGuid { - n: index as u32 + 500, - ..ExGuid::default() - }; - definitions.insert( - id, - Definition { - kind: Kind::TagDefinition { - label: label.map(str::to_owned), - action_type: None, - shape: Some(shape), - color: None, - highlight: None, - }, - format: Format::default(), - }, - ); - nodes[index].text[0].tags.push(Tag { - definition: Some(id), - action_type: None, - status, - created: None, - completed: None, - start: None, - due: None, - task_id: None, - extra_set: 0, - }); - } - let mut engine = TextEngine::default(); - let mut editor = CanvasEditor::from_outlines( - &mut engine, - vec![Outline { - id: ExGuid { - n: 900, - ..ExGuid::default() - }, - layout: Layout { - max_width: Some(120.0), - ..Layout::default() - }, - indents: vec![18.0, 0.0], - paragraphs: nodes, - unsupported: Vec::new(), - }], - definitions, - ) - .unwrap(); - let mut access = Accessibility::default(); - let viewport = Viewport { - size: [800, 600], - origin: [48.0; 2], - scale: 2.0, - }; - for step in 0..4 { - if step == 2 { - editor.insert(&mut engine, "new ").unwrap(); - } - if step == 3 { - editor.undo(&mut engine).unwrap(); - } - let update = access.update(&editor, viewport, "Test", None).unwrap(); - assert_eq!( - update - .nodes - .iter() - .filter_map(|(_, n)| n.description()) - .collect::>(), - [ - "Rehearsal, incomplete", - "Rehearsal, completed", - "Question", - "Music, disabled" - ] - ); - let tree = accesskit_consumer::Tree::new(update, true); - let text = tree.state().focus().unwrap().document_range().text(); - assert_eq!( - text, - if step == 2 { - "new task one\ntask two\nquestion\nmusic" - } else { - "task one\ntask two\nquestion\nmusic" - } - ); - } - } - - #[test] - fn collapsed_paragraphs_keep_visible_accessibility_ranges_in_source_coordinates() { - let document = TextDocument::new( - [ - "root", - "folded", - "hidden", - "last", - "folded tail", - "hidden tail", - ] - .into_iter() - .map(|text| Paragraph::new(text.into(), Format::default())) - .collect(), - ) - .unwrap(); - let mut nodes = document.nodes().to_vec(); - for (child, parent, level) in [(1, 0, 2), (2, 1, 3), (4, 3, 2), (5, 4, 3)] { - nodes[child].parent = Some(nodes[parent].id); - nodes[child].level = level; - } - nodes[1].collapsed = true; - nodes[4].collapsed = true; - let mut engine = TextEngine::default(); - let mut editor = - CanvasEditor::new(&mut engine, TextDocument::from_nodes(nodes).unwrap(), 180.0) - .unwrap(); - let mut access = Accessibility::default(); - let viewport = Viewport { - size: [900, 700], - scale: 2.0, - origin: [13.0, 19.0], - }; - let update = access.update(&editor, viewport, "Test", None).unwrap(); - let tree = accesskit_consumer::Tree::new(update, true); - let field = tree.state().focus().unwrap(); - assert_eq!( - field.document_range().text(), - "root\nfolded\nlast\nfolded tail" - ); - assert!( - access - .runs - .iter() - .flat_map(|run| &run.positions) - .all(|position| ![2, 5].contains(&position.paragraph)) - ); - let source = access - .selection( - editor.active_outline().id, - field.document_range().to_text_selection(), - ) - .unwrap(); - assert_eq!( - source.positions[0], - TextPosition { - paragraph: 0, - offset: 0 - } - ); - assert_eq!( - source.positions[1], - TextPosition { - paragraph: 4, - offset: 11 - } - ); - editor.select(source).unwrap(); - let update = access.update(&editor, viewport, "Test", None).unwrap(); - let tree = accesskit_consumer::Tree::new(update, true); - assert_eq!( - tree.state() - .focus() - .unwrap() - .text_selection() - .unwrap() - .text(), - "root\nfolded\nlast\nfolded tail" - ); - editor - .select( - [TextPosition { - paragraph: 4, - offset: 0, - }; 2] - .into(), - ) - .unwrap(); - access.update(&editor, viewport, "Test", None).unwrap(); - let caret = editor.caret(1.0).unwrap(); - assert_eq!(caret.x0, 27.0); - let position = access - .position( - editor.active_outline(), - editor.selection().positions[1], - Affinity::Downstream, - ) - .unwrap(); - let run = access - .runs - .iter() - .find(|run| run.id == position.node) - .unwrap(); - assert_eq!(run.node.bounds().unwrap().x0, 27.0); - assert_eq!(run.node.bounds().unwrap().y0, caret.y0); - } - - #[test] - fn consumer_reads_unicode_blank_paragraphs_and_hidden_fields() { - let cases = [ - "", - "a\n\nשלום\nend", - "café e\u{301} 🌳 👩‍👩‍👧‍👦 words wrap here", - "\n\n", - "a\nb\n", - ]; - let mut engine = TextEngine::default(); - for text in cases - .into_iter() - .map(str::to_owned) - .chain([format!("a{}", "\u{301}".repeat(300)), "x".repeat(600)]) - { - for width in [24.0, 480.0, 10000.0] { - let document = TextDocument::new( - text.split('\n') - .map(|line| { - Paragraph::from_runs([ - ( - String::from("secret"), - Format { - hidden: Some(true), - ..Format::default() - }, - ), - (line.to_owned(), Format::default()), - ]) - }) - .collect(), - ) - .unwrap(); - let mut editor = CanvasEditor::new(&mut engine, document, width).unwrap(); - let mut access = Accessibility::default(); - let viewport = Viewport { - size: [800, 600], - origin: [48.0; 2], - scale: 2.0, - }; - let update = access.update(&editor, viewport, "Test", None).unwrap(); - for (_, node) in &update.nodes { - if node.role() == Role::TextRun { - assert_eq!( - node.character_lengths() - .iter() - .map(|n| *n as usize) - .sum::(), - node.value().unwrap().len() - ); - if let Some(positions) = node.character_positions() { - assert_eq!(positions.len(), node.character_lengths().len()); - } - if let Some(widths) = node.character_widths() { - assert_eq!(widths.len(), node.character_lengths().len()); - } - } - } - let tree = accesskit_consumer::Tree::new(update, true); - let field = tree.state().focus().unwrap(); - assert_eq!(field.document_range().text(), text, "width {width}"); - let selection = field.document_range().to_text_selection(); - let source_selection = access - .selection(editor.active_outline().id, selection) - .unwrap(); - editor.select(source_selection).unwrap(); - let selected = access.update(&editor, viewport, "Test", None).unwrap(); - let tree = accesskit_consumer::Tree::new(selected, true); - assert_eq!( - tree.state() - .focus() - .unwrap() - .text_selection() - .unwrap() - .text(), - text - ); - let stable_ids: Vec<_> = access.runs.iter().map(|run| run.id).collect(); - access - .update( - &editor, - Viewport { - scale: 3.0, - ..viewport - }, - "Test", - None, - ) - .unwrap(); - assert_eq!( - stable_ids, - access.runs.iter().map(|run| run.id).collect::>() - ); - access.deactivate(); - access.update(&editor, viewport, "Test", None).unwrap(); - assert!( - access - .selection(editor.active_outline().id, selection) - .is_err() - ); - let selection = access - .update(&editor, viewport, "Test", None) - .unwrap() - .nodes[1] - .1 - .text_selection() - .copied() - .unwrap(); - editor.insert(&mut engine, "replacement").unwrap(); - access.update(&editor, viewport, "Test", None).unwrap(); - assert!( - access - .selection(editor.active_outline().id, selection) - .is_err() - ); - editor.undo(&mut engine).unwrap(); - let update = access.update(&editor, viewport, "Test", None).unwrap(); - let tree = accesskit_consumer::Tree::new(update, true); - assert_eq!(tree.state().focus().unwrap().document_range().text(), text); - } - } - } - - #[test] - fn caret_geometry_uses_visible_offsets_and_canvas_line_metrics() { - let mut engine = TextEngine::default(); - let document = TextDocument::new(vec![ - Paragraph::from_runs([ - ("abc ".into(), Format::default()), - ( - "hidden field".into(), - Format { - hidden: Some(true), - ..Format::default() - }, - ), - ("e\u{301} 🌳 words wrap here".into(), Format::default()), - ]), - Paragraph::new("".into(), Format::default()), - Paragraph::new("abc שלום def عالم end".into(), Format::default()), - Paragraph::new("last\nline\n".into(), Format::default()), - ]) - .unwrap(); - let positions: Vec<_> = document - .paragraphs() - .enumerate() - .flat_map(|(paragraph, text)| { - text.text() - .char_indices() - .map(|(byte, _)| text.utf16_offset(byte).unwrap()) - .chain(std::iter::once( - text.utf16_offset(text.text().len()).unwrap(), - )) - .map(move |offset| TextPosition { paragraph, offset }) - }) - .collect(); - let mut editor = CanvasEditor::new(&mut engine, document, 48.0).unwrap(); - let mut access = Accessibility::default(); - let viewport = Viewport { - size: [800, 600], - origin: [31.0, -57.0], - scale: 2.5, - }; - for (position, affinity) in positions.into_iter().flat_map(|position| { - [Affinity::Upstream, Affinity::Downstream].map(|affinity| (position, affinity)) - }) { - editor - .select(Selection { - positions: [position; 2], - affinities: [affinity; 2], - }) - .unwrap(); - let caret = editor.caret(0.0).unwrap(); - let update = access.update(&editor, viewport, "Test", None).unwrap(); - let tree = accesskit_consumer::Tree::new(update, true); - let field = tree.state().focus().unwrap(); - assert_eq!( - field.document_range().text(), - "abc e\u{301} 🌳 words wrap here\n\nabc שלום def عالم end\nlast\nline\n" - ); - let rects = field.text_selection().unwrap().bounding_boxes(); - assert_eq!(rects.len(), 1, "{position:?}"); - let rect = rects[0]; - for (actual, expected) in [ - (rect.x0, caret.x0 * 2.5 + 31.0), - (rect.y0, caret.y0 * 2.5 - 57.0), - (rect.y1, caret.y1 * 2.5 - 57.0), - ] { - assert!( - (actual - expected).abs() < 0.0001, - "{position:?} {affinity:?}: {rect:?} vs {caret:?}" - ); - } - let selection = access - .selection( - editor.active_outline().id, - field.text_selection().unwrap().to_text_selection(), - ) - .unwrap(); - editor.select(selection).unwrap(); - assert_eq!( - editor.caret(0.0).unwrap(), - caret, - "{position:?} {affinity:?}: AX selection moved the caret" - ); - } - } - #[test] - fn keyboard_and_accessibility_preserve_visual_caret_at_wraps() { - use one_canvas::editor::Movement; - let mut engine = TextEngine::default(); - let document = TextDocument::new(vec![ - Paragraph::new( - "first words wrap here and continue".into(), - Format::default(), - ), - Paragraph::new("abc שלום def عالم end".into(), Format::default()), - Paragraph::new("last\nline".into(), Format::default()), - ]) - .unwrap(); - let mut editor = CanvasEditor::new(&mut engine, document, 72.0).unwrap(); - let mut access = Accessibility::default(); - let viewport = Viewport { - size: [800, 600], - origin: [0.0; 2], - scale: 1.0, - }; - for movement in [ - Movement::LineEnd, - Movement::Right, - Movement::Left, - Movement::Down, - Movement::LineStart, - Movement::LineEnd, - Movement::Down, - Movement::Down, - Movement::Down, - Movement::LineEnd, - Movement::Left, - Movement::Left, - Movement::Right, - Movement::LineStart, - Movement::Up, - Movement::Right, - Movement::LineEnd, - ] { - editor.move_selection(movement, false).unwrap(); - let caret = editor.caret(0.0).unwrap(); - let tree = accesskit_consumer::Tree::new( - access.update(&editor, viewport, "Test", None).unwrap(), - true, - ); - let field = tree.state().focus().unwrap(); - let selection = field.text_selection().unwrap(); - let rects = selection.bounding_boxes(); - assert_eq!(rects.len(), 1); - let rect = rects[0]; - for (actual, expected) in [ - (rect.x0, caret.x0), - (rect.y0, caret.y0), - (rect.y1, caret.y1), - ] { - assert!( - (actual - expected).abs() < 0.0001, - "{movement:?}: {rect:?} vs {caret:?}" - ); - } - let source_selection = access - .selection(editor.active_outline().id, selection.to_text_selection()) - .unwrap(); - editor.select(source_selection).unwrap(); - let restored = editor.caret(0.0).unwrap(); - assert_eq!( - restored, caret, - "{movement:?}: AX selection moved the caret" - ); - } - } - #[test] - fn cached_runs_match_reconstruction_through_editing_and_view_changes() { - let mut engine = TextEngine::default(); - let document = TextDocument::new( - [ - "one two three", - "", - "אבג 日本 👩🏽‍💻 e\u{301}", - "last paragraph", - ] - .into_iter() - .map(|text| Paragraph::new(text.into(), Format::default())) - .collect(), - ) - .unwrap(); - let mut editor = CanvasEditor::new(&mut engine, document, 72.0).unwrap(); - let mut cached = Accessibility::default(); - let mut rebuilt = Accessibility::default(); - let mut compare = |editor: &CanvasEditor, step: usize| { - let viewport = Viewport { - size: [800, 600], - origin: [step as f32 * 0.125, -17.25], - scale: 0.75 + (step % 5) as f32 * 0.5, - }; - let preview = step - .is_multiple_of(2) - .then_some((editor.active_outline().id, [13.5, -7.25])); - for repeat in 0..2 { - rebuilt.paragraphs.clear(); - let actual = cached.update(editor, viewport, "Test", preview).unwrap(); - let expected = rebuilt.update(editor, viewport, "Test", preview).unwrap(); - assert_eq!(actual.nodes, expected.nodes, "step {step}, repeat {repeat}"); - assert_eq!(actual.focus, expected.focus); - let tree = accesskit_consumer::Tree::new(actual, true); - let field = tree.state().focus().unwrap(); - let selection = field.document_range().to_text_selection(); - assert_eq!( - cached - .selection(editor.active_outline().id, selection) - .unwrap(), - rebuilt - .selection(editor.active_outline().id, selection) - .unwrap() - ); - let count = editor.outlines().iter().map(|o| o.layouts().count()).sum(); - assert_eq!(cached.paragraphs.len(), count); - } - }; - compare(&editor, 0); - let mut seed = 0x923e_u64; - let mut next = || { - seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); - (seed >> 32) as usize - }; - for step in 1..=160 { - let id = editor.outlines()[next() % editor.outlines().len()].id; - editor.focus_outline(id).unwrap(); - let positions: Vec<_> = editor - .active_outline() - .document() - .paragraphs() - .enumerate() - .flat_map(|(paragraph, text)| { - (0..=text.text().len()).filter_map(move |byte| { - text.utf16_offset(byte) - .ok() - .map(|offset| TextPosition { paragraph, offset }) - }) - }) - .collect(); - let anchor = positions[next() % positions.len()]; - let focus = if step % 3 == 0 { - positions[next() % positions.len()] - } else { - anchor - }; - editor.select([anchor, focus].into()).unwrap(); - match step % 10 { - 0 => editor.insert(&mut engine, "\n").unwrap(), - 1 => editor.insert(&mut engine, "abc 👩🏽‍💻 שלום e\u{301}").unwrap(), - 2 => { - editor.delete(&mut engine, true).unwrap(); - } - 3 => editor - .resize(&mut engine, 48.0 + (step % 7) as f32 * 31.25) - .unwrap(), - 4 => { - editor - .create_outline(&mut engine, [step as f32, -5.0], 97.0) - .unwrap(); - editor.insert(&mut engine, "annotation").unwrap(); - } - 5 => { - editor.undo(&mut engine).unwrap(); - } - 6 => { - editor.redo(&mut engine).unwrap(); - } - 7 => editor.move_outline(id, [17.5, step as f32]).unwrap(), - 8 => { - editor.compose(&mut engine, "仮\n名".into(), 1..1).unwrap(); - compare(&editor, step); - editor.cancel_composition(&mut engine).unwrap(); - } - _ => {} - } - compare(&editor, step); - } - while editor.undo(&mut engine).unwrap() { - compare(&editor, 161); - } - while editor.redo(&mut engine).unwrap() { - compare(&editor, 162); - } - } - #[test] - fn outlines_have_independent_accessibility_identity_and_selection() { - let mut engine = TextEngine::default(); - let document = - TextDocument::new(vec![Paragraph::new("lyrics".into(), Format::default())]).unwrap(); - let mut editor = CanvasEditor::new(&mut engine, document, 240.0).unwrap(); - let first = editor.active_outline().id; - let second = editor - .create_outline(&mut engine, [300.0, 40.0], 120.0) - .unwrap(); - editor.insert(&mut engine, "annotation 🌳").unwrap(); - let mut access = Accessibility::default(); - let viewport = Viewport { - size: [1000, 720], - scale: 2.0, - origin: [48.0; 2], - }; - let update = access.update(&editor, viewport, "Test", None).unwrap(); - let fields: Vec<_> = update - .nodes - .iter() - .filter(|(_, node)| node.role() == Role::MultilineTextInput) - .map(|(id, node)| (*id, node.value().unwrap().to_owned())) - .collect(); - assert_eq!( - fields - .iter() - .map(|(_, text)| text.as_str()) - .collect::>(), - ["lyrics", "annotation 🌳"] - ); - assert_ne!(fields[0].0, fields[1].0); - assert_eq!(access.outline_for_node(fields[0].0), Some(first)); - assert_eq!(access.outline_for_node(fields[1].0), Some(second)); - let tree = accesskit_consumer::Tree::new(update, true); - let field = tree.state().focus().unwrap(); - assert_eq!(field.document_range().text(), "annotation 🌳"); - let selection = field.document_range().to_text_selection(); - assert!(access.selection(first, selection).is_err()); - let source_selection = access.selection(second, selection).unwrap(); - editor.select(source_selection).unwrap(); - let preview = access - .update(&editor, viewport, "Test", Some((second, [400.0, 50.0]))) - .unwrap(); - let preview_tree = accesskit_consumer::Tree::new(preview, true); - let preview_field = preview_tree.state().focus().unwrap(); - let rect = preview_field.document_range().bounding_boxes()[0]; - assert_eq!(rect.x0, 848.0); - assert_eq!(rect.y0, 148.0); - assert_eq!(editor.active_outline().origin(), [300.0, 40.0]); - editor.move_outline(second, [400.0, 50.0]).unwrap(); - access.update(&editor, viewport, "Test", None).unwrap(); - assert_eq!(access.outline_for_node(fields[1].0), Some(second)); - assert!(access.selection(second, selection).is_ok()); - editor.undo(&mut engine).unwrap(); - editor.undo(&mut engine).unwrap(); - editor.undo(&mut engine).unwrap(); - let update = access.update(&editor, viewport, "Test", None).unwrap(); - assert_eq!( - update - .nodes - .iter() - .filter(|(_, node)| node.role() == Role::MultilineTextInput) - .count(), - 1 - ); - assert_eq!(access.outline_for_node(fields[1].0), None); - assert!(access.selection(second, selection).is_err()); - assert_eq!(access.outline_for_node(fields[0].0), Some(first)); - } -} diff --git a/crates/canvas-app/src/macos.rs b/crates/canvas-app/src/macos.rs deleted file mode 100644 index 78f755cae0371a6370a9cde86cf435ef818f94aa..0000000000000000000000000000000000000000 --- a/crates/canvas-app/src/macos.rs +++ /dev/null @@ -1,102 +0,0 @@ -use objc2::{ - ClassType, DeclaredClass, declare_class, msg_send, msg_send_id, mutability, - rc::{Allocated, Retained}, - runtime::AnyObject, -}; -use objc2_app_kit::{NSAlert, NSAlertSecondButtonReturn, NSApplication, NSEvent, NSEventType}; -use objc2_foundation::{MainThreadMarker, NSString}; -use std::sync::OnceLock; -use winit::{ - error::EventLoopError, - event_loop::{EventLoop, EventLoopProxy}, - raw_window_handle::{HasWindowHandle, RawWindowHandle}, - window::Window, -}; - -static QUIT: OnceLock> = OnceLock::new(); - -declare_class!( - struct CanvasApplication; - - unsafe impl ClassType for CanvasApplication { - type Super = NSApplication; - type Mutability = mutability::MainThreadOnly; - const NAME: &'static str = "OneCanvasApplication"; - } - - impl DeclaredClass for CanvasApplication { - type Ivars = (); - } - - unsafe impl CanvasApplication { - #[method_id(init)] - fn init(this: Allocated) -> Retained { - let this = this.set_ivars(()); - unsafe { msg_send_id![super(this), init] } - } - - #[method(terminate:)] - fn terminate(&self, _sender: Option<&AnyObject>) { - if let Some(proxy) = QUIT.get() { let _ = proxy.send_event(crate::UserEvent::Quit); } - } - - // NSAlert consumes Escape before it reaches cancelOperation:. - #[method(sendEvent:)] - fn send_event(&self, event: &NSEvent) { - unsafe { - if event.r#type() == NSEventType::KeyDown - && self.modalWindow().is_some() - && event.charactersIgnoringModifiers().is_some_and(|text| text.to_string() == "\u{1b}") - { - self.stopModal(); - } else { - let _: () = msg_send![super(self), sendEvent: event]; - } - } - } - } -); - -pub fn event_loop() -> Result, EventLoopError> { - MainThreadMarker::new().expect("AppKit must start on the main thread"); - // Create the application's own subclass before Winit obtains the singleton. - let _: Retained = - unsafe { msg_send_id![CanvasApplication::class(), sharedApplication] }; - let event_loop = EventLoop::with_user_event().build()?; - QUIT.set(event_loop.create_proxy()) - .expect("Only one application event loop is created"); - Ok(event_loop) -} - -pub fn discard_changes() -> bool { - let mtm = MainThreadMarker::new().expect("Window events run on the main thread"); - // The alert and its strings stay on AppKit's main thread for the modal call. - unsafe { - let alert = NSAlert::new(mtm); - alert.setMessageText(&NSString::from_str("Discard this page?")); - alert.setInformativeText(&NSString::from_str( - "This temporary page has no saved copy. Closing it will discard your edits.", - )); - alert.addButtonWithTitle(&NSString::from_str("Keep Editing")); - alert.addButtonWithTitle(&NSString::from_str("Discard Changes")); - alert.runModal() == NSAlertSecondButtonReturn - } -} - -/// End AppKit preedit after a canvas action commits or cancels the composition. -pub fn clear_marked_text(window: &Window) { - MainThreadMarker::new().expect("Text input belongs to the main thread"); - let RawWindowHandle::AppKit(handle) = - window.window_handle().expect("Live AppKit window").as_raw() - else { - unreachable!() - }; - // Winit owns this view and implements the NSTextInputClient selectors. - unsafe { - let view = &*handle.ns_view.as_ptr().cast::(); - let marked: bool = msg_send![view, hasMarkedText]; - if marked { - let _: () = msg_send![view, unmarkText]; - } - } -} diff --git a/crates/canvas-app/src/main.rs b/crates/canvas-app/src/main.rs deleted file mode 100644 index d1601e1cf218172735c63fa6b31e77e25885cc83..0000000000000000000000000000000000000000 --- a/crates/canvas-app/src/main.rs +++ /dev/null @@ -1,2077 +0,0 @@ -mod accessibility; -mod macos; -#[cfg(test)] -mod profile; - -use one_canvas::{ - document::TextDocument, - editor::{CanvasEditor, Movement, TextOutline}, - layout::TextEngine, - page::Page, - text::Paragraph, -}; -use one_canvas_gpu::{Primitive, Renderer, Viewport, page::PageScene}; -use onestore::document::Format; -use std::{ - error::Error, - path::PathBuf, - sync::Arc, - time::{Duration, Instant}, -}; -use winit::{ - application::ApplicationHandler, - dpi::{LogicalSize, PhysicalPosition, PhysicalSize}, - event::{ElementState, Ime, MouseButton, MouseScrollDelta, WindowEvent}, - event_loop::{ActiveEventLoop, ControlFlow, EventLoopProxy}, - keyboard::{Key, ModifiersState, NamedKey}, - window::{Window, WindowId}, -}; - -const HANDLE_HEIGHT: f32 = 6.75; -const NEW_OUTLINE_WIDTH: f32 = 240.0; - -#[derive(Debug)] -enum UserEvent { - Quit, - Accessibility(accesskit_winit::Event), -} - -impl From for UserEvent { - fn from(event: accesskit_winit::Event) -> Self { - Self::Accessibility(event) - } -} - -fn trace_input(event: &impl std::fmt::Debug) { - if std::env::var_os("ONE_CANVAS_TRACE_INPUT").is_some() { - eprintln!("Input {:?}: {event:?}", std::time::SystemTime::now()); - } -} - -fn read_only_shortcut(key: &Key, modifiers: ModifiersState) -> bool { - key == &Key::Named(NamedKey::Escape) - || (modifiers.control_key() && key == &Key::Named(NamedKey::Tab)) - || (modifiers.super_key() - && matches!(key, Key::Character(value) if matches!(value.as_str(), "+" | "=" | "-" | "0") || (modifiers.shift_key() && value.eq_ignore_ascii_case("n")))) -} - -struct App { - proxy: EventLoopProxy, - input: Option, - substitutes: Vec, - state: Option, - startup_error: Option>, -} - -enum Input { - Notes { - document: TextDocument, - width: f32, - reference: Option, - }, - Page(Page), -} - -enum Drag { - Text, - Outline { - id: onestore::ExGuid, - grab: [f32; 2], - pending_press: Option<[f32; 2]>, - }, -} - -struct State { - window: Arc, - instance: wgpu::Instance, - surface: wgpu::Surface<'static>, - config: wgpu::SurfaceConfiguration, - renderer: Renderer, - engine: TextEngine, - editor: CanvasEditor, - initial: Vec<(onestore::ExGuid, onestore::document::Layout, TextDocument)>, - scene: Option<(PageScene, [f32; 2])>, - viewport: Viewport, - display_scale: f32, - pointer: [f32; 2], - drag: Option, - read_only_focus: Option, - modifiers: ModifiersState, - focused: bool, - occluded: bool, - caret: bool, - blink_at: Instant, - clipboard: arboard::Clipboard, - access_adapter: accesskit_winit::Adapter, - accessibility: accessibility::Accessibility, -} - -impl State { - async fn new( - event_loop: &ActiveEventLoop, - proxy: EventLoopProxy, - input: Input, - substitutes: &[PathBuf], - ) -> Result> { - let window = Arc::new( - event_loop.create_window( - Window::default_attributes() - .with_visible(false) - .with_title(match &input { - Input::Notes { - reference: Some(page), - .. - } => format!("{} · Reference and temporary notes", page.title), - Input::Page(page) => format!("{} · Temporary page", page.title), - Input::Notes { - reference: None, .. - } => "Untitled · Temporary page".into(), - }) - .with_inner_size(LogicalSize::new(1000.0, 720.0)), - )?, - ); - let access_adapter = - accesskit_winit::Adapter::with_event_loop_proxy(event_loop, &window, proxy); - window.set_visible(true); - let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_with_display_handle( - Box::new(window.clone()), - )); - let surface = instance.create_surface(window.clone())?; - let adapter = instance - .request_adapter(&wgpu::RequestAdapterOptions { - compatible_surface: Some(&surface), - ..Default::default() - }) - .await?; - let (device, queue) = adapter.request_device(&Default::default()).await?; - let size = window.inner_size(); - let config = surface - .get_default_config(&adapter, size.width, size.height) - .ok_or("No supported canvas surface")?; - surface.configure(&device, &config); - let renderer = Renderer::new(device, queue, config.format); - let mut engine = TextEngine::default(); - for path in substitutes { - let target = engine - .register_substitute(parley::fontique::Blob::new(Arc::new(std::fs::read(path)?)))?; - eprintln!("Using {} for {target}", path.display()); - } - let (editor, scene) = match input { - Input::Notes { - document, - width, - reference, - } => { - let editor = CanvasEditor::new(&mut engine, document, width)?; - let scene = reference - .map(|page| { - PageScene::new(page, &mut engine).map(|scene| (scene, [width + 24.0, 0.0])) - }) - .transpose()?; - (editor, scene) - } - Input::Page(page) => { - let (scene, editor) = PageScene::from_page(page, &mut engine)?; - (editor, Some((scene, [0.0; 2]))) - } - }; - let initial = editor - .outlines() - .iter() - .map(|outline| { - ( - outline.id, - outline.layout().clone(), - outline.document().clone(), - ) - }) - .collect(); - let dpr = window.scale_factor() as f32; - window.set_ime_allowed(true); - window.set_cursor(winit::window::CursorIcon::Text); - window.request_redraw(); - eprintln!("Canvas GPU: {:?}; scale factor {dpr}", adapter.get_info()); - Ok(Self { - window, - instance, - surface, - config, - renderer, - engine, - editor, - initial, - scene, - viewport: Viewport { - size: [size.width, size.height], - scale: dpr * 96.0 / 72.0, - origin: [48.0 * dpr; 2], - }, - display_scale: dpr, - pointer: [0.0; 2], - drag: None, - read_only_focus: None, - modifiers: ModifiersState::empty(), - focused: true, - occluded: false, - caret: true, - blink_at: Instant::now() + Duration::from_millis(500), - clipboard: arboard::Clipboard::new()?, - access_adapter, - accessibility: accessibility::Accessibility::default(), - }) - } - - fn preview(&self) -> Option<(onestore::ExGuid, [f32; 2])> { - let Drag::Outline { - id, - grab, - pending_press, - } = self.drag.as_ref()? - else { - return None; - }; - let origin = self - .editor - .outlines() - .iter() - .find(|outline| outline.id == *id)? - .origin(); - if pending_press.is_some() { - return Some((*id, origin)); - } - let point = self.viewport.document_point(self.pointer); - let position = [point[0] - grab[0], point[1] - grab[1]]; - Some(( - *id, - if self.modifiers.alt_key() { - position - } else { - snap_to_grid(position) - }, - )) - } - - fn set_read_only_focus(&mut self, index: Option) { - if self.read_only_focus != index { - self.editor.finish_composition(); - self.drag = None; - self.read_only_focus = index; - self.window.set_ime_allowed(index.is_none()); - } - } - - fn changed(&mut self) -> Result<(), Box> { - self.caret = true; - self.blink_at = Instant::now() + Duration::from_millis(500); - let mut rect = self.editor.caret(1.0)?; - let origin = self - .preview() - .map(|(_, origin)| origin) - .unwrap_or_else(|| self.editor.active_outline().origin()); - rect.x0 += f64::from(origin[0]); - rect.y0 += f64::from(origin[1]); - rect.x1 += f64::from(origin[0]); - rect.y1 += f64::from(origin[1]); - let v = self.viewport; - self.window.set_ime_cursor_area( - PhysicalPosition::new( - rect.x0 * f64::from(v.scale) + f64::from(v.origin[0]), - rect.y0 * f64::from(v.scale) + f64::from(v.origin[1]), - ), - PhysicalSize::new( - (rect.width() * f64::from(v.scale)).max(1.0), - (rect.height() * f64::from(v.scale)).max(1.0), - ), - ); - self.update_accessibility()?; - self.window.request_redraw(); - Ok(()) - } - - fn update_accessibility(&mut self) -> Result<(), Box> { - let mut error = None; - let preview = self.preview(); - self.access_adapter.update_if_active(|| { - match self.accessibility.update( - &self.editor, - self.viewport, - &self.window.title(), - preview, - ) { - Ok(mut update) => { - self.accessibility.append_read_only( - &mut update, - self.scene.as_ref(), - self.viewport, - self.read_only_focus, - ); - update - } - Err(failure) => { - error = Some(failure); - self.accessibility.deactivate(); - let mut root = accesskit::Node::new(accesskit::Role::Window); - root.set_label(self.window.title()); - accesskit::TreeUpdate { - nodes: vec![(accessibility::ROOT, root)], - tree: Some(accesskit::TreeInfo::new(accessibility::ROOT)), - tree_id: accesskit::TreeId::ROOT, - focus: accessibility::ROOT, - } - } - } - }); - if let Some(error) = error { - return Err(error.into()); - } - Ok(()) - } - - fn access_action(&mut self, request: accesskit::ActionRequest) -> Result<(), Box> { - trace_input(&request); - use accesskit::{Action, ActionData}; - if request.target_tree != accesskit::TreeId::ROOT { - return Ok(()); - } - if let Some(index) = self.accessibility.read_only_for_node(request.target_node) { - if request.action == Action::Focus { - self.set_read_only_focus(Some(index)); - self.window.focus_window(); - self.reveal_focus()?; - self.changed()?; - } - return Ok(()); - } - let Some(outline) = self.accessibility.outline_for_node(request.target_node) else { - return Ok(()); - }; - match (request.action, request.data) { - (Action::Focus, _) => { - self.editor.focus_outline(outline)?; - self.window.focus_window(); - } - (Action::SetTextSelection, Some(ActionData::SetTextSelection(selection))) => { - let selection = self.accessibility.selection(outline, selection)?; - self.editor.focus_outline(outline)?; - self.editor.select(selection)?; - trace_input(&self.editor.selection()); - } - (Action::SetValue, Some(ActionData::Value(text))) => { - self.editor.focus_outline(outline)?; - self.editor.select_all()?; - self.editor.commit_text(&mut self.engine, text.into())?; - } - (Action::ReplaceSelectedText, Some(ActionData::Value(text))) => { - self.editor.focus_outline(outline)?; - self.editor.commit_text(&mut self.engine, text.into())?; - } - _ => return Ok(()), - } - self.set_read_only_focus(None); - self.reveal_focus()?; - self.changed() - } - - fn reveal_focus(&mut self) -> Result<(), Box> { - if self.viewport.size.contains(&0) { - return Ok(()); - } - let rect = if let Some(index) = self.read_only_focus { - let (scene, offset) = self.scene.as_ref().unwrap(); - let [x0, y0, x1, y1] = scene.read_only().nth(index).unwrap().rect; - parley::BoundingBox { - x0: f64::from(x0 + offset[0]), - y0: f64::from(y0 + offset[1]), - x1: f64::from(x1 + offset[0]), - y1: f64::from(y1 + offset[1]), - } - } else { - let mut rect = self.editor.caret(1.0)?; - let origin = self.editor.active_outline().origin(); - rect.x0 += f64::from(origin[0]); - rect.x1 += f64::from(origin[0]); - rect.y0 += f64::from(origin[1]); - rect.y1 += f64::from(origin[1]); - rect - }; - let outline = self.editor.active_outline().bounds(); - for (axis, (mut start, mut end)) in [(rect.x0, rect.x1), (rect.y0, rect.y1)] - .into_iter() - .enumerate() - { - let size = f64::from(self.viewport.size[axis]); - let margin = f64::from(16.0 * self.display_scale).min(size * 0.25); - let (outline_start, outline_end) = - [(outline.x0, outline.x1), (outline.y0, outline.y1)][axis]; - let outline_start = outline_start.min(start); - let outline_end = outline_end.max(end); - if self.read_only_focus.is_none() - && (outline_end - outline_start) * f64::from(self.viewport.scale) - <= size - margin * 2.0 - { - start = outline_start; - end = outline_end; - } - let start = - start * f64::from(self.viewport.scale) + f64::from(self.viewport.origin[axis]); - let end = end * f64::from(self.viewport.scale) + f64::from(self.viewport.origin[axis]); - let shift = if start < margin { - margin - start - } else if end > size - margin { - size - margin - end - } else { - 0.0 - }; - self.viewport.origin[axis] += shift as f32; - } - Ok(()) - } - - fn zoom(&mut self, factor: f32) { - let point = self.viewport.document_point(self.pointer); - let dpr = self.window.scale_factor() as f32; - self.viewport.scale = (self.viewport.scale * factor).clamp(dpr / 3.0, dpr * 16.0 / 3.0); - self.viewport.origin = [ - self.pointer[0] - point[0] * self.viewport.scale, - self.pointer[1] - point[1] * self.viewport.scale, - ]; - } - - fn draw(&mut self) -> Result<(), Box> { - trace_input(&("Draw", self.viewport.origin, self.viewport.scale)); - if self.occluded || self.viewport.size.contains(&0) { - return Ok(()); - } - let mut reconfigure = false; - let frame = match self.surface.get_current_texture() { - wgpu::CurrentSurfaceTexture::Success(frame) => frame, - wgpu::CurrentSurfaceTexture::Suboptimal(frame) => { - reconfigure = true; - frame - } - wgpu::CurrentSurfaceTexture::Outdated => { - self.surface.configure(&self.renderer.device, &self.config); - self.window.request_redraw(); - return Ok(()); - } - wgpu::CurrentSurfaceTexture::Lost => { - self.surface = self.instance.create_surface(self.window.clone())?; - self.surface.configure(&self.renderer.device, &self.config); - self.window.request_redraw(); - return Ok(()); - } - wgpu::CurrentSurfaceTexture::Timeout => { - trace_input(&"Surface timeout"); - return Ok(()); - } - wgpu::CurrentSurfaceTexture::Occluded => { - trace_input(&"Surface occluded"); - return Ok(()); - } - wgpu::CurrentSurfaceTexture::Validation => { - return Err("Canvas surface validation failed".into()); - } - }; - let primitives = page_primitives( - &self.editor, - self.scene.as_ref(), - self.preview(), - self.read_only_focus, - self.caret && self.focused && !matches!(self.drag, Some(Drag::Outline { .. })), - self.viewport.scale, - self.display_scale, - )?; - self.renderer - .draw( - &frame.texture.create_view(&Default::default()), - self.viewport, - &primitives, - ) - .map_err(|error| format!("Canvas drawing failed: {error:?}"))?; - self.window.pre_present_notify(); - self.renderer.queue.present(frame); - trace_input(&"Present submitted"); - if reconfigure { - self.surface.configure(&self.renderer.device, &self.config); - } - Ok(()) - } - - fn key(&mut self, key: &Key, text: Option<&str>) -> Result<(), Box> { - let shift = self.modifiers.shift_key(); - let command = self.modifiers.super_key(); - let option = self.modifiers.alt_key(); - if self.read_only_focus.is_some() { - if !read_only_shortcut(key, self.modifiers) { - return Ok(()); - } - if key == &Key::Named(NamedKey::Escape) { - self.set_read_only_focus(None); - self.reveal_focus()?; - return self.changed(); - } - } - if matches!(self.drag, Some(Drag::Outline { .. })) { - if matches!( - key, - Key::Named( - NamedKey::Alt - | NamedKey::AltGraph - | NamedKey::Control - | NamedKey::Shift - | NamedKey::Super - | NamedKey::Meta - ) - ) { - return Ok(()); - } - self.drag = None; - if key == &Key::Named(NamedKey::Escape) { - return self.changed(); - } - } - if command && option { - let delta = match key { - Key::Named(NamedKey::ArrowLeft) => Some([-1.0, 0.0]), - Key::Named(NamedKey::ArrowRight) => Some([1.0, 0.0]), - Key::Named(NamedKey::ArrowUp) => Some([0.0, -1.0]), - Key::Named(NamedKey::ArrowDown) => Some([0.0, 1.0]), - _ => None, - }; - if let Some(delta) = delta { - let outline = self.editor.active_outline(); - let origin = outline.origin(); - let step = if shift { 10.0 } else { 1.0 }; - self.editor.move_outline( - outline.id, - [origin[0] + delta[0] * step, origin[1] + delta[1] * step], - )?; - self.reveal_focus()?; - return self.changed(); - } - } - if self.modifiers.control_key() && key == &Key::Named(NamedKey::Tab) { - let outlines = self.editor.outlines(); - let count = outlines.len() - + self - .scene - .as_ref() - .map_or(0, |(scene, _)| scene.read_only().count()); - if count == 0 { - return self.changed(); - } - let index = self.read_only_focus.map_or_else( - || { - outlines - .iter() - .position(|outline| outline.id == self.editor.active_outline().id) - }, - |index| Some(outlines.len() + index), - ); - let next = index.map_or(if shift { count - 1 } else { 0 }, |index| { - if shift { - (index + count - 1) % count - } else { - (index + 1) % count - } - }); - if next < outlines.len() { - self.editor.focus_outline(outlines[next].id)?; - self.set_read_only_focus(None); - } else { - self.set_read_only_focus(Some(next - outlines.len())); - } - self.reveal_focus()?; - return self.changed(); - } - if command && let Key::Character(key) = key { - match key.to_lowercase().as_str() { - "n" if shift => { - let position = if let Some(index) = self.read_only_focus { - let (scene, offset) = self.scene.as_ref().unwrap(); - let rect = scene.read_only().nth(index).unwrap().rect; - [rect[2] + offset[0] + 24.0, rect[1] + offset[1]] - } else { - let bounds = self.editor.active_outline().bounds(); - [bounds.x1 as f32 + 24.0, bounds.y0 as f32] - }; - self.editor.place_caret( - &mut self.engine, - snap_to_grid(position), - NEW_OUTLINE_WIDTH, - )?; - self.set_read_only_focus(None); - } - "a" => self.editor.select_all()?, - "z" => { - if shift { - self.editor.redo(&mut self.engine)?; - } else { - self.editor.undo(&mut self.engine)?; - } - } - "c" | "x" => { - let [anchor, focus] = self.editor.selection().positions; - let selected = self - .editor - .active_outline() - .document() - .slice(anchor.min(focus)..anchor.max(focus))?; - let text = selected - .iter() - .map(|paragraph| { - paragraph - .project() - .map(|projection| projection.text().text().to_owned()) - }) - .collect::, _>>()? - .join("\n"); - if !text.is_empty() { - self.clipboard.set_text(text)?; - if key.eq_ignore_ascii_case("x") { - self.editor.insert(&mut self.engine, "")?; - } - } - } - "v" => { - let text = self.clipboard.get_text()?; - self.editor.commit_text(&mut self.engine, text)?; - } - "+" | "=" => { - self.zoom(1.1); - return self.changed(); - } - "-" => { - self.zoom(1.0 / 1.1); - return self.changed(); - } - "0" => { - self.zoom((self.display_scale * 96.0 / 72.0) / self.viewport.scale); - return self.changed(); - } - _ => return Ok(()), - } - } else { - let movement = match key { - Key::Named(NamedKey::ArrowLeft) => Some(if command { - Movement::LineStart - } else if option { - Movement::WordLeft - } else { - Movement::Left - }), - Key::Named(NamedKey::ArrowRight) => Some(if command { - Movement::LineEnd - } else if option { - Movement::WordRight - } else { - Movement::Right - }), - Key::Named(NamedKey::ArrowUp) => Some(Movement::Up), - Key::Named(NamedKey::ArrowDown) => Some(Movement::Down), - Key::Named(NamedKey::Home) => Some(Movement::LineStart), - Key::Named(NamedKey::End) => Some(Movement::LineEnd), - _ => None, - }; - if let Some(movement) = movement { - self.editor.move_selection(movement, shift)?; - } else if self.editor.marked_range().is_none() { - match key { - Key::Named(NamedKey::Backspace) => { - self.editor.delete(&mut self.engine, true)?; - } - Key::Named(NamedKey::Delete) => { - self.editor.delete(&mut self.engine, false)?; - } - Key::Named(NamedKey::Enter) => self.editor.insert(&mut self.engine, "\n")?, - Key::Named(NamedKey::Tab) => self.editor.insert(&mut self.engine, "\t")?, - _ if !command && !self.modifiers.control_key() => { - if let Some(text) = text - .filter(|text| !text.is_empty() && !text.chars().any(char::is_control)) - { - self.editor.insert(&mut self.engine, text)?; - } - } - _ => {} - } - } else if key == &Key::Named(NamedKey::Escape) { - self.editor.cancel_composition(&mut self.engine)?; - } - } - self.reveal_focus()?; - self.changed() - } -} - -impl App { - fn close(&self, event_loop: &ActiveEventLoop) { - if self.state.as_ref().is_none_or(|state| { - state - .editor - .caret_outline() - .is_none_or(TextOutline::is_empty) - && state - .initial - .iter() - .map(|(id, layout, document)| (id, layout, document)) - .eq(state - .editor - .outlines() - .iter() - .map(|outline| (&outline.id, outline.layout(), outline.document()))) - }) || macos::discard_changes() - { - event_loop.exit(); - } - } -} - -impl ApplicationHandler for App { - fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) { - let event = match event { - UserEvent::Quit => { - self.close(event_loop); - return; - } - UserEvent::Accessibility(event) => event, - }; - let Some(state) = &mut self.state else { - return; - }; - if event.window_id != state.window.id() { - return; - } - let was_marked = state.editor.marked_range().is_some(); - let result = match event.window_event { - accesskit_winit::WindowEvent::InitialTreeRequested => state.update_accessibility(), - accesskit_winit::WindowEvent::ActionRequested(request) => state.access_action(request), - accesskit_winit::WindowEvent::AccessibilityDeactivated => { - state.accessibility.deactivate(); - Ok(()) - } - }; - if was_marked && state.editor.marked_range().is_none() { - macos::clear_marked_text(&state.window); - } - if let Err(error) = result { - eprintln!("{error}"); - } - } - - fn resumed(&mut self, event_loop: &ActiveEventLoop) { - if self.state.is_some() { - return; - } - match pollster::block_on(State::new( - event_loop, - self.proxy.clone(), - self.input.take().unwrap(), - &self.substitutes, - )) { - Ok(state) => self.state = Some(state), - Err(error) => { - self.startup_error = Some(error); - event_loop.exit(); - } - } - } - - fn window_event(&mut self, event_loop: &ActiveEventLoop, _: WindowId, event: WindowEvent) { - if matches!( - event, - WindowEvent::KeyboardInput { .. } - | WindowEvent::Ime(_) - | WindowEvent::MouseInput { .. } - | WindowEvent::CursorMoved { .. } - | WindowEvent::ModifiersChanged(_) - | WindowEvent::Focused(_) - ) { - trace_input(&event); - } - if let Some(state) = &mut self.state { - state.access_adapter.process_event(&state.window, &event); - } - if matches!(event, WindowEvent::CloseRequested) { - self.close(event_loop); - return; - } - let Some(state) = &mut self.state else { - return; - }; - let was_marked = - !matches!(event, WindowEvent::Ime(_)) && state.editor.marked_range().is_some(); - let result = (|| -> Result<(), Box> { - match event { - WindowEvent::RedrawRequested => state.draw()?, - WindowEvent::Resized(size) => { - state.viewport.size = [size.width, size.height]; - if size.width > 0 && size.height > 0 { - state.config.width = size.width; - state.config.height = size.height; - state - .surface - .configure(&state.renderer.device, &state.config); - state.changed()?; - } - } - WindowEvent::ScaleFactorChanged { scale_factor, .. } => { - let scale = scale_factor as f32; - let ratio = scale / state.display_scale; - state.viewport.scale *= ratio; - state.viewport.origin[0] *= ratio; - state.viewport.origin[1] *= ratio; - state.display_scale = scale; - state.renderer.clear_glyph_cache(); - state.changed()?; - } - WindowEvent::Focused(focused) => { - state.focused = focused; - if !focused { - state.drag = None; - } - state.changed()?; - } - WindowEvent::Occluded(occluded) => { - trace_input(&("Window occluded", occluded)); - state.occluded = occluded; - if !occluded { - state.changed()?; - } - } - WindowEvent::ModifiersChanged(modifiers) => { - state.modifiers = modifiers.state(); - if matches!(state.drag, Some(Drag::Outline { .. })) { - state.changed()?; - } - } - WindowEvent::CursorMoved { position, .. } => { - state.pointer = [position.x as f32, position.y as f32]; - match &mut state.drag { - Some(Drag::Text) => { - let point = state.viewport.document_point(state.pointer); - let origin = state.editor.active_outline().origin(); - state.editor.select_at( - point[0] - origin[0], - point[1] - origin[1], - true, - )?; - state.changed()?; - } - Some(Drag::Outline { pending_press, .. }) => { - if pending_press.is_some_and(|press| { - (0..2).any(|axis| { - (state.pointer[axis] - press[axis]).abs() - > 2.0 * state.display_scale - }) - }) { - *pending_press = None; - } - state.changed()?; - } - None => {} - } - } - WindowEvent::MouseInput { - state: button_state, - button: MouseButton::Left, - .. - } => { - let point = state.viewport.document_point(state.pointer); - if button_state == ElementState::Pressed { - match page_hit_test( - &state.editor, - state.scene.as_ref(), - point, - state.display_scale / state.viewport.scale, - ) { - Some(Hit::ReadOnly(index)) => state.set_read_only_focus(Some(index)), - Some(Hit::Handle { id, grab }) => { - state.set_read_only_focus(None); - state.editor.focus_outline(id)?; - state.drag = Some(Drag::Outline { - id, - grab, - pending_press: Some(state.pointer), - }); - } - Some(Hit::Text { id, point }) => { - let extend = state.modifiers.shift_key() - && state.read_only_focus.is_none() - && id == state.editor.active_outline().id; - state.set_read_only_focus(None); - state.editor.focus_outline(id)?; - state.editor.select_at(point[0], point[1], extend)?; - state.drag = Some(Drag::Text); - } - None => { - let position = [ - point[0], - point[1] - 7.0 * state.display_scale / state.viewport.scale, - ]; - let position = if state.modifiers.alt_key() { - position - } else { - snap_to_grid(position) - }; - state.editor.place_caret( - &mut state.engine, - position, - NEW_OUTLINE_WIDTH, - )?; - state.set_read_only_focus(None); - state.drag = Some(Drag::Text); - } - } - } else { - let preview = state.preview(); - state.drag = None; - if let Some((id, origin)) = preview { - state.editor.move_outline(id, origin)?; - } - } - state.changed()?; - } - WindowEvent::MouseWheel { delta, .. } => { - let dpr = state.window.scale_factor() as f32; - let delta = match delta { - MouseScrollDelta::LineDelta(x, y) => [x * 32.0 * dpr, y * 32.0 * dpr], - MouseScrollDelta::PixelDelta(p) => [p.x as f32, p.y as f32], - }; - if state.modifiers.super_key() { - state.zoom((delta[1] * 0.005).exp()); - } else { - state.viewport.origin[0] += delta[0]; - state.viewport.origin[1] += delta[1]; - } - state.changed()?; - } - WindowEvent::KeyboardInput { event, .. } - if event.state == ElementState::Pressed => - { - state.key(&event.logical_key, event.text.as_deref())?; - } - WindowEvent::Ime(_) if state.read_only_focus.is_some() => {} - WindowEvent::Ime(Ime::Preedit(text, cursor)) => { - if text.is_empty() { - state.editor.cancel_composition(&mut state.engine)?; - } else { - let (start, end) = cursor.unwrap_or((text.len(), text.len())); - let start: u32 = text - .get(..start) - .ok_or("IME range splits UTF-8")? - .encode_utf16() - .count() - .try_into()?; - let end: u32 = text - .get(..end) - .ok_or("IME range splits UTF-8")? - .encode_utf16() - .count() - .try_into()?; - state.editor.compose(&mut state.engine, text, start..end)?; - } - state.reveal_focus()?; - state.changed()?; - } - WindowEvent::Ime(Ime::Commit(text)) => { - state.editor.commit_text(&mut state.engine, text)?; - state.reveal_focus()?; - state.changed()?; - } - WindowEvent::Ime(Ime::Disabled) => { - state.editor.cancel_composition(&mut state.engine)?; - state.reveal_focus()?; - state.changed()?; - } - _ => {} - } - Ok(()) - })(); - if was_marked && state.editor.marked_range().is_none() { - macos::clear_marked_text(&state.window); - } - if let Err(error) = result { - eprintln!("{error}"); - } - } - - fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { - let Some(state) = &mut self.state else { - return; - }; - let [anchor, focus] = state.editor.selection().positions; - if state.focused && !state.occluded && state.read_only_focus.is_none() && anchor == focus { - let now = Instant::now(); - if now >= state.blink_at { - state.caret = !state.caret; - state.blink_at = now + Duration::from_millis(500); - state.window.request_redraw(); - } - event_loop.set_control_flow(ControlFlow::WaitUntil(state.blink_at)); - } else { - event_loop.set_control_flow(ControlFlow::Wait); - } - } -} - -#[derive(Debug, PartialEq)] -enum Hit { - Text { - id: onestore::ExGuid, - point: [f32; 2], - }, - Handle { - id: onestore::ExGuid, - grab: [f32; 2], - }, - ReadOnly(usize), -} - -fn page_hit_test( - editor: &CanvasEditor, - scene: Option<&(PageScene, [f32; 2])>, - point: [f32; 2], - pixel: f32, -) -> Option { - let hit = |outline: &TextOutline, offset: [f32; 2]| { - let (bounds, body_top) = outline_chrome(outline, pixel); - let local = [point[0] - offset[0], point[1] - offset[1]]; - let [x, y] = local; - if x >= bounds[0] && x <= bounds[2] && y >= bounds[1] && y < body_top { - return Some(Hit::Handle { - id: outline.id, - grab: [ - point[0] - outline.origin()[0], - point[1] - outline.origin()[1], - ], - }); - } - if (x >= bounds[0] && x <= bounds[2] && y >= body_top && y <= bounds[3]) - || outline.layouts().any(|(_, paragraph)| { - paragraph.tags.iter().any(|tag| { - let origin = outline.origin(); - let x = origin[0] + tag.origin[0]; - let y = origin[1] + paragraph.origin[1] + tag.origin[1]; - let size = one_canvas::outline::ParagraphTag::SIZE; - (x..=x + size).contains(&local[0]) && (y..=y + size).contains(&local[1]) - }) - }) - { - return Some(Hit::Text { - id: outline.id, - point: [ - local[0] - outline.origin()[0], - local[1] - outline.origin()[1], - ], - }); - } - None - }; - if let Some(hit) = editor - .outlines() - .iter() - .rev() - .filter(|outline| scene.is_none_or(|(scene, _)| !scene.contains_outline(outline.id))) - .find_map(|outline| hit(outline, [0.0; 2])) - { - return Some(hit); - } - let (scene, offset) = scene?; - match scene.hit_test([point[0] - offset[0], point[1] - offset[1]], |id| { - editor - .outlines() - .iter() - .find(|outline| outline.id == id) - .and_then(|outline| hit(outline, *offset)) - })? { - one_canvas_gpu::page::SceneHit::Outline(hit) => Some(hit), - one_canvas_gpu::page::SceneHit::ReadOnly(index) => Some(Hit::ReadOnly(index)), - } -} - -fn page_primitives<'a>( - editor: &'a CanvasEditor, - scene: Option<&'a (PageScene, [f32; 2])>, - preview: Option<(onestore::ExGuid, [f32; 2])>, - read_only_focus: Option, - show_caret: bool, - scale: f32, - display_scale: f32, -) -> Result>, Box> { - let mut primitives = Vec::new(); - let paint = |id, offset: [f32; 2], primitives: &mut Vec<_>| { - let outline = editor - .outlines() - .iter() - .find(|outline| outline.id == id) - .ok_or(one_canvas_gpu::page::SceneError::MissingOutline)?; - let origin = preview - .filter(|(id, _)| *id == outline.id) - .map(|(_, origin)| origin) - .unwrap_or_else(|| outline.origin()); - append_outline( - (read_only_focus.is_none() && outline.id == editor.active_outline().id) - .then_some(editor), - outline, - [origin[0] + offset[0], origin[1] + offset[1]], - show_caret, - scale, - display_scale / scale, - primitives, - ) - }; - if let Some((scene, origin)) = scene { - scene.append_primitives_with(&mut primitives, *origin, &paint)?; - } - for outline in editor - .outlines() - .iter() - .filter(|outline| scene.is_none_or(|(scene, _)| !scene.contains_outline(outline.id))) - { - paint(outline.id, [0.0; 2], &mut primitives)?; - } - if read_only_focus.is_none() - && let Some(outline) = editor.caret_outline() - { - append_outline( - Some(editor), - outline, - outline.origin(), - show_caret, - scale, - display_scale / scale, - &mut primitives, - )?; - } - if let Some(index) = read_only_focus { - let (scene, offset) = scene.unwrap(); - let [x0, y0, x1, y1] = scene.read_only().nth(index).unwrap().rect; - let [x0, y0, x1, y1] = [ - x0 + offset[0], - y0 + offset[1], - x1 + offset[0], - y1 + offset[1], - ]; - let border = 2.0 / scale; - for rect in [ - [x0, y0, x1, y0 + border], - [x0, y1 - border, x1, y1], - [x0, y0, x0 + border, y1], - [x1 - border, y0, x1, y1], - ] { - primitives.push(Primitive::Rect { - rect, - color: [0.25, 0.45, 0.7, 1.0], - }); - } - } - Ok(primitives) -} - -fn snap_to_grid(point: [f32; 2]) -> [f32; 2] { - std::array::from_fn(|axis| { - let offset = [0.0, 14.4][axis]; - let cell = (point[axis] - offset) / 18.0; - let nearest = cell.round(); - // Native midpoints remain free; account for the source coordinate's float precision. - if ((cell - nearest).abs() - 0.5).abs() <= f32::EPSILON * cell.abs().max(1.0) { - point[axis] - } else { - nearest * 18.0 + offset - } - }) -} - -fn outline_chrome(outline: &TextOutline, pixel: f32) -> ([f32; 4], f32) { - let bounds = outline.bounds(); - // Native chrome combines page-scaled gutters with a fixed screen inset. - let inset = 5.0 * pixel; - let body_top = bounds.y0 as f32 - inset; - ( - [ - bounds.x0 as f32 - 7.5 - inset, - body_top - HANDLE_HEIGHT, - bounds.x1 as f32 + inset, - bounds.y1 as f32 + HANDLE_HEIGHT + inset, - ], - body_top, - ) -} - -fn append_outline<'a>( - editor: Option<&'a CanvasEditor>, - outline: &'a TextOutline, - origin: [f32; 2], - show_caret: bool, - scale: f32, - pixel: f32, - primitives: &mut Vec>, -) -> Result<(), Box> { - let [x, y] = origin; - let (bounds, body_top) = outline_chrome(outline, pixel); - let [dx, dy] = [x - outline.origin()[0], y - outline.origin()[1]]; - let [left, top, right, bottom] = [ - bounds[0] + dx, - bounds[1] + dy, - bounds[2] + dx, - bounds[3] + dy, - ]; - let body_top = body_top + dy; - let border = if editor.is_some() { - [0.25, 0.45, 0.7, 0.65] - } else { - [0.5, 0.5, 0.5, 0.2] - }; - if editor.is_none_or(|editor| editor.caret_outline().is_none()) { - for rect in [ - [left, top, right, body_top], - [left, body_top, left + pixel, bottom], - [right - pixel, body_top, right, bottom], - [left + pixel, bottom - pixel, right - pixel, bottom], - ] { - primitives.push(Primitive::Rect { - rect, - color: border, - }); - } - } - for (_, paragraph) in outline.layouts() { - let layout = ¶graph.text; - let paragraph_origin = paragraph.origin; - for (rect, color) in layout.backgrounds() { - primitives.push(Primitive::Rect { - rect: [ - rect.x0 as f32 + x + paragraph_origin[0], - rect.y0 as f32 + y + paragraph_origin[1], - rect.x1 as f32 + x + paragraph_origin[0], - rect.y1 as f32 + y + paragraph_origin[1], - ], - color: one_canvas_gpu::colorref(color), - }); - } - } - if let Some(editor) = editor { - for rect in editor.selection_rects()? { - primitives.push(Primitive::Rect { - rect: [ - rect.x0 as f32 + x, - rect.y0 as f32 + y, - rect.x1 as f32 + x, - rect.y1 as f32 + y, - ], - color: [0.55, 0.73, 1.0, 0.5], - }); - } - } - for (_, paragraph) in outline.layouts() { - let layout = ¶graph.text; - let paragraph_origin = paragraph.origin; - primitives.push(Primitive::Text { - layout, - origin: [x + paragraph_origin[0], y + paragraph_origin[1]], - }); - for (layout, origin) in ¶graph.markers { - primitives.push(Primitive::Text { - layout, - origin: [x + origin[0], y + (origin[1] + paragraph_origin[1])], - }); - } - for tag in ¶graph.tags { - primitives.push(Primitive::Tag { - tag, - origin: [x, y + paragraph_origin[1]], - }); - } - } - if let Some(editor) = editor { - for rect in editor.marked_rects()? { - primitives.push(Primitive::Rect { - rect: [ - rect.x0 as f32 + x, - rect.y1 as f32 + y - 1.0 / scale, - rect.x1 as f32 + x, - rect.y1 as f32 + y, - ], - color: [0.0, 0.0, 0.0, 1.0], - }); - } - let [anchor, focus] = editor.selection().positions; - if show_caret && anchor == focus { - let rect = editor.caret(1.0 / scale)?; - primitives.push(Primitive::Rect { - rect: [ - rect.x0 as f32 + x, - rect.y0 as f32 + y, - rect.x1 as f32 + x, - rect.y1 as f32 + y, - ], - color: [0.0, 0.0, 0.0, 1.0], - }); - } - } - Ok(()) -} - -fn main() -> Result<(), Box> { - let mut args = std::env::args_os().skip(1); - let mut positional = Vec::new(); - let mut substitutes = Vec::new(); - let mut reference = None; - let mut editable = false; - while let Some(arg) = args.next() { - if arg == "--substitute-font" { - substitutes.push(PathBuf::from( - args.next() - .ok_or("Provide a font file after --substitute-font.")?, - )); - } else if arg == "--reference" || arg == "--page" { - if reference.is_some() { - return Err("Only one page can be opened.".into()); - } - editable = arg == "--page"; - let path = args - .next() - .ok_or("Provide a section file and page title after the page option.")?; - let title = args - .next() - .ok_or("Provide a page title after the section file.")?; - let bytes = std::fs::read(path)?; - let store = onestore::Store::parse(&bytes)?; - let index = onestore::RevisionIndex::parse(&store)?; - reference = Some(Page::from_document( - &onestore::document::Document::parse(&index)?, - title - .to_str() - .ok_or("The page title must be valid Unicode.")?, - )?); - } else { - positional.push(arg); - } - } - if editable && !positional.is_empty() { - return Err( - "Use --page with a section file and page title, without a text file or width.".into(), - ); - } - if positional.len() > 2 { - return Err( - "Usage: one-canvas-app [TEXT_FILE] [WIDTH_POINTS] [--reference SECTION PAGE_TITLE | --page SECTION PAGE_TITLE] [--substitute-font FONT_FILE]..." - .into(), - ); - } - let text = positional - .first() - .map(std::fs::read_to_string) - .transpose()? - .unwrap_or_default(); - let width = positional - .get(1) - .map(|value| value.to_string_lossy().parse::()) - .transpose()? - .unwrap_or(if reference.is_some() { - NEW_OUTLINE_WIDTH - } else { - 480.0 - }); - let input = if editable { - Input::Page(reference.unwrap()) - } else { - Input::Notes { - document: TextDocument::new( - text.split('\n') - .map(|line| Paragraph::new(line.to_owned(), Format::default())) - .collect(), - )?, - width, - reference, - } - }; - let event_loop = macos::event_loop()?; - let mut app = App { - proxy: event_loop.create_proxy(), - input: Some(input), - substitutes, - state: None, - startup_error: None, - }; - event_loop.run_app(&mut app)?; - app.startup_error.map_or(Ok(()), Err) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn native_grid_matches_drag_offsets_midpoints_and_zoom() { - for (pixels, points) in [ - (5.0, 0.0), - (6.0, 0.0), - (7.0, 0.0), - (8.0, 0.0), - (11.0, 0.0), - (12.0, 9.0), - (13.0, 18.0), - (17.0, 18.0), - (18.0, 18.0), - (19.0, 18.0), - (25.0, 18.0), - (37.0, 36.0), - (-6.0, 0.0), - (-7.0, 0.0), - (-12.0, -9.0), - (-13.0, -18.0), - (-19.0, -18.0), - ] { - let origin = [486.0, 230.40001]; - let proposed = origin.map(|v| v + pixels * 72.0 / 96.0); - let result = snap_to_grid(proposed); - for axis in 0..2 { - assert!((result[axis] - origin[axis] - points).abs() < 0.00004); - } - } - for pixels in [6.0, 7.0, 12.0, 13.0, 19.0] { - let result = snap_to_grid([491.25 + pixels * 0.75, 235.65 + pixels * 0.75]); - assert!((result[0] - 504.0).abs() < 0.00004); - assert!((result[1] - 248.4).abs() < 0.00004); - } - for (scale, delta, expected) in [ - (96.0 / 72.0, [27.0, 31.0], [54.0, 104.4]), - (96.0 / 72.0, [13.0, 17.0], [54.0, 104.4]), - (192.0 / 72.0, [13.0, 17.0], [36.0, 104.4]), - ] { - for dpr in [1.0, 2.0] { - let origin = [36.0, 90.0]; - let proposed = - std::array::from_fn(|axis| origin[axis] + delta[axis] * dpr / (scale * dpr)); - let result = snap_to_grid(proposed); - for axis in 0..2 { - assert!((result[axis] - expected[axis]).abs() < 0.00004); - } - } - } - for point in [[9.0, 5.4], [-9.0, -12.6], [423.0, 239.4]] { - assert_eq!(snap_to_grid(point), point); - } - } - - #[test] - fn native_fresh_click_grid_uses_screen_offset_at_both_zoom_levels() { - for (x, y, scale, expected) in [ - (900.0, 500.0, 96.0 / 72.0, [639.0, 302.4]), - (901.0, 500.0, 96.0 / 72.0, [648.0, 302.4]), - (906.0, 500.0, 96.0 / 72.0, [648.0, 302.4]), - (913.0, 500.0, 96.0 / 72.0, [648.0, 302.4]), - (900.0, 501.0, 96.0 / 72.0, [639.0, 302.4]), - (900.0, 506.0, 96.0 / 72.0, [639.0, 320.4]), - (900.0, 520.0, 96.0 / 72.0, [639.0, 320.4]), - (900.0, 500.0, 192.0 / 72.0, [324.0, 158.4]), - ] { - for dpr in [1.0, 2.0] { - let viewport = Viewport { - size: [(1600.0 * dpr) as u32, (900.0 * dpr) as u32], - origin: [48.0 * dpr, 83.0 * dpr], - scale: scale * dpr, - }; - let point = viewport.document_point([x * dpr, y * dpr]); - let result = snap_to_grid([point[0], point[1] - 7.0 * dpr / viewport.scale]); - for axis in 0..2 { - assert!((result[axis] - expected[axis]).abs() < 0.00004); - } - } - } - } - - #[test] - fn blank_caret_and_composition_have_accessible_text_without_outline_chrome() { - let mut engine = TextEngine::default(); - let mut editor = CanvasEditor::new( - &mut engine, - TextDocument::new(vec![Paragraph::new(String::new(), Default::default())]).unwrap(), - 240.0, - ) - .unwrap(); - editor - .place_caret(&mut engine, [120.0, 100.0], 240.0) - .unwrap(); - let mut access = accessibility::Accessibility::default(); - let viewport = Viewport { - size: [1000, 720], - scale: 96.0 / 72.0, - origin: [48.0; 2], - }; - let initial = access.update(&editor, viewport, "Page", None).unwrap(); - let id = editor.active_outline().id; - assert_eq!(access.outline_for_node(initial.focus), Some(id)); - let rectangles = |editor: &CanvasEditor| { - page_primitives(editor, None, None, None, true, viewport.scale, 1.0) - .unwrap() - .into_iter() - .filter_map(|p| match p { - Primitive::Rect { rect, color } => Some((rect, color)), - _ => None, - }) - .collect::>() - }; - assert_eq!(rectangles(&editor).len(), 1); - assert_eq!( - page_hit_test(&editor, None, [120.0, 100.0], 1.0 / viewport.scale), - None - ); - editor.compose(&mut engine, "に".into(), 1..1).unwrap(); - assert!(editor.outlines().is_empty()); - assert!( - rectangles(&editor) - .iter() - .all(|(_, color)| *color == [0.0, 0.0, 0.0, 1.0]) - ); - let preedit = access.update(&editor, viewport, "Page", None).unwrap(); - assert_eq!(preedit.focus, initial.focus); - editor.commit_text(&mut engine, "日本".into()).unwrap(); - let committed = access.update(&editor, viewport, "Page", None).unwrap(); - assert_eq!(committed.focus, initial.focus); - assert_eq!(rectangles(&editor).len(), 5); - editor.undo(&mut engine).unwrap(); - assert!(editor.outlines().is_empty()); - assert_eq!(rectangles(&editor).len(), 1); - let empty = access.update(&editor, viewport, "Page", None).unwrap(); - assert_eq!(empty.focus, committed.focus); - assert_eq!(access.outline_for_node(committed.focus), Some(id)); - editor.redo(&mut engine).unwrap(); - editor.select_all().unwrap(); - editor.delete(&mut engine, true).unwrap(); - assert!(editor.outlines().is_empty()); - assert_eq!(rectangles(&editor).len(), 1); - let retired = access.update(&editor, viewport, "Page", None).unwrap(); - assert_eq!(retired.focus, committed.focus); - assert_eq!(access.outline_for_node(retired.focus), Some(id)); - editor.undo(&mut engine).unwrap(); - assert_eq!(rectangles(&editor).len(), 5); - let restored = access.update(&editor, viewport, "Page", None).unwrap(); - assert_eq!(restored.focus, committed.focus); - editor.redo(&mut engine).unwrap(); - assert_eq!(rectangles(&editor).len(), 1); - assert!(editor.caret_outline().unwrap().is_empty()); - editor.insert(&mut engine, " ").unwrap(); - assert!(editor.caret_outline().unwrap().is_empty()); - editor.compose(&mut engine, "に".into(), 1..1).unwrap(); - assert!(!editor.caret_outline().unwrap().is_empty()); - } - - #[test] - fn native_chrome_geometry_preserves_text_and_matches_hit_regions_across_zoom_and_dpi() { - let mut engine = TextEngine::default(); - let format = Format { - font: Some("Arial".into()), - font_size: Some(11.0), - ..Default::default() - }; - let mut editor = CanvasEditor::new(&mut engine, TextDocument::new(vec![ - Paragraph::new("HAMBURGEFONTS abcdefghijklmnopqrstuvwxyz 0123456789 HAMBURGEFONTS abcdefghijklmnopqrstuvwxyz 0123456789 HAMBURGEFONTS abcdefghijklmnopqrstuvwxyz 0123456789".into(), format.clone()), - Paragraph::new("Second paragraph abcdefghijklmnopqrstuvwxyz.".into(), format), - ]).unwrap(), 220.0).unwrap(); - let id = editor.active_outline().id; - editor.move_outline(id, [36.0, 90.0]).unwrap(); - let document = editor.active_outline().document().clone(); - let layout_ids: Vec<_> = editor - .active_outline() - .layouts() - .map(|(_, p)| p.text.id()) - .collect(); - for (zoom, expected) in [ - (0.5, [62.0, 134.0, 223.0, 219.0]), - (1.0, [81.0, 189.0, 394.0, 348.0]), - (1.5, [101.0, 245.0, 565.0, 479.0]), - (2.0, [120.0, 300.0, 736.0, 608.0]), - ] { - for dpr in [1.0, 2.0] { - let scale = zoom * (96.0 / 72.0) * dpr; - let pixel = dpr / scale; - let (frame, body_top) = outline_chrome(editor.active_outline(), pixel); - for (index, coordinate) in frame.iter().enumerate() { - let origin = if index % 2 == 0 { 48.0 } else { 83.0 }; - let screen = coordinate * scale / dpr + origin; - assert!( - (screen - expected[index]).abs() <= 1.5, - "zoom={zoom} dpr={dpr} edge={index}: {screen} vs {}", - expected[index] - ); - } - let header = [36.0, (frame[1] + body_top) / 2.0]; - assert!( - matches!(page_hit_test(&editor, None, header, pixel), Some(Hit::Handle {id: hit, ..}) if hit == id) - ); - let padding = [frame[0] + pixel, 90.0]; - assert!( - matches!(page_hit_test(&editor, None, padding, pixel), Some(Hit::Text {id: hit, ..}) if hit == id) - ); - assert_eq!( - page_hit_test(&editor, None, [frame[0] - pixel, 90.0], pixel), - None - ); - let primitives = - page_primitives(&editor, None, None, None, false, scale, dpr).unwrap(); - assert!( - matches!(&primitives[0], Primitive::Rect {rect, ..} if *rect == [frame[0], frame[1], frame[2], body_top]) - ); - assert!( - primitives.iter().any( - |p| matches!(p, Primitive::Text {origin, ..} if *origin == [36.0, 90.0]) - ) - ); - } - } - assert_eq!(editor.active_outline().document(), &document); - assert_eq!( - editor - .active_outline() - .layouts() - .map(|(_, p)| p.text.id()) - .collect::>(), - layout_ids - ); - } - - #[test] - fn a_later_text_body_takes_priority_over_an_earlier_drag_handle() { - let mut engine = TextEngine::default(); - let mut editor = CanvasEditor::new( - &mut engine, - TextDocument::new(vec![Paragraph::new("first".into(), Default::default())]).unwrap(), - 240.0, - ) - .unwrap(); - let first = editor.active_outline().id; - editor.move_outline(first, [0.0, 20.0]).unwrap(); - assert_eq!( - page_hit_test(&editor, None, [5.0, 12.0], 1.0), - Some(Hit::Handle { - id: first, - grab: [5.0, -8.0] - }) - ); - let second = editor - .create_outline(&mut engine, [0.0, 10.0], 240.0) - .unwrap(); - assert_eq!( - page_hit_test(&editor, None, [5.0, 12.0], 1.0), - Some(Hit::Text { - id: second, - point: [5.0, 2.0] - }) - ); - editor.undo(&mut engine).unwrap(); - assert_eq!( - page_hit_test(&editor, None, [5.0, 12.0], 1.0), - Some(Hit::Handle { - id: first, - grab: [5.0, -8.0] - }) - ); - } - - #[test] - fn overlapping_objects_follow_paint_order_through_creation_movement_and_undo() { - use one_canvas::page::{Outline, PageObject, Unsupported}; - for readonly_on_top in [false, true] { - let mut engine = TextEngine::default(); - let document = TextDocument::new(vec![Paragraph::new( - "Imported text".into(), - Default::default(), - )]) - .unwrap(); - let id = onestore::ExGuid { - guid: [5; 16], - n: 1, - }; - let mut objects = vec![ - PageObject::Outline(Outline { - id, - layout: onestore::document::Layout { - x: Some(30.0), - y: Some(40.0), - max_width: Some(180.0), - ..Default::default() - }, - indents: vec![18.0, 0.0], - paragraphs: document.nodes().to_vec(), - unsupported: Vec::new(), - }), - PageObject::Unsupported(Unsupported { - id: Default::default(), - jcid: 0xdead, - layout: onestore::document::Layout { - x: Some(20.0), - y: Some(20.0), - max_width: Some(200.0), - max_height: Some(100.0), - ..Default::default() - }, - }), - ]; - if !readonly_on_top { - objects.reverse(); - } - let page = Page { - title: String::new(), - margin_origin: [0.0; 2], - definitions: Default::default(), - objects, - }; - let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap(); - let scene = (scene, [0.0; 2]); - let imported_layout = editor - .active_outline() - .layouts() - .next() - .unwrap() - .1 - .text - .id(); - let texts: Vec<_> = page_primitives(&editor, Some(&scene), None, None, false, 1.0, 1.0) - .unwrap() - .into_iter() - .filter_map(|p| match p { - Primitive::Text { layout, .. } => Some(layout.id()), - _ => None, - }) - .collect(); - assert_eq!(texts.len(), 2); - assert_eq!(texts[usize::from(!readonly_on_top)], imported_layout); - let expected = if readonly_on_top { - Hit::ReadOnly(0) - } else { - Hit::Text { - id, - point: [10.0, 5.0], - } - }; - assert_eq!( - page_hit_test(&editor, Some(&scene), [40.0, 45.0], 1.0), - Some(expected) - ); - let header = page_hit_test(&editor, Some(&scene), [40.0, 32.0], 1.0); - if readonly_on_top { - assert_eq!(header, Some(Hit::ReadOnly(0))); - } else { - assert_eq!( - header, - Some(Hit::Handle { - id, - grab: [10.0, -8.0] - }) - ); - } - let annotation = editor - .create_outline(&mut engine, [30.0, 40.0], 180.0) - .unwrap(); - assert_eq!( - page_hit_test(&editor, Some(&scene), [40.0, 45.0], 1.0), - Some(Hit::Text { - id: annotation, - point: [10.0, 5.0] - }) - ); - assert_eq!( - page_hit_test(&editor, Some(&scene), [40.0, 32.0], 1.0), - Some(Hit::Handle { - id: annotation, - grab: [10.0, -8.0] - }) - ); - let texts: Vec<_> = page_primitives(&editor, Some(&scene), None, None, false, 1.0, 1.0) - .unwrap() - .into_iter() - .filter_map(|p| match p { - Primitive::Text { layout, .. } => Some(layout.id()), - _ => None, - }) - .collect(); - assert_eq!( - *texts.last().unwrap(), - editor - .active_outline() - .layouts() - .next() - .unwrap() - .1 - .text - .id() - ); - editor.move_outline(annotation, [500.0, 500.0]).unwrap(); - assert!( - !matches!(page_hit_test(&editor, Some(&scene), [40.0,45.0], 1.0), Some(Hit::Text { id, .. }) if id == annotation) - ); - editor.undo(&mut engine).unwrap(); - assert_eq!( - page_hit_test(&editor, Some(&scene), [40.0, 45.0], 1.0), - Some(Hit::Text { - id: annotation, - point: [10.0, 5.0] - }) - ); - editor.undo(&mut engine).unwrap(); - assert_eq!(editor.outlines().len(), 1); - assert_eq!(editor.active_outline().document(), &document); - assert_eq!( - page_hit_test(&editor, Some(&scene), [10.0, 10.0], 1.0), - None - ); - } - } - - #[test] - fn read_only_focus_accepts_navigation_without_edit_shortcuts() { - for modifiers in [ - ModifiersState::empty(), - ModifiersState::SHIFT, - ModifiersState::ALT, - ModifiersState::SUPER, - ModifiersState::SUPER | ModifiersState::SHIFT, - ModifiersState::SUPER | ModifiersState::ALT, - ] { - for key in [ - Key::Character("a".into()), - Key::Character("v".into()), - Key::Character("c".into()), - Key::Character("x".into()), - Key::Character("z".into()), - Key::Character("🌳".into()), - Key::Named(NamedKey::Backspace), - Key::Named(NamedKey::Delete), - Key::Named(NamedKey::Enter), - Key::Named(NamedKey::ArrowLeft), - Key::Named(NamedKey::ArrowRight), - Key::Named(NamedKey::ArrowUp), - Key::Named(NamedKey::ArrowDown), - ] { - assert!( - !read_only_shortcut(&key, modifiers), - "{key:?} {modifiers:?}" - ); - } - } - for (key, modifiers) in [ - (Key::Named(NamedKey::Escape), ModifiersState::empty()), - (Key::Named(NamedKey::Tab), ModifiersState::CONTROL), - ( - Key::Named(NamedKey::Tab), - ModifiersState::CONTROL | ModifiersState::SHIFT, - ), - ( - Key::Character("N".into()), - ModifiersState::SUPER | ModifiersState::SHIFT, - ), - (Key::Character("+".into()), ModifiersState::SUPER), - (Key::Character("0".into()), ModifiersState::SUPER), - (Key::Character("-".into()), ModifiersState::SUPER), - ] { - assert!(read_only_shortcut(&key, modifiers)); - } - assert!(!read_only_shortcut( - &Key::Character("n".into()), - ModifiersState::SUPER - )); - assert!(!read_only_shortcut( - &Key::Named(NamedKey::Tab), - ModifiersState::empty() - )); - } - - #[test] - fn read_only_focus_retires_text_overlays_and_draws_a_scaled_focus_border() { - use one_canvas::page::{PageObject, Unsupported}; - let mut engine = TextEngine::default(); - let mut editor = CanvasEditor::new( - &mut engine, - TextDocument::new(vec![Paragraph::new("Draft".into(), Default::default())]).unwrap(), - 240.0, - ) - .unwrap(); - let page = Page { - title: String::new(), - margin_origin: [0.0; 2], - definitions: Default::default(), - objects: vec![PageObject::Unsupported(Unsupported { - id: Default::default(), - jcid: 0xdead, - layout: onestore::document::Layout { - x: Some(100.0), - y: Some(80.0), - max_width: Some(180.0), - max_height: Some(80.0), - ..Default::default() - }, - })], - }; - let scene = (PageScene::new(page, &mut engine).unwrap(), [20.0, 30.0]); - let original = editor.active_outline().document().clone(); - let rectangles = |primitives: Vec>| { - primitives - .into_iter() - .filter_map(|p| match p { - Primitive::Rect { rect, color } => Some((rect, color)), - _ => None, - }) - .collect::>() - }; - for scale in [1.0, 2.0] { - let text = rectangles( - page_primitives(&editor, Some(&scene), None, None, true, scale, 1.0).unwrap(), - ); - assert!(text.iter().any(|(_, color)| *color == [0.0, 0.0, 0.0, 1.0])); - let selected = rectangles( - page_primitives(&editor, Some(&scene), None, Some(0), true, scale, 1.0).unwrap(), - ); - assert!( - !selected - .iter() - .any(|(_, color)| *color == [0.0, 0.0, 0.0, 1.0]) - ); - assert_eq!( - selected, - rectangles( - page_primitives(&editor, Some(&scene), None, Some(0), false, scale, 1.0) - .unwrap() - ) - ); - assert_eq!( - selected.last().unwrap().0, - [300.0 - 2.0 / scale, 110.0, 300.0, 190.0] - ); - } - editor.select_all().unwrap(); - let text = rectangles( - page_primitives(&editor, Some(&scene), None, None, false, 1.0, 1.0).unwrap(), - ); - assert!( - text.iter() - .any(|(_, color)| *color == [0.55, 0.73, 1.0, 0.5]) - ); - let selected = rectangles( - page_primitives(&editor, Some(&scene), None, Some(0), true, 1.0, 1.0).unwrap(), - ); - assert!( - !selected - .iter() - .any(|(_, color)| *color == [0.55, 0.73, 1.0, 0.5]) - ); - assert_eq!(editor.active_outline().document(), &original); - } - - #[test] - #[ignore = "requires CANVAS_TEST_SECTION and CANVAS_TEST_PAGE private fixture inputs"] - fn imported_page_widgets_and_accessibility_follow_edits() { - let bytes = std::fs::read(std::env::var_os("CANVAS_TEST_SECTION").unwrap()).unwrap(); - let page = { - let store = onestore::Store::parse(&bytes).unwrap(); - let index = onestore::RevisionIndex::parse(&store).unwrap(); - Page::from_document( - &onestore::document::Document::parse(&index).unwrap(), - &std::env::var("CANVAS_TEST_PAGE").unwrap(), - ) - .unwrap() - }; - drop(bytes); - let mut engine = TextEngine::default(); - if let Some(font) = std::env::var_os("CANVAS_TEST_SUBSTITUTE") { - engine - .register_substitute(parley::fontique::Blob::new(Arc::new( - std::fs::read(font).unwrap(), - ))) - .unwrap(); - } - let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap(); - let scene = (scene, [0.0; 2]); - let viewport = Viewport { - size: [1000, 720], - scale: 96.0 / 72.0, - origin: [48.0; 2], - }; - let mut tag_hits = 0; - for outline in editor.outlines() { - for (_, paragraph) in outline.layouts() { - for tag in ¶graph.tags { - let point = [ - outline.origin()[0] - + tag.origin[0] - + one_canvas::outline::ParagraphTag::SIZE / 2.0, - outline.origin()[1] - + paragraph.origin[1] - + tag.origin[1] - + one_canvas::outline::ParagraphTag::SIZE / 2.0, - ]; - assert!( - matches!(page_hit_test(&editor, Some(&scene), point, 1.0), Some(Hit::Text { id, .. }) if id == outline.id), - "Tag center {point:?} did not target outline {:?}", - outline.id - ); - tag_hits += 1; - } - } - } - eprintln!("Verified {tag_hits} imported tag-gutter targets"); - let mut access = accessibility::Accessibility::default(); - let tags = |editor: &CanvasEditor| { - page_primitives(editor, Some(&scene), None, None, true, viewport.scale, 1.0) - .unwrap() - .into_iter() - .filter_map(|primitive| match primitive { - Primitive::Tag { tag, origin } => Some((tag.clone(), origin)), - _ => None, - }) - .collect::>() - }; - let original_tags = tags(&editor); - let ids: Vec<_> = editor.outlines().iter().map(|outline| outline.id).collect(); - for id in ids { - editor.focus_outline(id).unwrap(); - let original = editor.active_outline().document().clone(); - editor - .select( - [one_canvas::document::TextPosition { - paragraph: 0, - offset: 0, - }; 2] - .into(), - ) - .unwrap(); - let before = access.update(&editor, viewport, "Test", None).unwrap(); - let before = accesskit_consumer::Tree::new(before, true); - let before = before.state().focus().unwrap().document_range().text(); - editor.insert(&mut engine, "CANVAS CHECK ").unwrap(); - { - let update = access.update(&editor, viewport, "Test", None).unwrap(); - let tree = accesskit_consumer::Tree::new(update, true); - assert_eq!( - tree.state().focus().unwrap().document_range().text(), - format!("CANVAS CHECK {before}") - ); - let primitives = - page_primitives(&editor, Some(&scene), None, None, true, viewport.scale, 1.0) - .unwrap(); - assert!(!primitives.is_empty()); - let edited_tags = tags(&editor); - assert_eq!(edited_tags.len(), original_tags.len()); - for ((edited, _), (original, _)) in edited_tags.iter().zip(&original_tags) { - assert_eq!(edited.icon, original.icon); - assert_eq!(edited.label, original.label); - assert_eq!(edited.disabled, original.disabled); - } - } - editor.undo(&mut engine).unwrap(); - assert_eq!(editor.active_outline().document(), &original); - assert_eq!(tags(&editor), original_tags); - let update = access.update(&editor, viewport, "Test", None).unwrap(); - let tree = accesskit_consumer::Tree::new(update, true); - assert_eq!( - tree.state().focus().unwrap().document_range().text(), - before - ); - } - eprintln!( - "Rendered and restored {} imported outline widgets and accessibility fields", - editor.outlines().len() - ); - } -} diff --git a/crates/canvas-app/src/profile.rs b/crates/canvas-app/src/profile.rs deleted file mode 100644 index 8a0fcc8a166f09a62fd8877a81dd564f5a138878..0000000000000000000000000000000000000000 --- a/crates/canvas-app/src/profile.rs +++ /dev/null @@ -1,192 +0,0 @@ -use super::*; -use one_canvas::document::TextPosition; -use std::{hint::black_box, process::Command}; - -fn timed(case: &str, phase: &str, sample: usize, operation: impl FnOnce() -> T) -> T { - let start = Instant::now(); - let result = operation(); - eprintln!( - "canvas_profile\t{case}\t{phase}\t{sample}\t{}", - start.elapsed().as_nanos() - ); - result -} - -fn resident(case: &str, phase: &str, sample: usize) { - let output = Command::new("/bin/ps") - .args(["-o", "rss=", "-p", &std::process::id().to_string()]) - .output() - .unwrap(); - assert!(output.status.success()); - let kib: u64 = std::str::from_utf8(&output.stdout) - .unwrap() - .trim() - .parse() - .unwrap(); - eprintln!("canvas_rss_kib\t{case}\t{phase}\t{sample}\t{kib}"); -} - -fn layout_ids(editor: &CanvasEditor) -> Vec { - editor - .outlines() - .iter() - .flat_map(|outline| outline.layouts().map(|(_, paragraph)| paragraph.text.id())) - .collect() -} - -fn editing_cost( - case: &str, - engine: &mut TextEngine, - editor: &mut CanvasEditor, - scene: Option<&(PageScene, [f32; 2])>, -) { - let viewport = Viewport { - size: [1000, 720], - scale: 96.0 / 72.0, - origin: [48.0; 2], - }; - let initial: Vec<_> = timed(case, "opening_snapshot", 0, || { - editor - .outlines() - .iter() - .map(|outline| outline.document().clone()) - .collect() - }); - let mut access = accessibility::Accessibility::default(); - let update = timed(case, "ax_first", 0, || { - access.update(editor, viewport, "Profile", None).unwrap() - }); - eprintln!("canvas_count\t{case}\tax_nodes\t0\t{}", update.nodes.len()); - drop(update); - let count: usize = editor - .outlines() - .iter() - .map(|outline| outline.document().nodes().len()) - .sum(); - eprintln!("canvas_count\t{case}\tparagraphs\t0\t{count}"); - for sample in 0..32 { - let index = sample % editor.outlines().len(); - let id = editor.outlines()[index].id; - editor.focus_outline(id).unwrap(); - let paragraph = if sample % 2 == 0 { - 0 - } else { - editor.active_outline().layouts().last().unwrap().0 - }; - editor - .select( - [TextPosition { - paragraph, - offset: 0, - }; 2] - .into(), - ) - .unwrap(); - let original_layouts = layout_ids(editor); - timed(case, "insert", sample, || { - editor.insert(engine, "x").unwrap() - }); - let edited_layouts = layout_ids(editor); - let changed = original_layouts - .iter() - .zip(&edited_layouts) - .filter(|(a, b)| a != b) - .count(); - assert_eq!(changed, 1); - eprintln!("canvas_count\t{case}\tchanged_layouts\t{sample}\t{changed}"); - let primitives = timed(case, "frame_primitives", sample, || { - let primitives = - page_primitives(editor, scene, None, None, true, viewport.scale, 1.0).unwrap(); - black_box(&primitives); - primitives.len() - }); - eprintln!("canvas_count\t{case}\tprimitives\t{sample}\t{primitives}"); - timed(case, "ax_insert", sample, || { - black_box(access.update(editor, viewport, "Profile", None).unwrap()); - }); - assert_eq!(edited_layouts, layout_ids(editor)); - timed(case, "undo", sample, || { - assert!(editor.undo(engine).unwrap()) - }); - timed(case, "ax_undo", sample, || { - black_box(access.update(editor, viewport, "Profile", None).unwrap()); - }); - assert_eq!(editor.active_outline().document(), &initial[index]); - } - resident(case, "after_edits", 0); -} - -#[test] -#[ignore = "manual release canvas pipeline timing probe"] -fn canvas_pipeline_cost() { - if let Some(path) = std::env::var_os("CANVAS_TEST_SECTION") { - let bytes = timed("native", "read", 0, || std::fs::read(path).unwrap()); - let title = std::env::var("CANVAS_TEST_PAGE").unwrap(); - let mut engine = timed("native", "engine", 0, TextEngine::default); - if let Some(font) = std::env::var_os("CANVAS_TEST_SUBSTITUTE") { - timed("native", "font", 0, || { - engine - .register_substitute(parley::fontique::Blob::new(Arc::new( - std::fs::read(font).unwrap(), - ))) - .unwrap() - }); - } - let cycles = std::env::var("CANVAS_PROFILE_CYCLES") - .map(|value| value.parse::().unwrap()) - .unwrap_or(16); - assert!(cycles > 0); - for cycle in 0..cycles { - let page = timed("native", "parse", cycle, || { - let store = onestore::Store::parse(&bytes).unwrap(); - let index = onestore::RevisionIndex::parse(&store).unwrap(); - Page::from_document( - &onestore::document::Document::parse(&index).unwrap(), - &title, - ) - .unwrap() - }); - let (scene, mut editor) = timed("native", "scene", cycle, || { - PageScene::from_page(page, &mut engine).unwrap() - }); - let scene = (scene, [0.0; 2]); - if cycle == 0 { - editing_cost("native", &mut engine, &mut editor, Some(&scene)); - } else { - black_box( - page_primitives(&editor, Some(&scene), None, None, false, 96.0 / 72.0, 1.0) - .unwrap(), - ); - } - resident("native", "open", cycle); - drop(editor); - drop(scene); - resident("native", "closed", cycle); - } - } else { - for count in [100, 1_000, 5_000] { - let case = format!("synthetic_{count}"); - let mut engine = timed(&case, "engine", 0, TextEngine::default); - let document = timed(&case, "document", 0, || { - TextDocument::new( - (0..count) - .map(|_| { - Paragraph::new( - "A paragraph of plain text with several words.".into(), - Format::default(), - ) - }) - .collect(), - ) - .unwrap() - }); - let mut editor = timed(&case, "scene", 0, || { - CanvasEditor::new(&mut engine, document, 480.0).unwrap() - }); - editing_cost(&case, &mut engine, &mut editor, None); - drop(editor); - drop(engine); - resident(&case, "closed", 0); - } - } -} diff --git a/crates/canvas-gpu/Cargo.toml b/crates/canvas-gpu/Cargo.toml deleted file mode 100644 index 3fbdb2cb8ad3adbf37a0c22254e711161ceab646..0000000000000000000000000000000000000000 --- a/crates/canvas-gpu/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "one-canvas-gpu" -version = "0.1.0" -edition = "2024" -publish = false - -[dependencies] -onestore = { path = "../onestore" } -one-canvas = { path = "../canvas" } -bytemuck = { version = "1.25", features = ["derive"] } -swash = "0.2.10" -parley.workspace = true -wgpu.workspace = true -image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } - -[dev-dependencies] -pollster = "0.4" -png = "0.18" diff --git a/crates/canvas-gpu/examples/render_page.rs b/crates/canvas-gpu/examples/render_page.rs deleted file mode 100644 index 58f95bf3f1354ee906e61d7b8ab88076168777e4..0000000000000000000000000000000000000000 --- a/crates/canvas-gpu/examples/render_page.rs +++ /dev/null @@ -1,125 +0,0 @@ -use one_canvas::{layout::TextEngine, page::Page}; -use one_canvas_gpu::{Renderer, Viewport, page::PageScene}; -use onestore::{RevisionIndex, Store, document::Document}; -use std::{env, fs, sync::Arc, time::Duration}; - -fn main() -> Result<(), Box> { - let mut args = env::args().skip(1).peekable(); - let path = args - .next() - .ok_or("Expected a section snapshot, page title and new PNG path")?; - let title = args.next().ok_or("Expected a page title")?; - let destination = args.next().ok_or("Expected a new PNG path")?; - let origin_y: f32 = if args - .peek() - .is_some_and(|value| value != "--substitute-font") - { - args.next().unwrap().parse()? - } else { - 0.0 - }; - let mut engine = TextEngine::default(); - while let Some(option) = args.next() { - if option != "--substitute-font" { - return Err(format!("Unknown option: {option}").into()); - } - let path = args - .next() - .ok_or("Provide a font file after --substitute-font.")?; - engine.register_substitute(parley::fontique::Blob::new(Arc::new(fs::read(path)?)))?; - } - let page = { - let bytes = fs::read(path)?; - let store = Store::parse(&bytes)?; - let index = RevisionIndex::parse(&store)?; - Page::from_document(&Document::parse(&index)?, &title)? - }; - let margin_origin = page.margin_origin; - let object_count = page.objects.len(); - let scene = PageScene::new(page, &mut engine)?; - let mut primitives = Vec::new(); - scene.append_primitives(&mut primitives, [0.0; 2])?; - let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); - let adapter = pollster::block_on(instance.request_adapter(&Default::default()))?; - let (device, queue) = pollster::block_on(adapter.request_device(&Default::default()))?; - let size = [1386_u32, 759]; - let format = wgpu::TextureFormat::Rgba8UnormSrgb; - let target = device.create_texture(&wgpu::TextureDescriptor { - label: Some("Native page comparison"), - size: wgpu::Extent3d { - width: size[0], - height: size[1], - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, - view_formats: &[], - }); - let row_bytes = (size[0] * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT); - let buffer = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("Native page comparison readback"), - size: u64::from(row_bytes) * u64::from(size[1]), - usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, - mapped_at_creation: false, - }); - let mut renderer = Renderer::new(device, queue, format); - renderer - .draw( - &target.create_view(&Default::default()), - Viewport { - size, - scale: 96.0 / 72.0, - origin: [ - (36.0 - margin_origin[0]) * (96.0 / 72.0), - (14.4 - margin_origin[1]) * (96.0 / 72.0) + origin_y, - ], - }, - &primitives, - ) - .map_err(|e| format!("Page comparison rendering failed: {e:?}"))?; - let mut encoder = renderer.device.create_command_encoder(&Default::default()); - encoder.copy_texture_to_buffer( - target.as_image_copy(), - wgpu::TexelCopyBufferInfo { - buffer: &buffer, - layout: wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(row_bytes), - rows_per_image: Some(size[1]), - }, - }, - wgpu::Extent3d { - width: size[0], - height: size[1], - depth_or_array_layers: 1, - }, - ); - renderer.queue.submit([encoder.finish()]); - let (sender, receiver) = std::sync::mpsc::channel(); - buffer.map_async(wgpu::MapMode::Read, .., move |result| { - let _ = sender.send(result); - }); - renderer.device.poll(wgpu::PollType::Wait { - submission_index: None, - timeout: Some(Duration::from_secs(10)), - })?; - receiver.recv_timeout(Duration::from_secs(10))??; - let mapped = buffer.get_mapped_range(..)?; - let pixels: Vec<_> = mapped - .chunks_exact(row_bytes as usize) - .flat_map(|row| row[..size[0] as usize * 4].iter().copied()) - .collect(); - let mut encoder = png::Encoder::new(fs::File::create_new(destination)?, size[0], size[1]); - encoder.set_color(png::ColorType::Rgba); - encoder.set_depth(png::BitDepth::Eight); - encoder.write_header()?.write_image_data(&pixels)?; - eprintln!( - "Rendered {} page objects at 100% / 96 DPI, origin Y {origin_y} px using {:?}", - object_count, - adapter.get_info() - ); - Ok(()) -} diff --git a/crates/canvas-gpu/src/canvas.wgsl b/crates/canvas-gpu/src/canvas.wgsl deleted file mode 100644 index 477fb80de78bda2063d4678f9c1c54588d490442..0000000000000000000000000000000000000000 --- a/crates/canvas-gpu/src/canvas.wgsl +++ /dev/null @@ -1,19 +0,0 @@ -struct Vertex { - @builtin(position) position: vec4, - @location(0) uv: vec2, - @location(1) color: vec4, -} - -@group(0) @binding(0) var atlas: texture_2d; -@group(0) @binding(1) var atlas_sampler: sampler; - -@vertex -fn vertex(@location(0) position: vec2, @location(1) uv: vec2, - @location(2) color: vec4) -> Vertex { - return Vertex(vec4(position, 0.0, 1.0), uv, color); -} - -@fragment -fn fragment(input: Vertex) -> @location(0) vec4 { - return textureSample(atlas, atlas_sampler, input.uv) * input.color; -} diff --git a/crates/canvas-gpu/src/lib.rs b/crates/canvas-gpu/src/lib.rs deleted file mode 100644 index f49f92257486ab4d077905568f363174d55cf982..0000000000000000000000000000000000000000 --- a/crates/canvas-gpu/src/lib.rs +++ /dev/null @@ -1,1346 +0,0 @@ -pub mod page; -#[cfg(test)] -mod profile; -mod tags; - -use bytemuck::{Pod, Zeroable}; -use image::ImageDecoder; -use one_canvas::{ - layout::TextLayout, - outline::{ParagraphTag, TagIcon}, -}; -use parley::{PositionedLayoutItem, fontique::Blob}; -use std::{ - collections::{HashMap, HashSet}, - ops::Range, -}; -use swash::{ - FontRef, - scale::{Render, ScaleContext, Source, StrikeWith, image::Content}, - zeno::{Angle, Format, Transform, Vector}, -}; - -const ATLAS_SIZE: u32 = 2048; -const MAX_GLYPHS: usize = 8192; -const MAX_VERTICES: usize = 65_536; -const MAX_IMAGE_BYTES: u64 = 64 * 1024 * 1024; -const MAX_IMAGES: usize = 256; - -#[derive(Clone)] -pub struct RasterImage { - size: [u32; 2], - pixels: Blob, -} - -impl RasterImage { - pub fn decode(encoded: &[u8]) -> Result { - if encoded.len() as u64 > MAX_IMAGE_BYTES { - return Err(RenderError::ImageBudget); - } - let format = image::guess_format(encoded).map_err(RenderError::ImageDecode)?; - let mut reader = image::ImageReader::with_format(std::io::Cursor::new(encoded), format); - let mut limits = image::Limits::default(); - limits.max_image_width = Some(16_384); - limits.max_image_height = Some(16_384); - limits.max_alloc = Some(MAX_IMAGE_BYTES); - reader.limits(limits.clone()); - let mut decoder = reader.into_decoder().map_err(RenderError::ImageDecode)?; - let (width, height) = decoder.dimensions(); - if u64::from(width) * u64::from(height) * 4 > MAX_IMAGE_BYTES - || decoder.total_bytes() > MAX_IMAGE_BYTES - { - return Err(RenderError::ImageBudget); - } - limits - .reserve(decoder.total_bytes()) - .map_err(RenderError::ImageDecode)?; - decoder - .set_limits(limits) - .map_err(RenderError::ImageDecode)?; - let pixels = image::DynamicImage::from_decoder(decoder) - .map_err(RenderError::ImageDecode)? - .into_rgba8(); - Self::new([width, height], pixels.into_raw()) - } - - /// Immutable, straight-alpha sRGB RGBA pixels retain one cache identity across clones. - pub fn new(size: [u32; 2], mut pixels: Vec) -> Result { - let bytes = u64::from(size[0]) - .checked_mul(u64::from(size[1])) - .and_then(|v| v.checked_mul(4)) - .ok_or(RenderError::InvalidImage)?; - if size.contains(&0) || bytes != pixels.len() as u64 { - return Err(RenderError::InvalidImage); - } - if bytes > MAX_IMAGE_BYTES { - return Err(RenderError::ImageBudget); - } - // Premultiply in linear light before filtering; transparent RGB must not bleed at edges. - for pixel in pixels.chunks_exact_mut(4) { - let alpha = pixel[3]; - if alpha == 255 { - continue; - } - let linear = colorref(u32::from_le_bytes(pixel.try_into().unwrap())); - for (byte, value) in pixel[..3].iter_mut().zip(linear) { - let value = value * f32::from(alpha) / 255.0; - *byte = srgb_byte(value); - } - } - Ok(Self { - size, - pixels: pixels.into(), - }) - } -} - -struct CachedImage { - binding: wgpu::BindGroup, - bytes: u64, -} - -struct Batch { - vertices: Range, - image: Option, -} - -#[repr(C)] -#[derive(Clone, Copy, Pod, Zeroable)] -struct Vertex { - position: [f32; 2], - uv: [f32; 2], - color: [f32; 4], -} - -#[derive(Hash, PartialEq, Eq)] -enum AtlasKey { - Text { - font: u64, - index: u32, - glyph: u16, - size: u32, - coords: Vec, - phase: [u8; 2], - embolden: bool, - skew: u32, - }, - Tag { - icon: TagIcon, - size: u32, - phase: [u8; 2], - }, -} - -#[derive(Clone, Copy)] -struct AtlasGlyph { - x: u32, - y: u32, - width: u32, - height: u32, - left: i32, - top: i32, - color: bool, -} - -#[derive(Clone, Copy)] -pub struct Viewport { - pub size: [u32; 2], - /// Physical pixels per document point. - pub scale: f32, - /// Physical pixel position of the document origin. - pub origin: [f32; 2], -} - -impl Viewport { - pub fn document_point(&self, point: [f32; 2]) -> [f32; 2] { - [ - (point[0] - self.origin[0]) / self.scale, - (point[1] - self.origin[1]) / self.scale, - ] - } - - fn visible_image_rect(&self, rect: [f32; 4]) -> Result, RenderError> { - let rect = [ - rect[0] * self.scale + self.origin[0], - rect[1] * self.scale + self.origin[1], - rect[2] * self.scale + self.origin[0], - rect[3] * self.scale + self.origin[1], - ]; - if rect.iter().any(|v| !v.is_finite()) || rect[2] < rect[0] || rect[3] < rect[1] { - return Err(RenderError::InvalidPrimitive); - } - Ok((rect[0] < self.size[0] as f32 - && rect[1] < self.size[1] as f32 - && rect[2] > 0.0 - && rect[3] > 0.0 - && rect[0] < rect[2] - && rect[1] < rect[3]) - .then_some(rect)) - } -} - -/// Coordinates are document points; colors are linear RGBA. -pub enum Primitive<'a> { - Tag { - tag: &'a ParagraphTag, - origin: [f32; 2], - }, - Text { - layout: &'a TextLayout, - origin: [f32; 2], - }, - Rect { - rect: [f32; 4], - color: [f32; 4], - }, - Image { - image: &'a RasterImage, - rect: [f32; 4], - }, -} - -#[derive(Debug)] -pub enum RenderError { - AtlasFull, - FrameTooLarge, - UnreadableFont, - UnsupportedGlyph, - InvalidViewport, - InvalidPrimitive, - InvalidImage, - ImageBudget, - ImageTooLarge, - ImageDecode(image::ImageError), -} - -pub struct Renderer { - pub device: wgpu::Device, - pub queue: wgpu::Queue, - pipeline: wgpu::RenderPipeline, - image_pipeline: wgpu::RenderPipeline, - atlas: wgpu::Texture, - bind_group: wgpu::BindGroup, - image_sampler: wgpu::Sampler, - vertex_buffer: wgpu::Buffer, - vertices: Vec, - glyphs: HashMap>, - images: HashMap, - batches: Vec, - scaler: ScaleContext, - pen: [u32; 2], - row_height: u32, -} - -impl Renderer { - pub fn new(device: wgpu::Device, queue: wgpu::Queue, format: wgpu::TextureFormat) -> Self { - let shader = device.create_shader_module(wgpu::include_wgsl!("canvas.wgsl")); - let binding_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("Canvas texture"), - entries: &[ - wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }, - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 1, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }, - ], - }); - let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("Canvas"), - bind_group_layouts: &[Some(&binding_layout)], - ..Default::default() - }); - let make_pipeline = |blend| { - device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("Canvas"), - layout: Some(&pipeline_layout), - vertex: wgpu::VertexState { - module: &shader, - entry_point: Some("vertex"), - compilation_options: Default::default(), - buffers: &[Some(wgpu::VertexBufferLayout { - array_stride: size_of::() as u64, - step_mode: wgpu::VertexStepMode::Vertex, - attributes: &wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Float32x4], - })], - }, - fragment: Some(wgpu::FragmentState { - module: &shader, - entry_point: Some("fragment"), - compilation_options: Default::default(), - targets: &[Some(wgpu::ColorTargetState { - format, - blend: Some(blend), - write_mask: wgpu::ColorWrites::ALL, - })], - }), - primitive: Default::default(), - depth_stencil: None, - multisample: Default::default(), - multiview_mask: None, - cache: None, - }) - }; - let pipeline = make_pipeline(wgpu::BlendState::ALPHA_BLENDING); - let image_pipeline = make_pipeline(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING); - let atlas = device.create_texture(&wgpu::TextureDescriptor { - label: Some("Glyph atlas"), - size: wgpu::Extent3d { - width: ATLAS_SIZE, - height: ATLAS_SIZE, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8UnormSrgb, - usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, - view_formats: &[], - }); - let view = atlas.create_view(&Default::default()); - let sampler = device.create_sampler(&wgpu::SamplerDescriptor { - mag_filter: wgpu::FilterMode::Nearest, - min_filter: wgpu::FilterMode::Nearest, - ..Default::default() - }); - let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("Glyph atlas"), - layout: &pipeline.get_bind_group_layout(0), - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: wgpu::BindingResource::TextureView(&view), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: wgpu::BindingResource::Sampler(&sampler), - }, - ], - }); - let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("Canvas vertices"), - size: (MAX_VERTICES * size_of::()) as u64, - usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - let image_sampler = device.create_sampler(&wgpu::SamplerDescriptor { - label: Some("Canvas image filtering"), - mag_filter: wgpu::FilterMode::Linear, - min_filter: wgpu::FilterMode::Linear, - ..Default::default() - }); - queue.write_texture( - atlas.as_image_copy(), - &[255; 4], - wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(4), - rows_per_image: Some(1), - }, - wgpu::Extent3d { - width: 1, - height: 1, - depth_or_array_layers: 1, - }, - ); - Self { - device, - queue, - pipeline, - image_pipeline, - atlas, - bind_group, - image_sampler, - vertex_buffer, - vertices: Vec::new(), - glyphs: HashMap::new(), - images: HashMap::new(), - batches: Vec::new(), - scaler: ScaleContext::with_max_entries(32), - pen: [1, 0], - row_height: 1, - } - } - - pub fn clear_glyph_cache(&mut self) { - self.glyphs.clear(); - self.pen = [1, 0]; - self.row_height = 1; - } - - pub fn draw( - &mut self, - target: &wgpu::TextureView, - viewport: Viewport, - primitives: &[Primitive<'_>], - ) -> Result<(), RenderError> { - if viewport.size.contains(&0) - || !viewport.scale.is_finite() - || viewport.scale <= 0.0 - || viewport.origin.iter().any(|v| !v.is_finite()) - { - return Err(RenderError::InvalidViewport); - } - let mut active_images = HashSet::new(); - let mut image_bytes = 0; - for primitive in primitives { - if let Primitive::Image { image, rect } = primitive - && viewport.visible_image_rect(*rect)?.is_some() - && active_images.insert(image.pixels.id()) - { - image_bytes += image.pixels.as_ref().len() as u64; - if image_bytes > MAX_IMAGE_BYTES || active_images.len() > MAX_IMAGES { - return Err(RenderError::ImageBudget); - } - } - } - for attempt in 0..2 { - self.vertices.clear(); - self.batches.clear(); - match self.prepare(viewport, primitives, &active_images) { - Err(RenderError::AtlasFull) if attempt == 0 => self.clear_glyph_cache(), - result => { - result?; - break; - } - } - } - self.queue - .write_buffer(&self.vertex_buffer, 0, bytemuck::cast_slice(&self.vertices)); - let mut encoder = self.device.create_command_encoder(&Default::default()); - { - let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("Canvas"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: target, - depth_slice: None, - resolve_target: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color::WHITE), - store: wgpu::StoreOp::Store, - }, - })], - ..Default::default() - }); - pass.set_vertex_buffer(0, self.vertex_buffer.slice(..)); - for batch in &self.batches { - pass.set_pipeline(if batch.image.is_some() { - &self.image_pipeline - } else { - &self.pipeline - }); - let binding = batch - .image - .map(|id| &self.images[&id].binding) - .unwrap_or(&self.bind_group); - pass.set_bind_group(0, binding, &[]); - pass.draw(batch.vertices.clone(), 0..1); - } - } - self.queue.submit([encoder.finish()]); - Ok(()) - } - - fn prepare( - &mut self, - viewport: Viewport, - primitives: &[Primitive<'_>], - active_images: &HashSet, - ) -> Result<(), RenderError> { - for primitive in primitives { - let start = self.vertices.len() as u32; - let mut image_id = None; - match primitive { - Primitive::Tag { tag, origin } => { - let origin = [ - viewport.origin[0] + (origin[0] + tag.origin[0]) * viewport.scale, - viewport.origin[1] + (origin[1] + tag.origin[1]) * viewport.scale, - ]; - self.tag(Viewport { origin, ..viewport }, tag)?; - } - Primitive::Text { layout, origin } => { - let origin = [ - viewport.origin[0] + origin[0] * viewport.scale, - viewport.origin[1] + origin[1] * viewport.scale, - ]; - if origin.iter().any(|v| !v.is_finite()) { - return Err(RenderError::InvalidPrimitive); - } - self.text(Viewport { origin, ..viewport }, layout)?; - } - Primitive::Rect { rect, color } => { - self.quad( - viewport, - [ - rect[0] * viewport.scale + viewport.origin[0], - rect[1] * viewport.scale + viewport.origin[1], - rect[2] * viewport.scale + viewport.origin[0], - rect[3] * viewport.scale + viewport.origin[1], - ], - [0.5 / ATLAS_SIZE as f32; 4], - *color, - )?; - } - Primitive::Image { image, rect } => { - if let Some(rect) = viewport.visible_image_rect(*rect)? { - self.image(image, active_images)?; - image_id = Some(image.pixels.id()); - self.quad(viewport, rect, [0.0, 0.0, 1.0, 1.0], [1.0; 4])?; - } - } - } - let end = self.vertices.len() as u32; - if start != end { - if let Some(last) = self.batches.last_mut() - && last.image == image_id - { - last.vertices.end = end; - } else { - self.batches.push(Batch { - vertices: start..end, - image: image_id, - }); - } - } - } - Ok(()) - } - - fn image(&mut self, image: &RasterImage, active: &HashSet) -> Result<(), RenderError> { - if self.images.contains_key(&image.pixels.id()) { - return Ok(()); - } - if image - .size - .iter() - .any(|v| *v > self.device.limits().max_texture_dimension_2d) - { - return Err(RenderError::ImageTooLarge); - } - let bytes = image.pixels.as_ref().len() as u64; - while self.images.values().map(|i| i.bytes).sum::() + bytes > MAX_IMAGE_BYTES - || self.images.len() >= MAX_IMAGES - { - let id = self - .images - .keys() - .find(|id| !active.contains(id)) - .copied() - .ok_or(RenderError::ImageBudget)?; - self.images.remove(&id); - } - let size = wgpu::Extent3d { - width: image.size[0], - height: image.size[1], - depth_or_array_layers: 1, - }; - let texture = self.device.create_texture(&wgpu::TextureDescriptor { - label: Some("Canvas image"), - size, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8UnormSrgb, - usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, - view_formats: &[], - }); - self.queue.write_texture( - texture.as_image_copy(), - image.pixels.as_ref(), - wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(image.size[0] * 4), - rows_per_image: Some(image.size[1]), - }, - size, - ); - let binding = self.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("Canvas image"), - layout: &self.pipeline.get_bind_group_layout(0), - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: wgpu::BindingResource::TextureView( - &texture.create_view(&Default::default()), - ), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: wgpu::BindingResource::Sampler(&self.image_sampler), - }, - ], - }); - self.images - .insert(image.pixels.id(), CachedImage { binding, bytes }); - Ok(()) - } - - fn text(&mut self, viewport: Viewport, layout: &TextLayout) -> Result<(), RenderError> { - let scale = viewport.scale; - let origin = viewport.origin; - for (line, line_box) in layout.lines() { - let top = line_box.top * scale + origin[1]; - if top + line_box.height * scale < 0.0 || top > viewport.size[1] as f32 { - continue; - } - for item in line.items() { - let PositionedLayoutItem::GlyphRun(glyph_run) = item else { - continue; - }; - let run = glyph_run.run(); - let data = &run.font().font; - let size = run.font_size() * scale; - if !size.is_finite() || size > ATLAS_SIZE as f32 { - return Err(RenderError::AtlasFull); - } - let synthesis = run.synthesis(); - let coords: Vec = run - .normalized_coords() - .iter() - .map(|c| c.to_bits()) - .collect(); - let color = glyph_run.style().brush.color; - for glyph in glyph_run.positioned_glyphs() { - let x = glyph.x * scale + origin[0]; - let y = - (glyph.y + line_box.baseline - line.metrics().baseline) * scale + origin[1]; - // Quantization affects raster coverage only, never advances or line breaks. - let x = (x * 4.0).round() * 0.25; - let y = (y * 4.0).round() * 0.25; - let glyph_id = glyph - .id - .try_into() - .map_err(|_| RenderError::UnsupportedGlyph)?; - let phase = [((x - x.floor()) * 4.0) as u8, ((y - y.floor()) * 4.0) as u8]; - let key = AtlasKey::Text { - font: data.data.id(), - index: data.index, - glyph: glyph_id, - size: size.to_bits(), - coords: coords.clone(), - phase, - embolden: synthesis.embolden(), - skew: synthesis.skew().unwrap_or(0.0).to_bits(), - }; - let cached = if let Some(cached) = self.glyphs.get(&key) { - *cached - } else { - if self.glyphs.len() >= MAX_GLYPHS { - return Err(RenderError::AtlasFull); - } - let font = FontRef::from_index(data.data.as_ref(), data.index as usize) - .ok_or(RenderError::UnreadableFont)?; - let mut scaler = self - .scaler - .builder_with_id(font, [data.data.id(), u64::from(data.index)]) - .size(size) - .hint(false) - .normalized_coords(coords.iter().copied()) - .build(); - let mut render = Render::new(&[ - Source::ColorOutline(0), - Source::ColorBitmap(StrikeWith::BestFit), - Source::Outline, - ]); - // Swash rasterizes outlines in Y-up coordinates. - render.format(Format::Alpha).offset(Vector::new( - f32::from(phase[0]) * 0.25, - -f32::from(phase[1]) * 0.25, - )); - if synthesis.embolden() { - render.embolden(size / 24.0); - } - if let Some(skew) = synthesis.skew() { - render.transform(Some(Transform::skew( - Angle::from_degrees(skew), - Angle::from_degrees(0.0), - ))); - } - let image = render.render(&mut scaler, glyph_id); - let cached = if let Some(image) = - image.filter(|i| i.placement.width > 0 && i.placement.height > 0) - { - Some(self.upload(image)?) - } else { - None - }; - self.glyphs.insert(key, cached); - cached - }; - if let Some(glyph) = cached { - let left = x.floor() + glyph.left as f32; - let top = y.floor() - glyph.top as f32; - let atlas_size = ATLAS_SIZE as f32; - self.quad( - viewport, - [ - left, - top, - left + glyph.width as f32, - top + glyph.height as f32, - ], - [ - glyph.x as f32 / atlas_size, - glyph.y as f32 / atlas_size, - (glyph.x + glyph.width) as f32 / atlas_size, - (glyph.y + glyph.height) as f32 / atlas_size, - ], - if glyph.color { - [1.0; 4] - } else { - colorref(color) - }, - )?; - } - } - let metrics = run.font_metrics(); - for (decoration, offset, thickness) in [ - ( - &glyph_run.style().underline, - metrics.underline_offset, - metrics.underline_size, - ), - ( - &glyph_run.style().strikethrough, - metrics.strikethrough_offset, - metrics.strikethrough_size, - ), - ] { - if let Some(decoration) = decoration { - let x = glyph_run.offset() * scale + origin[0]; - let y = (line_box.baseline - decoration.offset.unwrap_or(offset)) * scale - + origin[1]; - self.quad( - viewport, - [ - x, - y, - x + glyph_run.advance() * scale, - y + (decoration.size.unwrap_or(thickness) * scale).max(1.0), - ], - [0.5 / ATLAS_SIZE as f32; 4], - colorref(decoration.brush.color), - )?; - } - } - } - } - Ok(()) - } - - fn upload(&mut self, image: swash::scale::image::Image) -> Result { - let p = image.placement; - if p.width + 2 > ATLAS_SIZE || p.height + 2 > ATLAS_SIZE { - return Err(RenderError::AtlasFull); - } - if self.pen[0] + p.width + 1 > ATLAS_SIZE { - self.pen[0] = 0; - self.pen[1] += self.row_height + 1; - self.row_height = 0; - } - if self.pen[1] + p.height + 1 > ATLAS_SIZE { - return Err(RenderError::AtlasFull); - } - let cached = AtlasGlyph { - x: self.pen[0], - y: self.pen[1], - width: p.width, - height: p.height, - left: p.left, - top: p.top, - color: image.content == Content::Color, - }; - let rgba = match image.content { - Content::Mask => image - .data - .iter() - .flat_map(|a| [255, 255, 255, *a]) - .collect(), - _ => image.data, - }; - self.queue.write_texture( - wgpu::TexelCopyTextureInfo { - origin: wgpu::Origin3d { - x: cached.x, - y: cached.y, - z: 0, - }, - ..self.atlas.as_image_copy() - }, - &rgba, - wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(p.width * 4), - rows_per_image: Some(p.height), - }, - wgpu::Extent3d { - width: p.width, - height: p.height, - depth_or_array_layers: 1, - }, - ); - self.pen[0] += p.width + 1; - self.row_height = self.row_height.max(p.height); - Ok(cached) - } - - fn tag(&mut self, viewport: Viewport, tag: &ParagraphTag) -> Result<(), RenderError> { - let size = ParagraphTag::SIZE * viewport.scale; - let [x, y] = viewport.origin.map(|v| (v * 4.0).round() * 0.25); - if !size.is_finite() || [x, y].iter().any(|v| !v.is_finite()) { - return Err(RenderError::InvalidPrimitive); - } - if x + size + 1.0 < 0.0 - || y + size + 1.0 < 0.0 - || x - 1.0 > viewport.size[0] as f32 - || y - 1.0 > viewport.size[1] as f32 - { - return Ok(()); - } - if size + 3.0 > ATLAS_SIZE as f32 { - return Err(RenderError::AtlasFull); - } - let phase = [((x - x.floor()) * 4.0) as u8, ((y - y.floor()) * 4.0) as u8]; - let key = AtlasKey::Tag { - icon: tag.icon, - size: size.to_bits(), - phase, - }; - let glyph = if let Some(Some(glyph)) = self.glyphs.get(&key) { - *glyph - } else { - if self.glyphs.len() >= MAX_GLYPHS { - return Err(RenderError::AtlasFull); - } - let glyph = self.upload(tags::rasterize(tag.icon, size, phase))?; - self.glyphs.insert(key, Some(glyph)); - glyph - }; - let left = x.floor(); - let top = y.floor(); - let atlas = ATLAS_SIZE as f32; - self.quad( - viewport, - [ - left, - top, - left + glyph.width as f32, - top + glyph.height as f32, - ], - [ - glyph.x as f32 / atlas, - glyph.y as f32 / atlas, - (glyph.x + glyph.width) as f32 / atlas, - (glyph.y + glyph.height) as f32 / atlas, - ], - [1.0, 1.0, 1.0, if tag.disabled { 0.45 } else { 1.0 }], - ) - } - - fn quad( - &mut self, - viewport: Viewport, - rect: [f32; 4], - uv: [f32; 4], - color: [f32; 4], - ) -> Result<(), RenderError> { - if rect.iter().chain(&color).any(|v| !v.is_finite()) - || rect[2] < rect[0] - || rect[3] < rect[1] - { - return Err(RenderError::InvalidPrimitive); - } - if rect[2] < 0.0 - || rect[3] < 0.0 - || rect[0] > viewport.size[0] as f32 - || rect[1] > viewport.size[1] as f32 - { - return Ok(()); - } - if self.vertices.len() + 6 > MAX_VERTICES { - return Err(RenderError::FrameTooLarge); - } - let x0 = rect[0] * 2.0 / viewport.size[0] as f32 - 1.0; - let x1 = rect[2] * 2.0 / viewport.size[0] as f32 - 1.0; - let y0 = 1.0 - rect[1] * 2.0 / viewport.size[1] as f32; - let y1 = 1.0 - rect[3] * 2.0 / viewport.size[1] as f32; - if [x0, x1, y0, y1].iter().any(|v| !v.is_finite()) { - return Err(RenderError::InvalidPrimitive); - } - for (position, uv) in [ - ([x0, y0], [uv[0], uv[1]]), - ([x0, y1], [uv[0], uv[3]]), - ([x1, y1], [uv[2], uv[3]]), - ([x0, y0], [uv[0], uv[1]]), - ([x1, y1], [uv[2], uv[3]]), - ([x1, y0], [uv[2], uv[1]]), - ] { - self.vertices.push(Vertex { - position, - uv, - color, - }); - } - Ok(()) - } -} - -fn srgb_byte(value: f32) -> u8 { - let srgb = if value <= 0.0031308 { - value * 12.92 - } else { - 1.055 * value.powf(1.0 / 2.4) - 0.055 - }; - (srgb * 255.0).round() as u8 -} - -pub fn colorref(color: u32) -> [f32; 4] { - let linear = |byte: u32| { - let value = (byte & 255) as f32 / 255.0; - if value <= 0.04045 { - value / 12.92 - } else { - ((value + 0.055) / 1.055).powf(2.4) - } - }; - [linear(color), linear(color >> 8), linear(color >> 16), 1.0] -} - -#[cfg(test)] -mod tests { - use super::*; - use one_canvas::{layout::TextEngine, text::Paragraph}; - use onestore::document::Format as TextFormat; - use std::time::Duration; - - #[test] - fn decoding_preserves_pixels_and_rejects_large_or_truncated_images() { - let mut encoded = Vec::new(); - let mut encoder = png::Encoder::new(&mut encoded, 2, 1); - encoder.set_color(png::ColorType::Rgba); - encoder.set_depth(png::BitDepth::Eight); - let rgba = [255, 64, 0, 255, 0, 128, 255, 128]; - encoder - .write_header() - .unwrap() - .write_image_data(&rgba) - .unwrap(); - let decoded = RasterImage::decode(&encoded).unwrap(); - assert_eq!(decoded.size, [2, 1]); - assert_eq!(decoded.pixels.as_ref(), [255, 64, 0, 255, 0, 93, 188, 128]); - for end in 0..encoded.len() - 12 { - assert!(RasterImage::decode(&encoded[..end]).is_err(), "{end}"); - } - - let mut jpeg = Vec::new(); - image::codecs::jpeg::JpegEncoder::new(&mut jpeg) - .encode(&[255; 4 * 4 * 3], 4, 4, image::ExtendedColorType::Rgb8) - .unwrap(); - let decoded = RasterImage::decode(&jpeg).unwrap(); - assert_eq!(decoded.size, [4, 4]); - assert_eq!(decoded.pixels.as_ref(), [255; 4 * 4 * 4]); - - for size in [[8192, 8192], [16_385, 1]] { - let mut header = Vec::new(); - let mut encoder = png::Encoder::new(&mut header, size[0], size[1]); - encoder.set_color(png::ColorType::Rgba); - encoder.set_depth(png::BitDepth::Eight); - let mut writer = encoder.write_header().unwrap(); - writer.write_chunk(png::chunk::IDAT, &[]).unwrap(); - drop(writer); - let error = RasterImage::decode(&header).err().unwrap(); - if size[0] == 8192 { - assert!(matches!(error, RenderError::ImageBudget), "{error:?}"); - } else { - assert!( - matches!( - error, - RenderError::ImageDecode(image::ImageError::Limits(_)) - ), - "{error:?}" - ); - } - } - } - - #[test] - fn image_pixels_have_valid_dimensions_and_immutable_clone_identity() { - assert!(matches!( - RasterImage::new([0, 1], Vec::new()), - Err(RenderError::InvalidImage) - )); - assert!(matches!( - RasterImage::new([2, 2], vec![255; 15]), - Err(RenderError::InvalidImage) - )); - assert!(matches!( - RasterImage::new([u32::MAX; 2], Vec::new()), - Err(RenderError::InvalidImage) - )); - let image = RasterImage::new([1, 1], vec![1, 2, 3, 255]).unwrap(); - assert_eq!(image.pixels.id(), image.clone().pixels.id()); - } - - #[test] - #[ignore = "requires a native GPU adapter"] - fn eviction_and_repaint_reproduce_pixels() { - let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); - let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap(); - let (device, queue) = - pollster::block_on(adapter.request_device(&Default::default())).unwrap(); - let format = wgpu::TextureFormat::Rgba8UnormSrgb; - let target = device.create_texture(&wgpu::TextureDescriptor { - label: Some("Canvas readback test"), - size: wgpu::Extent3d { - width: 512, - height: 256, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, - view_formats: &[], - }); - let readback = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("Canvas readback"), - size: 512 * 256 * 4, - usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - let mut renderer = Renderer::new(device, queue, format); - let mut engine = TextEngine::default(); - let layout = engine - .layout( - &Paragraph::new( - "Hello 🌳\nCafé e\u{301} שלום".into(), - TextFormat { - underline: Some(true), - ..TextFormat::default() - }, - ), - 220.0, - ) - .unwrap(); - let viewport = Viewport { - size: [512, 256], - scale: 2.0, - origin: [24.0; 2], - }; - let mut captures = Vec::new(); - let image = RasterImage::new( - [2, 2], - vec![255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 0, 0, 0, 0], - ) - .unwrap(); - let transparent_edge = - RasterImage::new([2, 1], vec![255, 0, 0, 255, 0, 0, 255, 0]).unwrap(); - let tags = [ - TagIcon::CheckBox { checked: false }, - TagIcon::CheckBox { checked: true }, - TagIcon::Question, - TagIcon::Music, - ] - .map(|icon| ParagraphTag { - icon, - origin: [0.0; 2], - label: String::new(), - disabled: false, - }); - let phase_layout = engine - .layout( - &Paragraph::new( - "H".into(), - TextFormat { - font: Some("Arial".into()), - font_size: Some(11.0), - ..TextFormat::default() - }, - ), - 20.0, - ) - .unwrap(); - let mut primitives = vec![ - Primitive::Rect { - rect: [0.0, 0.0, 70.0, 14.0], - color: [0.55, 0.73, 1.0, 0.5], - }, - Primitive::Text { - layout: &layout, - origin: [0.0; 2], - }, - Primitive::Text { - layout: &layout, - origin: [8.0, 64.0], - }, - Primitive::Image { - image: &image, - rect: [128.0, 8.0, 160.0, 40.0], - }, - Primitive::Rect { - rect: [128.0, 8.0, 132.0, 12.0], - color: [0.0, 0.0, 1.0, 1.0], - }, - Primitive::Image { - image: &transparent_edge, - rect: [176.0, 8.0, 208.0, 40.0], - }, - Primitive::Tag { - tag: &tags[0], - origin: [128.125, 80.25], - }, - Primitive::Tag { - tag: &tags[1], - origin: [148.125, 80.25], - }, - Primitive::Tag { - tag: &tags[2], - origin: [168.125, 80.25], - }, - Primitive::Tag { - tag: &tags[3], - origin: [188.125, 80.25], - }, - ]; - primitives.extend((0..9).map(|step| Primitive::Text { - layout: &phase_layout, - origin: [8.0 + 24.0 * step as f32, 96.0 + 0.125 * step as f32], - })); - for pass in 0..4 { - if pass == 2 { - renderer.clear_glyph_cache(); - renderer.images.clear(); - } else if pass == 3 { - renderer = Renderer::new(renderer.device.clone(), renderer.queue.clone(), format); - } - renderer - .draw( - &target.create_view(&Default::default()), - viewport, - &primitives, - ) - .unwrap(); - let mut encoder = renderer.device.create_command_encoder(&Default::default()); - encoder.copy_texture_to_buffer( - target.as_image_copy(), - wgpu::TexelCopyBufferInfo { - buffer: &readback, - layout: wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(512 * 4), - rows_per_image: Some(256), - }, - }, - wgpu::Extent3d { - width: 512, - height: 256, - depth_or_array_layers: 1, - }, - ); - renderer.queue.submit([encoder.finish()]); - let (sender, receiver) = std::sync::mpsc::channel(); - readback.map_async(wgpu::MapMode::Read, .., move |result| { - sender.send(result).unwrap(); - }); - renderer - .device - .poll(wgpu::PollType::Wait { - submission_index: None, - timeout: Some(Duration::from_secs(5)), - }) - .unwrap(); - receiver - .recv_timeout(Duration::from_secs(5)) - .unwrap() - .unwrap(); - captures.push(readback.get_mapped_range(..).unwrap().to_vec()); - readback.unmap(); - } - let centroids: Vec<_> = (0..9) - .map(|step| { - let mut weight = 0.0_f64; - let mut moment = 0.0_f64; - for y in 212..252 { - for x in (40 + 48 * step)..(72 + 48 * step) { - let value = captures[0][(y * 512 + x) * 4]; - let coverage = 1.0 - f64::from(colorref(u32::from(value))[0]); - weight += coverage; - moment += coverage * y as f64; - } - } - assert!(weight > 10.0, "phase sample {step} was not painted"); - moment / weight - }) - .collect(); - for (step, centroid) in centroids.iter().enumerate() { - let movement = centroid - centroids[0]; - assert!( - (movement - step as f64 * 0.25).abs() < 0.06, - "quarter-pixel step {step} moved the glyph by {movement} px" - ); - } - assert!(!renderer.glyphs.is_empty()); - assert!(renderer.glyphs.len() <= MAX_GLYPHS); - assert_eq!( - renderer - .glyphs - .keys() - .filter(|key| matches!(key, AtlasKey::Tag { .. })) - .count(), - 4 - ); - for x in [280, 320, 360, 400] { - let colored = (184..210) - .flat_map(|y| (x..x + 26).map(move |x| (y * 512 + x) * 4)) - .filter(|offset| captures[0][*offset..*offset + 3].iter().any(|v| *v < 180)) - .count(); - assert!(colored > 10, "tag at {x} was not painted"); - } - let count = renderer.glyphs.len(); - renderer - .draw( - &target.create_view(&Default::default()), - viewport, - &[Primitive::Tag { - tag: &tags[0], - origin: [10000.0; 2], - }], - ) - .unwrap(); - assert_eq!(renderer.glyphs.len(), count); - assert!( - captures[0] - .chunks_exact(4) - .filter(|pixel| pixel[0] < 200) - .count() - > 100 - ); - assert_eq!(captures[0], captures[1]); - assert_eq!(captures[0], captures[2]); - assert_eq!(captures[0], captures[3]); - for (x, y, color) in [ - (288, 48, [255, 0, 0, 255]), - (336, 96, [255; 4]), - (284, 44, [0, 0, 255, 255]), - ] { - let offset = (y * 512 + x) * 4; - assert_eq!(captures[0][offset..offset + 4], color); - } - let edge = &captures[0][(48 * 512 + 408) * 4..(48 * 512 + 408) * 4 + 4]; - assert_eq!(edge[0], 255, "{edge:?}"); - assert_eq!(edge[1], edge[2], "{edge:?}"); - assert!((180..=200).contains(&edge[1]), "{edge:?}"); - assert_eq!(renderer.images.len(), 2); - for primitive in [ - Primitive::Rect { - rect: [0.0, 0.0, f32::NAN, 1.0], - color: [1.0; 4], - }, - Primitive::Rect { - rect: [2.0, 0.0, 1.0, 1.0], - color: [1.0; 4], - }, - Primitive::Text { - layout: &layout, - origin: [f32::MAX; 2], - }, - ] { - assert!(matches!( - renderer.draw( - &target.create_view(&Default::default()), - viewport, - &[primitive] - ), - Err(RenderError::InvalidPrimitive) - )); - } - let too_many: Vec<_> = (0..=MAX_IMAGES) - .map(|_| RasterImage::new([1, 1], vec![255; 4]).unwrap()) - .collect(); - let primitives: Vec<_> = too_many - .iter() - .map(|image| Primitive::Image { - image, - rect: [0.0, 0.0, 1.0, 1.0], - }) - .collect(); - assert!(matches!( - renderer.draw( - &target.create_view(&Default::default()), - viewport, - &primitives - ), - Err(RenderError::ImageBudget) - )); - let primitives: Vec<_> = too_many - .iter() - .map(|image| Primitive::Image { - image, - rect: [-20.0, -20.0, -19.0, -19.0], - }) - .collect(); - renderer - .draw( - &target.create_view(&Default::default()), - viewport, - &primitives, - ) - .unwrap(); - assert_eq!(renderer.images.len(), 2); - let mut seen = HashSet::from([image.pixels.id(), transparent_edge.pixels.id()]); - let mut previous = None; - for _ in 0..5 { - let large = RasterImage::new([2048; 2], vec![255; 2048 * 2048 * 4]).unwrap(); - seen.insert(large.pixels.id()); - let mut primitives = vec![Primitive::Image { - image: &large, - rect: [0.0, 0.0, 16.0, 16.0], - }]; - if let Some(image) = &previous { - primitives.push(Primitive::Image { - image, - rect: [16.0, 0.0, 32.0, 16.0], - }); - } - renderer - .draw( - &target.create_view(&Default::default()), - viewport, - &primitives, - ) - .unwrap(); - assert!(renderer.images.contains_key(&large.pixels.id())); - if let Some(image) = &previous { - assert!(renderer.images.contains_key(&image.pixels.id())); - } - assert!(renderer.images.values().map(|i| i.bytes).sum::() <= MAX_IMAGE_BYTES); - previous = Some(large); - } - assert!(renderer.images.len() <= MAX_IMAGES); - assert!(seen.iter().any(|id| !renderer.images.contains_key(id))); - renderer.images.clear(); - renderer - .draw( - &target.create_view(&Default::default()), - viewport, - &[Primitive::Image { - image: &image, - rect: [128.0, 8.0, 160.0, 40.0], - }], - ) - .unwrap(); - assert_eq!(renderer.images.len(), 1); - assert!(renderer.images.contains_key(&image.pixels.id())); - } -} diff --git a/crates/canvas-gpu/src/page.rs b/crates/canvas-gpu/src/page.rs deleted file mode 100644 index 9d80fa2205d87870ed85085644eaeb11f7caef20..0000000000000000000000000000000000000000 --- a/crates/canvas-gpu/src/page.rs +++ /dev/null @@ -1,923 +0,0 @@ -use crate::{Primitive, RasterImage, RenderError, colorref}; -use one_canvas::{ - editor::{CanvasEditor, EditorError, TextOutline}, - layout::{LayoutError, TextEngine, TextLayout}, - outline::OutlineLayout, - page::{Page, PageObject}, - text::Paragraph, -}; -use std::fmt; - -/// Retained drawing data in the source page's coordinate system. -pub struct PageScene { - objects: Vec, -} - -#[derive(Debug, PartialEq)] -pub enum SceneHit { - Outline(T), - ReadOnly(usize), -} - -enum Content { - Outline { - origin: [f32; 2], - layout: OutlineLayout, - }, - Image { - origin: [f32; 2], - image: RasterImage, - size: [f32; 2], - }, - Editable(onestore::ExGuid), - ReadOnly(Box), -} - -pub struct ReadOnlyObject { - pub source: PageObject, - pub rect: [f32; 4], - pub message: &'static str, - label: TextLayout, -} - -impl ReadOnlyObject { - fn new( - source: PageObject, - margin: [f32; 2], - message: &'static str, - engine: &mut TextEngine, - ) -> Result, SceneError> { - let (layout, offset) = match &source { - PageObject::Outline(v) => (&v.layout, [0.0; 2]), - PageObject::Title(v) => (&v.layout, margin), - PageObject::Image(v) => (&v.layout, [0.0; 2]), - PageObject::Unsupported(v) => (&v.layout, [0.0; 2]), - }; - let x = layout.x.unwrap_or(0.0) + offset[0]; - let y = layout.y.unwrap_or(0.0) + offset[1]; - let width = layout.max_width.unwrap_or(160.0); - let height = layout.max_height.unwrap_or(42.0); - if [x, y, width, height].iter().any(|v| !v.is_finite()) || width <= 0.0 || height <= 0.0 { - return Err(SceneError::InvalidGeometry); - } - let width = width.max(160.0); - let label = engine.layout( - &Paragraph::new( - message.into(), - onestore::document::Format { - font: Some("Arial".into()), - font_size: Some(11.0), - color: Some(0x005d554e), - ..Default::default() - }, - ), - width - 16.0, - )?; - let height = height.max(label.height() + 16.0); - let rect = [x, y, x + width, y + height]; - if rect.iter().any(|v| !v.is_finite()) { - return Err(SceneError::InvalidGeometry); - } - Ok(Box::new(Self { - source, - rect, - message, - label, - })) - } -} - -#[derive(Debug)] -pub enum SceneError { - Layout(LayoutError), - Editor(EditorError), - MissingOutline, - Image(RenderError), - MissingImage, - InvalidGeometry, -} - -impl fmt::Display for SceneError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Layout(error) => error.fmt(f), - Self::Editor(error) => error.fmt(f), - Self::MissingOutline => f.write_str("An editable outline is missing from this page."), - Self::Image(error) => write!(f, "Image rendering failed: {error:?}"), - Self::MissingImage => f.write_str("An image is missing its data or dimensions."), - Self::InvalidGeometry => { - f.write_str("The page contains invalid object dimensions or positions.") - } - } - } -} -impl std::error::Error for SceneError {} -impl From for SceneError { - fn from(error: LayoutError) -> Self { - Self::Layout(error) - } -} - -impl PageScene { - pub fn new(mut page: Page, engine: &mut TextEngine) -> Result { - Self::build(&mut page, engine, false).map(|(scene, _)| scene) - } - - pub fn from_page( - mut page: Page, - engine: &mut TextEngine, - ) -> Result<(Self, CanvasEditor), SceneError> { - let (scene, mut outlines) = Self::build(&mut page, engine, true)?; - if outlines.is_empty() { - let x = scene - .read_only() - .map(|object| object.rect[2]) - .fold(0.0_f32, f32::max); - outlines.push( - TextOutline::new( - engine, - one_canvas::document::TextDocument::new(vec![Paragraph::new( - String::new(), - Default::default(), - )]) - .map_err(EditorError::from) - .map_err(SceneError::Editor)?, - 240.0, - [if x > 0.0 { x + 24.0 } else { 0.0 }, 0.0], - ) - .map_err(SceneError::Editor)?, - ); - } - let editor = CanvasEditor::from_text_outlines(outlines, page.definitions) - .map_err(SceneError::Editor)?; - Ok((scene, editor)) - } - - fn build( - page: &mut Page, - engine: &mut TextEngine, - editable: bool, - ) -> Result<(Self, Vec), SceneError> { - if page.margin_origin.iter().any(|v| !v.is_finite()) { - return Err(SceneError::InvalidGeometry); - } - let mut objects = Vec::new(); - let mut outlines = Vec::new(); - let mut image_bytes = 0_u64; - for object in std::mem::take(&mut page.objects) { - match &object { - PageObject::Outline(outline) => { - let origin = [ - outline.layout.x.unwrap_or(0.0), - outline.layout.y.unwrap_or(0.0), - ]; - if origin.iter().any(|v| !v.is_finite()) { - return Err(SceneError::InvalidGeometry); - } - if editable { - match TextOutline::from_outline(engine, outline, &page.definitions) { - Ok(outline) => { - objects.push(Content::Editable(outline.id)); - outlines.push(outline); - } - Err(EditorError::Layout(LayoutError::UnsupportedContent)) => objects - .push(Content::ReadOnly(ReadOnlyObject::new( - object, - page.margin_origin, - "Unsupported content\nRead-only", - engine, - )?)), - Err(error) => return Err(SceneError::Editor(error)), - } - } else { - match outline.layout(engine, &page.definitions) { - Ok(layout) => objects.push(Content::Outline { origin, layout }), - Err(LayoutError::UnsupportedContent) => { - objects.push(Content::ReadOnly(ReadOnlyObject::new( - object, - page.margin_origin, - "Unsupported content\nRead-only", - engine, - )?)) - } - Err(error) => return Err(error.into()), - } - } - } - PageObject::Title(title) => { - let layouts = match title.layout(engine, &page.definitions) { - Ok(layouts) => layouts, - Err(LayoutError::UnsupportedContent) => { - objects.push(Content::ReadOnly(ReadOnlyObject::new( - object, - page.margin_origin, - "Unsupported content\nRead-only", - engine, - )?)); - continue; - } - Err(error) => return Err(error.into()), - }; - for (origin, layout) in layouts { - let origin = [ - title.layout.x.unwrap_or(0.0) + origin[0] + page.margin_origin[0], - title.layout.y.unwrap_or(0.0) + origin[1] + page.margin_origin[1], - ]; - if origin.iter().any(|v| !v.is_finite()) { - return Err(SceneError::InvalidGeometry); - } - objects.push(Content::Outline { origin, layout }); - } - } - PageObject::Image(source) => { - if source.bytes.is_none() - || source.layout.max_width.is_none() - || source.layout.max_height.is_none() - { - objects.push(Content::ReadOnly(ReadOnlyObject::new( - object, - page.margin_origin, - "Image unavailable\nRead-only", - engine, - )?)); - continue; - } - let origin = [ - source.layout.x.unwrap_or(0.0), - source.layout.y.unwrap_or(0.0), - ]; - let size = [ - source.layout.max_width.ok_or(SceneError::MissingImage)?, - source.layout.max_height.ok_or(SceneError::MissingImage)?, - ]; - if origin.iter().any(|v| !v.is_finite()) - || size.iter().any(|v| !v.is_finite() || *v <= 0.0) - { - return Err(SceneError::InvalidGeometry); - } - let image = RasterImage::decode( - source.bytes.as_deref().ok_or(SceneError::MissingImage)?, - ) - .map_err(SceneError::Image)?; - image_bytes += image.pixels.as_ref().len() as u64; - if image_bytes > crate::MAX_IMAGE_BYTES { - return Err(SceneError::Image(RenderError::ImageBudget)); - } - objects.push(Content::Image { - origin, - image, - size, - }); - } - PageObject::Unsupported(_) => objects.push(Content::ReadOnly(ReadOnlyObject::new( - object, - page.margin_origin, - "Unsupported content\nRead-only", - engine, - )?)), - } - } - Ok((Self { objects }, outlines)) - } - - pub fn read_only(&self) -> impl Iterator { - self.objects.iter().filter_map(|object| match object { - Content::ReadOnly(object) => Some(object.as_ref()), - _ => None, - }) - } - - pub fn contains_outline(&self, id: onestore::ExGuid) -> bool { - self.objects - .iter() - .any(|object| matches!(object, Content::Editable(candidate) if *candidate == id)) - } - - /// The point and outline hit callback use scene coordinates. - pub fn hit_test( - &self, - point: [f32; 2], - mut outline: impl FnMut(onestore::ExGuid) -> Option, - ) -> Option> { - let mut readonly = self.read_only().count(); - for object in self.objects.iter().rev() { - match object { - Content::Editable(id) => { - if let Some(hit) = outline(*id) { - return Some(SceneHit::Outline(hit)); - } - } - Content::ReadOnly(object) => { - readonly -= 1; - let [x0, y0, x1, y1] = object.rect; - if (x0..=x1).contains(&point[0]) && (y0..=y1).contains(&point[1]) { - return Some(SceneHit::ReadOnly(readonly)); - } - } - Content::Outline { .. } | Content::Image { .. } => {} - } - } - None - } - - /// The offset is in document points. - pub fn append_primitives<'a>( - &'a self, - primitives: &mut Vec>, - offset: [f32; 2], - ) -> Result<(), SceneError> { - self.append_primitives_with(primitives, offset, |_, _, _| { - Err(SceneError::MissingOutline) - }) - } - - pub fn append_primitives_with<'a, E>( - &'a self, - primitives: &mut Vec>, - offset: [f32; 2], - mut outline: impl FnMut(onestore::ExGuid, [f32; 2], &mut Vec>) -> Result<(), E>, - ) -> Result<(), E> { - for content in &self.objects { - let origin = match content { - Content::Outline { origin, .. } | Content::Image { origin, .. } => origin, - Content::ReadOnly(object) => { - let [x0, y0, x1, y1] = object.rect; - let rect = [ - x0 + offset[0], - y0 + offset[1], - x1 + offset[0], - y1 + offset[1], - ]; - primitives.push(Primitive::Rect { - rect, - color: colorref(0x00e4ddd6), - }); - primitives.push(Primitive::Rect { - rect: [rect[0] + 1.0, rect[1] + 1.0, rect[2] - 1.0, rect[3] - 1.0], - color: colorref(0x00faf7f3), - }); - primitives.push(Primitive::Text { - layout: &object.label, - origin: [rect[0] + 8.0, rect[1] + 8.0], - }); - continue; - } - Content::Editable(id) => { - outline(*id, offset, primitives)?; - continue; - } - }; - let object_origin = [origin[0] + offset[0], origin[1] + offset[1]]; - match content { - Content::Image { image, size, .. } => primitives.push(Primitive::Image { - image, - rect: [ - object_origin[0], - object_origin[1], - object_origin[0] + size[0], - object_origin[1] + size[1], - ], - }), - Content::Outline { - layout: outline, .. - } => { - for paragraph in &outline.paragraphs { - let origin = [ - object_origin[0] + paragraph.origin[0], - object_origin[1] + paragraph.origin[1], - ]; - for (rect, color) in paragraph.text.backgrounds() { - primitives.push(Primitive::Rect { - rect: [ - rect.x0 as f32 + origin[0], - rect.y0 as f32 + origin[1], - rect.x1 as f32 + origin[0], - rect.y1 as f32 + origin[1], - ], - color: colorref(color), - }); - } - primitives.push(Primitive::Text { - layout: ¶graph.text, - origin, - }); - for (layout, origin) in ¶graph.markers { - primitives.push(Primitive::Text { - layout, - origin: [ - object_origin[0] + origin[0], - object_origin[1] + (origin[1] + paragraph.origin[1]), - ], - }); - } - for tag in ¶graph.tags { - primitives.push(Primitive::Tag { - tag, - origin: [object_origin[0], object_origin[1] + paragraph.origin[1]], - }); - } - } - } - Content::Editable(_) | Content::ReadOnly(_) => unreachable!(), - } - } - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use one_canvas::page::Image; - use onestore::{ExGuid, document::Layout}; - use std::{collections::BTreeMap, sync::Arc}; - - #[test] - fn reverse_hit_order_keeps_read_only_focus_indices() { - let objects = [0.0, 20.0] - .into_iter() - .map(|position| { - PageObject::Unsupported(one_canvas::page::Unsupported { - id: ExGuid { - guid: [position as u8; 16], - n: 1, - }, - jcid: 0xdead, - layout: Layout { - x: Some(position), - y: Some(position), - max_width: Some(160.0), - max_height: Some(80.0), - ..Default::default() - }, - }) - }) - .collect(); - let page = Page { - title: String::new(), - margin_origin: [0.0; 2], - definitions: BTreeMap::new(), - objects, - }; - let scene = PageScene::new(page, &mut TextEngine::default()).unwrap(); - assert_eq!( - scene.hit_test::<()>([30.0, 30.0], |_| unreachable!()), - Some(SceneHit::ReadOnly(1)) - ); - assert_eq!( - scene.hit_test::<()>([5.0, 5.0], |_| unreachable!()), - Some(SceneHit::ReadOnly(0)) - ); - assert_eq!( - scene.hit_test::<()>([300.0, 300.0], |_| unreachable!()), - None - ); - } - - #[test] - fn unsupported_tags_and_titles_leave_supported_outlines_editable() { - use one_canvas::{ - document::TextDocument, - page::{Definition, Outline, Title}, - }; - use onestore::document::{Kind, Tag}; - for title in [false, true] { - let definition = ExGuid { - guid: [9; 16], - n: 1, - }; - let make_outline = |text: &str| { - let document = - TextDocument::new(vec![Paragraph::new(text.into(), Default::default())]) - .unwrap(); - Outline { - id: ExGuid { - guid: [u8::from(text == "Editable") + 1; 16], - n: 1, - }, - layout: Layout { - max_width: Some(200.0), - ..Default::default() - }, - indents: vec![18.0, 0.0], - paragraphs: document.nodes().to_vec(), - unsupported: Vec::new(), - } - }; - let mut tagged = make_outline("Tagged"); - tagged.paragraphs[0].tags.push(Tag { - definition: Some(definition), - status: 1, - action_type: None, - created: None, - completed: None, - start: None, - due: None, - task_id: None, - extra_set: 0, - }); - let unsupported = if title { - PageObject::Title(Title { - id: ExGuid::default(), - layout: Layout { - x: Some(10.0), - y: Some(20.0), - ..Default::default() - }, - outlines: vec![tagged], - }) - } else { - PageObject::Outline(tagged) - }; - let page = Page { - title: String::new(), - margin_origin: [36.0, 14.0], - objects: vec![unsupported, PageObject::Outline(make_outline("Editable"))], - definitions: BTreeMap::from([( - definition, - Definition { - kind: Kind::TagDefinition { - shape: Some(999), - label: Some("Unknown icon".into()), - color: None, - highlight: None, - action_type: None, - }, - format: Default::default(), - }, - )]), - }; - let mut engine = TextEngine::default(); - let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap(); - assert_eq!(editor.outlines().len(), 1); - let readonly = scene.read_only().next().unwrap(); - assert_eq!(scene.read_only().count(), 1); - if title { - assert_eq!(&readonly.rect[..2], &[46.0, 34.0]); - } - editor.insert(&mut engine, "Still ").unwrap(); - assert_eq!( - editor - .active_outline() - .document() - .paragraphs() - .next() - .unwrap() - .text(), - "Still Editable" - ); - editor.undo(&mut engine).unwrap(); - assert_eq!( - editor - .active_outline() - .document() - .paragraphs() - .next() - .unwrap() - .text(), - "Editable" - ); - } - } - - #[test] - fn unsupported_sources_stay_owned_and_read_only_across_edit_and_undo() { - use one_canvas::{ - document::TextDocument, - page::{Outline, Unsupported}, - }; - let document = TextDocument::new(vec![Paragraph::new( - "preserved text".into(), - Default::default(), - )]) - .unwrap(); - let original = document.nodes().to_vec(); - let unknown = Unsupported { - id: ExGuid { - guid: [8; 16], - n: 1, - }, - jcid: 0xdead, - layout: Layout::default(), - }; - let page = Page { - title: String::new(), - margin_origin: [0.0; 2], - definitions: BTreeMap::new(), - objects: vec![ - PageObject::Outline(Outline { - id: ExGuid { - guid: [7; 16], - n: 1, - }, - layout: Layout { - x: Some(50.0), - y: Some(70.0), - max_width: Some(200.0), - ..Default::default() - }, - indents: vec![18.0, 0.0], - paragraphs: original.clone(), - unsupported: vec![unknown.clone()], - }), - PageObject::Image(Image { - id: ExGuid::default(), - layout: Layout { - y: Some(150.0), - ..Default::default() - }, - bytes: None, - alt: Some("missing diagram".into()), - background: false, - }), - PageObject::Unsupported(unknown.clone()), - ], - }; - let mut engine = TextEngine::default(); - let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap(); - assert_eq!(scene.read_only().count(), 3); - assert!( - editor.active_outline().origin()[0] - > scene - .read_only() - .map(|object| object.rect[2]) - .fold(0.0_f32, f32::max) - ); - let labels: Vec<_> = scene.read_only().map(|object| object.label.id()).collect(); - for edit in [true, false] { - if edit { - editor.insert(&mut engine, "new annotation").unwrap(); - } else { - editor.undo(&mut engine).unwrap(); - } - let readonly: Vec<_> = scene.read_only().collect(); - let PageObject::Outline(outline) = &readonly[0].source else { - panic!() - }; - assert_eq!(outline.paragraphs, original); - assert_eq!( - outline.unsupported.as_slice(), - std::slice::from_ref(&unknown) - ); - let PageObject::Image(image) = &readonly[1].source else { - panic!() - }; - assert_eq!(image.alt.as_deref(), Some("missing diagram")); - assert!(image.bytes.is_none()); - let PageObject::Unsupported(source) = &readonly[2].source else { - panic!() - }; - assert_eq!(source, &unknown); - assert_eq!( - readonly - .iter() - .map(|object| object.label.id()) - .collect::>(), - labels - ); - let primitives = paint_editor(&scene, &editor); - assert_eq!(primitives.len(), 9); - assert_eq!( - primitives - .iter() - .filter(|p| matches!(p, Primitive::Text { .. })) - .count(), - 3 - ); - } - assert_eq!( - editor - .active_outline() - .document() - .paragraphs() - .next() - .unwrap() - .text(), - "" - ); - } - - fn paint_editor<'a>(scene: &'a PageScene, editor: &'a CanvasEditor) -> Vec> { - let mut primitives = Vec::new(); - scene - .append_primitives_with(&mut primitives, [0.0; 2], |id, offset, primitives| { - let outline = editor - .outlines() - .iter() - .find(|outline| outline.id == id) - .ok_or(SceneError::MissingOutline)?; - for (_, paragraph) in outline.layouts() { - primitives.push(Primitive::Text { - layout: ¶graph.text, - origin: [ - outline.origin()[0] + paragraph.origin[0] + offset[0], - outline.origin()[1] + paragraph.origin[1] + offset[1], - ], - }); - } - Ok::<_, SceneError>(()) - }) - .unwrap(); - primitives - } - - #[test] - fn editable_outlines_keep_paint_order_and_images_across_edit_and_undo() { - use one_canvas::{document::TextDocument, page::Outline, text::Paragraph}; - let outline = |x, text: &str| { - let document = - TextDocument::new(vec![Paragraph::new(text.into(), Default::default())]).unwrap(); - PageObject::Outline(Outline { - id: ExGuid { - guid: [x as u8; 16], - n: 1, - }, - layout: Layout { - x: Some(x), - max_width: Some(180.0), - width_set_by_user: Some(true), - ..Default::default() - }, - indents: vec![18.0, 0.0], - paragraphs: document.nodes().to_vec(), - unsupported: Vec::new(), - }) - }; - let mut bytes = Vec::new(); - { - let mut encoder = png::Encoder::new(&mut bytes, 1, 1); - encoder.set_color(png::ColorType::Rgba); - encoder.set_depth(png::BitDepth::Eight); - encoder - .write_header() - .unwrap() - .write_image_data(&[17, 34, 51, 255]) - .unwrap(); - } - let page = || Page { - title: String::new(), - margin_origin: [0.0; 2], - definitions: BTreeMap::new(), - objects: vec![ - outline(2.0, "first"), - PageObject::Image(Image { - id: ExGuid::default(), - layout: Layout { - x: Some(5.0), - y: Some(7.0), - max_width: Some(40.0), - max_height: Some(30.0), - ..Default::default() - }, - bytes: Some(Arc::from(bytes.clone())), - alt: None, - background: false, - }), - outline(50.0, "last"), - ], - }; - let mut engine = TextEngine::default(); - let reference = PageScene::new(page(), &mut engine).unwrap(); - let (scene, mut editor) = PageScene::from_page(page(), &mut engine).unwrap(); - let mut reference_primitives = Vec::new(); - reference - .append_primitives(&mut reference_primitives, [0.0; 2]) - .unwrap(); - let image_id; - { - let actual = paint_editor(&scene, &editor); - assert_eq!(actual.len(), 3); - for (actual, expected) in actual.iter().zip(&reference_primitives) { - match (actual, expected) { - ( - Primitive::Text { - layout: a, - origin: x, - }, - Primitive::Text { - layout: b, - origin: y, - }, - ) => { - assert_eq!(x, y); - assert_eq!( - a.lines().next().unwrap().0.metrics().advance, - b.lines().next().unwrap().0.metrics().advance - ); - } - ( - Primitive::Image { image: a, rect: x }, - Primitive::Image { image: b, rect: y }, - ) => { - assert_eq!(x, y); - assert_eq!(a.pixels.as_ref(), b.pixels.as_ref()); - } - _ => panic!("paint order changed"), - } - } - let Primitive::Image { image, .. } = &actual[1] else { - panic!() - }; - image_id = image.pixels.id(); - } - let original = editor.active_outline().document().clone(); - editor.insert(&mut engine, "new words ").unwrap(); - { - let actual = paint_editor(&scene, &editor); - let Primitive::Text { layout, .. } = &actual[0] else { - panic!() - }; - let Primitive::Text { layout: before, .. } = &reference_primitives[0] else { - panic!() - }; - assert!( - layout.lines().next().unwrap().0.metrics().advance - > before.lines().next().unwrap().0.metrics().advance - ); - let Primitive::Image { image, .. } = &actual[1] else { - panic!() - }; - assert_eq!(image.pixels.id(), image_id); - } - editor.undo(&mut engine).unwrap(); - assert_eq!(editor.active_outline().document(), &original); - let actual = paint_editor(&scene, &editor); - let Primitive::Text { layout, .. } = &actual[0] else { - panic!() - }; - let Primitive::Text { layout: before, .. } = &reference_primitives[0] else { - panic!() - }; - assert_eq!( - layout.lines().next().unwrap().0.metrics().advance, - before.lines().next().unwrap().0.metrics().advance - ); - assert!(matches!( - scene.append_primitives(&mut Vec::new(), [0.0; 2]), - Err(SceneError::MissingOutline) - )); - } - - #[test] - fn scene_owns_decoded_images_and_reuses_identity_across_translated_frames() { - let mut encoded = Vec::new(); - { - let mut encoder = png::Encoder::new(&mut encoded, 1, 1); - encoder.set_color(png::ColorType::Rgba); - encoder.set_depth(png::BitDepth::Eight); - encoder - .write_header() - .unwrap() - .write_image_data(&[255, 0, 0, 255]) - .unwrap(); - } - let page = |width| Page { - title: String::new(), - margin_origin: [0.0; 2], - definitions: BTreeMap::new(), - objects: vec![PageObject::Image(Image { - id: ExGuid::default(), - layout: Layout { - x: Some(2.0), - y: Some(5.0), - max_width: Some(width), - max_height: Some(4.0), - ..Default::default() - }, - bytes: Some(Arc::from(encoded.clone())), - alt: None, - background: false, - })], - }; - let mut engine = TextEngine::default(); - let scene = PageScene::new(page(3.0), &mut engine).unwrap(); - for invalid in [f32::NAN, f32::INFINITY, -1.0, 0.0] { - assert!(matches!( - PageScene::new(page(invalid), &mut engine), - Err(SceneError::InvalidGeometry) - )); - } - let mut primitives = Vec::new(); - scene.append_primitives(&mut primitives, [0.0; 2]).unwrap(); - scene - .append_primitives(&mut primitives, [10.0, -20.0]) - .unwrap(); - let [ - Primitive::Image { - image: first, - rect: a, - }, - Primitive::Image { - image: second, - rect: b, - }, - ] = primitives.as_slice() - else { - panic!() - }; - assert_eq!(*a, [2.0, 5.0, 5.0, 9.0]); - assert_eq!(*b, [12.0, -15.0, 15.0, -11.0]); - assert_eq!(first.pixels.id(), second.pixels.id()); - assert_eq!(first.pixels.as_ref(), [255, 0, 0, 255]); - } -} diff --git a/crates/canvas-gpu/src/profile.rs b/crates/canvas-gpu/src/profile.rs deleted file mode 100644 index cb1fc6108ff3db57b140ac90bf19a0b10b6ced5a..0000000000000000000000000000000000000000 --- a/crates/canvas-gpu/src/profile.rs +++ /dev/null @@ -1,150 +0,0 @@ -use super::*; -use one_canvas::{layout::TextEngine, page::Page, text::Paragraph}; -use std::{ - sync::Arc, - time::{Duration, Instant}, -}; - -#[test] -#[ignore = "manual release GPU timing and cache occupancy probe"] -fn renderer_cost() { - let mut engine = TextEngine::default(); - if let Some(font) = std::env::var_os("CANVAS_TEST_SUBSTITUTE") { - engine - .register_substitute(Blob::new(Arc::new(std::fs::read(font).unwrap()))) - .unwrap(); - } - let source = std::env::var_os("CANVAS_TEST_SECTION").map(|path| std::fs::read(path).unwrap()); - let title = std::env::var("CANVAS_TEST_PAGE").unwrap_or_default(); - let mut scene = source - .as_ref() - .map(|bytes| load_scene(bytes, &title, &mut engine)); - let layout = source.is_none().then(|| { - engine - .layout( - &Paragraph::new( - "A paragraph with café, trees 🌳 and a few words.\n".repeat(2000), - Default::default(), - ), - 360.0, - ) - .unwrap() - }); - let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); - let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap(); - eprintln!("canvas_gpu_adapter\t{:?}", adapter.get_info()); - let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap(); - let size = [1386, 759]; - let format = wgpu::TextureFormat::Rgba8UnormSrgb; - let target = device.create_texture(&wgpu::TextureDescriptor { - label: Some("Canvas profile target"), - size: wgpu::Extent3d { - width: size[0], - height: size[1], - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT, - view_formats: &[], - }); - let view = target.create_view(&Default::default()); - let mut renderer = Renderer::new(device, queue, format); - eprintln!( - "canvas_gpu_allocated_bytes\tatlas\t{}\tvertex_buffer\t{}\ttarget\t{}", - u64::from(renderer.atlas.width()) * u64::from(renderer.atlas.height()) * 4, - renderer.vertex_buffer.size(), - u64::from(size[0]) * u64::from(size[1]) * 4 - ); - for phase in [ - "warm", - "pan", - "zoom", - "evict", - "offscreen_x", - "offscreen_y", - "reopen", - ] { - if phase == "reopen" && source.is_none() { - continue; - } - if phase.starts_with("offscreen") { - renderer.clear_glyph_cache(); - renderer.images.clear(); - } - for sample in 0..32 { - if phase == "evict" { - renderer.clear_glyph_cache(); - renderer.images.clear(); - } - let mut viewport = Viewport { - size, - scale: 96.0 / 72.0, - origin: [0.0; 2], - }; - match phase { - "pan" => viewport.origin[1] = -(sample as f32 * 63.25), - "zoom" => viewport.scale *= [0.5, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0][sample % 8], - "offscreen_x" => viewport.origin[0] = 1_000_000.0, - "offscreen_y" => viewport.origin[1] = 1_000_000.0, - "reopen" => { - scene = Some(load_scene(source.as_ref().unwrap(), &title, &mut engine)); - } - _ => {} - } - let mut primitives = Vec::new(); - if let Some(scene) = &scene { - scene.append_primitives(&mut primitives, [0.0; 2]).unwrap(); - } else { - primitives.push(Primitive::Text { - layout: layout.as_ref().unwrap(), - origin: [36.0, 90.0], - }); - } - let start = Instant::now(); - renderer.draw(&view, viewport, &primitives).unwrap(); - let submitted = start.elapsed().as_nanos(); - renderer - .device - .poll(wgpu::PollType::Wait { - submission_index: None, - timeout: Some(Duration::from_secs(10)), - }) - .unwrap(); - let completed = start.elapsed().as_nanos(); - let image_bytes: u64 = renderer.images.values().map(|image| image.bytes).sum(); - let atlas_texels: u64 = renderer - .glyphs - .values() - .flatten() - .map(|glyph| u64::from(glyph.width) * u64::from(glyph.height)) - .sum(); - assert!(renderer.glyphs.len() <= MAX_GLYPHS); - assert!(renderer.images.len() <= MAX_IMAGES && image_bytes <= MAX_IMAGE_BYTES); - assert!(renderer.vertices.len() <= MAX_VERTICES); - eprintln!( - "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{}", - renderer.glyphs.len(), - renderer.images.len(), - renderer.vertices.len(), - renderer.batches.len(), - renderer.vertices.capacity() * size_of::(), - renderer.batches.capacity() * size_of::(), - renderer.glyphs.capacity(), - renderer.images.capacity() - ); - } - } -} - -fn load_scene(bytes: &[u8], title: &str, engine: &mut TextEngine) -> page::PageScene { - let store = onestore::Store::parse(bytes).unwrap(); - let index = onestore::RevisionIndex::parse(&store).unwrap(); - page::PageScene::new( - Page::from_document(&onestore::document::Document::parse(&index).unwrap(), title).unwrap(), - engine, - ) - .unwrap() -} diff --git a/crates/canvas-gpu/src/tags.rs b/crates/canvas-gpu/src/tags.rs deleted file mode 100644 index ea2a0902c24ddb23409998fd1b24852414539298..0000000000000000000000000000000000000000 --- a/crates/canvas-gpu/src/tags.rs +++ /dev/null @@ -1,120 +0,0 @@ -use one_canvas::outline::TagIcon; -use swash::{ - scale::image::{Content, Image}, - zeno::{Mask, Placement, Transform, Vector}, -}; - -pub(crate) fn rasterize(icon: TagIcon, size: f32, phase: [u8; 2]) -> Image { - let layers = match icon { - TagIcon::CheckBox { checked } => { - let mut layers = vec![ - ("M0 0H16V16H0Z", 0xf7b691, 0xef9864), - ("M1 1H15V15H1Z", 0xffffff, 0xffffff), - ("M2 2H14V14H2Z", 0xf6af7c, 0xe98755), - ("M3 3H13V13H3Z", 0xffffff, 0xf5e6d6), - ]; - if checked { - layers.push(("M2 8L6 11L13 3L14 5L6 14L1 10Z", 0x778cf4, 0x2544c8)); - } - layers - } - TagIcon::Question => vec![( - "M8 5C8 1 15 1 15 5C15 7 11 8 11 10H9C9 6 13 6 13 5C13 3 10 3 10 5ZM9 12H11V14H9Z", - 0xe83ace, - 0x8f008f, - )], - TagIcon::Music => vec![ - ( - "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", - 0xf0bb9c, - 0xa35546, - ), - ("M2 11C3 9 6 9 7 10C7 11 4 13 2 12Z", 0xffe8dc, 0xda9786), - ], - }; - let width = (size + 1.0).ceil() as u32; - let mut pixels = vec![[0.0_f32; 4]; (width * width) as usize]; - for (path, top, bottom) in layers { - let (mask, _) = Mask::new(path) - .transform(Some(Transform::scale(size / 16.0, size / 16.0))) - .render_offset(Vector::new(phase[0] as f32 * 0.25, phase[1] as f32 * 0.25)) - .size(width, width) - .render(); - let top = super::colorref(top); - let bottom = super::colorref(bottom); - for (index, (pixel, coverage)) in pixels.iter_mut().zip(mask).enumerate() { - let alpha = f32::from(coverage) / 255.0; - let y = ((index / width as usize) as f32 / size).min(1.0); - for channel in 0..3 { - let color = top[channel] + (bottom[channel] - top[channel]) * y; - pixel[channel] = color * alpha + pixel[channel] * (1.0 - alpha); - } - pixel[3] = alpha + pixel[3] * (1.0 - alpha); - } - } - let data = pixels - .into_iter() - .flat_map(|pixel| { - let alpha = pixel[3]; - let channel = |value: f32| { - let value = if alpha > 0.0 { value / alpha } else { 0.0 }; - super::srgb_byte(value) - }; - [ - channel(pixel[0]), - channel(pixel[1]), - channel(pixel[2]), - (alpha * 255.0).round() as u8, - ] - }) - .collect(); - Image { - content: Content::Color, - placement: Placement { - left: 0, - top: 0, - width, - height: width, - }, - data, - ..Image::default() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn tag_masks_keep_transparency_and_checked_state_across_scales() { - for size in [8.0, 16.0, 32.0, 57.5] { - for phase in [[0, 0], [1, 2], [3, 3]] { - let mut masks = Vec::new(); - for icon in [ - TagIcon::CheckBox { checked: false }, - TagIcon::CheckBox { checked: true }, - TagIcon::Question, - TagIcon::Music, - ] { - let image = rasterize(icon, size, phase); - assert_eq!( - image.data.len(), - (image.placement.width * image.placement.height * 4) as usize - ); - assert!(image.data.chunks_exact(4).any(|p| p[3] > 0)); - assert!(image.data.chunks_exact(4).any(|p| p[3] < 255)); - assert!( - image - .data - .chunks_exact(4) - .filter(|p| p[3] == 0) - .all(|p| p[..3] == [0; 3]) - ); - masks.push(image.data); - } - assert_ne!(masks[0], masks[1]); - assert!(masks[1].chunks_exact(4).any(|p| p[0] > p[2] && p[3] > 0)); - } - } - } -} diff --git a/crates/canvas/Cargo.toml b/crates/canvas/Cargo.toml index e7e9804fff9c8ea8d1843395d8dafbe74ddb5c4c..b2de15cff31d0af90f66cf00821a858af46d20f8 100644 --- a/crates/canvas/Cargo.toml +++ b/crates/canvas/Cargo.toml @@ -1,9 +1,12 @@ [package] -name = "one-canvas" +name = "canvas" version = "0.1.0" edition = "2024" publish = false +[features] +gpu = ["dep:bytemuck", "dep:swash", "dep:wgpu", "dep:image", "dep:roxmltree"] + [dependencies] onestore = { path = "../onestore" } parley.workspace = true @@ -12,6 +15,12 @@ serde_json = "1.0.151" skrifa = "0.44.0" getrandom = "0.4.3" +bytemuck = { version = "1.25", features = ["derive"], optional = true } +swash = { version = "0.2.10", optional = true } +wgpu = { workspace = true, optional = true } +image = { version = "0.25", default-features = false, features = ["png", "jpeg"], optional = true } +roxmltree = { version = "0.21", optional = true } + [[bin]] name = "layout-probe" path = "src/bin/layout_probe.rs" @@ -19,3 +28,11 @@ path = "src/bin/layout_probe.rs" [[bin]] name = "page-probe" path = "src/bin/page_probe.rs" + +[dev-dependencies] +pollster = "0.4" +png = "0.18" + +[[example]] +name = "render_page" +required-features = ["gpu"] diff --git a/crates/canvas/assets/tags/checkbox.svg b/crates/canvas/assets/tags/checkbox.svg new file mode 100644 index 0000000000000000000000000000000000000000..17d1680279ef1c19bfdce1024d5f83a5bb86d8ea --- /dev/null +++ b/crates/canvas/assets/tags/checkbox.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/tags/checkmark.svg b/crates/canvas/assets/tags/checkmark.svg new file mode 100644 index 0000000000000000000000000000000000000000..3405bd54c277dbeb67827431048659690d70828e --- /dev/null +++ b/crates/canvas/assets/tags/checkmark.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/canvas/assets/tags/music.svg b/crates/canvas/assets/tags/music.svg new file mode 100644 index 0000000000000000000000000000000000000000..1dc29e92c0613dacb541b9af4c47c1d2fe20b27a --- /dev/null +++ b/crates/canvas/assets/tags/music.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/tags/question.svg b/crates/canvas/assets/tags/question.svg new file mode 100644 index 0000000000000000000000000000000000000000..18ab658b3b8bf9ea3cec4fd1d319402eef9258f4 --- /dev/null +++ b/crates/canvas/assets/tags/question.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/canvas/examples/render_page.rs b/crates/canvas/examples/render_page.rs new file mode 100644 index 0000000000000000000000000000000000000000..e21e395fef208433b7049783b166b6306a8d3722 --- /dev/null +++ b/crates/canvas/examples/render_page.rs @@ -0,0 +1,125 @@ +use canvas::gpu::{Renderer, Viewport, page::PageScene}; +use canvas::{layout::TextEngine, page::Page}; +use onestore::{RevisionIndex, Store, document::Document}; +use std::{env, fs, sync::Arc, time::Duration}; + +fn main() -> Result<(), Box> { + let mut args = env::args().skip(1).peekable(); + let path = args + .next() + .ok_or("Expected a section snapshot, page title and new PNG path")?; + let title = args.next().ok_or("Expected a page title")?; + let destination = args.next().ok_or("Expected a new PNG path")?; + let origin_y: f32 = if args + .peek() + .is_some_and(|value| value != "--substitute-font") + { + args.next().unwrap().parse()? + } else { + 0.0 + }; + let mut engine = TextEngine::default(); + while let Some(option) = args.next() { + if option != "--substitute-font" { + return Err(format!("Unknown option: {option}").into()); + } + let path = args + .next() + .ok_or("Provide a font file after --substitute-font.")?; + engine.register_substitute(parley::fontique::Blob::new(Arc::new(fs::read(path)?)))?; + } + let page = { + let bytes = fs::read(path)?; + let store = Store::parse(&bytes)?; + let index = RevisionIndex::parse(&store)?; + Page::from_document(&Document::parse(&index)?, &title)? + }; + let margin_origin = page.margin_origin; + let object_count = page.objects.len(); + let scene = PageScene::new(page, &mut engine)?; + let mut primitives = Vec::new(); + scene.append_primitives(&mut primitives, [0.0; 2])?; + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let adapter = pollster::block_on(instance.request_adapter(&Default::default()))?; + let (device, queue) = pollster::block_on(adapter.request_device(&Default::default()))?; + let size = [1386_u32, 759]; + let format = wgpu::TextureFormat::Rgba8UnormSrgb; + let target = device.create_texture(&wgpu::TextureDescriptor { + label: Some("Native page comparison"), + size: wgpu::Extent3d { + width: size[0], + height: size[1], + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let row_bytes = (size[0] * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT); + let buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Native page comparison readback"), + size: u64::from(row_bytes) * u64::from(size[1]), + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + let mut renderer = Renderer::new(device, queue, format); + renderer + .draw( + &target.create_view(&Default::default()), + Viewport { + size, + scale: 96.0 / 72.0, + origin: [ + (36.0 - margin_origin[0]) * (96.0 / 72.0), + (14.4 - margin_origin[1]) * (96.0 / 72.0) + origin_y, + ], + }, + &primitives, + ) + .map_err(|e| format!("Page comparison rendering failed: {e:?}"))?; + let mut encoder = renderer.device.create_command_encoder(&Default::default()); + encoder.copy_texture_to_buffer( + target.as_image_copy(), + wgpu::TexelCopyBufferInfo { + buffer: &buffer, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(row_bytes), + rows_per_image: Some(size[1]), + }, + }, + wgpu::Extent3d { + width: size[0], + height: size[1], + depth_or_array_layers: 1, + }, + ); + renderer.queue.submit([encoder.finish()]); + let (sender, receiver) = std::sync::mpsc::channel(); + buffer.map_async(wgpu::MapMode::Read, .., move |result| { + let _ = sender.send(result); + }); + renderer.device.poll(wgpu::PollType::Wait { + submission_index: None, + timeout: Some(Duration::from_secs(10)), + })?; + receiver.recv_timeout(Duration::from_secs(10))??; + let mapped = buffer.get_mapped_range(..)?; + let pixels: Vec<_> = mapped + .chunks_exact(row_bytes as usize) + .flat_map(|row| row[..size[0] as usize * 4].iter().copied()) + .collect(); + let mut encoder = png::Encoder::new(fs::File::create_new(destination)?, size[0], size[1]); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + encoder.write_header()?.write_image_data(&pixels)?; + eprintln!( + "Rendered {} page objects at 100% / 96 DPI, origin Y {origin_y} px using {:?}", + object_count, + adapter.get_info() + ); + Ok(()) +} diff --git a/crates/canvas/src/bin/layout_probe.rs b/crates/canvas/src/bin/layout_probe.rs index 9239b450e08c67ac7f99e04fd12968b82a09611a..4f3e5c131179513aca8c27d63d47c4c928a5def5 100644 --- a/crates/canvas/src/bin/layout_probe.rs +++ b/crates/canvas/src/bin/layout_probe.rs @@ -1,4 +1,4 @@ -use one_canvas::{layout::TextEngine, text::Paragraph}; +use canvas::{layout::TextEngine, text::Paragraph}; use onestore::document::Format; use serde::{Deserialize, Serialize}; use serde_json::json; diff --git a/crates/canvas/src/bin/page_probe.rs b/crates/canvas/src/bin/page_probe.rs index fb447a69296ac22bf43d0c0c05a51f6c84c2d456..3b9004aae6eb887f92c357a92e92ecb7c3465bcc 100644 --- a/crates/canvas/src/bin/page_probe.rs +++ b/crates/canvas/src/bin/page_probe.rs @@ -1,4 +1,5 @@ -use one_canvas::{ +use canvas::{ + document::TextDocument, layout::TextEngine, outline::OutlineLayout, page::{Outline, Page, PageObject}, @@ -49,8 +50,10 @@ fn main() -> Result<(), Box> { None, )?), PageObject::Title(title) => { - title_areas.push(json!({"id": title.id, "layout": title.layout, - "outlines": title.outlines.iter().map(|o| o.id).collect::>()})); + title_areas.push( + json!({"id": title.id, "layout": title.layout, "date_outline": title.date, + "outlines": title.outlines.iter().map(|o| o.id).collect::>()}), + ); for (outline, (origin, layout)) in title .outlines .iter() @@ -69,7 +72,7 @@ fn main() -> Result<(), Box> { serde_json::to_writer_pretty( io::stdout().lock(), &json!({ - "title": page.title, "title_areas": title_areas, "margin_origin": page.margin_origin, "objects": objects, + "title": page.title, "created_filetime": page.created, "title_areas": title_areas, "margin_origin": page.margin_origin, "objects": objects, "definitions": page.definitions.iter().map(|(id, definition)| (id.to_string(), json!({ "kind": definition.kind, "format": definition.format, }))).collect::>(), "import_ms": import_ms, @@ -86,9 +89,13 @@ fn outline_json( title_origin: Option<[f32; 2]>, ) -> Result> { let mut paragraphs = Vec::new(); - for paragraph in &outline.paragraphs { + let document = TextDocument::from_nodes(outline.paragraphs.clone())?; + for paragraph in document.text_nodes() { let placed = laid_out.paragraphs.iter().find(|p| p.id == paragraph.id); - let source = paragraph.combined_text(); + let source = ¶graph + .text() + .ok_or("Unsupported paragraph content")? + .text; let projection; let visible = if let Some(placed) = placed { placed.projection.text() @@ -115,13 +122,13 @@ fn outline_json( .collect(); paragraphs.push(json!({ "id": paragraph.id, "parent": paragraph.parent, "level": paragraph.level, + "date_fields": paragraph.text().into_iter().filter_map(|text| text.date_field).map(|field| format!("{field:?}")).collect::>(), "format": paragraph.format, "collapsed": paragraph.collapsed, "lists": paragraph.lists, - "tags": paragraph.tags.iter().chain(paragraph.text.iter().flat_map(|t| &t.tags)).collect::>(), + "tags": paragraph.tags.iter().chain(paragraph.text().into_iter().flat_map(|t| &t.tags)).collect::>(), "source_utf16": source.text().encode_utf16().count(), "visible_utf16": visible.text().encode_utf16().count(), "visible_text": visible.text(), "spans": visible.spans().iter().map(|s| json!({"end_utf8": s.end, "format": s.format})).collect::>(), - "unsupported": paragraph.unsupported.iter().map(|u| u.jcid).collect::>(), "height": layout.as_ref().map(|l| l.height()), "lines": lines, "origin": placed.map(|p| p.origin), "tag_icons": placed.map(|p| p.tags.iter().map(|tag| json!({ @@ -137,8 +144,12 @@ fn outline_json( } Ok( json!({"kind": "outline", "id": outline.id, "layout": outline.layout, - "indents": outline.indents, "is_title": title_origin.is_some(), "title_origin": title_origin, "paragraphs": paragraphs, + "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, "size": laid_out.size, + "tables": laid_out.tables.iter().map(|table| json!({ + "id": table.id, "borders": table.borders, + "cells": table.cells.iter().map(|cell| json!({"id": cell.id, "rect": cell.rect})).collect::>() + })).collect::>(), "unsupported": outline.unsupported.iter().map(|u| u.jcid).collect::>() }), ) } diff --git a/crates/canvas/src/date.rs b/crates/canvas/src/date.rs new file mode 100644 index 0000000000000000000000000000000000000000..f36c607cd516049bdc51bb84fb8dcdf96a1f5288 --- /dev/null +++ b/crates/canvas/src/date.rs @@ -0,0 +1,247 @@ +use crate::{ + document::TextDocument, + editor::EditorError, + layout::TextEngine, + outline::{OutlineLayout, TITLE_WIDTH}, + page::{Definition, Outline, PageParagraph}, + text::EditError, +}; +use onestore::ExGuid; +use std::collections::BTreeMap; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DateField { + Date = 0, + Time = 1, +} + +#[derive(Clone)] +pub struct PageDate { + timestamp: u64, + source: Outline, + layout: OutlineLayout, +} + +impl PageDate { + /// FILETIME ticks, in 100 ns units since 1601-01-01 UTC. + pub fn timestamp(&self) -> u64 { + self.timestamp + } + + pub fn source(&self) -> &Outline { + &self.source + } + + pub fn layout(&self) -> &OutlineLayout { + &self.layout + } + + pub fn fields(&self) -> impl Iterator { + let swapped = self + .source + .paragraphs + .iter() + .enumerate() + .any(|(index, paragraph)| { + matches!( + (index, paragraph.text().unwrap().date_field), + (0, Some(DateField::Time)) | (1, Some(DateField::Date)) + ) + }); + self.source + .paragraphs + .iter() + .enumerate() + .map(move |(index, paragraph)| { + let field = + paragraph + .text() + .unwrap() + .date_field + .unwrap_or(if (index == 0) != swapped { + DateField::Date + } else { + DateField::Time + }); + (field, paragraph) + }) + } + + pub fn new( + timestamp: u64, + source: Outline, + engine: &mut TextEngine, + definitions: &BTreeMap, + ) -> Result { + if source.title || source.paragraphs.is_empty() || source.paragraphs.len() > 2 { + return Err(EditError::UnsupportedContent.into()); + } + let document = TextDocument::from_nodes(source.paragraphs.clone())?; + document.validate_flat()?; + if source + .paragraphs + .iter() + .any(|paragraph| paragraph.text().unwrap().text.spans().len() != 1) + { + return Err(EditError::UnsupportedContent.into()); + } + let layout = source.layout_with_width(engine, definitions, TITLE_WIDTH)?; + let date = Self { + timestamp, + source, + layout, + }; + let mut seen = [false; 2]; + for (field, _) in date.fields() { + if std::mem::replace(&mut seen[field as usize], true) { + return Err(EditError::UnsupportedContent.into()); + } + } + Ok(date) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + editor::{CanvasEditor, TextOutline}, + text::Paragraph, + }; + + #[test] + fn date_and_text_edits_share_history_without_changing_identities_or_selection() { + let mut engine = TextEngine::default(); + let definitions = BTreeMap::new(); + let body = TextOutline::new( + &mut engine, + TextDocument::new(vec![Paragraph::new("Body".into(), Default::default())]).unwrap(), + 240.0, + [36.0, 90.0], + ) + .unwrap(); + let duplicate = PageDate::new(1, body.snapshot(), &mut engine, &definitions).unwrap(); + assert!( + CanvasEditor::from_text_outlines(vec![body.clone()], BTreeMap::new(), Some(duplicate)) + .is_err() + ); + let source = TextOutline::new( + &mut engine, + TextDocument::new( + ["Monday, September 07, 2026", "6:14 AM"] + .map(|text| Paragraph::new(text.into(), Default::default())) + .into(), + ) + .unwrap(), + 468.0, + [0.0; 2], + ) + .unwrap() + .snapshot(); + let original = source.paragraphs.clone(); + let date = PageDate::new(100_123_456, source, &mut engine, &definitions).unwrap(); + let original_layout = date.layout.paragraphs[0].text.id(); + let mut editor = + CanvasEditor::from_text_outlines(vec![body], definitions, Some(date)).unwrap(); + let body = editor.active_outline().document().clone(); + editor.compose(&mut engine, "new ".into(), 4..4).unwrap(); + let selection = editor.selection(); + let text = ["Wednesday, September 09, 2026".into(), "6:14 AM".into()]; + assert!( + editor + .change_date(&mut engine, 200_123_456, text.clone()) + .unwrap() + ); + assert!(editor.marked_range().is_none()); + assert_eq!(editor.selection(), selection); + let changed = editor.date().unwrap().source.paragraphs.clone(); + for (old, new) in original.iter().zip(&changed) { + assert_eq!(old.id, new.id); + assert_eq!(old.text().unwrap().id, new.text().unwrap().id); + let mut restored = new.clone(); + restored.text_mut().unwrap().text = old.text().unwrap().text.clone(); + assert_eq!(&restored, old); + } + assert!(editor.undo(&mut engine).unwrap()); + assert_eq!(editor.date().unwrap().timestamp, 100_123_456); + assert_eq!(editor.date().unwrap().source.paragraphs, original); + assert_eq!( + editor.date().unwrap().layout.paragraphs[0].text.id(), + original_layout + ); + assert_eq!(editor.selection(), selection); + assert!(editor.undo(&mut engine).unwrap()); + assert_eq!(editor.active_outline().document(), &body); + assert!(editor.redo(&mut engine).unwrap()); + assert!(editor.redo(&mut engine).unwrap()); + assert_eq!(editor.date().unwrap().source.paragraphs, changed); + assert!(!editor.change_date(&mut engine, 200_123_456, text).unwrap()); + editor.undo(&mut engine).unwrap(); + editor + .change_date( + &mut engine, + 300_123_456, + ["Friday".into(), "7:00 AM".into()], + ) + .unwrap(); + assert!(!editor.redo(&mut engine).unwrap()); + } + + #[test] + fn explicit_roles_control_reordered_and_time_only_fields() { + let mut engine = TextEngine::default(); + let source = TextOutline::new( + &mut engine, + TextDocument::new( + ["6:14 AM", "Monday"] + .map(|text| Paragraph::new(text.into(), Default::default())) + .into(), + ) + .unwrap(), + 468.0, + [0.0; 2], + ) + .unwrap() + .snapshot(); + for count in [1, 2] { + let mut source = source.clone(); + source.paragraphs.truncate(count); + source.paragraphs[0].text_mut().unwrap().date_field = Some(DateField::Time); + let date = PageDate::new(1, source, &mut engine, &BTreeMap::new()).unwrap(); + assert_eq!(date.fields().next().unwrap().0, DateField::Time); + let mut editor = CanvasEditor::new( + &mut engine, + TextDocument::new(vec![Paragraph::new("Body".into(), Default::default())]).unwrap(), + 200.0, + ) + .unwrap(); + let outlines = editor.outlines().to_vec(); + editor = + CanvasEditor::from_text_outlines(outlines, BTreeMap::new(), Some(date)).unwrap(); + editor + .change_date(&mut engine, 2, ["New date".into(), "New time".into()]) + .unwrap(); + assert_eq!( + editor.date().unwrap().source.paragraphs[0] + .text() + .unwrap() + .text + .text(), + "New time" + ); + if count == 2 { + assert_eq!( + editor.date().unwrap().source.paragraphs[1] + .text() + .unwrap() + .text + .text(), + "New date" + ); + let mut duplicate = editor.date().unwrap().source.clone(); + duplicate.paragraphs[1].text_mut().unwrap().date_field = Some(DateField::Time); + assert!(PageDate::new(3, duplicate, &mut engine, &BTreeMap::new()).is_err()); + } + } + } +} diff --git a/crates/canvas/src/document.rs b/crates/canvas/src/document.rs index 21ff8401f37eac5b7317f2ba59715dedd9457f30..19c3522f32f5b6d2e916a0897bb8e6ad41636118 100644 --- a/crates/canvas/src/document.rs +++ b/crates/canvas/src/document.rs @@ -1,8 +1,8 @@ use crate::{ - page::{PageParagraph, TextObject}, + page::{PageParagraph, ParagraphContent, TextObject}, text::{EditError, Paragraph, new_id}, }; -use onestore::document::Format; +use onestore::{ExGuid, document::Format}; use std::{ collections::{BTreeMap, BTreeSet}, ops::Range, @@ -14,7 +14,7 @@ pub struct TextPosition { pub offset: u32, } -/// Each editable paragraph owns one rich-text object; structural edits require flat content. +/// Paragraph positions enumerate text leaves in document order, including table cells. #[derive(Clone, Debug, PartialEq)] pub struct TextDocument { nodes: Vec, @@ -22,58 +22,218 @@ pub struct TextDocument { #[derive(Clone, Debug, PartialEq)] pub(crate) struct DocumentEdit { + /// Cell identity, or None for the document root. + pub container: Option, pub range: Range, pub replacement: Vec, + /// Width patches address surviving tables outside the replacement subtree. + pub columns: BTreeMap>, } -fn node(text: Paragraph, format: Format) -> Result { +pub(crate) fn node(text: Paragraph, format: Format) -> Result { Ok(PageParagraph { id: new_id()?, parent: None, level: 1, format, - text: vec![TextObject { + content: crate::page::ParagraphContent::Text(TextObject { + date_field: None, id: new_id()?, text, tags: Vec::new(), - }], - unsupported: Vec::new(), + }), lists: Vec::new(), tags: Vec::new(), collapsed: false, + style: None, }) } -fn validate_nodes<'a>(nodes: impl Iterator) -> Result<(), EditError> { - let mut ids = BTreeSet::new(); - let mut parents = BTreeMap::new(); +pub(crate) fn edited_nodes<'a>( + nodes: &'a [PageParagraph], + container: Option, + edit: Option<&'a DocumentEdit>, +) -> impl Clone + Iterator { + let (range, replacement) = match edit.filter(|edit| edit.container == container) { + Some(edit) => (edit.range.clone(), edit.replacement.as_slice()), + None => (0..0, &[][..]), + }; + nodes[..range.start] + .iter() + .chain(replacement) + .chain(&nodes[range.end..]) +} + +pub(crate) fn leaves<'a>( + nodes: &'a [PageParagraph], + edit: Option<&'a DocumentEdit>, +) -> impl Iterator, usize, &'a PageParagraph)> { + descendants(nodes, edit).filter(|(_, _, node)| node.text().is_some()) +} + +pub(crate) fn descendants<'a>( + nodes: &'a [PageParagraph], + edit: Option<&'a DocumentEdit>, +) -> impl Iterator, usize, &'a PageParagraph)> { + let mut current = (None, edited_nodes(nodes, None, edit).enumerate()); + let mut pending = Vec::new(); + std::iter::from_fn(move || { + loop { + let Some((index, node)) = current.1.next() else { + current = pending.pop()?; + continue; + }; + let container = current.0; + if let ParagraphContent::Table(table) = &node.content { + pending.push(current.clone()); + pending.extend( + table + .rows + .iter() + .rev() + .flat_map(|row| row.cells.iter().rev()) + .map(|cell| { + ( + Some(cell.id), + edited_nodes(&cell.paragraphs, Some(cell.id), edit).enumerate(), + ) + }), + ); + current = pending.pop().unwrap(); + } + return Some((container, index, node)); + } + }) +} + +pub(crate) fn swap_columns(nodes: &mut [PageParagraph], widths: &mut BTreeMap>) { + if widths.is_empty() { + return; + } for node in nodes { - if node.text.len() != 1 || !node.unsupported.is_empty() { - return Err(EditError::UnsupportedContent); + if let ParagraphContent::Table(table) = &mut node.content { + if let Some(widths) = widths.get_mut(&table.id) { + for (column, width) in table.columns.iter_mut().zip(widths) { + std::mem::swap(&mut column.width, width); + } + } + for cell in table.rows.iter_mut().flat_map(|row| &mut row.cells) { + swap_columns(&mut cell.paragraphs, widths); + } } - if !ids.insert(node.id) || !ids.insert(node.text[0].id) { + } +} + +pub(crate) fn validate_nodes( + nodes: &[PageParagraph], + edit: Option<&DocumentEdit>, + ids: &mut BTreeSet, +) -> Result<(), EditError> { + let mut pending = vec![(None, nodes, 0)]; + while let Some((container, nodes, depth)) = pending.pop() { + if depth > 64 { return Err(EditError::InvalidStructure); } - if node.level == 0 - || node - .parent - .is_some_and(|id| parents.get(&id).is_none_or(|level| *level >= node.level)) - { - return Err(EditError::InvalidStructure); + let mut nodes = edited_nodes(nodes, container, edit).peekable(); + if nodes.peek().is_none() { + return Err(EditError::InvalidRange); + } + let mut parents = BTreeMap::new(); + for node in nodes { + if !ids.insert(node.id) + || node.level == 0 + || node + .parent + .is_some_and(|id| parents.get(&id).is_none_or(|level| *level >= node.level)) + { + return Err(EditError::InvalidStructure); + } + parents.insert(node.id, node.level); + match &node.content { + ParagraphContent::Text(text) => { + if !ids.insert(text.id) { + return Err(EditError::InvalidStructure); + } + } + ParagraphContent::Unsupported(_) => return Err(EditError::UnsupportedContent), + ParagraphContent::Table(table) => { + if !ids.insert(table.id) + || table.rows.is_empty() + || table.columns.is_empty() + || table.columns.len() > 255 + || table + .columns + .iter() + .any(|column| !column.width.is_finite() || column.width < 36.0) + { + return Err(EditError::InvalidStructure); + } + for row in &table.rows { + if !ids.insert(row.id) || row.cells.len() != table.columns.len() { + return Err(EditError::InvalidStructure); + } + for cell in &row.cells { + if !ids.insert(cell.id) { + return Err(EditError::InvalidStructure); + } + if !cell.unsupported.is_empty() { + return Err(EditError::UnsupportedContent); + } + pending.push((Some(cell.id), cell.paragraphs.as_slice(), depth + 1)); + } + } + } + } } - parents.insert(node.id, node.level); } Ok(()) } fn validate_text(nodes: &[PageParagraph]) -> Result<(), EditError> { - for node in nodes { - let text = &node.text[0].text; + for (_, _, node) in leaves(nodes, None) { + let text = &node.text().unwrap().text; text.utf16_offset(text.text().len())?; } Ok(()) } +pub(crate) fn validate_flat<'a>( + mut nodes: impl Iterator, +) -> Result<(), EditError> { + if nodes.any(|node| { + !node.lists.is_empty() + || !node.tags.is_empty() + || node.collapsed + || node.parent.is_some() + || node.text().is_none_or(|text| !text.tags.is_empty()) + }) { + return Err(EditError::UnsupportedContent); + } + Ok(()) +} + +pub(crate) fn container_mut( + nodes: &mut Vec, + id: Option, +) -> Option<&mut Vec> { + let Some(id) = id else { + return Some(nodes); + }; + for node in nodes { + if let ParagraphContent::Table(table) = &mut node.content { + for cell in table.rows.iter_mut().flat_map(|row| &mut row.cells) { + if cell.id == id { + return Some(&mut cell.paragraphs); + } + if let Some(nodes) = container_mut(&mut cell.paragraphs, Some(id)) { + return Some(nodes); + } + } + } + } + None +} + impl TextDocument { pub fn new(paragraphs: Vec) -> Result { Self::from_nodes( @@ -85,10 +245,7 @@ impl TextDocument { } pub fn from_nodes(nodes: Vec) -> Result { - if nodes.is_empty() { - return Err(EditError::InvalidRange); - } - validate_nodes(nodes.iter())?; + validate_nodes(&nodes, None, &mut BTreeSet::new())?; validate_text(&nodes)?; Ok(Self { nodes }) } @@ -98,45 +255,66 @@ impl TextDocument { } pub(crate) fn validate_flat(&self) -> Result<(), EditError> { - if self.nodes.iter().any(|node| { - !node.lists.is_empty() - || !node.tags.is_empty() - || node.collapsed - || node.parent.is_some() - || node.level != 1 - || !node.text[0].tags.is_empty() - }) { - return Err(EditError::UnsupportedContent); + validate_flat(self.nodes.iter()) + } + + pub fn text_nodes(&self) -> impl Iterator { + leaves(&self.nodes, None).map(|(_, _, node)| node) + } + + pub fn paragraphs(&self) -> impl Iterator { + self.text_nodes().map(|node| &node.text().unwrap().text) + } + + pub(crate) fn container(&self, id: Option) -> Result<&[PageParagraph], EditError> { + let Some(id) = id else { + return Ok(&self.nodes); + }; + let mut pending = vec![self.nodes.as_slice()]; + while let Some(nodes) = pending.pop() { + for node in nodes { + if let ParagraphContent::Table(table) = &node.content { + for cell in table.rows.iter().flat_map(|row| &row.cells) { + if cell.id == id { + return Ok(&cell.paragraphs); + } + pending.push(&cell.paragraphs); + } + } + } } - Ok(()) - } - - pub fn paragraphs(&self) -> impl ExactSizeIterator + DoubleEndedIterator { - self.nodes.iter().map(|node| &node.text[0].text) + Err(EditError::InvalidRange) } pub fn slice(&self, range: Range) -> Result, EditError> { if range.start > range.end { return Err(EditError::InvalidRange); } - let nodes = self - .nodes - .get(range.start.paragraph..=range.end.paragraph) + let count = range + .end + .paragraph + .checked_sub(range.start.paragraph) + .and_then(|n| n.checked_add(1)) .ok_or(EditError::InvalidRange)?; - nodes - .iter() + let result = self + .paragraphs() + .skip(range.start.paragraph) + .take(count) .enumerate() - .map(|(index, node)| { - let paragraph = &node.text[0].text; + .map(|(index, paragraph)| { let start = if index == 0 { range.start.offset } else { 0 }; - let end = if index == nodes.len() - 1 { + let end = if index == count - 1 { range.end.offset } else { paragraph.utf16_offset(paragraph.text().len())? }; paragraph.slice(start..end) }) - .collect() + .collect::, EditError>>()?; + if result.len() != count { + return Err(EditError::InvalidRange); + } + Ok(result) } pub(crate) fn replace( @@ -147,59 +325,88 @@ impl TextDocument { if range.start > range.end { return Err(EditError::InvalidRange); } + let (container, start, first) = leaves(&self.nodes, None) + .nth(range.start.paragraph) + .ok_or(EditError::InvalidRange)?; + let (end_container, end, last) = leaves(&self.nodes, None) + .nth(range.end.paragraph) + .ok_or(EditError::InvalidRange)?; + if container != end_container { + return Err(EditError::UnsupportedContent); + } if range.start.paragraph != range.end.paragraph || replacement.len() != 1 { - self.validate_flat()?; + validate_flat(self.container(container)?.iter().enumerate().filter_map( + |(index, node)| { + ((start..=end).contains(&index) || node.text().is_some()).then_some(node) + }, + ))?; } - let first = self - .nodes - .get(range.start.paragraph) - .ok_or(EditError::InvalidRange)?; - let last = self - .nodes - .get(range.end.paragraph) - .ok_or(EditError::InvalidRange)?; - let mut prefix = first.text[0].text.slice(0..range.start.offset)?; - let last_text = &last.text[0].text; + let mut prefix = first.text().unwrap().text.slice(0..range.start.offset)?; + let last_text = &last.text().unwrap().text; let suffix = last_text.slice(range.end.offset..last_text.utf16_offset(last_text.text().len())?)?; let mut replacement = replacement.into_iter(); prefix.append(replacement.next().ok_or(EditError::InvalidRange)?)?; let mut head = first.clone(); - head.text[0].text = prefix; + head.text_mut().unwrap().text = prefix; let mut nodes = vec![head]; let following = replacement.len(); for (index, text) in replacement.enumerate() { if index + 1 == following && range.start.paragraph != range.end.paragraph { let mut end = last.clone(); - end.text[0].text = text; + end.text_mut().unwrap().text = text; nodes.push(end); } else { - nodes.push(node(text, first.format.clone())?); + let mut next = node(text, first.format.clone())?; + next.level = first.level; + next.style = first.style; + nodes.push(next); } } if !suffix.text().is_empty() { let end = nodes.last_mut().unwrap(); - end.text[0].text.append(suffix)?; + end.text_mut().unwrap().text.append(suffix)?; } Ok(DocumentEdit { - range: range.start.paragraph..range.end.paragraph + 1, + columns: BTreeMap::new(), + container, + range: start..end + 1, replacement: nodes, }) } pub(crate) fn validate_edit(&self, edit: &DocumentEdit) -> Result<(), EditError> { - if edit.range.start > edit.range.end - || edit.range.end > self.nodes.len() - || (edit.range.len() == self.nodes.len() && edit.replacement.is_empty()) - { + let nodes = self.container(edit.container)?; + if edit.range.start > edit.range.end || edit.range.end > nodes.len() { return Err(EditError::InvalidRange); } - validate_nodes( - self.nodes[..edit.range.start] - .iter() - .chain(&edit.replacement) - .chain(&self.nodes[edit.range.end..]), - )?; + validate_nodes(&self.nodes, Some(edit), &mut BTreeSet::new())?; + if !edit.columns.is_empty() { + let replaced = descendants(&edit.replacement, None) + .filter_map(|(_, _, node)| match &node.content { + ParagraphContent::Table(table) => Some(table.id), + _ => None, + }) + .collect::>(); + let tables = descendants(&self.nodes, Some(edit)) + .filter_map(|(_, _, node)| match &node.content { + ParagraphContent::Table(table) => Some((table.id, table)), + _ => None, + }) + .collect::>(); + for (id, widths) in &edit.columns { + if replaced.contains(id) + || tables + .get(id) + .is_none_or(|table| table.columns.len() != widths.len()) + || widths + .iter() + .any(|width| !width.is_finite() || *width < 36.0) + { + return Err(EditError::InvalidStructure); + } + } + } validate_text(&edit.replacement) } @@ -211,20 +418,366 @@ impl TextDocument { .checked_add(edit.replacement.len()) .ok_or(EditError::TextTooLong)?; let range = edit.range.start..end; - let replacement = self.nodes.splice(edit.range, edit.replacement).collect(); - Ok(DocumentEdit { range, replacement }) + let nodes = + container_mut(&mut self.nodes, edit.container).ok_or(EditError::InvalidRange)?; + let replacement = nodes.splice(edit.range, edit.replacement).collect(); + let mut columns = edit.columns; + swap_columns(&mut self.nodes, &mut columns); + Ok(DocumentEdit { + container: edit.container, + range, + replacement, + columns, + }) } } #[cfg(test)] mod tests { use super::*; + use crate::page::{Table, TableCell, TableColumn, TableRow}; use onestore::document::Format; fn position(paragraph: usize, offset: u32) -> TextPosition { TextPosition { paragraph, offset } } + fn table_document() -> TextDocument { + let format = Format::default(); + let mut cells = Vec::new(); + for texts in [vec!["a🌲b", "second"], vec!["right"]] { + cells.push(TableCell { + id: new_id().unwrap(), + layout: Default::default(), + indents: vec![18.0, 0.0, 27.0, 27.0], + shading: None, + paragraphs: texts + .into_iter() + .map(|text| { + node(Paragraph::new(text.into(), format.clone()), format.clone()).unwrap() + }) + .collect(), + unsupported: Vec::new(), + }); + } + let table = PageParagraph { + id: new_id().unwrap(), + parent: None, + level: 1, + style: None, + format: format.clone(), + lists: Vec::new(), + tags: Vec::new(), + collapsed: false, + content: ParagraphContent::Table(Table { + id: new_id().unwrap(), + columns: vec![ + TableColumn { + width: 72.0, + locked: true + }; + 2 + ], + rows: vec![TableRow { + id: new_id().unwrap(), + cells, + }], + borders: Some(true), + layout: Default::default(), + tags: Vec::new(), + }), + }; + TextDocument::from_nodes(vec![ + node( + Paragraph::new("before".into(), format.clone()), + format.clone(), + ) + .unwrap(), + table, + node(Paragraph::new("after".into(), format.clone()), format).unwrap(), + ]) + .unwrap() + } + + #[test] + fn text_and_column_widths_share_an_exact_inverse() { + let mut document = table_document(); + let source = document.clone(); + let ParagraphContent::Table(table) = &source.nodes()[1].content else { + panic!() + }; + let id = table.id; + let neighbor = document.paragraphs().nth(3).unwrap().text().as_ptr(); + let mut edit = document + .replace( + position(1, 1)..position(1, 1), + vec![Paragraph::new("X".into(), Format::default())], + ) + .unwrap(); + edit.columns.insert(id, vec![81.24, 36.0]); + let mut undo = document.apply(edit).unwrap(); + assert_eq!(undo.columns[&id], [72.0, 72.0]); + let after = document.clone(); + for _ in 0..3 { + let redo = document.apply(undo).unwrap(); + assert_eq!(document, source); + assert_eq!( + document.paragraphs().nth(3).unwrap().text().as_ptr(), + neighbor + ); + undo = document.apply(redo).unwrap(); + assert_eq!(document, after); + assert_eq!( + document.paragraphs().nth(3).unwrap().text().as_ptr(), + neighbor + ); + } + let resize = DocumentEdit { + container: None, + range: 0..0, + replacement: Vec::new(), + columns: [(id, vec![37.11, 99.875])].into(), + }; + let undo = document.apply(resize).unwrap(); + document.apply(undo).unwrap(); + assert_eq!(document, after); + } + + #[test] + fn invalid_column_patches_do_not_partially_edit_text() { + let mut document = table_document(); + let source = document.clone(); + let ParagraphContent::Table(table) = &source.nodes()[1].content else { + panic!() + }; + let id = table.id; + for widths in [ + vec![], + vec![72.0], + vec![72.0, 72.0, 72.0], + vec![72.0, 35.99], + vec![f32::NAN, 72.0], + vec![f32::INFINITY, 72.0], + ] { + let mut edit = document + .replace( + position(1, 1)..position(1, 1), + vec![Paragraph::new("X".into(), Format::default())], + ) + .unwrap(); + edit.columns.insert(id, widths); + assert!(document.apply(edit).is_err()); + assert_eq!(document, source); + } + for (range, replacement, id) in [ + (0..0, Vec::new(), new_id().unwrap()), + (1..2, Vec::new(), id), + (1..2, vec![source.nodes()[1].clone()], id), + ] { + let edit = DocumentEdit { + container: None, + range, + replacement, + columns: [(id, vec![81.0, 90.0])].into(), + }; + assert!(document.apply(edit).is_err()); + assert_eq!(document, source); + } + } + + #[test] + fn cell_text_edits_preserve_the_table_and_restore_paragraph_identity() { + let mut document = table_document(); + let original = document.clone(); + let ParagraphContent::Table(table) = &document.nodes()[1].content else { + panic!() + }; + let cell = table.rows[0].cells[0].id; + let other_text = table.rows[0].cells[1].paragraphs[0] + .text() + .unwrap() + .text + .text() + .as_ptr(); + assert_eq!( + document + .paragraphs() + .map(Paragraph::text) + .collect::>(), + ["before", "a🌲b", "second", "right", "after"] + ); + assert_eq!( + document + .slice(position(1, 1)..position(3, 2)) + .unwrap() + .iter() + .map(Paragraph::text) + .collect::>(), + ["🌲b", "second", "ri"] + ); + let edit = document + .replace( + position(1, 1)..position(2, 3), + vec![Paragraph::new("NEW".into(), Format::default())], + ) + .unwrap(); + assert_eq!(edit.container, Some(cell)); + assert_eq!(edit.range, 0..2); + let undo = document.apply(edit).unwrap(); + assert_eq!( + document + .paragraphs() + .map(Paragraph::text) + .collect::>(), + ["before", "aNEWond", "right", "after"] + ); + let ParagraphContent::Table(table) = &document.nodes()[1].content else { + panic!() + }; + assert_eq!( + table.rows[0].cells[1].paragraphs[0] + .text() + .unwrap() + .text + .text() + .as_ptr(), + other_text + ); + assert_eq!( + document.text_nodes().nth(1).unwrap().id, + original.text_nodes().nth(1).unwrap().id + ); + assert_eq!(undo.container, Some(cell)); + assert_eq!(undo.range, 0..1); + let redo = document.apply(undo).unwrap(); + assert_eq!(document, original); + let undo = document.apply(redo).unwrap(); + document.apply(undo).unwrap(); + assert_eq!(document, original); + } + + #[test] + fn surrounding_text_splits_without_flattening_the_table() { + let mut document = table_document(); + let original = document.clone(); + let edit = document + .replace( + position(4, 2)..position(4, 2), + vec![Paragraph::new(String::new(), Format::default()); 2], + ) + .unwrap(); + assert_eq!(edit.container, None); + assert_eq!(edit.range, 2..3); + let undo = document.apply(edit).unwrap(); + assert_eq!( + document + .paragraphs() + .map(Paragraph::text) + .collect::>(), + ["before", "a🌲b", "second", "right", "af", "ter"] + ); + assert_eq!(document.nodes()[1], original.nodes()[1]); + document.apply(undo).unwrap(); + assert_eq!(document, original); + assert_eq!( + document.replace( + position(0, 0)..position(4, 0), + vec![Paragraph::new(String::new(), Format::default())] + ), + Err(EditError::UnsupportedContent) + ); + } + + #[test] + fn cell_edits_validate_utf16_and_container_boundaries_before_mutation() { + let mut document = table_document(); + let original = document.clone(); + let text = || vec![Paragraph::new("X".into(), Format::default())]; + assert_eq!( + document.replace(position(1, 2)..position(1, 3), text()), + Err(EditError::InvalidRange) + ); + assert_eq!( + document.replace(position(1, 0)..position(3, 1), text()), + Err(EditError::UnsupportedContent) + ); + let ParagraphContent::Table(table) = &document.nodes()[1].content else { + panic!() + }; + let cell = table.rows[0].cells[1].id; + for edit in [ + DocumentEdit { + columns: BTreeMap::new(), + container: Some(cell), + range: 0..1, + replacement: Vec::new(), + }, + DocumentEdit { + columns: BTreeMap::new(), + container: Some(new_id().unwrap()), + range: 0..1, + replacement: vec![document.nodes()[0].clone()], + }, + DocumentEdit { + columns: BTreeMap::new(), + container: Some(cell), + range: 0..usize::MAX, + replacement: Vec::new(), + }, + ] { + assert_eq!(document.apply(edit), Err(EditError::InvalidRange)); + assert_eq!(document, original); + } + let alias = DocumentEdit { + columns: BTreeMap::new(), + container: Some(cell), + range: 0..1, + replacement: vec![document.nodes()[0].clone()], + }; + assert_eq!(document.apply(alias), Err(EditError::InvalidStructure)); + assert_eq!(document, original); + } + + #[test] + fn nested_cell_edits_keep_leaf_order_and_split_paragraphs_locally() { + let mut document = table_document(); + let nested = table_document().nodes.remove(1); + let ParagraphContent::Table(table) = &document.nodes()[1].content else { + panic!() + }; + let parent = table.rows[0].cells[0].id; + document + .apply(DocumentEdit { + columns: BTreeMap::new(), + container: Some(parent), + range: 0..2, + replacement: vec![nested], + }) + .unwrap(); + let original = document.clone(); + let edit = document + .replace( + position(2, 3)..position(2, 3), + vec![ + Paragraph::new("X".into(), Format::default()), + Paragraph::new("Y".into(), Format::default()), + ], + ) + .unwrap(); + assert_ne!(edit.container, Some(parent)); + assert_eq!(edit.range, 1..2); + let undo = document.apply(edit).unwrap(); + assert_eq!( + document + .paragraphs() + .map(Paragraph::text) + .collect::>(), + ["before", "a🌲b", "secX", "Yond", "right", "right", "after"] + ); + document.apply(undo).unwrap(); + assert_eq!(document, original); + } + #[test] #[ignore = "requires CANVAS_TEST_SECTION and CANVAS_TEST_PAGE private fixture inputs"] fn imported_nodes_preserve_identity_through_edit_and_undo() { @@ -272,12 +825,12 @@ mod tests { let edited = document.clone(); assert_eq!(edited.nodes()[paragraph].id, original.nodes()[paragraph].id); assert_eq!( - edited.nodes()[paragraph].text[0].id, - original.nodes()[paragraph].text[0].id + edited.nodes()[paragraph].text().unwrap().id, + original.nodes()[paragraph].text().unwrap().id ); let mut restored_text = edited.nodes().to_vec(); - restored_text[paragraph].text[0].text = - original.nodes()[paragraph].text[0].text.clone(); + restored_text[paragraph].text_mut().unwrap().text = + original.nodes()[paragraph].text().unwrap().text.clone(); assert_eq!(restored_text, original.nodes()); let redo = document.apply(undo).unwrap(); assert_eq!(document, original); @@ -310,7 +863,7 @@ mod tests { .unwrap() }) .collect::>(); - source[2].text[0].text = Paragraph::new( + source[2].text_mut().unwrap().text = Paragraph::new( "tail".into(), Format { bold: Some(true), @@ -339,8 +892,8 @@ mod tests { for index in [0, 2] { assert_eq!(document.nodes()[index].id, original.nodes()[index].id); assert_eq!( - document.nodes()[index].text[0].id, - original.nodes()[index].text[0].id + document.nodes()[index].text().unwrap().id, + original.nodes()[index].text().unwrap().id ); assert_eq!( document.nodes()[index].format, @@ -350,10 +903,10 @@ mod tests { let old_ids: BTreeSet<_> = original .nodes() .iter() - .flat_map(|n| [n.id, n.text[0].id]) + .flat_map(|n| [n.id, n.text().unwrap().id]) .collect(); assert!(!old_ids.contains(&document.nodes()[1].id)); - assert!(!old_ids.contains(&document.nodes()[1].text[0].id)); + assert!(!old_ids.contains(&document.nodes()[1].text().unwrap().id)); let edited = document.clone(); let redo = document.apply(undo).unwrap(); assert_eq!(document, original); @@ -378,20 +931,27 @@ mod tests { TextDocument::new(vec![Paragraph::new("text".into(), Format::default())]).unwrap(); let original = document.clone(); let duplicate = DocumentEdit { + columns: BTreeMap::new(), + container: None, range: 1..1, replacement: original.nodes().to_vec(), }; assert_eq!(document.apply(duplicate), Err(EditError::InvalidStructure)); let mut unsupported = original.nodes().to_vec(); - unsupported[0] - .text - .push(original.nodes()[0].text[0].clone()); + unsupported[0].content = + crate::page::ParagraphContent::Unsupported(crate::page::Unsupported { + id: new_id().unwrap(), + jcid: 0x60012, + layout: Default::default(), + }); assert_eq!( TextDocument::from_nodes(unsupported.clone()), Err(EditError::UnsupportedContent) ); assert_eq!( document.apply(DocumentEdit { + columns: BTreeMap::new(), + container: None, range: 0..1, replacement: unsupported }), @@ -415,7 +975,7 @@ mod tests { nodes[2].parent = Some(nodes[1].id); nodes[2].level = 5; let original = TextDocument::from_nodes(nodes.clone()).unwrap(); - for parent in [nodes[2].id, nodes[0].text[0].id, new_id().unwrap()] { + for parent in [nodes[2].id, nodes[0].text().unwrap().id, new_id().unwrap()] { let mut invalid = nodes.clone(); invalid[1].parent = Some(parent); assert_eq!( @@ -434,6 +994,8 @@ mod tests { let mut document = original.clone(); assert_eq!( document.apply(DocumentEdit { + columns: BTreeMap::new(), + container: None, range: 0..1, replacement: Vec::new() }), @@ -466,7 +1028,7 @@ mod tests { task_id: Some([7; 16]), extra_set: 0, }); - nodes[1].text[0].tags = nodes[1].tags.clone(); + nodes[1].text_mut().unwrap().tags = nodes[1].tags.clone(); nodes[1].collapsed = true; nodes[2].parent = Some(nodes[1].id); nodes[2].level = 3; @@ -498,7 +1060,7 @@ mod tests { let undo = document.apply(edit).unwrap(); let edited = document.clone(); let mut restored = edited.nodes().to_vec(); - restored[1].text[0].text = text.clone(); + restored[1].text_mut().unwrap().text = text.clone(); assert_eq!(restored, original.nodes()); let redo = document.apply(undo).unwrap(); assert_eq!(document, original); @@ -585,79 +1147,90 @@ mod tests { Paragraph::new("日本z".into(), regular.clone()), ]) .unwrap(); - let positions: Vec<_> = original - .paragraphs() - .enumerate() - .flat_map(|(index, paragraph)| { - (0..=paragraph.text().len()).filter_map(move |byte| { - paragraph - .utf16_offset(byte) - .ok() - .map(|offset| position(index, offset)) + for (original, regions) in [ + (original, vec![0, 0, 0]), + (table_document(), vec![0, 1, 1, 2, 3]), + ] { + let positions: Vec<_> = original + .paragraphs() + .enumerate() + .flat_map(|(index, paragraph)| { + (0..=paragraph.text().len()).filter_map(move |byte| { + paragraph + .utf16_offset(byte) + .ok() + .map(|offset| position(index, offset)) + }) }) - }) - .collect(); - let replacements = [ - vec![Paragraph::new(String::new(), regular.clone())], - vec![Paragraph::new("x👩🏽‍💻".into(), bold.clone())], - vec![ - Paragraph::new(String::new(), bold.clone()), - Paragraph::new(String::new(), regular.clone()), - ], - vec![ - Paragraph::new("one".into(), bold.clone()), - Paragraph::new(String::new(), regular.clone()), - Paragraph::new("two".into(), bold), - ], - ]; - let plain = original - .paragraphs() - .map(Paragraph::text) - .collect::>() - .join("\n"); - for &start in &positions { - for &end in positions.iter().filter(|&&end| end >= start) { - for replacement in &replacements { - let mut document = original.clone(); - let planned = document.replace(start..end, replacement.clone()).unwrap(); - assert_eq!(document, original); - let undo = document.apply(planned).unwrap(); - assert!(document.paragraphs().len() > 0); - let byte = |position: TextPosition| { - original - .paragraphs() - .take(position.paragraph) - .map(|p| p.text().len() + 1) - .sum::() - + original + .collect(); + let replacements = [ + vec![Paragraph::new(String::new(), regular.clone())], + vec![Paragraph::new("x👩🏽‍💻".into(), bold.clone())], + vec![ + Paragraph::new(String::new(), bold.clone()), + Paragraph::new(String::new(), regular.clone()), + ], + vec![ + Paragraph::new("one".into(), bold.clone()), + Paragraph::new(String::new(), regular.clone()), + Paragraph::new("two".into(), bold.clone()), + ], + ]; + let plain = original + .paragraphs() + .map(Paragraph::text) + .collect::>() + .join("\n"); + for &start in &positions { + for &end in positions.iter().filter(|&&end| end >= start) { + for replacement in &replacements { + let mut document = original.clone(); + let planned = document.replace(start..end, replacement.clone()); + if regions[start.paragraph] != regions[end.paragraph] { + assert_eq!(planned, Err(EditError::UnsupportedContent)); + assert_eq!(document, original); + continue; + } + let planned = planned.unwrap(); + assert_eq!(document, original); + let undo = document.apply(planned).unwrap(); + assert!(document.paragraphs().next().is_some()); + let byte = |position: TextPosition| { + original .paragraphs() - .nth(position.paragraph) - .unwrap() - .byte_offset(position.offset) - .unwrap() - }; - let mut expected = plain.clone(); - expected.replace_range( - byte(start)..byte(end), - &replacement - .iter() - .map(Paragraph::text) - .collect::>() - .join("\n"), - ); - assert_eq!( - document - .paragraphs() - .map(Paragraph::text) - .collect::>() - .join("\n"), - expected - ); - let after = document.clone(); - let redo = document.apply(undo).unwrap(); - assert_eq!(document, original, "{start:?}..{end:?}"); - document.apply(redo).unwrap(); - assert_eq!(document, after); + .take(position.paragraph) + .map(|p| p.text().len() + 1) + .sum::() + + original + .paragraphs() + .nth(position.paragraph) + .unwrap() + .byte_offset(position.offset) + .unwrap() + }; + let mut expected = plain.clone(); + expected.replace_range( + byte(start)..byte(end), + &replacement + .iter() + .map(Paragraph::text) + .collect::>() + .join("\n"), + ); + assert_eq!( + document + .paragraphs() + .map(Paragraph::text) + .collect::>() + .join("\n"), + expected + ); + let after = document.clone(); + let redo = document.apply(undo).unwrap(); + assert_eq!(document, original, "{start:?}..{end:?}"); + document.apply(redo).unwrap(); + assert_eq!(document, after); + } } } } @@ -686,6 +1259,8 @@ mod tests { assert!( document .apply(DocumentEdit { + columns: BTreeMap::new(), + container: None, range, replacement: Vec::new() }) diff --git a/crates/canvas/src/editor.rs b/crates/canvas/src/editor.rs index 4576c18db69c6cdf2e29acd526171ea52b5cc666..961241bbac764878132ee9ae4dd9c66f69184071 100644 --- a/crates/canvas/src/editor.rs +++ b/crates/canvas/src/editor.rs @@ -1,8 +1,9 @@ use crate::{ - document::{DocumentEdit, TextDocument, TextPosition}, + date::PageDate, + document::{DocumentEdit, TextDocument, TextPosition, edited_nodes, leaves}, layout::{LayoutError, TextEngine}, - outline::{OutlineLayout, ParagraphLayout, arrange, visible_paragraphs}, - page::{Definition, Outline}, + outline::{OutlineLayout, ParagraphLayout, arrange, indentation, visible_paragraphs}, + page::{Definition, Outline, PageParagraph}, text::{EditError, Paragraph}, }; use onestore::ExGuid; @@ -16,6 +17,12 @@ use std::{ ops::Range, }; +pub const DEFAULT_OUTLINE_WIDTH: f32 = 468.0; + +pub(crate) mod page; +mod table; +pub use page::ReadOnlyObject; + #[derive(Clone, Copy, Debug)] pub enum Movement { Left, @@ -26,12 +33,24 @@ pub enum Movement { WordRight, LineStart, LineEnd, + ParagraphStart, + ParagraphEnd, + DocumentStart, + DocumentEnd, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SelectionUnit { + Grapheme, + Word, + Paragraph, } #[derive(Debug)] pub enum EditorError { Edit(EditError), Layout(LayoutError), + InvalidGeometry, } impl fmt::Display for EditorError { @@ -39,6 +58,9 @@ impl fmt::Display for EditorError { match self { Self::Edit(error) => error.fmt(f), Self::Layout(error) => error.fmt(f), + Self::InvalidGeometry => { + f.write_str("The page contains invalid object dimensions or positions.") + } } } } @@ -73,6 +95,8 @@ impl From<[TextPosition; 2]> for Selection { } pub struct CanvasEditor { + pub(crate) objects: Vec, + date: Option, outlines: Vec, definitions: BTreeMap, active: Focus, @@ -84,14 +108,21 @@ pub struct CanvasEditor { enum Focus { Outline(usize), + Draft { + index: usize, + outline: Box, + }, Caret { outline: Box, index: usize, }, } +#[derive(Clone)] pub struct TextOutline { pub id: onestore::ExGuid, + pub title: bool, + min_width: Option, layout: onestore::document::Layout, document: TextDocument, indents: Vec, @@ -114,6 +145,15 @@ impl TextOutline { .all(|byte| byte == b' ') } + /// Tests an outline-local point against the provisional paragraph band. + pub fn contains_extension(&self, point: [f32; 2]) -> bool { + !self.title + && self.document.validate_flat().is_ok() + && (0.0..=self.shaped.size[0]).contains(&point[0]) + && point[1] > self.shaped.size[1] + && point[1] <= self.shaped.size[1] + 27.0 + } + pub fn new( engine: &mut TextEngine, document: TextDocument, @@ -124,19 +164,30 @@ impl TextOutline { return Err(EditError::InvalidRange.into()); } let indents = vec![18.0, 0.0, 27.0, 27.0]; - let shaped = visible_paragraphs(document.nodes()) - .map(|paragraph| { - ParagraphLayout::shape(engine, paragraph, width, &indents, &BTreeMap::new()) - }) - .collect::, _>>()?; - let shaped = OutlineLayout::new(shaped, width, true)?; + let fixed_width = document.nodes().len() != 1 + || document.nodes()[0] + .text() + .is_none_or(|text| !text.text.text().is_empty()); + let shaped = OutlineLayout::flow( + document.nodes().iter(), + &indents, + width, + fixed_width, + 0, + None, + &mut |paragraph, width, indents| { + ParagraphLayout::shape(engine, paragraph, width, indents, &BTreeMap::new()) + }, + )?; Ok(Self { id: crate::text::new_id()?, + title: false, + min_width: None, layout: onestore::document::Layout { x: Some(position[0]), y: Some(position[1]), max_width: Some(width), - width_set_by_user: Some(true), + width_set_by_user: Some(fixed_width), ..Default::default() }, document, @@ -162,10 +213,37 @@ impl TextOutline { { return Err(EditError::InvalidRange.into()); } - let shaped = outline.layout(engine, definitions)?; + if outline + .min_width + .is_some_and(|width| !width.is_finite() || width <= 0.0) + { + return Err(LayoutError::InvalidWidth.into()); + } + let shaped = if outline.title { + if outline + .layout + .max_height + .is_some_and(|height| !height.is_finite() || height < 0.0) + { + return Err(LayoutError::InvalidSpacing.into()); + } + outline.layout_with_width( + engine, + definitions, + outline + .layout + .reserved_width + .or(outline.layout.max_width) + .unwrap_or(crate::outline::TITLE_WIDTH), + )? + } else { + outline.layout(engine, definitions)? + }; let document = TextDocument::from_nodes(outline.paragraphs.clone())?; Ok(Self { id: outline.id, + title: outline.title, + min_width: outline.min_width, layout: outline.layout.clone(), indents: outline.indents.clone(), document, @@ -178,9 +256,11 @@ impl TextOutline { }) } - fn snapshot(&self) -> Outline { + pub fn snapshot(&self) -> Outline { Outline { id: self.id, + title: self.title, + min_width: self.min_width, layout: self.layout.clone(), indents: self.indents.clone(), paragraphs: self.document.nodes().to_vec(), @@ -196,25 +276,68 @@ impl TextOutline { &self.layout } + pub fn shaped(&self) -> &OutlineLayout { + &self.shaped + } + pub fn origin(&self) -> [f32; 2] { [self.layout.x.unwrap_or(0.0), self.layout.y.unwrap_or(0.0)] } + pub fn wrap_width(&self) -> f32 { + self.layout + .reserved_width + .or(self.layout.max_width) + .unwrap_or(crate::outline::TITLE_WIDTH) + } + + /// Native typing room expands the interactive bounds without changing the wrap constraint. pub fn bounds(&self) -> BoundingBox { let [x, y] = self.origin().map(f64::from); + let width = if self.layout.width_set_by_user == Some(true) { + self.shaped.size[0] + } else { + let text_width = if self.shaped.tables.is_empty() { + Some(self.shaped.size[0]) + } else { + let roots = self + .document + .nodes() + .iter() + .filter(|node| node.text().is_some()) + .map(|node| node.id) + .collect::>(); + self.shaped + .paragraphs + .iter() + .filter(|paragraph| roots.contains(¶graph.id)) + .map(|paragraph| paragraph.origin[0] + paragraph.text.shaped.width()) + .reduce(f32::max) + }; + text_width.map_or(self.shaped.size[0], |width| { + (width + 36.0) + .max(72.0) + .min(self.wrap_width()) + .max(self.shaped.size[0]) + }) + } + .max(self.min_width.unwrap_or(0.0)); BoundingBox { x0: x, y0: y, - x1: x + f64::from(self.shaped.size[0]), - y1: y + f64::from(self.shaped.size[1]), + x1: x + f64::from(width), + y1: y + f64::from(if self.title { + self.shaped.size[1].max(self.layout.max_height.unwrap_or(0.0)) + } else { + self.shaped.size[1] + }), } } pub fn layouts(&self) -> impl Iterator { let mut shaped = self.shaped.paragraphs.iter().peekable(); self.document - .nodes() - .iter() + .text_nodes() .enumerate() .filter_map(move |(index, node)| { if shaped @@ -231,8 +354,8 @@ impl TextOutline { fn visible_index(&self, source: usize) -> Result { let id = self .document - .nodes() - .get(source) + .text_nodes() + .nth(source) .ok_or(EditError::InvalidRange)? .id; self.shaped @@ -245,8 +368,7 @@ impl TextOutline { fn source_index(&self, visible: usize) -> usize { let id = self.shaped.paragraphs[visible].id; self.document - .nodes() - .iter() + .text_nodes() .position(|paragraph| paragraph.id == id) .unwrap() } @@ -254,6 +376,160 @@ impl TextOutline { pub fn paragraph_layout(&self, source: usize) -> Result<&ParagraphLayout, EditError> { Ok(&self.shaped.paragraphs[self.visible_index(source)?]) } + + fn column(&self, x: f32, current: ExGuid) -> Result, EditError> { + let paragraphs = self + .shaped + .paragraphs + .iter() + .enumerate() + .map(|(index, paragraph)| (paragraph.id, index)) + .collect::>(); + let tables = self + .shaped + .tables + .iter() + .map(|table| (table.id, table)) + .collect::>(); + let mut result = Vec::new(); + let mut pending = vec![visible_paragraphs(self.document.nodes().iter())]; + while let Some(nodes) = pending.last_mut() { + let Some(node) = nodes.next() else { + pending.pop(); + continue; + }; + match &node.content { + crate::page::ParagraphContent::Text(_) => { + result.push( + *paragraphs + .get(&node.id) + .ok_or(EditError::InvalidStructure)?, + ); + } + crate::page::ParagraphContent::Table(table) => { + let layout = tables.get(&table.id).ok_or(EditError::InvalidStructure)?; + let cells = layout + .cells + .get(..table.columns.len()) + .ok_or(EditError::InvalidStructure)?; + let (first, last) = cells + .first() + .zip(cells.last()) + .ok_or(EditError::InvalidStructure)?; + let x = x.clamp(first.rect[0], last.rect[2]); + let column = cells + .iter() + .enumerate() + .min_by(|(_, a), (_, b)| { + let distance = |cell: &crate::outline::CellLayout| { + (f64::from(x) - f64::from(x.clamp(cell.rect[0], cell.rect[2]))) + .abs() + }; + distance(a).total_cmp(&distance(b)) + }) + .ok_or(EditError::InvalidStructure)? + .0; + for row in table.rows.iter().rev() { + let cell = row + .cells + .iter() + .find(|cell| { + leaves(&cell.paragraphs, None) + .any(|(_, _, node)| node.id == current) + }) + .unwrap_or(&row.cells[column]); + pending.push(visible_paragraphs(cell.paragraphs.iter())); + } + } + crate::page::ParagraphContent::Unsupported(_) => { + return Err(EditError::UnsupportedContent); + } + } + } + Ok(result) + } + + fn paragraph_at(&self, x: f32, y: f32) -> Result { + if self.shaped.tables.is_empty() { + return Ok(self + .shaped + .paragraphs + .partition_point(|paragraph| paragraph.origin[1] <= y) + .saturating_sub(1)); + } + let paragraphs = self + .shaped + .paragraphs + .iter() + .enumerate() + .map(|(index, paragraph)| (paragraph.id, (index, paragraph))) + .collect::>(); + let tables = self + .shaped + .tables + .iter() + .map(|table| (table.id, table)) + .collect::>(); + let mut nodes = self.document.nodes(); + loop { + let mut target = None; + for node in visible_paragraphs(nodes.iter()) { + let top = match &node.content { + crate::page::ParagraphContent::Text(_) => { + paragraphs + .get(&node.id) + .ok_or(EditError::InvalidStructure)? + .1 + .origin[1] + } + crate::page::ParagraphContent::Table(table) => { + tables + .get(&table.id) + .and_then(|layout| layout.cells.first()) + .ok_or(EditError::InvalidStructure)? + .rect[1] + - 1.86 + } + crate::page::ParagraphContent::Unsupported(_) => { + return Err(EditError::UnsupportedContent); + } + }; + if target.is_none() || top <= y { + target = Some(node); + } + if top > y { + break; + } + } + let target = target.ok_or(EditError::InvalidStructure)?; + let crate::page::ParagraphContent::Table(table) = &target.content else { + return paragraphs + .get(&target.id) + .map(|(index, _)| *index) + .ok_or(EditError::InvalidStructure); + }; + let layout = tables.get(&table.id).ok_or(EditError::InvalidStructure)?; + let (first, last) = layout + .cells + .first() + .zip(layout.cells.last()) + .ok_or(EditError::InvalidStructure)?; + let x = x.clamp(first.rect[0], last.rect[2]); + let y = y.clamp(first.rect[1], last.rect[3]); + let cell = layout + .cells + .iter() + .map(|cell| { + let dx = f64::from(x) - f64::from(x.clamp(cell.rect[0], cell.rect[2])); + let dy = f64::from(y) - f64::from(y.clamp(cell.rect[1], cell.rect[3])); + (cell, dy.abs(), dx.abs()) + }) + .min_by(|a, b| a.1.total_cmp(&b.1).then(a.2.total_cmp(&b.2))) + .ok_or(EditError::InvalidStructure)? + .0; + nodes = self.document.container(Some(cell.id))?; + } + } } struct Composition { @@ -262,6 +538,17 @@ struct Composition { } impl ParagraphLayout { + fn reset_origin(&mut self, x: f32) { + let offset = x - self.origin[0]; + self.origin = [x, 0.0]; + for (_, origin) in &mut self.markers { + origin[0] += offset; + } + for tag in &mut self.tags { + tag.origin[0] += offset; + } + } + fn cursor(&self, source: u32, affinity: Affinity) -> Result { let visible = self.projection.visible_offset(source)?; let byte = self.projection.text().byte_offset(visible)?; @@ -273,6 +560,13 @@ impl ParagraphLayout { struct TextChange { edit: DocumentEdit, selection: Selection, + positions: Vec, +} + +#[derive(Clone)] +struct Placement { + id: ExGuid, + position: [Option; 2], } enum RestoreFocus { @@ -285,14 +579,26 @@ enum RestoreFocus { } enum History { + Date(Box), + Draft { + outlines: Box<[TextOutline; 2]>, + index: usize, + caret: Box, + selection: Selection, + restore_caret: bool, + }, Text { outline: onestore::ExGuid, - change: TextChange, + change: Box, }, Position { outline: onestore::ExGuid, position: [Option; 2], }, + Layout { + outline: ExGuid, + layout: onestore::document::Layout, + }, Remove { outline: onestore::ExGuid, focus: RestoreFocus, @@ -331,6 +637,8 @@ impl CanvasEditor { Ok(Self { outlines, definitions: BTreeMap::new(), + objects: Vec::new(), + date: None, active, undo: Vec::new(), redo: Vec::new(), @@ -339,6 +647,212 @@ impl CanvasEditor { }) } + pub fn from_page( + mut page: crate::page::Page, + engine: &mut TextEngine, + ) -> Result { + let (objects, mut outlines, date) = page::build(&mut page, engine, true)?; + let needs_caret = outlines.is_empty(); + if needs_caret { + let x = objects + .iter() + .filter_map(|object| match object { + page::Content::ReadOnly(object) => Some(object.rect()[2]), + _ => None, + }) + .fold(0.0_f32, f32::max); + outlines.push(TextOutline::new( + engine, + TextDocument::new(vec![Paragraph::new(String::new(), Default::default())])?, + 240.0, + [if x > 0.0 { x + 24.0 } else { 0.0 }, 0.0], + )?); + } + let mut editor = Self::from_text_outlines(outlines, page.definitions, date)?; + if needs_caret { + editor.active = Focus::Caret { + outline: Box::new(editor.outlines.pop().unwrap()), + index: 0, + }; + } + editor.objects = objects; + let mut ids = BTreeSet::new(); + if !editor.object_layouts().all(|(id, _)| ids.insert(id)) { + return Err(EditError::InvalidStructure.into()); + } + Ok(editor) + } + + /// Whether this outline occupies a slot in the imported page's paint order. + pub fn has_page_outline(&self, id: ExGuid) -> bool { + self.objects + .iter() + .any(|object| matches!(object, page::Content::Editable(candidate) if *candidate == id)) + } + + pub fn object_layouts(&self) -> impl Iterator { + self.outlines + .iter() + .map(|outline| (outline.id, &outline.layout)) + .chain( + self.date + .iter() + .map(|date| (date.source().id, &date.source().layout)), + ) + .chain(self.objects.iter().filter_map(page::Content::layout)) + } + + fn object_layout_mut(&mut self, id: ExGuid) -> Option<&mut onestore::document::Layout> { + self.outlines + .iter_mut() + .map(|outline| (outline.id, &mut outline.layout)) + .chain( + self.objects + .iter_mut() + .filter_map(page::Content::layout_mut), + ) + .find_map(|(candidate, layout)| (candidate == id).then_some(layout)) + } + + fn header_bottom(&self, title: ExGuid, text_bottom: f32) -> f32 { + self.objects + .iter() + .filter_map(|object| match object { + page::Content::Outline { + source, + layout, + below_title, + } if *below_title == Some(title) => { + Some(text_bottom + source.layout.y.unwrap_or(0.0) + layout.size[1]) + } + page::Content::Date { below_title } if *below_title == Some(title) => { + self.date.as_ref().map(|date| { + text_bottom + date.source().layout.y.unwrap_or(0.0) + date.layout().size[1] + }) + } + _ => None, + }) + .fold(text_bottom, f32::max) + } + + pub fn leave_title(&mut self, engine: &mut TextEngine) -> Result<(), EditorError> { + let title = self.active_outline(); + if !title.title { + return Ok(()); + } + let bottom = self.header_bottom(title.id, title.bounds().y1 as f32); + let position = [ + title.origin()[0], + ((((f64::from(bottom) - 14.4) / 18.0).ceil() + 1.0) * 18.0 + 14.4) as f32, + ]; + if let Some((id, origin)) = self + .outlines() + .iter() + .rev() + .find(|outline| { + let bounds = outline.bounds(); + let [x, y] = position.map(f64::from); + !outline.title + && x >= bounds.x0 + && x <= bounds.x1 + && y >= bounds.y0 + && y <= bounds.y1 + }) + .map(|outline| (outline.id, outline.origin())) + { + self.focus_outline(id)?; + self.select_at(position[0] - origin[0], position[1] - origin[1], false)?; + } else { + self.place_caret(engine, position, DEFAULT_OUTLINE_WIDTH)?; + } + Ok(()) + } + + fn title_flow(&self, height: f32) -> Result, EditorError> { + let title = self.active_outline(); + if !title.title { + return Ok(Vec::new()); + } + // Native flow uses text height even when the stored title bounds are taller. + let old = self.header_bottom(title.id, title.origin()[1] + title.shaped.size[1]); + let new = self.header_bottom(title.id, title.origin()[1] + height); + if !old.is_finite() || !new.is_finite() { + return Err(EditorError::InvalidGeometry); + } + if new <= old { + return Ok(Vec::new()); + } + let left = title.origin()[0] - 36.0; + let right = title.origin()[0] + title.wrap_width() + 36.0; + let destination = new + 11.52; + let bodies = || { + self.outlines + .iter() + .filter(|outline| !outline.title) + .map(|outline| { + let rect = outline.bounds(); + ( + outline.id, + &outline.layout, + [ + rect.x0 as f32, + rect.y0 as f32, + rect.x1 as f32, + rect.y1 as f32, + ], + ) + }) + .chain(self.objects.iter().filter_map(|object| match object { + page::Content::Image(image) if !image.background => { + let x = image.layout.x.unwrap_or(0.0); + let y = image.layout.y.unwrap_or(0.0); + Some(( + image.id, + &image.layout, + [ + x, + y, + x + image.layout.max_width?, + y + image.layout.max_height?, + ], + )) + } + page::Content::ReadOnly(object) + if !matches!(object.source, crate::page::PageObject::Title(_)) => + { + Some((object.source.id(), object.source.layout(), object.rect())) + } + _ => None, + })) + }; + let Some(first) = bodies() + .filter(|(_, _, rect)| { + rect[1] >= old + 10.8 + && rect[1] < destination + && rect[0] <= right + && rect[2] >= left + }) + .map(|(_, _, rect)| rect[1]) + .min_by(f32::total_cmp) + else { + return Ok(Vec::new()); + }; + let delta = destination - first; + bodies() + .filter(|(_, _, rect)| rect[1] >= first) + .map(|(id, layout, rect)| { + let y = rect[1] + delta; + if !y.is_finite() || !(rect[3] + delta).is_finite() { + return Err(EditorError::InvalidGeometry); + } + Ok(Placement { + id, + position: [layout.x, Some(y)], + }) + }) + .collect() + } + pub fn from_outlines( engine: &mut TextEngine, outlines: Vec, @@ -348,33 +862,36 @@ impl CanvasEditor { .iter() .map(|outline| TextOutline::from_outline(engine, outline, &definitions)) .collect::>()?; - Self::from_text_outlines(outlines, definitions) + Self::from_text_outlines(outlines, definitions, None) } pub fn from_text_outlines( outlines: Vec, definitions: BTreeMap, + date: Option, ) -> Result { if outlines.is_empty() { return Err(EditError::InvalidRange.into()); } let mut ids = BTreeSet::new(); - for outline in &outlines { - for id in - std::iter::once(outline.id).chain( - outline.document.nodes().iter().flat_map(|p| { - std::iter::once(p.id).chain(p.text.iter().map(|text| text.id)) - }), - ) - { - if !ids.insert(id) { - return Err(EditError::InvalidStructure.into()); - } + for (outline_id, paragraphs) in outlines + .iter() + .map(|outline| (outline.id, outline.document.nodes())) + .chain( + date.iter() + .map(|date| (date.source().id, date.source().paragraphs.as_slice())), + ) + { + if !ids.insert(outline_id) { + return Err(EditError::InvalidStructure.into()); } + crate::document::validate_nodes(paragraphs, None, &mut ids)?; } Ok(Self { outlines, definitions, + objects: Vec::new(), + date, active: Focus::Outline(0), undo: Vec::new(), redo: Vec::new(), @@ -383,20 +900,62 @@ impl CanvasEditor { }) } + pub fn date(&self) -> Option<&PageDate> { + self.date.as_ref() + } + + pub fn change_date( + &mut self, + engine: &mut TextEngine, + timestamp: u64, + mut text: [String; 2], + ) -> Result { + let date = self.date.as_ref().ok_or(EditError::InvalidRange)?; + if timestamp == date.timestamp() { + return Ok(false); + } + let mut source = date.source().clone(); + for ((field, _), paragraph) in date.fields().zip(&mut source.paragraphs) { + let format = paragraph.text().unwrap().text.format_at(0)?.clone(); + paragraph.text_mut().unwrap().text = + Paragraph::new(std::mem::take(&mut text[field as usize]), format); + } + let updated = PageDate::new(timestamp, source, engine, &self.definitions)?; + self.finish_composition(); + self.undo + .push(History::Date(Box::new(self.date.replace(updated).unwrap()))); + self.redo.clear(); + Ok(true) + } + + /// Stored content; arrow-created paragraphs appear only in `visible_outlines`. pub fn outlines(&self) -> &[TextOutline] { &self.outlines } + pub fn visible_outlines(&self) -> impl DoubleEndedIterator { + self.outlines + .iter() + .enumerate() + .map(|(index, outline)| match &self.active { + Focus::Draft { + index: active, + outline, + } if index == *active => outline.as_ref(), + _ => outline, + }) + } + pub fn active_outline(&self) -> &TextOutline { match &self.active { Focus::Outline(index) => &self.outlines[*index], - Focus::Caret { outline, .. } => outline, + Focus::Caret { outline, .. } | Focus::Draft { outline, .. } => outline, } } fn active_outline_mut(&mut self) -> &mut TextOutline { match &mut self.active { Focus::Outline(index) => &mut self.outlines[*index], - Focus::Caret { outline, .. } => outline, + Focus::Caret { outline, .. } | Focus::Draft { outline, .. } => outline, } } @@ -404,7 +963,7 @@ impl CanvasEditor { pub fn caret_outline(&self) -> Option<&TextOutline> { match &self.active { Focus::Caret { outline, .. } => Some(outline), - Focus::Outline(_) => None, + Focus::Outline(_) | Focus::Draft { .. } => None, } } @@ -425,12 +984,105 @@ impl CanvasEditor { Ok(()) } + fn blank_paragraph(&self, base: &PageParagraph) -> Result { + let format = match base.style { + Some(id) => self + .definitions + .get(&id) + .filter(|definition| { + matches!(definition.kind, onestore::document::Kind::Style { .. }) + }) + .ok_or(EditError::InvalidStructure)? + .format + .clone(), + None => Default::default(), + }; + let mut node = + crate::document::node(Paragraph::new(String::new(), format.clone()), format)?; + node.level = base.level; + node.style = base.style; + Ok(node) + } + + pub fn select_below( + &mut self, + engine: &mut TextEngine, + id: ExGuid, + point: [f32; 2], + ) -> Result { + if point.iter().any(|coordinate| !coordinate.is_finite()) { + return Err(EditError::InvalidRange.into()); + } + self.finish_composition(); + let Some(index) = self.outlines.iter().position(|outline| outline.id == id) else { + return Ok(false); + }; + let outline = &self.outlines[index]; + if !outline.contains_extension(point) { + return Ok(false); + } + let draft = Box::new(match &self.active { + Focus::Draft { + outline, + index: active, + } if *active == index => (**outline).clone(), + _ => outline.clone(), + }); + let previous = std::mem::replace( + &mut self.active, + Focus::Draft { + outline: draft, + index, + }, + ); + let preferred_x = self.preferred_x; + let result = (|| { + // Tiny source font sizes must not make one click generate unbounded paragraphs. + for _ in 0..256 { + if self.active_outline().shaped.size[1] >= point[1] { + break; + } + let outline = self.active_outline(); + let count = outline.document.nodes().len(); + let height = outline.shaped.size[1]; + let node = self.blank_paragraph(&outline.document.nodes()[count - 1])?; + self.apply( + engine, + DocumentEdit { + columns: BTreeMap::new(), + container: None, + range: count..count, + replacement: vec![node], + }, + outline.selection, + false, + None, + )?; + if self.active_outline().shaped.size[1] <= height { + return Err(EditError::InvalidStructure.into()); + } + } + if self.active_outline().shaped.size[1] < point[1] { + return Err(EditError::TextTooLong.into()); + } + self.select_at(point[0], point[1], false)?; + Ok(true) + })(); + if result.is_err() { + self.active = previous; + self.preferred_x = preferred_x; + } + result + } + pub fn focus_outline(&mut self, id: onestore::ExGuid) -> Result<(), EditError> { if self.active_outline().id != id && !self.outlines.iter().any(|outline| outline.id == id) { return Err(EditError::InvalidRange); } self.finish_composition(); - if let Some(index) = self.outlines.iter().position(|outline| outline.id == id) { + if let Some(index) = self.outlines.iter().position(|outline| outline.id == id) + && !matches!(self.active, Focus::Draft { index: active, .. } if active == index) + { self.active = Focus::Outline(index); } self.preferred_x = None; @@ -453,7 +1105,9 @@ impl CanvasEditor { self.undo.push(History::Remove { outline: id, focus: match &self.active { - Focus::Outline(index) => RestoreFocus::Outline(self.outlines[*index].id), + Focus::Outline(index) | Focus::Draft { index, .. } => { + RestoreFocus::Outline(self.outlines[*index].id) + } Focus::Caret { outline, index } => RestoreFocus::Caret { source: Box::new(outline.snapshot()), selection: outline.selection, @@ -484,6 +1138,9 @@ impl CanvasEditor { .find(|outline| outline.id == id) .ok_or(EditError::InvalidRange)? }; + if outline.title { + return Err(EditError::UnsupportedContent); + } if position == outline.origin() { return Ok(()); } @@ -524,27 +1181,68 @@ impl CanvasEditor { { return Ok(()); } - let paragraphs = visible_paragraphs(outline.document.nodes()) - .map(|paragraph| { - ParagraphLayout::shape( - engine, - paragraph, - width, - &outline.indents, - &self.definitions, - ) - }) - .collect::>()?; - let shaped = OutlineLayout::new(paragraphs, width, true)?; - let outline = self.active_outline_mut(); - outline.layout.max_width = Some(width); - outline.layout.reserved_width = None; - outline.layout.width_set_by_user = Some(true); - outline.shaped = shaped; + let previous = outline.layout.clone(); + let resized = self.preview_resize(engine, width)?; + self.finish_composition(); + if let Focus::Draft { index, .. } = self.active { + self.active = Focus::Outline(index); + } + let id = resized.id; + *self.active_outline_mut() = resized; + if self.caret_outline().is_none() { + self.undo.push(History::Layout { + outline: id, + layout: previous, + }); + self.redo.clear(); + } self.preferred_x = None; Ok(()) } + pub fn preview_resize( + &self, + engine: &mut TextEngine, + width: f32, + ) -> Result { + let outline = match &self.active { + Focus::Draft { index, .. } => &self.outlines[*index], + _ => self.active_outline(), + }; + if outline.title { + return Err(EditError::UnsupportedContent.into()); + } + if !width.is_finite() || width <= 0.0 { + return Err(LayoutError::InvalidWidth.into()); + } + let width = width.max(outline.shaped.table_width()); + let shaped = OutlineLayout::flow( + outline.document.nodes().iter(), + &outline.indents, + width, + true, + 0, + None, + &mut |paragraph, width, indents| { + ParagraphLayout::shape(engine, paragraph, width, indents, &self.definitions) + }, + )?; + let mut layout = outline.layout.clone(); + layout.max_width = Some(width); + layout.reserved_width = None; + layout.width_set_by_user = Some(true); + Ok(TextOutline { + id: outline.id, + title: outline.title, + min_width: outline.min_width, + layout, + document: outline.document.clone(), + indents: outline.indents.clone(), + shaped, + selection: outline.selection, + }) + } + pub fn select(&mut self, selection: Selection) -> Result<(), EditError> { for position in selection.positions { self.active_outline().visible_index(position.paragraph)?; @@ -587,35 +1285,71 @@ impl CanvasEditor { } pub fn select_at(&mut self, x: f32, y: f32, extend: bool) -> Result<(), EditError> { + let mut selection = self.selection_at(x, y, SelectionUnit::Grapheme)?; + if extend { + selection.positions[0] = self.selection().positions[0]; + selection.affinities[0] = self.selection().affinities[0]; + } + self.select(selection) + } + + pub fn selection_at( + &self, + x: f32, + y: f32, + unit: SelectionUnit, + ) -> Result { if !x.is_finite() || !y.is_finite() { return Err(EditError::InvalidRange); } - let index = self - .active_outline() - .shaped - .paragraphs - .partition_point(|paragraph| paragraph.origin[1] <= y) - .saturating_sub(1); + let index = self.active_outline().paragraph_at(x, y)?; let paragraph = &self.active_outline().shaped.paragraphs[index]; - let cursor = paragraph - .text - .hit_test(x - paragraph.origin[0], y - paragraph.origin[1]); - let visible = paragraph.projection.text().utf16_offset(cursor.index())?; - let position = TextPosition { - paragraph: self.active_outline().source_index(index), - offset: paragraph - .projection - .source_offset(visible, cursor.affinity())?, + let layout = ¶graph.text.shaped; + let point = [x - paragraph.origin[0], y - paragraph.origin[1]]; + let cursor = paragraph.text.hit_test(point[0], point[1]); + let local = match unit { + SelectionUnit::Grapheme => cursor.into(), + SelectionUnit::Word => { + let rect = cursor.geometry(layout, 1.0); + ParagraphSelection::word_from_point( + layout, + point[0], + ((rect.y0 + rect.y1) * 0.5) as f32, + ) + } + SelectionUnit::Paragraph => ParagraphSelection::new( + Cursor::from_byte_index(layout, 0, Affinity::Downstream), + Cursor::from_byte_index(layout, usize::MAX, Affinity::Upstream), + ), }; - self.finish_composition(); - self.preferred_x = None; - if !extend { - self.active_outline_mut().selection.positions[0] = position; - self.active_outline_mut().selection.affinities[0] = cursor.affinity(); + let mut selection = self.selection(); + for (slot, cursor) in [local.anchor(), local.focus()].into_iter().enumerate() { + let visible = paragraph.projection.text().utf16_offset(cursor.index())?; + selection.positions[slot] = TextPosition { + paragraph: self.active_outline().source_index(index), + offset: paragraph + .projection + .source_offset(visible, cursor.affinity())?, + }; + selection.affinities[slot] = cursor.affinity(); } - self.active_outline_mut().selection.positions[1] = position; - self.active_outline_mut().selection.affinities[1] = cursor.affinity(); - Ok(()) + if unit == SelectionUnit::Paragraph + && index + 1 < self.active_outline().shaped.paragraphs.len() + { + let document = &self.active_outline().document; + let source = selection.positions[0].paragraph; + let next = self.active_outline().source_index(index + 1); + let (container, local, _) = leaves(document.nodes(), None).nth(source).unwrap(); + let (next_container, next_local, _) = leaves(document.nodes(), None).nth(next).unwrap(); + if container == next_container && next_local == local + 1 { + selection.positions[1] = TextPosition { + paragraph: next, + offset: 0, + }; + selection.affinities[1] = Affinity::Downstream; + } + } + Ok(selection) } pub fn caret(&self, width: f32) -> Result { @@ -631,9 +1365,118 @@ impl CanvasEditor { Ok(rect) } - pub fn move_selection(&mut self, movement: Movement, extend: bool) -> Result<(), EditError> { + pub fn move_selection( + &mut self, + engine: &mut TextEngine, + movement: Movement, + extend: bool, + ) -> Result<(), EditorError> { self.finish_composition(); let [anchor, focus] = self.active_outline().selection.positions; + if !extend + && self.caret_outline().is_some() + && self + .active_outline() + .document + .paragraphs() + .next() + .unwrap() + .text() + .is_empty() + && let Some(step) = match movement { + Movement::Left => Some([-18.0, 0.0]), + Movement::Right => Some([18.0, 0.0]), + Movement::Up => Some([0.0, -18.0]), + Movement::Down => Some([0.0, 18.0]), + _ => None, + } + { + let origin = self.active_outline().origin(); + let position = [origin[0] + step[0], origin[1] + step[1]]; + let center_y = position[1] + self.caret(1.0)?.height() as f32 * 0.5; + let target = self + .outlines + .iter() + .rev() + .find(|outline| { + let rect = outline.bounds(); + let bottom = rect.y1 + + if matches!(movement, Movement::Up) { + 36.0 + } else { + 0.0 + }; + (rect.x0..=rect.x1).contains(&f64::from(position[0])) + && (rect.y0..=bottom).contains(&f64::from(center_y)) + }) + .map(|outline| (outline.id, outline.origin())); + if let Some((id, origin)) = target { + let x = position[0] + + if step[0] == 0.0 { + self.preferred_x.unwrap_or(0.0) + } else { + 0.0 + } + - origin[0]; + self.focus_outline(id)?; + self.select_at(x, center_y - origin[1], false)?; + self.preferred_x = (step[0] == 0.0).then_some(x); + } else { + let outline = self.active_outline_mut(); + outline.layout.x = Some(position[0]); + outline.layout.y = Some(position[1]); + if step[0] != 0.0 { + self.preferred_x = None; + } + } + return Ok(()); + } + if matches!( + movement, + Movement::ParagraphStart + | Movement::ParagraphEnd + | Movement::DocumentStart + | Movement::DocumentEnd + ) { + let outline = self.active_outline(); + let current = outline.visible_index(focus.paragraph)?; + let end = matches!(movement, Movement::ParagraphEnd | Movement::DocumentEnd); + let current_end = outline.document.paragraphs().nth(focus.paragraph).unwrap(); + let current_end = current_end.utf16_offset(current_end.text().len())?; + let index = match movement { + Movement::DocumentStart => 0, + Movement::DocumentEnd => outline.shaped.paragraphs.len() - 1, + Movement::ParagraphStart if focus.offset == 0 => current.saturating_sub(1), + Movement::ParagraphEnd if focus.offset == current_end => { + (current + 1).min(outline.shaped.paragraphs.len() - 1) + } + _ => current, + }; + let source = outline.source_index(index); + let paragraph = outline.document.paragraphs().nth(source).unwrap(); + let position = TextPosition { + paragraph: source, + offset: if end { + paragraph.utf16_offset(paragraph.text().len())? + } else { + 0 + }, + }; + let affinity = if end { + Affinity::Upstream + } else { + Affinity::Downstream + }; + let selection = &mut self.active_outline_mut().selection; + if !extend { + selection.positions[0] = position; + selection.affinities[0] = affinity; + } + selection.positions[1] = position; + selection.affinities[1] = affinity; + self.preferred_x = None; + return Ok(()); + } let horizontal = matches!( movement, Movement::Left | Movement::Right | Movement::WordLeft | Movement::WordRight @@ -663,31 +1506,167 @@ impl CanvasEditor { .lines() .position(|(_, line)| f64::from(line.top) == caret.y0) .unwrap(); - let target = if matches!(movement, Movement::Up) { - if line > 0 { - paragraph.text.lines().nth(line - 1).map(|(_, line)| line) - } else if index > 0 { - index -= 1; - self.active_outline().shaped.paragraphs[index] - .text - .lines() - .last() - .map(|(_, line)| line) + let up = matches!(movement, Movement::Up); + let target = if up { + line.checked_sub(1) + .and_then(|line| paragraph.text.lines().nth(line)) + } else { + paragraph.text.lines().nth(line + 1) + } + .map(|(_, line)| line); + let target = if target.is_some() { + target + } else { + let neighbor = if self.active_outline().shaped.tables.is_empty() { + if up { + index.checked_sub(1) + } else { + (index + 1 < self.active_outline().shaped.paragraphs.len()) + .then_some(index + 1) + } } else { - None - } - } else if let Some((_, line)) = paragraph.text.lines().nth(line + 1) { - Some(line) - } else if index + 1 < self.active_outline().shaped.paragraphs.len() { - index += 1; - self.active_outline().shaped.paragraphs[index] - .text - .lines() - .next() + let column = self.active_outline().column(x, paragraph.id)?; + let current = column + .iter() + .position(|candidate| *candidate == index) + .ok_or(EditError::InvalidStructure)?; + if up { + current.checked_sub(1).map(|i| column[i]) + } else { + column.get(current + 1).copied() + } + }; + neighbor.and_then(|neighbor| { + index = neighbor; + let paragraph = &self.active_outline().shaped.paragraphs[index]; + if up { + paragraph.text.lines().last() + } else { + paragraph.text.lines().next() + } .map(|(_, line)| line) - } else { - None + }) }; + if target.is_none() && !extend && anchor == focus && !self.active_outline().title { + let outline = self.active_outline(); + crate::document::validate_flat( + outline + .document + .nodes() + .iter() + .filter(|node| node.text().is_some()), + )?; + let last = outline.document.nodes().len() - 1; + let up = matches!(movement, Movement::Up); + let base_node = &outline.document.nodes()[if up { 0 } else { last }]; + let node = self.blank_paragraph(base_node)?; + let trailing = outline + .document + .nodes() + .iter() + .rev() + .take_while(|node| { + node.text() + .is_some_and(|text| text.text.text().bytes().all(|byte| byte == b' ')) + }) + .count(); + let position = if up { + Some([ + outline.origin()[0] + x, + (((f64::from(outline.origin()[1]) / 18.0).ceil() - 3.0) * 18.0 + 14.4) + as f32, + ]) + } else if outline.origin()[0] >= 180.0 && trailing >= 2 { + let node = &outline.document.nodes()[last.saturating_sub(trailing)]; + let bottom = match &node.content { + crate::page::ParagraphContent::Text(_) => { + let paragraph = outline + .shaped + .paragraphs + .iter() + .find(|paragraph| paragraph.id == node.id) + .ok_or(EditError::InvalidStructure)?; + f64::from(paragraph.origin[1]) + f64::from(paragraph.text.height()) + } + crate::page::ParagraphContent::Table(table) => { + let cell = outline + .shaped + .tables + .iter() + .find(|layout| layout.id == table.id) + .and_then(|layout| layout.cells.last()) + .ok_or(EditError::InvalidStructure)?; + f64::from(cell.rect[3]) + 1.68 + } + crate::page::ParagraphContent::Unsupported(_) => { + return Err(EditError::UnsupportedContent.into()); + } + } + f64::from(outline.origin()[1]); + let cell = (bottom - 14.4) / 18.0; + // Stored coordinates can straddle a grid boundary by one f32 ULP. + let grid = if (cell - cell.round()).abs() + <= f64::from(f32::EPSILON) * cell.abs().max(1.0) + { + cell.round() + } else { + cell.ceil() + }; + Some([outline.origin()[0], ((grid + 3.0) * 18.0 + 14.4) as f32]) + } else { + None + }; + if let Some(position) = position { + let outline = TextOutline::new( + engine, + TextDocument::from_nodes(vec![node])?, + DEFAULT_OUTLINE_WIDTH, + position, + )?; + self.active = Focus::Caret { + outline: Box::new(outline), + index: self.outlines.len(), + }; + self.preferred_x = Some(if up { 0.0 } else { x }); + return Ok(()); + } + let edit = DocumentEdit { + columns: BTreeMap::new(), + container: None, + range: last + 1..last + 1, + replacement: vec![node], + }; + let paragraph = outline.document.paragraphs().count(); + let previous = if let Focus::Outline(index) = self.active { + let draft = self.outlines[index].clone(); + Some(std::mem::replace( + &mut self.active, + Focus::Draft { + index, + outline: Box::new(draft), + }, + )) + } else { + None + }; + if let Err(error) = self.apply( + engine, + edit, + [TextPosition { + paragraph, + offset: 0, + }; 2] + .into(), + false, + None, + ) { + if let Some(previous) = previous { + self.active = previous; + } + return Err(error); + } + self.preferred_x = Some(x); + return Ok(()); + } next = target .map(|line| { self.active_outline().shaped.paragraphs[index] @@ -713,11 +1692,16 @@ impl CanvasEditor { next = match movement { Movement::Left => local.previous_visual(layout, extend), Movement::Right => local.next_visual(layout, extend), - Movement::WordLeft => local.previous_visual_word(layout, extend), - Movement::WordRight => local.next_visual_word(layout, extend), + Movement::WordLeft => paragraph.text.word_cursor(local.focus(), true).into(), + Movement::WordRight => paragraph.text.word_cursor(local.focus(), false).into(), Movement::LineStart => local.line_start(layout, extend), Movement::LineEnd => local.line_end(layout, extend), - Movement::Up | Movement::Down => unreachable!(), + Movement::Up + | Movement::Down + | Movement::ParagraphStart + | Movement::ParagraphEnd + | Movement::DocumentStart + | Movement::DocumentEnd => unreachable!(), } .focus(); if horizontal && next == cursor && (extend || anchor == focus) { @@ -741,15 +1725,45 @@ impl CanvasEditor { line.top + line.height * 0.5, ); if matches!(movement, Movement::WordLeft | Movement::WordRight) { - next = if left { - next.previous_visual_word(¶graph.text.shaped) - } else { - next.next_visual_word(¶graph.text.shaped) - }; + next = paragraph.text.word_cursor(next, left); } } } } + if !extend + && anchor == focus + && next == cursor + && self.active_outline().source_index(index) == focus.paragraph + && matches!(movement, Movement::Left | Movement::Right) + { + let caret = self.caret(1.0)?; + let origin = self.active_outline().origin(); + let x = caret.x0 + f64::from(origin[0]); + let y = (caret.y0 + caret.y1) * 0.5 + f64::from(origin[1]); + let target = self + .outlines + .iter() + .filter(|outline| outline.id != self.active_outline().id) + .filter_map(|outline| { + let rect = outline.bounds(); + let distance = if left { x - rect.x1 } else { rect.x0 - x }; + ((rect.y0..rect.y1).contains(&y) && distance > 0.0).then_some(( + distance, + outline.id, + outline.origin(), + )) + }) + .min_by(|a, b| a.0.total_cmp(&b.0)); + if let Some((_, id, origin)) = target { + self.focus_outline(id)?; + self.select_at( + if left { f32::MAX } else { -1.0 }, + y as f32 - origin[1], + false, + )?; + return Ok(()); + } + } if !matches!(movement, Movement::Up | Movement::Down) { self.preferred_x = None; } @@ -799,10 +1813,11 @@ impl CanvasEditor { ) }; let mut rectangles = Vec::new(); - for (index, paragraph) in self + for (visible, (index, paragraph)) in self .active_outline() .layouts() - .filter(|(index, _)| *index >= start.paragraph && *index <= end.paragraph) + .enumerate() + .filter(|(_, (index, _))| *index >= start.paragraph && *index <= end.paragraph) { let source = self .active_outline() @@ -834,6 +1849,12 @@ impl CanvasEditor { rect.x1 += f64::from(paragraph.origin[0]); rect.y0 += f64::from(paragraph.origin[1]); rect.y1 += f64::from(paragraph.origin[1]); + if let Some(cell) = self.active_outline().shaped.paragraph_cell(visible) { + let Some(clipped) = cell.clip(rect) else { + continue; + }; + rect = clipped; + } rectangles.push(rect); } } @@ -897,6 +1918,60 @@ impl CanvasEditor { self.replace(engine, replacement) } + pub fn indent(&mut self, engine: &mut TextEngine, outdent: bool) -> Result { + let outline = self.active_outline(); + if outline.title { + return Ok(false); + } + let [anchor, focus] = outline.selection.positions; + let start = anchor.min(focus); + let end = anchor.max(focus); + let (container, local_start, _) = leaves(outline.document.nodes(), None) + .nth(start.paragraph) + .ok_or(EditError::InvalidRange)?; + let (end_container, local_end, _) = leaves(outline.document.nodes(), None) + .nth(end.paragraph) + .ok_or(EditError::InvalidRange)?; + if container != end_container { + return Err(EditError::UnsupportedContent.into()); + } + let range = local_start + ..local_end + usize::from(end.offset != 0 || start.paragraph == end.paragraph); + let nodes = outline.document.container(container)?; + crate::document::validate_flat(nodes.iter().enumerate().filter_map(|(index, node)| { + (range.contains(&index) || node.text().is_some()).then_some(node) + }))?; + let nodes = &nodes[range.clone()]; + if outdent && nodes.iter().all(|node| node.level == 1) { + return Ok(false); + } + let replacement = nodes + .iter() + .map(|node| { + let mut node = node.clone(); + node.level = if outdent { + node.level.saturating_sub(1).max(1) + } else { + node.level + .checked_add(1) + .ok_or(EditError::InvalidStructure)? + }; + Ok(node) + }) + .collect::, EditError>>()?; + self.commit( + engine, + DocumentEdit { + columns: BTreeMap::new(), + container, + range, + replacement, + }, + outline.selection, + )?; + Ok(true) + } + pub fn delete(&mut self, engine: &mut TextEngine, backward: bool) -> Result { let [anchor, focus] = self.active_outline().selection.positions; let mut range = anchor.min(focus)..anchor.max(focus); @@ -915,36 +1990,74 @@ impl CanvasEditor { range.end.offset = paragraph .projection .source_offset(visible.utf16_offset(bytes.end)?, Affinity::Upstream)?; - } else if backward && focus.paragraph > 0 { - let previous = self - .active_outline() - .document - .paragraphs() - .nth(focus.paragraph - 1) - .unwrap(); - range.start = TextPosition { - paragraph: focus.paragraph - 1, - offset: previous.utf16_offset(previous.text().len())?, + } else { + let document = &self.active_outline().document; + let Some(neighbor) = (if backward { + focus.paragraph.checked_sub(1) + } else { + focus.paragraph.checked_add(1) + }) else { + return Ok(false); }; - range.end.offset = 0; - } else if !backward - && focus.paragraph + 1 < self.active_outline().document.nodes().len() - { - let current = self - .active_outline() - .document - .paragraphs() + let (container, local, current) = leaves(document.nodes(), None) .nth(focus.paragraph) - .unwrap(); - range.start.offset = current.utf16_offset(current.text().len())?; - range.end = TextPosition { - paragraph: focus.paragraph + 1, + .ok_or(EditError::InvalidRange)?; + let Some((next_container, next_local, next)) = + leaves(document.nodes(), None).nth(neighbor) + else { + return Ok(false); + }; + if container != next_container || local.abs_diff(next_local) != 1 { + return Ok(false); + } + let (previous, start, end) = if backward { + (next, neighbor, focus.paragraph) + } else { + (current, focus.paragraph, neighbor) + }; + let text = &previous.text().unwrap().text; + range = TextPosition { + paragraph: start, + offset: text.utf16_offset(text.text().len())?, + }..TextPosition { + paragraph: end, offset: 0, }; - } else { - return Ok(false); } } + self.delete_range(engine, range) + } + + pub fn delete_to( + &mut self, + engine: &mut TextEngine, + movement: Movement, + ) -> Result { + self.finish_composition(); + let original = self.selection(); + let preferred_x = self.preferred_x; + if original.positions[0] == original.positions[1] { + let result = self.move_selection(engine, movement, true); + if let Err(error) = result { + self.active_outline_mut().selection = original; + self.preferred_x = preferred_x; + return Err(error); + } + } + let [anchor, focus] = self.selection().positions; + self.active_outline_mut().selection = original; + self.preferred_x = preferred_x; + self.delete_range(engine, anchor.min(focus)..anchor.max(focus)) + } + + fn delete_range( + &mut self, + engine: &mut TextEngine, + range: Range, + ) -> Result { + if range.is_empty() { + return Ok(false); + } let caret = range.start; let format = self .active_outline() @@ -1011,7 +2124,23 @@ impl CanvasEditor { history: History, ) -> Result { let valid = match &history { - History::Text { outline, .. } | History::Position { outline, .. } => { + History::Date(date) => self + .date + .as_ref() + .is_some_and(|current| current.source().id == date.source().id), + History::Draft { + outlines, index, .. + } => { + *index <= self.outlines.len() + && self + .outlines + .iter() + .position(|item| item.id == outlines[0].id) + .is_none_or(|found| found == *index) + } + History::Text { outline, .. } + | History::Position { outline, .. } + | History::Layout { outline, .. } => { self.outlines.iter().any(|item| item.id == *outline) } History::Remove { outline, focus } => { @@ -1031,6 +2160,67 @@ impl CanvasEditor { return Err((history, EditError::InvalidRange.into())); } let inverse = match history { + History::Date(date) => History::Date(Box::new(self.date.replace(*date).unwrap())), + History::Draft { + outlines, + index, + caret, + selection, + restore_caret, + } => { + let draft = if restore_caret { + match TextOutline::from_outline(engine, &caret, &self.definitions) { + Ok(mut draft) => { + draft.selection = selection; + Some(draft) + } + Err(error) => { + return Err(( + History::Draft { + outlines, + index, + caret, + selection, + restore_caret, + }, + error, + )); + } + } + } else { + None + }; + let outline = outlines[usize::from(!restore_caret)].clone(); + if self + .outlines + .get(index) + .is_some_and(|item| item.id == outline.id) + { + self.outlines.remove(index); + } + self.active = if outline.is_empty() { + Focus::Caret { + outline: Box::new(outline), + index, + } + } else { + self.outlines.insert(index, outline); + match draft { + Some(outline) => Focus::Draft { + index, + outline: Box::new(outline), + }, + None => Focus::Outline(index), + } + }; + History::Draft { + outlines, + index, + caret, + selection, + restore_caret: !restore_caret, + } + } History::Text { outline, change } => { let index = self .outlines @@ -1038,8 +2228,13 @@ impl CanvasEditor { .position(|item| item.id == outline) .unwrap(); let previous = std::mem::replace(&mut self.active, Focus::Outline(index)); - let inverse = match self.apply(engine, change.edit.clone(), change.selection, false) - { + let inverse = match self.apply( + engine, + change.edit.clone(), + change.selection, + false, + Some(&change.positions), + ) { Ok(change) => change, Err(error) => { self.active = previous; @@ -1048,7 +2243,7 @@ impl CanvasEditor { }; History::Text { outline, - change: inverse, + change: Box::new(inverse), } } History::Position { outline, position } => { @@ -1066,6 +2261,35 @@ impl CanvasEditor { self.active = Focus::Outline(index); inverse } + History::Layout { outline, layout } => { + let index = self + .outlines + .iter() + .position(|item| item.id == outline) + .unwrap(); + let mut source = self.outlines[index].snapshot(); + let previous = std::mem::replace(&mut source.layout, layout); + let mut resized = + match TextOutline::from_outline(engine, &source, &self.definitions) { + Ok(resized) => resized, + Err(error) => { + return Err(( + History::Layout { + outline, + layout: source.layout, + }, + error, + )); + } + }; + resized.selection = self.outlines[index].selection; + self.outlines[index] = resized; + self.active = Focus::Outline(index); + History::Layout { + outline, + layout: previous, + } + } History::Remove { outline, focus } => { let index = self .outlines @@ -1206,10 +2430,23 @@ impl CanvasEditor { affinities: [Affinity::Downstream; 2], }, false, + None, )?; let original = if let Some(composition) = self.composition.take() { let mut original = composition.original; original.edit.range = inverse.edit.range; + for (id, widths) in inverse.edit.columns { + original.edit.columns.entry(id).or_insert(widths); + } + for position in inverse.positions { + if !original + .positions + .iter() + .any(|original| original.id == position.id) + { + original.positions.push(position); + } + } original } else { inverse @@ -1232,7 +2469,13 @@ impl CanvasEditor { return Ok(false); }; let original = composition.original.clone(); - self.apply(engine, original.edit, original.selection, false)?; + self.apply( + engine, + original.edit, + original.selection, + false, + Some(&original.positions), + )?; self.composition = None; Ok(true) } @@ -1262,13 +2505,122 @@ impl CanvasEditor { edit: DocumentEdit, selection: Selection, ) -> Result<(), EditorError> { - let inverse = self.apply(engine, edit, selection, true)?; + let inverse = self.apply(engine, edit, selection, true, None)?; self.record_change(inverse); Ok(()) } fn record_change(&mut self, change: TextChange) { - if self.caret_outline().is_some() { + if let Focus::Draft { index, .. } = self.active { + let Focus::Draft { mut outline, .. } = + std::mem::replace(&mut self.active, Focus::Outline(index)) + else { + unreachable!() + }; + let mut caret = outline.snapshot(); + crate::document::container_mut(&mut caret.paragraphs, change.edit.container) + .expect("inverse edit container exists") + .splice(change.edit.range, change.edit.replacement); + let mut columns = change.edit.columns; + crate::document::swap_columns(&mut caret.paragraphs, &mut columns); + let last_selected = outline.selection.positions[0] + .paragraph + .max(outline.selection.positions[1].paragraph); + let mut count = outline.document.nodes().len(); + let mut selected_end = 0; + let selected_root = outline + .document + .nodes() + .iter() + .position(|node| { + selected_end += leaves(std::slice::from_ref(node), None).count(); + selected_end > last_selected + }) + .expect("selection addresses a text leaf"); + let original_ids = self.outlines[index] + .document + .nodes() + .iter() + .map(|node| node.id) + .collect::>(); + while count > selected_root + 1 { + let node = &outline.document.nodes()[count - 1]; + if node.text().is_none_or(|text| !text.text.text().is_empty()) + || original_ids.contains(&node.id) + { + break; + } + count -= 1; + } + if count < outline.document.nodes().len() { + let removed = outline.document.nodes().len() - count; + outline + .document + .apply(DocumentEdit { + columns: BTreeMap::new(), + container: None, + range: count..outline.document.nodes().len(), + replacement: Vec::new(), + }) + .expect("removing a provisional suffix preserves a valid document"); + outline + .shaped + .paragraphs + .truncate(outline.shaped.paragraphs.len() - removed); + if outline.shaped.tables.is_empty() { + let (origins, size) = arrange( + outline.shaped.paragraphs.iter(), + outline.wrap_width(), + outline.layout.width_set_by_user == Some(true), + ) + .expect("a prefix of the validated layout fits"); + for (paragraph, y) in outline.shaped.paragraphs.iter_mut().zip(origins) { + paragraph.origin[1] = y; + } + outline.shaped.size = size; + } else { + let cached = outline + .shaped + .paragraphs + .iter() + .map(|paragraph| (paragraph.id, paragraph)) + .collect::>(); + let shaped = OutlineLayout::flow( + outline.document.nodes().iter(), + &outline.indents, + outline.wrap_width(), + outline.layout.width_set_by_user == Some(true), + 0, + None, + &mut |node, width, indents| { + let mut paragraph = cached[&node.id].clone(); + paragraph.reset_origin(indentation(node.level, indents, width)?); + Ok(paragraph) + }, + ) + .expect("removing a provisional suffix preserves table layout"); + outline.shaped = shaped; + } + } + if outline.document == self.outlines[index].document { + self.outlines[index].selection = outline.selection; + return; + } + let versions = Box::new([self.outlines[index].clone(), (*outline).clone()]); + if outline.is_empty() { + self.outlines.remove(index); + self.active = Focus::Caret { outline, index }; + } else { + self.outlines[index] = *outline; + } + self.undo.push(History::Draft { + outlines: versions, + index, + caret: Box::new(caret), + selection: change.selection, + restore_caret: true, + }); + } else if self.caret_outline().is_some() { if self.active_outline().is_empty() { return; } @@ -1291,7 +2643,7 @@ impl CanvasEditor { }, }); self.outlines.insert(index, *outline); - } else if self.active_outline().is_empty() { + } else if self.active_outline().is_empty() && !self.active_outline().title { let Focus::Outline(index) = self.active else { unreachable!() }; @@ -1316,7 +2668,7 @@ impl CanvasEditor { } else { self.undo.push(History::Text { outline: self.active_outline().id, - change, + change: Box::new(change), }); } self.redo.clear(); @@ -1328,70 +2680,154 @@ impl CanvasEditor { edit: DocumentEdit, selection: Selection, finish_composition: bool, + positions: Option<&[Placement]>, ) -> Result { let outline = self.active_outline(); outline.document.validate_edit(&edit)?; - let width = outline - .layout - .reserved_width - .or(outline.layout.max_width) - .unwrap(); - let visible_start = outline - .layouts() - .take_while(|(i, _)| *i < edit.range.start) - .count(); - let visible_end = outline - .layouts() - .take_while(|(i, _)| *i < edit.range.end) - .count(); - if visible_end - visible_start != edit.range.len() { - return Err(EditError::UnsupportedContent.into()); - } - let shaped = edit - .replacement + let width = outline.wrap_width(); + let (visible, shaped, origins, size, tables) = if outline + .document + .nodes() .iter() - .map(|paragraph| { - ParagraphLayout::shape( - engine, - paragraph, - width, - &outline.indents, - &self.definitions, - ) - }) - .collect::, _>>()?; - let proposed_layouts = || { - outline.shaped.paragraphs[..visible_start] + .chain(&edit.replacement) + .any(|node| matches!(node.content, crate::page::ParagraphContent::Table(_))) + { + let sources = outline + .document + .text_nodes() + .map(|node| (node.id, node)) + .collect::>(); + let existing = outline + .shaped + .paragraphs .iter() - .chain(&shaped) - .chain(&outline.shaped.paragraphs[visible_end..]) + .map(|layout| (layout.id, (sources[&layout.id], layout))) + .collect::>(); + let layout = OutlineLayout::flow( + edited_nodes(outline.document.nodes(), None, Some(&edit)), + &outline.indents, + width, + outline.layout.width_set_by_user == Some(true), + 0, + Some(&edit), + &mut |node, width, indents| { + let indent = indentation(node.level, indents, width)?; + if let Some((source, cached)) = existing.get(&node.id) + && *source == node + && cached.text.shaped.layout_max_advance() == width - indent + && cached + .markers + .iter() + .all(|(marker, _)| marker.shaped.layout_max_advance() == width) + { + let mut result = (*cached).clone(); + result.reset_origin(indent); + return Ok(result); + } + ParagraphLayout::shape(engine, node, width, indents, &self.definitions) + }, + )?; + let origins = layout + .paragraphs + .iter() + .map(|p| p.origin[1]) + .collect::>(); + ( + 0..outline.shaped.paragraphs.len(), + layout.paragraphs, + origins, + layout.size, + layout.tables, + ) + } else { + let visible_start = outline + .layouts() + .take_while(|(i, _)| *i < edit.range.start) + .count(); + let visible_end = outline + .layouts() + .take_while(|(i, _)| *i < edit.range.end) + .count(); + if visible_end - visible_start != edit.range.len() { + return Err(EditError::UnsupportedContent.into()); + } + let shaped = edit + .replacement + .iter() + .map(|paragraph| { + ParagraphLayout::shape( + engine, + paragraph, + width, + &outline.indents, + &self.definitions, + ) + }) + .collect::, _>>()?; + let (origins, size) = arrange( + outline.shaped.paragraphs[..visible_start] + .iter() + .chain(&shaped) + .chain(&outline.shaped.paragraphs[visible_end..]), + width, + outline.layout.width_set_by_user == Some(true), + )?; + ( + visible_start..visible_end, + shaped, + origins, + size, + Vec::new(), + ) }; - let (origins, size) = arrange( - proposed_layouts(), - width, - outline.layout.width_set_by_user == Some(true), - )?; for position in selection.positions { - let node = outline.document.nodes()[..edit.range.start] - .iter() - .chain(&edit.replacement) - .chain(&outline.document.nodes()[edit.range.end..]) + let (_, _, node) = leaves(outline.document.nodes(), Some(&edit)) .nth(position.paragraph) .ok_or(EditError::InvalidRange)?; - node.text[0].text.byte_offset(position.offset)?; - if !proposed_layouts().any(|paragraph| paragraph.id == node.id) { + node.text().unwrap().text.byte_offset(position.offset)?; + if !outline.shaped.paragraphs[..visible.start] + .iter() + .chain(&shaped) + .chain(&outline.shaped.paragraphs[visible.end..]) + .any(|paragraph| paragraph.id == node.id) + { return Err(EditError::InvalidRange.into()); } } - if finish_composition { + let placements = match positions { + Some(positions) => positions.to_vec(), + None => self.title_flow(size[1])?, + }; + let previous_positions = placements + .iter() + .map(|placement| { + let layout = self + .object_layouts() + .find_map(|(id, layout)| (id == placement.id).then_some(layout)) + .ok_or(EditError::InvalidStructure)?; + if placement + .position + .iter() + .flatten() + .any(|value| !value.is_finite()) + { + return Err(EditorError::InvalidGeometry); + } + Ok(Placement { + id: placement.id, + position: [layout.x, layout.y], + }) + }) + .collect::, EditorError>>()?; + if finish_composition && self.composition.is_some() { + // Committing marked text can remove provisional paragraphs from the validated view. self.finish_composition(); + return self.apply(engine, edit, selection, false, positions); } let outline = self.active_outline_mut(); let inverse = outline.document.apply(edit)?; - outline - .shaped - .paragraphs - .splice(visible_start..visible_end, shaped); + outline.shaped.paragraphs.splice(visible, shaped); + outline.shaped.tables = tables; for (paragraph, y) in outline.shaped.paragraphs.iter_mut().zip(origins) { paragraph.origin[1] = y; } @@ -1399,9 +2835,16 @@ impl CanvasEditor { self.preferred_x = None; let previous_selection = std::mem::replace(&mut self.active_outline_mut().selection, selection); + for placement in placements { + let layout = self + .object_layout_mut(placement.id) + .expect("validated object position"); + [layout.x, layout.y] = placement.position; + } Ok(TextChange { edit: inverse, selection: previous_selection, + positions: previous_positions, }) } } @@ -1447,6 +2890,2376 @@ mod tests { use super::*; use onestore::document::Format; + fn table_editor(engine: &mut TextEngine) -> CanvasEditor { + use crate::{ + page::{ParagraphContent, Table, TableCell, TableColumn, TableRow}, + text::new_id, + }; + let mut editor = CanvasEditor::new( + engine, + TextDocument::new(vec![Paragraph::new("Left".into(), Format::default())]).unwrap(), + 180.0, + ) + .unwrap(); + let first = editor.active_outline().document.nodes()[0].clone(); + let wrapper = PageParagraph { + id: new_id().unwrap(), + parent: None, + level: 1, + style: None, + format: Format::default(), + lists: Vec::new(), + tags: Vec::new(), + collapsed: false, + content: ParagraphContent::Table(Table { + id: new_id().unwrap(), + columns: vec![ + TableColumn { + width: 72.0, + locked: true + }; + 2 + ], + rows: [["Left", "Right"], ["Below", "Last"]] + .into_iter() + .enumerate() + .map(|(row, texts)| TableRow { + id: new_id().unwrap(), + cells: texts + .into_iter() + .enumerate() + .map(|(column, text)| TableCell { + id: new_id().unwrap(), + layout: Default::default(), + indents: vec![18.0, 0.0, 27.0], + shading: None, + paragraphs: vec![if row == 0 && column == 0 { + first.clone() + } else { + crate::document::node( + Paragraph::new(text.into(), Format::default()), + Format::default(), + ) + .unwrap() + }], + unsupported: Vec::new(), + }) + .collect(), + }) + .collect(), + borders: Some(true), + layout: Default::default(), + tags: Vec::new(), + }), + }; + editor + .commit( + engine, + DocumentEdit { + columns: BTreeMap::new(), + container: None, + range: 0..1, + replacement: vec![wrapper], + }, + [TextPosition { + paragraph: 1, + offset: 0, + }; 2] + .into(), + ) + .unwrap(); + editor + } + + #[test] + fn structured_construction_and_resize_preserve_cells_and_enclose_locked_columns() { + let mut engine = TextEngine::default(); + let mut source = table_editor(&mut engine).active_outline().snapshot(); + let crate::page::ParagraphContent::Table(table) = &mut source.paragraphs[0].content else { + panic!() + }; + table.rows[0].cells[0].paragraphs[0] + .text_mut() + .unwrap() + .text = Paragraph::new(String::new(), Format::default()); + source.paragraphs.push( + crate::document::node( + Paragraph::new( + "Text below the table must use the same width during drag and after release." + .into(), + Format::default(), + ), + Format::default(), + ) + .unwrap(), + ); + let document = TextDocument::from_nodes(source.paragraphs.clone()).unwrap(); + let mut editor = CanvasEditor::new(&mut engine, document.clone(), 180.0).unwrap(); + let imported = TextOutline::from_outline(&mut engine, &source, &BTreeMap::new()).unwrap(); + let cells = |outline: &TextOutline| { + outline + .shaped + .tables + .iter() + .flat_map(|table| { + table + .cells + .iter() + .map(move |cell| (table.id, table.borders, cell.id, cell.rect)) + }) + .collect::>() + }; + assert_eq!(editor.active_outline().layout.width_set_by_user, Some(true)); + assert_eq!(editor.active_outline().shaped.size, imported.shaped.size); + assert_eq!(cells(editor.active_outline()), cells(&imported)); + for (actual, expected) in editor + .active_outline() + .shaped + .paragraphs + .iter() + .zip(&imported.shaped.paragraphs) + { + assert_eq!(actual.id, expected.id); + assert_eq!(actual.origin, expected.origin); + assert_eq!(actual.text.height(), expected.text.height()); + } + let minimum = 2.0 * (72.0 + 4.98) - 1.83; + let preview = editor.preview_resize(&mut engine, 36.0).unwrap(); + assert!((preview.bounds().width() as f32 - minimum).abs() < 0.0001); + assert_eq!(preview.document, document); + assert_eq!(cells(&preview), cells(&imported)); + assert!((preview.wrap_width() - minimum).abs() < 0.0001); + let final_width_preview = editor + .preview_resize(&mut engine, preview.bounds().width() as f32) + .unwrap(); + assert_eq!(preview.shaped.size, final_width_preview.shaped.size); + assert_eq!( + preview + .shaped + .paragraphs + .last() + .unwrap() + .text + .lines() + .count(), + final_width_preview + .shaped + .paragraphs + .last() + .unwrap() + .text + .lines() + .count() + ); + assert_eq!(editor.active_outline().bounds().width(), 180.0); + assert!(editor.undo.is_empty()); + editor.resize(&mut engine, 36.0).unwrap(); + assert_eq!(editor.active_outline().bounds(), preview.bounds()); + editor.undo(&mut engine).unwrap(); + assert_eq!(editor.active_outline().bounds().width(), 180.0); + editor.redo(&mut engine).unwrap(); + assert_eq!(editor.active_outline().bounds(), preview.bounds()); + assert_eq!(editor.active_outline().document, document); + } + + #[test] + fn nested_table_extents_remain_reachable_beyond_the_parent_columns() { + use crate::page::ParagraphContent; + let mut engine = TextEngine::default(); + let mut source = table_editor(&mut engine).active_outline().snapshot(); + let mut nested = table_editor(&mut engine).active_outline().document.nodes()[0].clone(); + let ParagraphContent::Table(table) = &mut nested.content else { + panic!() + }; + table.columns[0].width = 240.0; + table.columns[1].width = 240.0; + let ParagraphContent::Table(table) = &mut source.paragraphs[0].content else { + panic!() + }; + table.rows[0].cells[1].paragraphs = vec![nested]; + source.layout.width_set_by_user = Some(false); + let mut editor = + CanvasEditor::from_outlines(&mut engine, vec![source], BTreeMap::new()).unwrap(); + let paragraph = editor.active_outline().paragraph_layout(2).unwrap(); + let point = [ + paragraph.origin[0] + 1.0, + paragraph.origin[1] + paragraph.text.height() * 0.5, + ]; + assert!(point[0] > 180.0); + assert!(f64::from(point[0]) < editor.active_outline().bounds().x1); + assert_eq!( + editor + .selection_at(point[0], point[1], SelectionUnit::Grapheme) + .unwrap() + .positions[1] + .paragraph, + 2 + ); + let preview = editor.preview_resize(&mut engine, 36.0).unwrap(); + assert!(preview.bounds().x1 > f64::from(point[0])); + editor + .select( + [TextPosition { + paragraph: 2, + offset: 0, + }; 2] + .into(), + ) + .unwrap(); + editor.insert(&mut engine, "!").unwrap(); + assert!(editor.active_outline().bounds().x1 > f64::from(point[0])); + editor.undo(&mut engine).unwrap(); + assert!(editor.active_outline().bounds().x1 > f64::from(point[0])); + } + + #[test] + fn automatic_table_bounds_add_typing_room_only_to_root_text() { + let mut engine = TextEngine::default(); + let mut source = table_editor(&mut engine).active_outline().snapshot(); + source.layout.width_set_by_user = Some(false); + let outline = TextOutline::from_outline(&mut engine, &source, &BTreeMap::new()).unwrap(); + assert_eq!(outline.bounds().width(), f64::from(outline.shaped.size[0])); + source.paragraphs.push( + crate::document::node( + Paragraph::new( + "A root paragraph needs room to continue typing".into(), + Format::default(), + ), + Format::default(), + ) + .unwrap(), + ); + let outline = TextOutline::from_outline(&mut engine, &source, &BTreeMap::new()).unwrap(); + let paragraph = outline.shaped.paragraphs.last().unwrap(); + let expected = (paragraph.origin[0] + paragraph.text.shaped.width() + 36.0) + .clamp(72.0, 180.0) + .max(outline.shaped.size[0]); + assert_eq!(outline.bounds().width(), f64::from(expected)); + assert!(outline.bounds().width() > f64::from(outline.shaped.size[0])); + } + + #[test] + fn cell_mouse_targets_and_paragraph_selection_follow_columns() { + let mut engine = TextEngine::default(); + let mut editor = table_editor(&mut engine); + editor + .select( + [TextPosition { + paragraph: 0, + offset: 4, + }; 2] + .into(), + ) + .unwrap(); + editor.insert(&mut engine, "\nSecond\nThird").unwrap(); + for source in 0..6 { + let paragraph = editor.active_outline().paragraph_layout(source).unwrap(); + let (_, line) = paragraph.text.lines().next().unwrap(); + let x = paragraph.origin[0] + 1.0; + let y = paragraph.origin[1] + line.top + line.height * 0.5; + for unit in [ + SelectionUnit::Grapheme, + SelectionUnit::Word, + SelectionUnit::Paragraph, + ] { + let selection = editor.selection_at(x, y, unit).unwrap(); + assert_eq!(selection.positions[0].paragraph, source); + if unit == SelectionUnit::Paragraph { + let end = if source < 2 { + TextPosition { + paragraph: source + 1, + offset: 0, + } + } else { + let text = editor + .active_outline() + .document + .paragraphs() + .nth(source) + .unwrap(); + TextPosition { + paragraph: source, + offset: text.utf16_offset(text.text().len()).unwrap(), + } + }; + assert_eq!(selection.positions[1], end); + } + } + } + let right = editor.active_outline().shaped.tables[0].cells[1].rect; + for x in [right[0] + 0.5, right[2] - 0.5, f32::MAX] { + let selection = editor + .selection_at(x, right[3] - 1.0, SelectionUnit::Grapheme) + .unwrap(); + assert_eq!(selection.positions[1].paragraph, 3); + } + let left = editor.active_outline().shaped.tables[0].cells[0].rect; + let selection = editor + .selection_at(-f32::MAX, left[3] - 1.0, SelectionUnit::Grapheme) + .unwrap(); + assert_eq!(selection.positions[1].paragraph, 2); + let history = editor.undo.len(); + let before = editor.active_outline().document.clone(); + editor + .select_at(right[0] + 5.0, right[1] + 8.0, false) + .unwrap(); + editor + .select_at(left[0] + 5.0, left[3] - 2.0, true) + .unwrap(); + assert_eq!(editor.selection().positions[0].paragraph, 3); + assert_eq!(editor.selection().positions[1].paragraph, 2); + assert!(!editor.selection_rects().unwrap().is_empty()); + assert_eq!(editor.active_outline().document, before); + assert_eq!(editor.undo.len(), history); + } + + #[test] + fn nested_table_mouse_targets_resolve_the_source_cell() { + use crate::{page::ParagraphContent, text::new_id}; + let mut engine = TextEngine::default(); + let nested = table_editor(&mut engine).active_outline().document.nodes()[0].clone(); + let mut editor = table_editor(&mut engine); + let mut wrapper = editor.active_outline().document.nodes()[0].clone(); + let ParagraphContent::Table(table) = &mut wrapper.content else { + panic!() + }; + table.columns[0].width = 180.0; + let cell = &mut table.rows[0].cells[0]; + cell.paragraphs.push(nested); + let mut tail = cell.paragraphs[0].clone(); + tail.id = new_id().unwrap(); + tail.text_mut().unwrap().id = new_id().unwrap(); + cell.paragraphs.push(tail); + let before = crate::document::node( + Paragraph::new("Before".into(), Format::default()), + Format::default(), + ) + .unwrap(); + let after = crate::document::node( + Paragraph::new("After".into(), Format::default()), + Format::default(), + ) + .unwrap(); + editor + .commit( + &mut engine, + DocumentEdit { + columns: BTreeMap::new(), + container: None, + range: 0..1, + replacement: vec![before, wrapper, after], + }, + [TextPosition { + paragraph: 0, + offset: 0, + }; 2] + .into(), + ) + .unwrap(); + assert_eq!(editor.active_outline().document.paragraphs().count(), 11); + for source in 0..11 { + let paragraph = editor.active_outline().paragraph_layout(source).unwrap(); + let (_, line) = paragraph.text.lines().next().unwrap(); + let selection = editor + .selection_at( + paragraph.origin[0] + 1.0, + paragraph.origin[1] + line.height * 0.5, + SelectionUnit::Grapheme, + ) + .unwrap(); + assert_eq!(selection.positions[0].paragraph, source); + } + let paragraph = editor.active_outline().paragraph_layout(1).unwrap(); + let selection = editor + .selection_at( + paragraph.origin[0] + 1.0, + paragraph.origin[1] + 5.0, + SelectionUnit::Paragraph, + ) + .unwrap(); + assert_eq!( + selection.positions[1], + TextPosition { + paragraph: 1, + offset: 4 + } + ); + let original = editor.active_outline().document.clone(); + editor + .select( + [TextPosition { + paragraph: 10, + offset: 2, + }; 2] + .into(), + ) + .unwrap(); + assert!(editor.indent(&mut engine, false).unwrap()); + assert_eq!(editor.active_outline().document.nodes()[2].level, 2); + editor.undo(&mut engine).unwrap(); + assert_eq!(editor.active_outline().document, original); + for (position, backward) in [ + ( + TextPosition { + paragraph: 0, + offset: 6, + }, + false, + ), + ( + TextPosition { + paragraph: 10, + offset: 0, + }, + true, + ), + ] { + editor.select([position; 2].into()).unwrap(); + assert!(!editor.delete(&mut engine, backward).unwrap()); + assert_eq!(editor.active_outline().document, original); + } + } + + #[test] + fn cell_delete_boundaries_and_local_indentation_preserve_neighbors() { + let mut engine = TextEngine::default(); + let mut editor = table_editor(&mut engine); + let original = editor.active_outline().document.clone(); + let history = editor.undo.len(); + for paragraph in 0..4 { + let text = original.paragraphs().nth(paragraph).unwrap(); + for (backward, offset) in [ + (true, 0), + (false, text.utf16_offset(text.text().len()).unwrap()), + ] { + let selection = [TextPosition { paragraph, offset }; 2].into(); + editor.select(selection).unwrap(); + assert!(!editor.delete(&mut engine, backward).unwrap()); + assert_eq!(editor.active_outline().document, original); + assert_eq!(editor.selection(), selection); + assert_eq!(editor.undo.len(), history); + } + } + editor + .select( + [TextPosition { + paragraph: 2, + offset: 2, + }; 2] + .into(), + ) + .unwrap(); + editor.insert(&mut engine, "\n").unwrap(); + let split = editor.active_outline().document.clone(); + for backward in [true, false] { + let position = if backward { + TextPosition { + paragraph: 3, + offset: 0, + } + } else { + TextPosition { + paragraph: 2, + offset: 2, + } + }; + let selection = [position; 2].into(); + editor.select(selection).unwrap(); + assert!(editor.delete(&mut engine, backward).unwrap()); + assert_eq!(editor.active_outline().document, original); + editor.undo(&mut engine).unwrap(); + assert_eq!(editor.active_outline().document, split); + assert_eq!(editor.selection(), selection); + } + let selection = [ + TextPosition { + paragraph: 2, + offset: 1, + }, + TextPosition { + paragraph: 3, + offset: 0, + }, + ] + .into(); + editor.select(selection).unwrap(); + assert!(editor.indent(&mut engine, false).unwrap()); + assert_eq!( + editor + .active_outline() + .document + .text_nodes() + .map(|node| node.level) + .collect::>(), + [1, 1, 2, 1, 1] + ); + assert_eq!(editor.selection(), selection); + editor.undo(&mut engine).unwrap(); + assert_eq!(editor.active_outline().document, split); + editor.redo(&mut engine).unwrap(); + assert!(editor.indent(&mut engine, true).unwrap()); + assert_eq!(editor.active_outline().document, split); + } + + #[test] + fn overflowing_cell_caret_moves_to_the_next_rows_visual_column() { + use crate::page::ParagraphContent; + let mut engine = TextEngine::default(); + let mut source = table_editor(&mut engine).active_outline().snapshot(); + let ParagraphContent::Table(table) = &mut source.paragraphs[0].content else { + panic!() + }; + table.rows[0].cells[0].paragraphs[0] + .text_mut() + .unwrap() + .text = Paragraph::new( + "M".into(), + Format { + font_size: Some(130.0), + ..Default::default() + }, + ); + let mut editor = + CanvasEditor::from_outlines(&mut engine, vec![source], BTreeMap::new()).unwrap(); + let original = editor.active_outline().document.clone(); + editor + .select( + [ + TextPosition { + paragraph: 0, + offset: 0, + }, + TextPosition { + paragraph: 0, + offset: 1, + }, + ] + .into(), + ) + .unwrap(); + let [_, _, right, _] = editor + .active_outline() + .shaped + .paragraph_cell(0) + .unwrap() + .text_bounds(); + let selection = editor.selection_rects().unwrap(); + assert!(!selection.is_empty()); + assert!(selection.iter().all(|rect| rect.x1 <= f64::from(right))); + for extend in [false, true] { + editor + .select( + [TextPosition { + paragraph: 0, + offset: 1, + }; 2] + .into(), + ) + .unwrap(); + let caret = editor.caret(1.0).unwrap(); + assert!( + caret.x0 > f64::from(editor.active_outline().shaped.tables[0].cells[0].rect[2]) + ); + editor + .move_selection(&mut engine, Movement::Down, extend) + .unwrap(); + assert_eq!(editor.selection().positions[1].paragraph, 3); + editor + .move_selection(&mut engine, Movement::Up, extend) + .unwrap(); + assert_eq!( + editor.selection().positions[1], + TextPosition { + paragraph: 1, + offset: 5 + } + ); + assert_eq!(editor.active_outline().document, original); + assert!(editor.undo.is_empty()); + } + } + + #[test] + fn table_vertical_arrows_preserve_columns_and_cell_paragraphs() { + let mut engine = TextEngine::default(); + let mut editor = table_editor(&mut engine); + let original = editor.active_outline().document.clone(); + for column in 0..2 { + editor + .select( + [TextPosition { + paragraph: column, + offset: 2, + }; 2] + .into(), + ) + .unwrap(); + editor + .move_selection(&mut engine, Movement::Down, false) + .unwrap(); + assert_eq!(editor.selection().positions[1].paragraph, column + 2); + editor + .move_selection(&mut engine, Movement::Up, false) + .unwrap(); + assert_eq!( + editor.selection().positions[1], + TextPosition { + paragraph: column, + offset: 2 + } + ); + } + editor + .select( + [TextPosition { + paragraph: 0, + offset: 2, + }; 2] + .into(), + ) + .unwrap(); + editor.insert(&mut engine, "\n").unwrap(); + editor + .select( + [TextPosition { + paragraph: 0, + offset: 1, + }; 2] + .into(), + ) + .unwrap(); + editor + .move_selection(&mut engine, Movement::Down, false) + .unwrap(); + assert_eq!(editor.selection().positions[1].paragraph, 1); + editor + .move_selection(&mut engine, Movement::Down, false) + .unwrap(); + assert_eq!(editor.selection().positions[1].paragraph, 3); + editor + .select( + [TextPosition { + paragraph: 2, + offset: 2, + }; 2] + .into(), + ) + .unwrap(); + editor + .move_selection(&mut engine, Movement::Down, false) + .unwrap(); + assert_eq!(editor.selection().positions[1].paragraph, 4); + editor + .move_selection(&mut engine, Movement::Up, false) + .unwrap(); + assert_eq!( + editor.selection().positions[1], + TextPosition { + paragraph: 2, + offset: 2 + } + ); + editor.undo(&mut engine).unwrap(); + assert_eq!(editor.active_outline().document, original); + } + + #[test] + fn table_edges_create_provisional_space_and_restore_it_with_history() { + let mut engine = TextEngine::default(); + let mut editor = table_editor(&mut engine); + editor.active_outline_mut().layout.x = Some(120.0); + editor.active_outline_mut().layout.y = Some(120.0); + let source = editor.active_outline().document.clone(); + editor + .select( + [TextPosition { + paragraph: 3, + offset: 4, + }; 2] + .into(), + ) + .unwrap(); + let history = editor.undo.len(); + editor + .move_selection(&mut engine, Movement::Down, false) + .unwrap(); + assert_eq!(editor.outlines()[0].document, source); + assert_eq!(editor.undo.len(), history); + assert_eq!( + editor.selection().positions, + [TextPosition { + paragraph: 4, + offset: 0 + }; 2] + ); + let draft = editor.active_outline().document.clone(); + let caret = editor.selection(); + editor.insert(&mut engine, "X").unwrap(); + let committed = editor.active_outline().document.clone(); + assert_eq!(committed.nodes().len(), 2); + assert_eq!(committed.paragraphs().last().unwrap().text(), "X"); + for _ in 0..2 { + editor.undo(&mut engine).unwrap(); + assert_eq!(editor.outlines()[0].document, source); + assert_eq!(editor.active_outline().document, draft); + assert_eq!(editor.selection(), caret); + editor.redo(&mut engine).unwrap(); + assert_eq!(editor.active_outline().document, committed); + } + editor.undo(&mut engine).unwrap(); + editor + .select( + [TextPosition { + paragraph: 0, + offset: 0, + }; 2] + .into(), + ) + .unwrap(); + editor + .move_selection(&mut engine, Movement::Up, false) + .unwrap(); + assert_eq!(editor.caret_outline().unwrap().origin(), [120.0, 86.4]); + assert_eq!(editor.outlines()[0].document, source); + editor.insert(&mut engine, "X").unwrap(); + assert_eq!(editor.outlines().len(), 2); + editor.undo(&mut engine).unwrap(); + assert!(editor.caret_outline().is_some()); + assert_eq!(editor.outlines().len(), 1); + assert_eq!(editor.outlines()[0].document, source); + } + + #[test] + fn editing_a_cell_trims_only_provisional_root_paragraphs() { + let mut engine = TextEngine::default(); + for compose in [false, true] { + let mut editor = table_editor(&mut engine); + let source = editor.active_outline().document.clone(); + editor + .select( + [TextPosition { + paragraph: 3, + offset: 4, + }; 2] + .into(), + ) + .unwrap(); + for _ in 0..2 { + editor + .move_selection(&mut engine, Movement::Down, false) + .unwrap(); + } + for _ in 0..2 { + editor + .move_selection(&mut engine, Movement::Up, false) + .unwrap(); + } + assert_eq!(editor.selection().positions[1].paragraph, 3); + let draft = editor.active_outline().document.clone(); + assert_eq!(draft.nodes().len(), 3); + let selection = editor.selection(); + if compose { + editor + .compose(&mut engine, "temporary".into(), 9..9) + .unwrap(); + editor.cancel_composition(&mut engine).unwrap(); + assert_eq!(editor.active_outline().document, draft); + editor.compose(&mut engine, "marked".into(), 6..6).unwrap(); + editor.commit_text(&mut engine, "X".into()).unwrap(); + } else { + editor.insert(&mut engine, "X").unwrap(); + } + let after = editor.active_outline().document.clone(); + assert_eq!(after.nodes().len(), 1); + assert_eq!(after.paragraphs().nth(3).unwrap().text(), "LastX"); + let fresh = editor + .active_outline() + .snapshot() + .layout(&mut engine, &BTreeMap::new()) + .unwrap(); + assert_eq!(editor.active_outline().shaped.size, fresh.size); + assert_eq!(editor.active_outline().shaped.paragraphs.len(), 4); + editor.undo(&mut engine).unwrap(); + assert_eq!(editor.outlines()[0].document, source); + assert_eq!(editor.active_outline().document, draft); + assert_eq!(editor.selection(), selection); + editor.redo(&mut engine).unwrap(); + assert_eq!(editor.active_outline().document, after); + } + } + + #[test] + fn editing_during_cell_composition_keeps_table_flow_and_history() { + let mut engine = TextEngine::default(); + let mut editor = table_editor(&mut engine); + let source = editor.active_outline().document.clone(); + editor + .select( + [TextPosition { + paragraph: 3, + offset: 4, + }; 2] + .into(), + ) + .unwrap(); + for _ in 0..2 { + editor + .move_selection(&mut engine, Movement::Down, false) + .unwrap(); + } + for _ in 0..2 { + editor + .move_selection(&mut engine, Movement::Up, false) + .unwrap(); + } + let draft = editor.active_outline().document.clone(); + editor.compose(&mut engine, "a".into(), 1..1).unwrap(); + editor.insert(&mut engine, "b").unwrap(); + assert_eq!( + editor + .active_outline() + .document + .paragraphs() + .last() + .unwrap() + .text(), + "Lastab" + ); + let fresh = editor + .active_outline() + .snapshot() + .layout(&mut engine, &BTreeMap::new()) + .unwrap(); + assert_eq!(editor.active_outline().shaped.size, fresh.size); + assert_eq!( + editor.active_outline().shaped.paragraphs.len(), + fresh.paragraphs.len() + ); + editor.undo(&mut engine).unwrap(); + assert_eq!( + editor + .active_outline() + .document + .paragraphs() + .last() + .unwrap() + .text(), + "Lasta" + ); + editor.undo(&mut engine).unwrap(); + assert_eq!(editor.outlines()[0].document, source); + assert_eq!(editor.active_outline().document, draft); + } + + #[test] + fn table_horizontal_arrows_follow_reading_order() { + let mut engine = TextEngine::default(); + let mut editor = table_editor(&mut engine); + let original = editor.active_outline().document.clone(); + let history = editor.undo.len(); + for paragraph in 0..4 { + let text = original.paragraphs().nth(paragraph).unwrap(); + let end = text.utf16_offset(text.text().len()).unwrap(); + editor + .select( + [TextPosition { + paragraph, + offset: end, + }; 2] + .into(), + ) + .unwrap(); + editor + .move_selection(&mut engine, Movement::Right, false) + .unwrap(); + let next = if paragraph < 3 { + TextPosition { + paragraph: paragraph + 1, + offset: 0, + } + } else { + TextPosition { + paragraph, + offset: end, + } + }; + assert_eq!(editor.selection().positions, [next; 2]); + if paragraph < 3 { + editor + .move_selection(&mut engine, Movement::Left, false) + .unwrap(); + assert_eq!( + editor.selection().positions, + [TextPosition { + paragraph, + offset: end + }; 2] + ); + } + } + assert_eq!(editor.active_outline().document, original); + assert_eq!(editor.undo.len(), history); + assert!(editor.caret_outline().is_none()); + } + + #[test] + fn column_width_edits_reflow_and_restore_structured_drafts() { + use crate::page::ParagraphContent; + let mut engine = TextEngine::default(); + for draft in [false, true] { + let mut editor = table_editor(&mut engine); + let stored = editor.outlines()[0].document.clone(); + editor + .select( + [TextPosition { + paragraph: 3, + offset: 4, + }; 2] + .into(), + ) + .unwrap(); + if draft { + for _ in 0..2 { + editor + .move_selection(&mut engine, Movement::Down, false) + .unwrap(); + } + for _ in 0..2 { + editor + .move_selection(&mut engine, Movement::Up, false) + .unwrap(); + } + } + let before = editor.active_outline().document.clone(); + let selection = editor.selection(); + let ParagraphContent::Table(table) = &before.nodes()[0].content else { + panic!() + }; + let position = selection.positions[1]; + let mut edit = before + .replace( + position..position, + vec![Paragraph::new( + " long cell content".into(), + Format::default(), + )], + ) + .unwrap(); + edit.columns.insert(table.id, vec![96.375, 36.0]); + editor.commit(&mut engine, edit, selection).unwrap(); + let after = editor.active_outline().document.clone(); + let fresh = editor + .active_outline() + .snapshot() + .layout(&mut engine, &BTreeMap::new()) + .unwrap(); + assert_eq!(editor.active_outline().shaped.size, fresh.size); + assert_eq!( + editor.active_outline().shaped.tables[0] + .cells + .iter() + .map(|c| c.rect) + .collect::>(), + fresh.tables[0] + .cells + .iter() + .map(|c| c.rect) + .collect::>() + ); + for _ in 0..2 { + editor.undo(&mut engine).unwrap(); + assert_eq!(editor.active_outline().document, before); + assert_eq!(editor.outlines()[0].document, stored); + assert_eq!(editor.selection(), selection); + editor.redo(&mut engine).unwrap(); + assert_eq!(editor.active_outline().document, after); + } + let selection = editor.selection(); + let history = editor.undo.len(); + for widths in [vec![f32::MAX; 2], vec![36.0, f32::NAN]] { + let position = selection.positions[1]; + let mut edit = after + .replace( + position..position, + vec![Paragraph::new("invalid".into(), Format::default())], + ) + .unwrap(); + edit.columns.insert(table.id, widths); + assert!(editor.commit(&mut engine, edit, selection).is_err()); + assert_eq!(editor.active_outline().document, after); + assert_eq!(editor.selection(), selection); + assert_eq!(editor.undo.len(), history); + } + } + } + + #[test] + fn cell_edits_reuse_neighbor_layouts_and_restore_table_geometry() { + let mut engine = TextEngine::default(); + let mut editor = table_editor(&mut engine); + let before = editor.active_outline().document.clone(); + let layout_ids = editor + .active_outline() + .shaped + .paragraphs + .iter() + .map(|p| p.text.id()) + .collect::>(); + let size = editor.active_outline().shaped.size; + let cell_rects = editor.active_outline().shaped.tables[0] + .cells + .iter() + .map(|cell| cell.rect) + .collect::>(); + let right_text = editor + .active_outline() + .document + .paragraphs() + .nth(1) + .unwrap() + .text() + .as_ptr(); + let selection = [TextPosition { + paragraph: 0, + offset: 2, + }; 2] + .into(); + editor.select(selection).unwrap(); + editor + .insert(&mut engine, " long text that wraps\nsecond paragraph") + .unwrap(); + let outline = editor.active_outline(); + assert_eq!(outline.document.paragraphs().count(), 5); + assert!(outline.shaped.size[1] > size[1]); + assert_eq!( + outline + .document + .paragraphs() + .nth(2) + .unwrap() + .text() + .as_ptr(), + right_text + ); + for (index, original) in layout_ids.iter().enumerate().skip(1) { + assert_eq!( + outline.paragraph_layout(index + 1).unwrap().text.id(), + *original + ); + } + assert_eq!( + outline + .layouts() + .map(|(index, _)| index) + .collect::>(), + [0, 1, 2, 3, 4] + ); + assert_eq!( + outline.shaped.tables[0].cells[0].rect[3], + outline.shaped.tables[0].cells[1].rect[3] + ); + assert!(outline.shaped.tables[0].cells[2].rect[1] > cell_rects[2][1]); + let after = outline.document.clone(); + let after_selection = outline.selection; + let after_rects = outline.shaped.tables[0] + .cells + .iter() + .map(|cell| cell.rect) + .collect::>(); + for _ in 0..3 { + editor.undo(&mut engine).unwrap(); + let outline = editor.active_outline(); + assert_eq!(outline.document, before); + assert_eq!(outline.selection, selection); + assert_eq!(outline.shaped.size, size); + assert_eq!( + outline.shaped.tables[0] + .cells + .iter() + .map(|cell| cell.rect) + .collect::>(), + cell_rects + ); + editor.redo(&mut engine).unwrap(); + let outline = editor.active_outline(); + assert_eq!(outline.document, after); + assert_eq!(outline.selection, after_selection); + assert_eq!( + outline.shaped.tables[0] + .cells + .iter() + .map(|cell| cell.rect) + .collect::>(), + after_rects + ); + } + editor.undo(&mut engine).unwrap(); + editor.undo(&mut engine).unwrap(); + assert_eq!( + editor + .active_outline() + .document + .paragraphs() + .next() + .unwrap() + .text(), + "Left" + ); + assert!(editor.active_outline().shaped.tables.is_empty()); + editor.redo(&mut engine).unwrap(); + assert_eq!(editor.active_outline().document, before); + assert_eq!(editor.active_outline().shaped.size, size); + } + + #[test] + fn cell_composition_and_failed_layout_leave_atomic_history() { + let mut engine = TextEngine::default(); + let mut editor = table_editor(&mut engine); + editor + .select( + [TextPosition { + paragraph: 2, + offset: 2, + }; 2] + .into(), + ) + .unwrap(); + let before = editor.active_outline().document.clone(); + let selection = editor.selection(); + let cells = editor.active_outline().shaped.tables[0] + .cells + .iter() + .map(|cell| cell.rect) + .collect::>(); + let ids = editor + .active_outline() + .shaped + .paragraphs + .iter() + .map(|p| p.text.id()) + .collect::>(); + let history = editor.undo.len(); + editor.compose(&mut engine, "🧊\n中".into(), 4..4).unwrap(); + editor.compose(&mut engine, "short".into(), 5..5).unwrap(); + assert!(editor.cancel_composition(&mut engine).unwrap()); + assert_eq!(editor.active_outline().document, before); + assert_eq!(editor.selection(), selection); + assert_eq!(editor.undo.len(), history); + assert_eq!( + editor.active_outline().shaped.tables[0] + .cells + .iter() + .map(|cell| cell.rect) + .collect::>(), + cells + ); + for index in [0, 1, 3] { + assert_eq!( + editor + .active_outline() + .paragraph_layout(index) + .unwrap() + .text + .id(), + ids[index] + ); + } + editor.compose(&mut engine, "🧊\n中".into(), 4..4).unwrap(); + editor.commit_text(&mut engine, "done".into()).unwrap(); + assert_eq!(editor.undo.len(), history + 1); + editor.undo(&mut engine).unwrap(); + assert_eq!(editor.active_outline().document, before); + assert_eq!(editor.selection(), selection); + let redo = editor.redo.len(); + assert!( + editor + .replace( + &mut engine, + vec![Paragraph::new( + "bad".into(), + Format { + font_size: Some(f32::NAN), + ..Format::default() + } + )] + ) + .is_err() + ); + assert_eq!(editor.active_outline().document, before); + assert_eq!(editor.selection(), selection); + assert_eq!(editor.undo.len(), history); + assert_eq!(editor.redo.len(), redo); + editor.redo(&mut engine).unwrap(); + assert_eq!( + editor + .active_outline() + .document + .paragraphs() + .nth(2) + .unwrap() + .text(), + "Bedonelow" + ); + } + + #[test] + fn retained_cell_edits_match_fresh_layout_for_every_text_range() { + let mut engine = TextEngine::default(); + let base = table_editor(&mut engine); + for (index, paragraph) in base.active_outline().document.paragraphs().enumerate() { + let offsets = paragraph + .text() + .char_indices() + .map(|(byte, _)| paragraph.utf16_offset(byte).unwrap()) + .chain(std::iter::once( + paragraph.utf16_offset(paragraph.text().len()).unwrap(), + )) + .collect::>(); + for &start in &offsets { + for &end in offsets.iter().filter(|&&end| end >= start) { + for text in ["", "🧊", "x\ny"] { + let mut editor = CanvasEditor::from_text_outlines( + vec![base.active_outline().clone()], + BTreeMap::new(), + None, + ) + .unwrap(); + let selection = [ + TextPosition { + paragraph: index, + offset: start, + }, + TextPosition { + paragraph: index, + offset: end, + }, + ] + .into(); + editor.select(selection).unwrap(); + editor.insert(&mut engine, text).unwrap(); + let retained = &editor.active_outline().shaped; + let fresh = editor + .active_outline() + .snapshot() + .layout(&mut engine, &editor.definitions) + .unwrap(); + assert_eq!(retained.size, fresh.size); + assert_eq!( + retained.tables[0] + .cells + .iter() + .map(|c| c.rect) + .collect::>(), + fresh.tables[0] + .cells + .iter() + .map(|c| c.rect) + .collect::>() + ); + for (retained, fresh) in retained.paragraphs.iter().zip(&fresh.paragraphs) { + assert_eq!(retained.id, fresh.id); + assert_eq!(retained.origin, fresh.origin); + assert_eq!(retained.text.height(), fresh.text.height()); + assert_eq!( + retained + .text + .lines() + .map(|(line, bounds)| ( + line.metrics().advance, + bounds.source.clone() + )) + .collect::>(), + fresh + .text + .lines() + .map(|(line, bounds)| ( + line.metrics().advance, + bounds.source.clone() + )) + .collect::>() + ); + } + editor.undo(&mut engine).unwrap(); + assert_eq!( + editor.active_outline().document, + base.active_outline().document + ); + assert_eq!(editor.selection(), selection); + } + } + } + } + } + + #[test] + fn cross_outline_validation_includes_table_row_and_cell_ids() { + use crate::page::ParagraphContent; + let mut engine = TextEngine::default(); + let first = table_editor(&mut engine).active_outline().clone(); + let second = table_editor(&mut engine).active_outline().clone(); + let ParagraphContent::Table(first_table) = &first.document.nodes()[0].content else { + panic!() + }; + for alias in 0..3 { + let mut second = second.clone(); + let mut nodes = second.document.nodes().to_vec(); + let ParagraphContent::Table(table) = &mut nodes[0].content else { + panic!() + }; + match alias { + 0 => table.id = first_table.id, + 1 => table.rows[0].id = first_table.rows[0].id, + _ => table.rows[0].cells[0].id = first_table.rows[0].cells[0].id, + } + second.document = TextDocument::from_nodes(nodes).unwrap(); + assert!(matches!( + CanvasEditor::from_text_outlines( + vec![first.clone(), second], + BTreeMap::new(), + None + ), + Err(EditorError::Edit(EditError::InvalidStructure)) + )); + } + } + + #[test] + fn title_flow_moves_page_objects_atomically_and_cancels_composition() { + use crate::page::{Image, Page, PageObject, Title}; + let mut engine = TextEngine::default(); + let mut title = TextOutline::new( + &mut engine, + TextDocument::new(vec![Paragraph::new("Header".into(), Default::default())]).unwrap(), + 468.0, + [0.0; 2], + ) + .unwrap() + .snapshot(); + title.title = true; + title.layout.max_width = None; + title.layout.width_set_by_user = None; + let body = TextOutline::new( + &mut engine, + TextDocument::new(vec![Paragraph::new("Anchor".into(), Default::default())]).unwrap(), + 72.0, + [120.0, 80.0], + ) + .unwrap() + .snapshot(); + let body_id = body.id; + let images = [ + (120.0, 90.0, false), + (600.0, 150.0, false), + (120.0, 0.0, false), + (120.0, 80.0, true), + ] + .map(|(x, y, background)| Image { + id: crate::text::new_id().unwrap(), + layout: onestore::document::Layout { + x: Some(x), + y: Some(y), + max_width: Some(72.0), + max_height: Some(40.0), + ..Default::default() + }, + bytes: Some(std::sync::Arc::from(b"deferred image payload".as_slice())), + alt: None, + background, + }); + let image_ids = images.each_ref().map(|image| image.id); + let image_bytes = images + .each_ref() + .map(|image| image.bytes.as_ref().unwrap().clone()); + let mut objects = vec![ + PageObject::Title(Title { + id: crate::text::new_id().unwrap(), + date: None, + layout: Default::default(), + outlines: vec![title], + }), + PageObject::Outline(body), + ]; + objects.extend(images.into_iter().map(PageObject::Image)); + let mut editor = CanvasEditor::from_page( + Page { + title: "Header".into(), + created: None, + margin_origin: [36.0, 14.4], + definitions: BTreeMap::new(), + objects, + }, + &mut engine, + ) + .unwrap(); + let positions = |editor: &CanvasEditor| { + editor + .object_layouts() + .map(|(id, layout)| (id, layout.clone())) + .collect::>() + }; + let original = positions(&editor); + let text = "Header\u{000b}Second\u{000b}Third\u{000b}Fourth\u{000b}Fifth\u{000b}Sixth"; + let end = text.encode_utf16().count().try_into().unwrap(); + editor.select_all().unwrap(); + editor.compose(&mut engine, text.into(), end..end).unwrap(); + let moved = positions(&editor); + let y = |items: &Vec<(ExGuid, onestore::document::Layout)>, id| { + items + .iter() + .find(|(candidate, _)| *candidate == id) + .unwrap() + .1 + .y + .unwrap() + }; + let delta = y(&moved, body_id) - y(&original, body_id); + assert!(delta > 0.0); + for id in &image_ids[..2] { + assert!((y(&moved, *id) - y(&original, *id) - delta).abs() < 0.001); + } + for id in &image_ids[2..] { + assert_eq!(y(&moved, *id), y(&original, *id)); + } + editor.compose(&mut engine, "Header".into(), 6..6).unwrap(); + assert_eq!(positions(&editor), moved); + editor.cancel_composition(&mut engine).unwrap(); + assert_eq!(positions(&editor), original); + assert!(!editor.undo(&mut engine).unwrap()); + editor.select_all().unwrap(); + editor.compose(&mut engine, text.into(), end..end).unwrap(); + let taller = format!("{text}\u{000b}Seventh\u{000b}Eighth"); + let taller_end = taller.encode_utf16().count().try_into().unwrap(); + editor + .compose(&mut engine, taller, taller_end..taller_end) + .unwrap(); + let farther = positions(&editor); + assert!(y(&farther, body_id) > y(&moved, body_id)); + editor.commit_text(&mut engine, "Header".into()).unwrap(); + assert_eq!(positions(&editor), farther); + editor.undo(&mut engine).unwrap(); + assert_eq!(positions(&editor), original); + assert!(!editor.undo(&mut engine).unwrap()); + editor.redo(&mut engine).unwrap(); + assert_eq!(positions(&editor), farther); + editor.undo(&mut engine).unwrap(); + editor.select_all().unwrap(); + editor.insert(&mut engine, text).unwrap(); + assert_eq!(positions(&editor), moved); + editor.select_all().unwrap(); + editor.insert(&mut engine, "Header").unwrap(); + assert_eq!(positions(&editor), moved); + editor.undo(&mut engine).unwrap(); + assert_eq!(positions(&editor), moved); + editor.undo(&mut engine).unwrap(); + assert_eq!(positions(&editor), original); + editor.redo(&mut engine).unwrap(); + editor.redo(&mut engine).unwrap(); + assert_eq!(positions(&editor), moved); + for (id, bytes) in image_ids.into_iter().zip(image_bytes) { + let image = editor + .objects + .iter() + .find_map(|object| match object { + page::Content::Image(image) if image.id == id => Some(image), + _ => None, + }) + .unwrap(); + assert!(std::sync::Arc::ptr_eq( + image.bytes.as_ref().unwrap(), + &bytes + )); + } + } + + #[test] + fn floating_outline_exit_uses_document_position_and_keeps_real_blank_lines() { + let mut engine = TextEngine::default(); + for (x, real_blanks, size, exit_y) in [ + (179.9, 0, 11.0, None), + (180.0, 0, 11.0, Some(230.4)), + (270.0, 1, 11.0, Some(230.4)), + (270.0, 0, 22.0, Some(248.4)), + (270.0, 0, 44.0, Some(320.4)), + ] { + let mut paragraphs = vec![Paragraph::new( + "Text".into(), + Format { + font_size: Some(size), + ..Default::default() + }, + )]; + paragraphs + .extend((0..real_blanks).map(|_| Paragraph::new(String::new(), Format::default()))); + let source = TextDocument::new(paragraphs).unwrap(); + let outline = TextOutline::new(&mut engine, source.clone(), 72.0, [x, 158.4]).unwrap(); + let mut editor = + CanvasEditor::from_text_outlines(vec![outline], BTreeMap::new(), None).unwrap(); + editor + .move_selection(&mut engine, Movement::DocumentEnd, false) + .unwrap(); + for _ in 0..3 - real_blanks { + editor + .move_selection(&mut engine, Movement::Down, false) + .unwrap(); + } + assert_eq!(editor.outlines[0].document, source); + assert_eq!(editor.caret_outline().is_some(), exit_y.is_some()); + if let Some(y) = exit_y { + assert_eq!(editor.active_outline().origin(), [x, y]); + assert_eq!( + editor.active_outline().layout.max_width, + Some(DEFAULT_OUTLINE_WIDTH) + ); + editor.insert(&mut engine, "New").unwrap(); + assert_eq!(editor.outlines.len(), 2); + editor.undo(&mut engine).unwrap(); + assert_eq!(editor.outlines.len(), 1); + assert_eq!(editor.active_outline().origin(), [x, y]); + } else { + assert_eq!(editor.active_outline().document.nodes().len(), 4); + } + assert!(editor.undo.is_empty()); + } + } + + #[test] + fn vertical_exit_and_reentry_preserve_the_text_column_and_source() { + let mut engine = TextEngine::default(); + for (x, y, above) in [ + (36.0, 158.4, 122.4), + (180.0, 158.4, 122.4), + (180.0, 178.0, 140.4), + (180.0, 180.0, 140.4), + (180.0, 184.0, 158.4), + ] { + let source = + TextDocument::new(vec![Paragraph::new("Text".into(), Format::default())]).unwrap(); + let outline = TextOutline::new(&mut engine, source.clone(), 72.0, [x, y]).unwrap(); + let id = outline.id; + let mut editor = + CanvasEditor::from_text_outlines(vec![outline], BTreeMap::new(), None).unwrap(); + editor + .move_selection(&mut engine, Movement::DocumentEnd, false) + .unwrap(); + let column = editor.caret(1.0).unwrap().x0 as f32; + editor + .move_selection(&mut engine, Movement::Up, false) + .unwrap(); + assert_eq!( + editor.caret_outline().unwrap().origin(), + [x + column, above] + ); + editor.focus_outline(id).unwrap(); + if x >= 180.0 { + for _ in 0..3 { + editor + .move_selection(&mut engine, Movement::Down, false) + .unwrap(); + } + assert!(editor.caret_outline().is_some()); + editor + .move_selection(&mut engine, Movement::Up, false) + .unwrap(); + assert!(editor.caret_outline().is_some()); + editor + .move_selection(&mut engine, Movement::Up, false) + .unwrap(); + if y == 184.0 { + assert!(editor.caret_outline().is_some()); + editor + .move_selection(&mut engine, Movement::Up, false) + .unwrap(); + } + assert_eq!(editor.active_outline().id, id); + assert_eq!(editor.selection().positions[1].offset, 4); + } + assert_eq!(editor.outlines[0].document, source); + assert!(editor.undo.is_empty()); + } + } + + #[test] + fn provisional_paragraphs_use_the_base_style_instead_of_local_run_formatting() { + let mut engine = TextEngine::default(); + let base = Format { + font: Some("Arial".into()), + font_size: Some(11.0), + ..Default::default() + }; + let style = ExGuid { + n: 9, + ..Default::default() + }; + let mut nodes = TextDocument::new(vec![Paragraph::new( + "Text".into(), + Format { + font_size: Some(22.0), + bold: Some(true), + ..base.clone() + }, + )]) + .unwrap() + .nodes() + .to_vec(); + nodes[0].style = Some(style); + let outline = TextOutline::new( + &mut engine, + TextDocument::from_nodes(nodes).unwrap(), + 240.0, + [0.0; 2], + ) + .unwrap(); + let mut editor = CanvasEditor::from_text_outlines( + vec![outline], + BTreeMap::from([( + style, + Definition { + kind: onestore::document::Kind::Style { + name: Some("p".into()), + }, + format: base.clone(), + }, + )]), + None, + ) + .unwrap(); + editor + .move_selection(&mut engine, Movement::Down, false) + .unwrap(); + let paragraph = &editor.active_outline().document.nodes()[1]; + assert_eq!(paragraph.style, Some(style)); + assert_eq!(paragraph.text().unwrap().text.spans()[0].format, base); + assert_eq!( + editor.active_outline().shaped.paragraphs[1].text.height(), + engine + .layout(&Paragraph::new(String::new(), base), 240.0) + .unwrap() + .height() + ); + assert_eq!(editor.outlines[0].document.nodes().len(), 1); + } + + #[test] + fn clicks_below_use_base_style_preserve_source_and_share_draft_history() { + let mut engine = TextEngine::default(); + for font_size in [11.0, 22.0] { + let style = ExGuid { + n: 900, + ..Default::default() + }; + let base = Format { + font: Some("Arial".into()), + font_size: Some(font_size), + ..Default::default() + }; + let mut nodes = TextDocument::new(vec![Paragraph::new( + "Body".into(), + Format { + font_size: Some(44.0), + bold: Some(true), + ..base.clone() + }, + )]) + .unwrap() + .nodes() + .to_vec(); + nodes[0].style = Some(style); + let outline = TextOutline::new( + &mut engine, + TextDocument::from_nodes(nodes).unwrap(), + 240.0, + [180.0, 90.0], + ) + .unwrap(); + let id = outline.id; + let source = outline.document.clone(); + let layout = outline.layout.clone(); + let text_id = outline.shaped.paragraphs[0].text.id(); + let bottom = outline.shaped.size[1]; + let mut editor = CanvasEditor::from_text_outlines( + vec![outline], + BTreeMap::from([( + style, + Definition { + kind: onestore::document::Kind::Style { + name: Some("p".into()), + }, + format: base.clone(), + }, + )]), + None, + ) + .unwrap(); + assert!( + editor + .select_below(&mut engine, id, [10.0, bottom + 20.0]) + .unwrap() + ); + let blanks = if font_size == 11.0 { 2 } else { 1 }; + assert_eq!(editor.active_outline().document.nodes().len(), blanks + 1); + assert_eq!( + editor.selection().positions[1], + TextPosition { + paragraph: blanks, + offset: 0 + } + ); + assert_eq!(editor.outlines[0].document, source); + assert_eq!( + editor.active_outline().shaped.paragraphs[0].text.id(), + text_id + ); + for paragraph in &editor.active_outline().document.nodes()[1..] { + assert_eq!(paragraph.style, Some(style)); + assert_eq!(paragraph.text().unwrap().text.spans()[0].format, base); + } + let pending = editor.active_outline().document.clone(); + let selection = editor.selection(); + assert!( + !editor + .select_below(&mut engine, id, [10.0, bottom + 28.0]) + .unwrap() + ); + assert_eq!(editor.active_outline().document, pending); + assert_eq!(editor.selection(), selection); + assert!(editor.undo.is_empty()); + editor.compose(&mut engine, "日本".into(), 2..2).unwrap(); + assert!(editor.cancel_composition(&mut engine).unwrap()); + assert_eq!(editor.active_outline().document, pending); + editor.insert(&mut engine, "X").unwrap(); + let edited = editor.active_outline().document.clone(); + assert_eq!(editor.outlines[0].layout, layout); + assert_eq!(edited.nodes()[0], source.nodes()[0]); + assert!(editor.undo(&mut engine).unwrap()); + assert_eq!(editor.active_outline().document, pending); + assert_eq!(editor.outlines[0].document, source); + assert_eq!(editor.selection(), selection); + assert!(editor.redo(&mut engine).unwrap()); + assert_eq!(editor.active_outline().document, edited); + assert!(editor.undo(&mut engine).unwrap()); + assert!( + editor + .select_below(&mut engine, id, [10.0, bottom + 1.0]) + .unwrap() + ); + assert_eq!(editor.active_outline().document.nodes().len(), blanks + 1); + editor + .select([selection.positions[1], editor.selection().positions[1]].into()) + .unwrap(); + assert_eq!(editor.outlines[0].document, source); + assert_eq!(editor.redo.len(), 1); + editor + .place_caret(&mut engine, [600.0, 600.0], 240.0) + .unwrap(); + let pending = editor.active_outline().document.clone(); + let selection = editor.selection(); + editor.definitions.get_mut(&style).unwrap().format.font_size = Some(0.001); + assert!( + editor + .select_below(&mut engine, id, [10.0, bottom + 20.0]) + .is_err() + ); + assert_eq!(editor.active_outline().document, pending); + assert_eq!(editor.selection(), selection); + assert_eq!(editor.outlines[0].document, source); + assert_eq!(editor.redo.len(), 1); + } + } + + #[test] + fn editing_during_provisional_composition_keeps_only_committed_layout_height() { + let mut engine = TextEngine::default(); + let source = + TextDocument::new(vec![Paragraph::new("body".into(), Format::default())]).unwrap(); + let mut editor = CanvasEditor::new(&mut engine, source.clone(), 240.0).unwrap(); + for _ in 0..3 { + editor + .move_selection(&mut engine, Movement::Down, false) + .unwrap(); + } + for _ in 0..2 { + editor + .move_selection(&mut engine, Movement::Up, false) + .unwrap(); + } + editor.compose(&mut engine, "a".into(), 1..1).unwrap(); + editor.insert(&mut engine, "b").unwrap(); + let expected = TextOutline::from_outline( + &mut engine, + &editor.active_outline().snapshot(), + &BTreeMap::new(), + ) + .unwrap(); + assert_eq!(editor.active_outline().shaped.size, expected.shaped.size); + assert_eq!(editor.active_outline().document.nodes().len(), 2); + editor.undo(&mut engine).unwrap(); + assert_eq!( + editor + .active_outline() + .document + .paragraphs() + .last() + .unwrap() + .text(), + "a" + ); + editor.undo(&mut engine).unwrap(); + assert_eq!(editor.outlines[0].document, source); + assert_eq!(editor.active_outline().document.nodes().len(), 4); + } + + #[test] + fn arrow_created_paragraphs_are_provisional_and_share_existing_shaping() { + let mut engine = TextEngine::default(); + let source = + TextDocument::new(vec![Paragraph::new("body".into(), Format::default())]).unwrap(); + let mut editor = CanvasEditor::new(&mut engine, source.clone(), 240.0).unwrap(); + let shaped = editor.outlines[0].shaped.paragraphs[0] + .text + .shaped + .styles() + .as_ptr(); + for _ in 0..3 { + editor + .move_selection(&mut engine, Movement::Down, false) + .unwrap(); + } + assert_eq!(editor.outlines[0].document, source); + assert_eq!(editor.active_outline().document.nodes().len(), 4); + assert_eq!( + editor + .visible_outlines() + .next() + .unwrap() + .document + .nodes() + .len(), + 4 + ); + assert_eq!( + editor.active_outline().shaped.paragraphs[0] + .text + .shaped + .styles() + .as_ptr(), + shaped + ); + assert!(editor.undo.is_empty()); + editor + .move_selection(&mut engine, Movement::Up, false) + .unwrap(); + editor + .move_selection(&mut engine, Movement::Up, false) + .unwrap(); + let pending = editor.active_outline().document.clone(); + let selection = editor.selection(); + editor.insert(&mut engine, "X").unwrap(); + assert_eq!( + editor.outlines[0] + .document + .paragraphs() + .map(Paragraph::text) + .collect::>(), + ["body", "X"] + ); + assert_eq!( + editor.outlines[0].shaped.paragraphs[0] + .text + .shaped + .styles() + .as_ptr(), + shaped + ); + editor.undo(&mut engine).unwrap(); + assert_eq!(editor.outlines[0].document, source); + assert_eq!(editor.active_outline().document, pending); + assert_eq!(editor.selection(), selection); + editor + .place_caret(&mut engine, [500.0, 500.0], 200.0) + .unwrap(); + editor.redo(&mut engine).unwrap(); + assert_eq!( + editor.outlines[0] + .document + .paragraphs() + .map(Paragraph::text) + .collect::>(), + ["body", "X"] + ); + editor.undo(&mut engine).unwrap(); + assert_eq!(editor.active_outline().document, pending); + assert_eq!(editor.selection(), selection); + editor + .place_caret(&mut engine, [500.0, 500.0], 200.0) + .unwrap(); + assert_eq!(editor.outlines[0].document, source); + } + + #[test] + fn deleting_through_provisional_paragraphs_removes_the_outline_and_restores_draft_on_undo() { + let mut engine = TextEngine::default(); + let source = + TextDocument::new(vec![Paragraph::new("body".into(), Format::default())]).unwrap(); + let mut editor = CanvasEditor::new(&mut engine, source.clone(), 240.0).unwrap(); + for _ in 0..2 { + editor + .move_selection(&mut engine, Movement::Down, false) + .unwrap(); + } + let pending = editor.active_outline().document.clone(); + editor.select_all().unwrap(); + let selection = editor.selection(); + editor.delete(&mut engine, true).unwrap(); + assert!(editor.outlines.is_empty()); + assert!(editor.caret_outline().is_some()); + editor + .place_caret(&mut engine, [500.0, 500.0], 200.0) + .unwrap(); + for _ in 0..2 { + editor.undo(&mut engine).unwrap(); + assert_eq!(editor.outlines[0].document, source); + assert_eq!(editor.active_outline().document, pending); + assert_eq!(editor.selection(), selection); + editor.redo(&mut engine).unwrap(); + assert!(editor.outlines.is_empty()); + } + } + + #[test] + fn provisional_composition_cancels_and_backspace_reenters_without_history() { + let mut engine = TextEngine::default(); + let source = + TextDocument::new(vec![Paragraph::new("body".into(), Format::default())]).unwrap(); + let mut editor = CanvasEditor::new(&mut engine, source.clone(), 240.0).unwrap(); + editor + .move_selection(&mut engine, Movement::Down, false) + .unwrap(); + let pending = editor.active_outline().document.clone(); + editor.compose(&mut engine, "日本".into(), 2..2).unwrap(); + assert_eq!(editor.outlines[0].document, source); + editor.cancel_composition(&mut engine).unwrap(); + assert_eq!(editor.active_outline().document, pending); + editor.compose(&mut engine, "日本".into(), 2..2).unwrap(); + editor.finish_composition(); + assert_eq!( + editor.outlines[0] + .document + .paragraphs() + .map(Paragraph::text) + .collect::>(), + ["body", "日本"] + ); + editor.undo(&mut engine).unwrap(); + assert_eq!(editor.active_outline().document, pending); + editor.delete(&mut engine, true).unwrap(); + assert_eq!(editor.outlines[0].document, source); + assert!(matches!(editor.active, Focus::Outline(0))); + assert!(editor.undo.is_empty()); + assert!(editor.redo(&mut engine).unwrap()); + } + + #[test] + fn word_navigation_terminates_at_soft_wrapped_bidi_cycles() { + let mut engine = TextEngine::default(); + let paragraph = Paragraph::from_runs(vec![ + ( + "العربية".into(), + Format { + font_size: Some(12.0), + bold: Some(false), + italic: Some(false), + ..Default::default() + }, + ), + ("ية 日本".into(), Format::default()), + ]); + let source = TextDocument::new(vec![paragraph]).unwrap(); + let mut editor = CanvasEditor::new(&mut engine, source.clone(), 1.0).unwrap(); + let selection = Selection { + positions: [TextPosition { + paragraph: 0, + offset: 7, + }; 2], + affinities: [Affinity::Upstream; 2], + }; + editor.select(selection).unwrap(); + editor.delete_to(&mut engine, Movement::WordRight).unwrap(); + assert_eq!( + editor + .active_outline() + .document + .paragraphs() + .next() + .unwrap() + .text(), + "ية 日本" + ); + editor.undo(&mut engine).unwrap(); + assert_eq!(editor.active_outline().document, source); + assert_eq!(editor.selection(), selection); + for offset in 0..=12 { + for affinity in [Affinity::Upstream, Affinity::Downstream] { + for movement in [Movement::WordLeft, Movement::WordRight] { + editor + .select(Selection { + positions: [TextPosition { + paragraph: 0, + offset, + }; 2], + affinities: [affinity; 2], + }) + .unwrap(); + editor.move_selection(&mut engine, movement, true).unwrap(); + editor.select(editor.selection()).unwrap(); + } + } + } + } + + #[test] + fn indentation_reflows_and_split_join_preserves_levels_and_undo() { + let mut engine = TextEngine::default(); + let source = TextDocument::new(vec![ + Paragraph::new("alpha beta gamma delta".into(), Format::default()), + Paragraph::new("untouched".into(), Format::default()), + ]) + .unwrap(); + let mut editor = CanvasEditor::new(&mut engine, source.clone(), 110.0).unwrap(); + let untouched = editor.active_outline().shaped.paragraphs[1] + .text + .shaped + .styles() + .as_ptr(); + let selection: Selection = [TextPosition { + paragraph: 0, + offset: 6, + }; 2] + .into(); + editor.select(selection).unwrap(); + editor.indent(&mut engine, false).unwrap(); + assert_eq!(editor.active_outline().document.nodes()[0].level, 2); + assert_eq!(editor.active_outline().shaped.paragraphs[0].origin[0], 27.0); + assert_eq!( + editor.active_outline().shaped.paragraphs[1] + .text + .shaped + .styles() + .as_ptr(), + untouched + ); + editor.insert(&mut engine, "\n").unwrap(); + assert_eq!( + editor + .active_outline() + .document + .nodes() + .iter() + .map(|n| n.level) + .collect::>(), + [2, 2, 1] + ); + editor.delete(&mut engine, true).unwrap(); + assert_eq!( + editor.active_outline().document.nodes()[0] + .text() + .unwrap() + .text + .text(), + "alpha beta gamma delta" + ); + editor.undo(&mut engine).unwrap(); + editor.undo(&mut engine).unwrap(); + editor.undo(&mut engine).unwrap(); + assert_eq!(editor.active_outline().document, source); + assert_eq!(editor.selection(), selection); + assert!(!editor.indent(&mut engine, true).unwrap()); + assert!(editor.redo(&mut engine).unwrap()); + } + + #[test] + fn indentation_excludes_selected_next_paragraph_start_and_rejects_overflow_atomically() { + let mut engine = TextEngine::default(); + let source = TextDocument::new(vec![ + Paragraph::new("first".into(), Format::default()), + Paragraph::new("second".into(), Format::default()), + ]) + .unwrap(); + let mut editor = CanvasEditor::new(&mut engine, source, 50.0).unwrap(); + let selection: Selection = [ + TextPosition { + paragraph: 1, + offset: 0, + }, + TextPosition { + paragraph: 0, + offset: 0, + }, + ] + .into(); + editor.select(selection).unwrap(); + editor.indent(&mut engine, false).unwrap(); + let indented = editor.active_outline().document.clone(); + assert_eq!( + indented.nodes().iter().map(|n| n.level).collect::>(), + [2, 1] + ); + assert!(editor.indent(&mut engine, false).is_err()); + assert_eq!(editor.active_outline().document, indented); + assert_eq!(editor.selection(), selection); + editor.undo(&mut engine).unwrap(); + assert!(!editor.undo(&mut engine).unwrap()); + } + + #[test] + fn paragraph_and_document_navigation_preserve_selection_anchor() { + let mut engine = TextEngine::default(); + let source = TextDocument::new(vec![ + Paragraph::new("one two three four five".into(), Format::default()), + Paragraph::new("🌳 last".into(), Format::default()), + ]) + .unwrap(); + let mut editor = CanvasEditor::new(&mut engine, source.clone(), 45.0).unwrap(); + let start = TextPosition { + paragraph: 0, + offset: 8, + }; + editor.select([start; 2].into()).unwrap(); + editor + .move_selection(&mut engine, Movement::ParagraphEnd, true) + .unwrap(); + assert_eq!( + editor.selection().positions, + [ + start, + TextPosition { + paragraph: 0, + offset: 23 + } + ] + ); + editor + .move_selection(&mut engine, Movement::ParagraphEnd, true) + .unwrap(); + assert_eq!( + editor.selection().positions, + [ + start, + TextPosition { + paragraph: 1, + offset: 7 + } + ] + ); + editor + .move_selection(&mut engine, Movement::DocumentStart, true) + .unwrap(); + assert_eq!( + editor.selection().positions, + [ + start, + TextPosition { + paragraph: 0, + offset: 0 + } + ] + ); + editor + .move_selection(&mut engine, Movement::DocumentEnd, false) + .unwrap(); + assert_eq!( + editor.selection().positions, + [TextPosition { + paragraph: 1, + offset: 7 + }; 2] + ); + editor + .move_selection(&mut engine, Movement::ParagraphStart, false) + .unwrap(); + assert_eq!( + editor.selection().positions, + [TextPosition { + paragraph: 1, + offset: 0 + }; 2] + ); + editor + .move_selection(&mut engine, Movement::ParagraphStart, false) + .unwrap(); + assert_eq!( + editor.selection().positions, + [TextPosition { + paragraph: 0, + offset: 0 + }; 2] + ); + assert_eq!(editor.active_outline().document, source); + assert!(!editor.undo(&mut engine).unwrap()); + } + + #[test] + fn word_deletion_restores_original_caret_and_styled_source_on_undo() { + let mut engine = TextEngine::default(); + let source = TextDocument::new(vec![Paragraph::new( + "first café 🌳 last".into(), + Format { + bold: Some(true), + ..Default::default() + }, + )]) + .unwrap(); + let mut editor = CanvasEditor::new(&mut engine, source.clone(), 200.0).unwrap(); + let selection: Selection = [TextPosition { + paragraph: 0, + offset: 10, + }; 2] + .into(); + editor.select(selection).unwrap(); + assert!(editor.delete_to(&mut engine, Movement::WordLeft).unwrap()); + assert_eq!( + editor + .active_outline() + .document + .paragraphs() + .next() + .unwrap() + .text(), + "first 🌳 last" + ); + editor.undo(&mut engine).unwrap(); + assert_eq!(editor.active_outline().document, source); + assert_eq!(editor.selection(), selection); + editor.redo(&mut engine).unwrap(); + editor.undo(&mut engine).unwrap(); + editor + .move_selection(&mut engine, Movement::DocumentStart, false) + .unwrap(); + assert!(!editor.delete_to(&mut engine, Movement::WordLeft).unwrap()); + assert!(editor.redo(&mut engine).unwrap()); + } + + #[test] + fn horizontal_navigation_and_blank_grid_keep_documents_and_history_unchanged() { + let mut engine = TextEngine::default(); + let mut outlines = Vec::new(); + for (text, origin) in [ + ("Solo", [36.0, 158.4]), + ("Right", [504.0, 158.4]), + ("Below", [504.0, 320.4]), + ] { + let document = + TextDocument::new(vec![Paragraph::new(text.into(), Format::default())]).unwrap(); + outlines.push(TextOutline::new(&mut engine, document, 72.0, origin).unwrap()); + } + let ids: Vec<_> = outlines.iter().map(|outline| outline.id).collect(); + let originals: Vec<_> = outlines + .iter() + .map(|outline| outline.document.clone()) + .collect(); + let mut editor = CanvasEditor::from_text_outlines(outlines, BTreeMap::new(), None).unwrap(); + editor + .move_selection(&mut engine, Movement::LineEnd, false) + .unwrap(); + editor + .move_selection(&mut engine, Movement::Right, true) + .unwrap(); + assert_eq!(editor.active_outline().id, ids[0]); + editor + .move_selection(&mut engine, Movement::Right, false) + .unwrap(); + assert_eq!(editor.active_outline().id, ids[1]); + assert_eq!(editor.selection().positions[1].offset, 0); + editor + .move_selection(&mut engine, Movement::Left, false) + .unwrap(); + assert_eq!(editor.active_outline().id, ids[0]); + assert_eq!(editor.selection().positions[1].offset, 4); + editor.focus_outline(ids[1]).unwrap(); + editor + .move_selection(&mut engine, Movement::LineEnd, false) + .unwrap(); + editor + .move_selection(&mut engine, Movement::Right, false) + .unwrap(); + assert_eq!(editor.active_outline().id, ids[1]); + assert_eq!(editor.selection().positions[1].offset, 5); + editor + .place_caret(&mut engine, [486.0, 194.4], 468.0) + .unwrap(); + editor + .move_selection(&mut engine, Movement::Right, false) + .unwrap(); + assert_eq!(editor.active_outline().origin(), [504.0, 194.4]); + editor + .move_selection(&mut engine, Movement::Up, false) + .unwrap(); + assert_eq!(editor.active_outline().id, ids[1]); + assert_eq!(editor.selection().positions[1].offset, 0); + assert!( + editor + .outlines + .iter() + .zip(&originals) + .all(|(outline, original)| &outline.document == original) + ); + assert!(!editor.undo(&mut engine).unwrap()); + } + + #[test] + fn word_and_paragraph_selection_use_source_ranges_across_wraps() { + let mut engine = TextEngine::default(); + let document = TextDocument::new(vec![Paragraph::new( + "alpha beta gamma 🌳 delta".into(), + Format::default(), + )]) + .unwrap(); + let mut editor = CanvasEditor::new(&mut engine, document, 70.0).unwrap(); + let point = TextPosition { + paragraph: 0, + offset: 7, + }; + editor.select([point; 2].into()).unwrap(); + let caret = editor.caret(1.0).unwrap(); + let x = caret.x0 as f32; + let y = ((caret.y0 + caret.y1) * 0.5) as f32; + let word = editor.selection_at(x, y, SelectionUnit::Word).unwrap(); + assert_eq!(word.positions.map(|p| p.offset), [6, 10]); + let paragraph = editor.selection_at(x, y, SelectionUnit::Paragraph).unwrap(); + assert_eq!(paragraph.positions.map(|p| p.offset), [0, 25]); + assert_eq!(editor.selection().positions, [point; 2]); + } + + #[test] + fn pointer_selection_uses_canvas_line_boxes_after_many_emoji_lines() { + let text = "alpha 🌳 beta\n".repeat(30); + let start = text[..text.rfind("beta").unwrap()].encode_utf16().count() as u32; + let mut engine = TextEngine::default(); + let document = TextDocument::new(vec![Paragraph::new(text, Format::default())]).unwrap(); + let mut editor = CanvasEditor::new(&mut engine, document, 70.0).unwrap(); + let position = TextPosition { + paragraph: 0, + offset: start + 1, + }; + editor.select([position; 2].into()).unwrap(); + let caret = editor.caret(1.0).unwrap(); + let point = [caret.x0 as f32, ((caret.y0 + caret.y1) * 0.5) as f32]; + assert_eq!( + editor + .selection_at(point[0], point[1], SelectionUnit::Word) + .unwrap() + .positions + .map(|p| p.offset), + [start, start + 4] + ); + assert_eq!( + editor + .selection_at(point[0], point[1], SelectionUnit::Grapheme) + .unwrap() + .positions, + [position; 2] + ); + } + #[test] fn retiring_text_restores_source_selection_order_and_geometry_through_history() { let mut engine = TextEngine::default(); @@ -1476,7 +5289,7 @@ mod tests { .iter() .map(|outline| outline.id) .collect::>(); - let mut editor = CanvasEditor::from_text_outlines(outlines, BTreeMap::new()).unwrap(); + let mut editor = CanvasEditor::from_text_outlines(outlines, BTreeMap::new(), None).unwrap(); editor.focus_outline(id).unwrap(); editor.select_all().unwrap(); let selection = editor.selection(); @@ -1551,7 +5364,8 @@ mod tests { let first = outlines[0].id; let second = outlines[1].id; let original = outlines[0].document.clone(); - let mut editor = CanvasEditor::from_text_outlines(outlines, BTreeMap::new()).unwrap(); + let mut editor = + CanvasEditor::from_text_outlines(outlines, BTreeMap::new(), None).unwrap(); editor.select_all().unwrap(); editor.compose(&mut engine, String::new(), 0..0).unwrap(); assert_eq!(editor.outlines.len(), 2); @@ -1626,6 +5440,24 @@ mod tests { editor.delete(&mut engine, true).unwrap(); assert!(editor.outlines.is_empty()); assert_eq!(editor.active_outline().document, spaces); + let undo_count = editor.undo.len(); + editor.select_all().unwrap(); + assert!(editor.delete_to(&mut engine, Movement::LineStart).unwrap()); + assert!(editor.outlines.is_empty()); + assert_eq!(editor.undo.len(), undo_count); + assert!(editor.undo(&mut engine).unwrap()); + assert_eq!( + editor + .active_outline() + .document + .paragraphs() + .next() + .unwrap() + .text(), + " x" + ); + assert!(editor.redo(&mut engine).unwrap()); + assert_eq!(editor.active_outline().document, spaces); for _ in 0..2 { editor.undo(&mut engine).unwrap(); } @@ -1779,7 +5611,7 @@ mod tests { .into_iter() .find_map(|object| match object { PageObject::Outline(outline) - if outline.paragraphs[0].combined_text().text() == "Spacing first" => + if outline.paragraphs[0].text().unwrap().text.text() == "Spacing first" => { Some(outline) } @@ -1811,6 +5643,89 @@ mod tests { } } + #[test] + fn resize_preview_preserves_history_and_commit_restores_automatic_width_on_undo() { + let mut engine = TextEngine::default(); + let mut editor = CanvasEditor::new( + &mut engine, + TextDocument::new(vec![Paragraph::new(String::new(), Format::default())]).unwrap(), + 468.0, + ) + .unwrap(); + editor.insert(&mut engine, "One").unwrap(); + let source = editor.active_outline().snapshot(); + let selection = editor.selection(); + editor.insert(&mut engine, "!").unwrap(); + editor.undo(&mut engine).unwrap(); + for width in [120.0, 360.0, 180.0] { + let preview = editor.preview_resize(&mut engine, width).unwrap(); + assert_eq!(preview.bounds().width(), f64::from(width)); + assert_eq!(preview.document, editor.active_outline().document); + assert_eq!(editor.active_outline().layout, source.layout); + assert_eq!(editor.undo.len(), 1); + assert_eq!(editor.redo.len(), 1); + } + assert!(editor.preview_resize(&mut engine, f32::NAN).is_err()); + editor.resize(&mut engine, 180.0).unwrap(); + assert!(editor.redo.is_empty()); + assert_eq!(editor.undo.len(), 2); + editor.undo(&mut engine).unwrap(); + assert_eq!(editor.active_outline().layout, source.layout); + assert_eq!(editor.active_outline().bounds().width(), 72.0); + assert_eq!(editor.selection(), selection); + editor.redo(&mut engine).unwrap(); + assert_eq!(editor.active_outline().bounds().width(), 180.0); + editor.resize(&mut engine, 180.0).unwrap(); + assert_eq!(editor.undo.len(), 2); + } + + #[test] + fn new_empty_outline_grows_shrinks_and_keeps_a_manually_set_width() { + let mut engine = TextEngine::default(); + let mut editor = CanvasEditor::new( + &mut engine, + TextDocument::new(vec![Paragraph::new(String::new(), Format::default())]).unwrap(), + 468.0, + ) + .unwrap(); + assert_eq!(editor.active_outline().bounds().width(), 72.0); + assert_eq!( + editor.active_outline().layout.width_set_by_user, + Some(false) + ); + editor + .insert(&mut engine, "A short sentence expands the container") + .unwrap(); + let expanded = editor.active_outline().bounds().width(); + assert!(expanded > 72.0 && expanded < 468.0); + editor.select_all().unwrap(); + editor.insert(&mut engine, &"words ".repeat(80)).unwrap(); + assert_eq!(editor.active_outline().bounds().width(), 468.0); + assert!( + editor + .active_outline() + .paragraph_layout(0) + .unwrap() + .text + .lines() + .count() + > 1 + ); + editor.select_all().unwrap(); + editor.insert(&mut engine, "One").unwrap(); + assert_eq!(editor.active_outline().bounds().width(), 72.0); + editor.resize(&mut engine, 180.0).unwrap(); + editor.select_all().unwrap(); + editor.insert(&mut engine, "Tiny").unwrap(); + assert_eq!(editor.active_outline().bounds().width(), 180.0); + editor + .insert(&mut engine, &" longer words".repeat(10)) + .unwrap(); + assert_eq!(editor.active_outline().bounds().width(), 180.0); + editor.undo(&mut engine).unwrap(); + assert_eq!(editor.active_outline().bounds().width(), 180.0); + } + #[test] fn resizing_an_automatic_outline_sets_its_explicit_width_and_preserves_spacing_metadata() { let document = TextDocument::new(vec![Paragraph::new( @@ -1823,6 +5738,8 @@ mod tests { .unwrap(); let source = document.nodes().to_vec(); let outline = Outline { + title: false, + min_width: None, id: crate::text::new_id().unwrap(), layout: onestore::document::Layout { max_width: Some(220.0), @@ -1913,6 +5830,8 @@ mod tests { }, )]); let outline = Outline { + title: false, + min_width: None, id: crate::text::new_id().unwrap(), layout: onestore::document::Layout { x: Some(30.0), @@ -1975,6 +5894,8 @@ mod tests { ); let rebuilt = Outline { id, + title: false, + min_width: None, layout: editor.active_outline().layout.clone(), indents: vec![18.0, 0.0, 27.0], paragraphs: editor.active_outline().document.nodes().to_vec(), @@ -2019,13 +5940,21 @@ mod tests { .into(), ) .unwrap(); - editor.move_selection(Movement::Right, false).unwrap(); + editor + .move_selection(&mut engine, Movement::Right, false) + .unwrap(); assert_eq!(editor.selection().positions[1].paragraph, 4); - editor.move_selection(Movement::Left, false).unwrap(); + editor + .move_selection(&mut engine, Movement::Left, false) + .unwrap(); assert_eq!(editor.selection().positions[1].paragraph, 2); - editor.move_selection(Movement::Down, false).unwrap(); + editor + .move_selection(&mut engine, Movement::Down, false) + .unwrap(); assert_eq!(editor.selection().positions[1].paragraph, 4); - editor.move_selection(Movement::Up, false).unwrap(); + editor + .move_selection(&mut engine, Movement::Up, false) + .unwrap(); assert_eq!(editor.selection().positions[1].paragraph, 2); editor.select_all().unwrap(); assert!(!editor.selection_rects().unwrap().is_empty()); @@ -2083,7 +6012,7 @@ mod tests { assert_eq!(layout_snapshot(&editor.active_outline().shaped), expected); let visible: Vec<_> = editor.active_outline().layouts().map(|(i, _)| i).collect(); for paragraph in visible { - let text = &nodes[paragraph].text[0].text; + let text = &nodes[paragraph].text().unwrap().text; let byte = text .text() .char_indices() @@ -2099,6 +6028,8 @@ mod tests { let edited = editor.active_outline().document.clone(); let rebuilt = Outline { id, + title: false, + min_width: None, layout: source_layout.clone(), indents: editor.active_outline().indents.clone(), paragraphs: edited.nodes().to_vec(), @@ -2145,14 +6076,18 @@ mod tests { offset: 3, }; for _ in 0..32 { - editor.move_selection(Movement::Right, false).unwrap(); + editor + .move_selection(&mut engine, Movement::Right, false) + .unwrap(); if editor.selection().positions[1] == last { break; } } assert_eq!(editor.selection().positions[1], last); for _ in 0..32 { - editor.move_selection(Movement::Left, false).unwrap(); + editor + .move_selection(&mut engine, Movement::Left, false) + .unwrap(); if editor.selection().positions[1] == first { break; } @@ -2188,11 +6123,16 @@ mod tests { let after = editor.active_outline().document.clone(); editor.resize(&mut engine, 35.0).unwrap(); editor.undo(&mut engine).unwrap(); + assert_eq!(editor.active_outline().document, after); + assert_eq!(editor.active_outline().layout.max_width, Some(100.0)); + editor.undo(&mut engine).unwrap(); assert_eq!(editor.active_outline().document, original); assert_eq!(editor.selection().positions, [anchor, focus]); editor.redo(&mut engine).unwrap(); assert_eq!(editor.active_outline().document, after); assert_eq!(editor.selection().positions[1].offset, 4); + editor.redo(&mut engine).unwrap(); + assert_eq!(editor.active_outline().layout.max_width, Some(35.0)); let styles = editor.active_outline().shaped.paragraphs[0] .text .shaped @@ -2206,7 +6146,7 @@ mod tests { Movement::LineStart, Movement::LineEnd, ] { - editor.move_selection(movement, false).unwrap(); + editor.move_selection(&mut engine, movement, false).unwrap(); assert_eq!( editor.active_outline().shaped.paragraphs[0] .text @@ -2358,7 +6298,10 @@ mod tests { assert_eq!(editor.active_outline().document, provisional); assert_eq!(editor.selection().positions, selection); assert_eq!(editor.marked_range(), marked); - editor.resize(&mut engine, 25.0).unwrap(); + let preview = editor.preview_resize(&mut engine, 25.0).unwrap(); + assert_eq!(preview.document, provisional); + assert_eq!(preview.layout.max_width, Some(25.0)); + assert_eq!(editor.active_outline().layout.max_width, Some(70.0)); editor.cancel_composition(&mut engine).unwrap(); assert_eq!(editor.active_outline().document, original); assert_eq!(editor.selection().positions, [last, first]); @@ -2391,7 +6334,9 @@ mod tests { }; editor.select([start, start].into()).unwrap(); let x = editor.caret(1.0).unwrap().x0; - editor.move_selection(Movement::Down, false).unwrap(); + editor + .move_selection(&mut engine, Movement::Down, false) + .unwrap(); assert_eq!( editor.selection().positions[1], TextPosition { @@ -2399,7 +6344,9 @@ mod tests { offset: 0 } ); - editor.move_selection(Movement::Down, false).unwrap(); + editor + .move_selection(&mut engine, Movement::Down, false) + .unwrap(); assert_eq!( editor.selection().positions[1], TextPosition { @@ -2408,8 +6355,12 @@ mod tests { } ); assert_eq!(editor.caret(1.0).unwrap().x0, x); - editor.move_selection(Movement::Up, true).unwrap(); - editor.move_selection(Movement::Up, true).unwrap(); + editor + .move_selection(&mut engine, Movement::Up, true) + .unwrap(); + editor + .move_selection(&mut engine, Movement::Up, true) + .unwrap(); assert_eq!( editor.selection().positions, [ @@ -2420,14 +6371,18 @@ mod tests { start ] ); - editor.move_selection(Movement::Left, false).unwrap(); + editor + .move_selection(&mut engine, Movement::Left, false) + .unwrap(); assert_eq!(editor.selection().positions, [start; 2]); let boundary = TextPosition { paragraph: 1, offset: 0, }; editor.select([boundary, boundary].into()).unwrap(); - editor.move_selection(Movement::Left, false).unwrap(); + editor + .move_selection(&mut engine, Movement::Left, false) + .unwrap(); assert_eq!( editor.selection().positions[1], TextPosition { @@ -2435,7 +6390,9 @@ mod tests { offset: 10 } ); - editor.move_selection(Movement::Right, false).unwrap(); + editor + .move_selection(&mut engine, Movement::Right, false) + .unwrap(); assert_eq!(editor.selection().positions[1], boundary); } @@ -2829,6 +6786,7 @@ mod tests { rebuilt.active_outline().document ); let selection = editor.selection().positions; + let previous_width = editor.active_outline().layout.max_width.unwrap(); editor.resize(&mut engine, 30.0).unwrap(); let rebuilt = CanvasEditor::new(&mut engine, editor.active_outline().document.clone(), 30.0).unwrap(); @@ -2837,8 +6795,16 @@ mod tests { assert!(editor.resize(&mut engine, f32::NAN).is_err()); assert_eq!(ys(editor.active_outline()), ys(rebuilt.active_outline())); editor.undo(&mut engine).unwrap(); - let rebuilt = - CanvasEditor::new(&mut engine, editor.active_outline().document.clone(), 30.0).unwrap(); + assert_eq!( + editor.active_outline().layout.max_width, + Some(previous_width) + ); + let rebuilt = CanvasEditor::new( + &mut engine, + editor.active_outline().document.clone(), + previous_width, + ) + .unwrap(); assert_eq!(ys(editor.active_outline()), ys(rebuilt.active_outline())); } diff --git a/crates/canvas/src/editor/page.rs b/crates/canvas/src/editor/page.rs new file mode 100644 index 0000000000000000000000000000000000000000..bbdf468641bc3b72fd4206eb233fd3ee48c14ae5 --- /dev/null +++ b/crates/canvas/src/editor/page.rs @@ -0,0 +1,313 @@ +use super::{EditorError, TextOutline}; +use crate::{ + date::PageDate, + layout::{LayoutError, TextEngine, TextLayout}, + outline::OutlineLayout, + page::{Image, Outline, Page, PageObject}, + text::Paragraph, +}; + +pub(crate) enum Content { + Date { + below_title: Option, + }, + Outline { + source: Outline, + layout: OutlineLayout, + below_title: Option, + }, + Image(Image), + Editable(onestore::ExGuid), + ReadOnly(Box), +} + +pub struct ReadOnlyObject { + pub source: PageObject, + pub message: &'static str, + pub(crate) label: TextLayout, +} + +impl Content { + pub(super) fn layout(&self) -> Option<(onestore::ExGuid, &onestore::document::Layout)> { + match self { + Self::Outline { source, .. } => Some((source.id, &source.layout)), + Self::Image(source) => Some((source.id, &source.layout)), + Self::ReadOnly(object) => Some((object.source.id(), object.source.layout())), + Self::Date { .. } | Self::Editable(_) => None, + } + } + + pub(super) fn layout_mut( + &mut self, + ) -> Option<(onestore::ExGuid, &mut onestore::document::Layout)> { + match self { + Self::Outline { source, .. } => Some((source.id, &mut source.layout)), + Self::Image(source) => Some((source.id, &mut source.layout)), + Self::ReadOnly(object) => Some((object.source.id(), object.source.layout_mut())), + Self::Date { .. } | Self::Editable(_) => None, + } + } +} + +impl ReadOnlyObject { + fn new( + mut source: PageObject, + margin: [f32; 2], + message: &'static str, + engine: &mut TextEngine, + ) -> Result, EditorError> { + let offset = if matches!(source, PageObject::Title(_)) { + margin + } else { + [0.0; 2] + }; + let layout = source.layout_mut(); + let x = layout.x.unwrap_or(0.0) + offset[0]; + let y = layout.y.unwrap_or(0.0) + offset[1]; + if offset[0] != 0.0 { + layout.x = Some(x); + } + if offset[1] != 0.0 { + layout.y = Some(y); + } + let width = layout.max_width.unwrap_or(160.0); + let height = layout.max_height.unwrap_or(42.0); + if [x, y, width, height].iter().any(|v| !v.is_finite()) || width <= 0.0 || height <= 0.0 { + return Err(EditorError::InvalidGeometry); + } + let width = width.max(160.0); + let label = engine.layout( + &Paragraph::new( + message.into(), + onestore::document::Format { + font: Some("Arial".into()), + font_size: Some(11.0), + color: Some(0x005d554e), + ..Default::default() + }, + ), + width - 16.0, + )?; + let height = height.max(label.height() + 16.0); + let rect = [x, y, x + width, y + height]; + if rect.iter().any(|v| !v.is_finite()) { + return Err(EditorError::InvalidGeometry); + } + Ok(Box::new(Self { + source, + message, + label, + })) + } + pub fn rect(&self) -> [f32; 4] { + let layout = self.source.layout(); + let x = layout.x.unwrap_or(0.0); + let y = layout.y.unwrap_or(0.0); + [ + x, + y, + x + layout.max_width.unwrap_or(160.0).max(160.0), + y + layout + .max_height + .unwrap_or(42.0) + .max(self.label.height() + 16.0), + ] + } +} + +#[expect( + clippy::type_complexity, + reason = "the three owned parts of an imported page" +)] +pub(crate) fn build( + page: &mut Page, + engine: &mut TextEngine, + editable: bool, +) -> Result<(Vec, Vec, Option), EditorError> { + if page.margin_origin.iter().any(|v| !v.is_finite()) { + return Err(EditorError::InvalidGeometry); + } + let mut objects = Vec::new(); + let mut outlines = Vec::new(); + let mut date = None; + for object in std::mem::take(&mut page.objects) { + match &object { + PageObject::Outline(outline) => { + let origin = [ + outline.layout.x.unwrap_or(0.0), + outline.layout.y.unwrap_or(0.0), + ]; + if origin.iter().any(|v| !v.is_finite()) { + return Err(EditorError::InvalidGeometry); + } + if editable { + match TextOutline::from_outline(engine, outline, &page.definitions) { + Ok(outline) => { + objects.push(Content::Editable(outline.id)); + outlines.push(outline); + } + Err(EditorError::Layout(LayoutError::UnsupportedContent)) => { + objects.push(Content::ReadOnly(ReadOnlyObject::new( + object, + page.margin_origin, + "Unsupported content\nRead-only", + engine, + )?)) + } + Err(error) => return Err(error), + } + } else { + match outline.layout(engine, &page.definitions) { + Ok(layout) => objects.push(Content::Outline { + source: outline.clone(), + layout, + below_title: None, + }), + Err(LayoutError::UnsupportedContent) => { + objects.push(Content::ReadOnly(ReadOnlyObject::new( + object, + page.margin_origin, + "Unsupported content\nRead-only", + engine, + )?)) + } + Err(error) => return Err(error.into()), + } + } + } + PageObject::Title(title) => { + let layouts = match title.layout(engine, &page.definitions) { + Ok(layouts) => layouts, + Err(LayoutError::UnsupportedContent) => { + objects.push(Content::ReadOnly(ReadOnlyObject::new( + object, + page.margin_origin, + "Unsupported content\nRead-only", + engine, + )?)); + continue; + } + Err(error) => return Err(error.into()), + }; + let mut anchor = if editable { + title + .outlines + .iter() + .zip(&layouts) + .find(|(source, _)| source.title) + .map(|(source, (origin, layout))| { + ( + source.id, + title.layout.y.unwrap_or(0.0) + + page.margin_origin[1] + + origin[1] + + layout.size[1].max(source.layout.max_height.unwrap_or(0.0)), + ) + }) + } else { + None + }; + for (index, (origin, layout)) in layouts.into_iter().enumerate() { + let mut origin = [ + title.layout.x.unwrap_or(0.0) + origin[0] + page.margin_origin[0], + title.layout.y.unwrap_or(0.0) + origin[1] + page.margin_origin[1], + ]; + if origin.iter().any(|v| !v.is_finite()) { + return Err(EditorError::InvalidGeometry); + } + if editable && title.outlines[index].title { + let mut source = title.outlines[index].clone(); + source.layout.x = Some(origin[0]); + source.layout.y = Some(origin[1]); + let outline = + TextOutline::from_outline(engine, &source, &page.definitions)?; + anchor = Some((outline.id, outline.bounds().y1 as f32)); + objects.push(Content::Editable(outline.id)); + outlines.push(outline); + } else { + if let Some((_, bottom)) = anchor { + origin[1] -= bottom; + } + if editable + && title.date == Some(title.outlines[index].id) + && let Some(timestamp) = page.created + { + let mut source = title.outlines[index].clone(); + source.layout.x = Some(origin[0]); + source.layout.y = Some(origin[1]); + match PageDate::new(timestamp, source, engine, &page.definitions) { + Ok(value) => { + if date.replace(value).is_some() { + return Err(EditorError::InvalidGeometry); + } + objects.push(Content::Date { + below_title: anchor.map(|(id, _)| id), + }); + continue; + } + Err(EditorError::Edit( + crate::text::EditError::UnsupportedContent, + )) => {} + Err(error) => return Err(error), + } + } + let mut source = title.outlines[index].clone(); + source.layout.x = Some(origin[0]); + source.layout.y = Some(origin[1]); + objects.push(Content::Outline { + source, + layout, + below_title: anchor.map(|(id, _)| id), + }); + } + } + } + PageObject::Image(source) => { + if source.bytes.is_none() + || source.layout.max_width.is_none() + || source.layout.max_height.is_none() + { + objects.push(Content::ReadOnly(ReadOnlyObject::new( + object, + page.margin_origin, + "Image unavailable\nRead-only", + engine, + )?)); + continue; + } + let origin = [ + source.layout.x.unwrap_or(0.0), + source.layout.y.unwrap_or(0.0), + ]; + let size = [ + source + .layout + .max_width + .ok_or(EditorError::InvalidGeometry)?, + source + .layout + .max_height + .ok_or(EditorError::InvalidGeometry)?, + ]; + if origin.iter().any(|v| !v.is_finite()) + || size.iter().any(|v| !v.is_finite() || *v <= 0.0) + || !(origin[0] + size[0]).is_finite() + || !(origin[1] + size[1]).is_finite() + { + return Err(EditorError::InvalidGeometry); + } + let PageObject::Image(source) = object else { + unreachable!() + }; + objects.push(Content::Image(source)); + } + PageObject::Unsupported(_) => objects.push(Content::ReadOnly(ReadOnlyObject::new( + object, + page.margin_origin, + "Unsupported content\nRead-only", + engine, + )?)), + } + } + Ok((objects, outlines, date)) +} diff --git a/crates/canvas/src/editor/table.rs b/crates/canvas/src/editor/table.rs new file mode 100644 index 0000000000000000000000000000000000000000..856d3a470757517ec0d27b5daf228c6afb660add --- /dev/null +++ b/crates/canvas/src/editor/table.rs @@ -0,0 +1,706 @@ +use super::*; +use crate::page::{ParagraphContent, TableCell, TableColumn, TableRow}; +use crate::text::new_id; + +struct CellLocation<'a> { + container: Option, + paragraph: usize, + node: &'a PageParagraph, + row: usize, + column: usize, +} + +fn locate(document: &TextDocument, id: ExGuid) -> Option> { + let mut pending = vec![(None, document.nodes())]; + while let Some((container, nodes)) = pending.pop() { + for (paragraph, node) in nodes.iter().enumerate() { + if let ParagraphContent::Table(table) = &node.content { + for (row, source) in table.rows.iter().enumerate() { + for (column, cell) in source.cells.iter().enumerate() { + if cell.id == id { + return Some(CellLocation { + container, + paragraph, + node, + row, + column, + }); + } + pending.push((Some(cell.id), cell.paragraphs.as_slice())); + } + } + } + } + } + None +} + +fn empty_cell(source: &TableCell) -> Result { + let (_, _, paragraph) = leaves(&source.paragraphs, None) + .next() + .ok_or(EditError::UnsupportedContent)?; + let format = paragraph.text().unwrap().text.format_at(0)?; + let mut node = crate::document::node( + Paragraph::new(String::new(), format.clone()), + paragraph.format.clone(), + )?; + node.style = paragraph.style; + Ok(TableCell { + id: new_id()?, + layout: Default::default(), + indents: source.indents.clone(), + shading: source.shading, + paragraphs: vec![node], + unsupported: Vec::new(), + }) +} + +fn cell_range(document: &TextDocument, cell: &TableCell) -> Result, EditError> { + let mut content = leaves(&cell.paragraphs, None); + let (_, _, first) = content.next().ok_or(EditError::InvalidStructure)?; + let (_, _, last) = content.last().unwrap_or((None, 0, first)); + let start = document + .text_nodes() + .position(|node| node.id == first.id) + .ok_or(EditError::InvalidStructure)?; + let end = document + .text_nodes() + .position(|node| node.id == last.id) + .ok_or(EditError::InvalidStructure)?; + let text = &last.text().unwrap().text; + Ok(TextPosition { + paragraph: start, + offset: 0, + }..TextPosition { + paragraph: end, + offset: text.utf16_offset(text.text().len())?, + }) +} + +impl CanvasEditor { + pub fn tab(&mut self, engine: &mut TextEngine, backward: bool) -> Result<(), EditorError> { + if self.active_outline().title && !backward { + return self.leave_title(engine); + } + let outline = self.active_outline(); + let [anchor, focus] = outline.selection.positions; + let (cell, local, source) = leaves(outline.document.nodes(), None) + .nth(focus.paragraph) + .ok_or(EditError::InvalidRange)?; + let Some(cell) = cell else { + if backward || anchor != focus || focus.offset == 0 { + self.indent(engine, backward)?; + return Ok(()); + } + let format = source.text().unwrap().text.format_at(focus.offset)?; + let mut split = outline.document.replace( + focus..focus, + vec![ + Paragraph::new(String::new(), format.clone()), + Paragraph::new(String::new(), format.clone()), + ], + )?; + let cells = split + .replacement + .drain(..) + .map(|mut paragraph| { + paragraph.level = 1; + paragraph.parent = None; + Ok(TableCell { + id: new_id()?, + layout: Default::default(), + indents: outline.indents.clone(), + shading: None, + paragraphs: vec![paragraph], + unsupported: Vec::new(), + }) + }) + .collect::, EditError>>()?; + let wrapper = PageParagraph { + id: new_id()?, + parent: source.parent, + level: source.level, + style: None, + format: source.format.clone(), + lists: Vec::new(), + tags: Vec::new(), + collapsed: false, + content: ParagraphContent::Table(crate::page::Table { + id: new_id()?, + columns: vec![ + TableColumn { + width: 37.11, + locked: false + }; + 2 + ], + rows: vec![TableRow { + id: new_id()?, + cells, + }], + borders: Some(true), + layout: Default::default(), + tags: Vec::new(), + }), + }; + split.replacement.push(wrapper); + return self.commit( + engine, + split, + [TextPosition { + paragraph: focus.paragraph + 1, + offset: 0, + }; 2] + .into(), + ); + }; + let location = locate(&outline.document, cell).ok_or(EditError::InvalidStructure)?; + let ParagraphContent::Table(table) = &location.node.content else { + unreachable!() + }; + let slot = location.row * table.columns.len() + location.column; + let next = if backward { + slot.checked_sub(1) + } else if slot + 1 < table.rows.len() * table.columns.len() { + Some(slot + 1) + } else { + None + }; + if let Some(next) = next { + let target = &table.rows[next / table.columns.len()].cells[next % table.columns.len()]; + let range = cell_range(&outline.document, target)?; + return self + .select([range.start, range.end].into()) + .map_err(Into::into); + } + if backward { + return Ok(()); + } + let mut wrapper = location.node.clone(); + let ParagraphContent::Table(table) = &mut wrapper.content else { + unreachable!() + }; + let next = if table.rows.len() == 1 { + let format = source.text().unwrap().text.format_at(focus.offset)?; + let split = outline.document.replace( + focus..focus, + vec![ + Paragraph::new(String::new(), format.clone()), + Paragraph::new(String::new(), format.clone()), + ], + )?; + let cell = &mut table.rows[0].cells[location.column]; + cell.paragraphs.splice(split.range, split.replacement); + let paragraphs = cell.paragraphs.split_off(local + 1); + let target = TableCell { + id: new_id()?, + layout: Default::default(), + indents: cell.indents.clone(), + shading: cell.shading, + paragraphs, + unsupported: Vec::new(), + }; + table.rows[0].cells.push(target); + table.columns.push(TableColumn { + width: 37.11, + locked: false, + }); + TextPosition { + paragraph: focus.paragraph + 1, + offset: 0, + } + } else { + let cells = table + .rows + .last() + .unwrap() + .cells + .iter() + .map(empty_cell) + .collect::, _>>()?; + table.rows.push(TableRow { + id: new_id()?, + cells, + }); + let paragraph = cell_range( + &outline.document, + &table.rows[location.row].cells[location.column], + )? + .end + .paragraph + + 1; + TextPosition { + paragraph, + offset: 0, + } + }; + self.commit( + engine, + DocumentEdit { + columns: BTreeMap::new(), + container: location.container, + range: location.paragraph..location.paragraph + 1, + replacement: vec![wrapper], + }, + [next; 2].into(), + ) + } + pub fn enter(&mut self, engine: &mut TextEngine, soft: bool) -> Result<(), EditorError> { + if soft { + return self.insert(engine, "\u{000b}"); + } + if self.active_outline().title { + return self.leave_title(engine); + } + let outline = self.active_outline(); + let [anchor, focus] = outline.selection.positions; + let (cell, local, source) = leaves(outline.document.nodes(), None) + .nth(focus.paragraph) + .ok_or(EditError::InvalidRange)?; + let Some(cell) = cell.filter(|_| anchor == focus) else { + return self.insert(engine, "\n"); + }; + let location = locate(&outline.document, cell).ok_or(EditError::InvalidStructure)?; + let ParagraphContent::Table(table) = &location.node.content else { + unreachable!() + }; + let row = &table.rows[location.row]; + let exit = location.row + 1 == table.rows.len() + && location.row > 0 + && location.column == 0 + && local == 0 + && focus.offset == 0 + && row.cells.iter().all(|cell| { + cell.paragraphs + .iter() + .all(|node| node.text().is_some_and(|text| text.text.text().is_empty())) + }); + let text = &source.text().unwrap().text; + let append = location.row + 1 == table.rows.len() + && location.column + 1 == table.columns.len() + && local + 1 == row.cells[location.column].paragraphs.len() + && focus.offset == text.utf16_offset(text.text().len())?; + if !exit && !append { + return self.insert(engine, "\n"); + } + let mut wrapper = location.node.clone(); + let ParagraphContent::Table(table) = &mut wrapper.content else { + unreachable!() + }; + let (replacement, next) = if exit { + table.rows.pop(); + let first = self.blank_paragraph(source)?; + let second = self.blank_paragraph(source)?; + ( + vec![wrapper, first, second], + TextPosition { + paragraph: focus.paragraph + 1, + offset: 0, + }, + ) + } else { + let cells = row + .cells + .iter() + .map(empty_cell) + .collect::, _>>()?; + table.rows.push(TableRow { + id: new_id()?, + cells, + }); + ( + vec![wrapper], + TextPosition { + paragraph: focus.paragraph + 1, + offset: 0, + }, + ) + }; + self.commit( + engine, + DocumentEdit { + columns: BTreeMap::new(), + container: location.container, + range: location.paragraph..location.paragraph + 1, + replacement, + }, + [next; 2].into(), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use onestore::document::Format; + + fn editor(engine: &mut TextEngine, text: &str) -> CanvasEditor { + CanvasEditor::new( + engine, + TextDocument::new(vec![Paragraph::new(text.into(), Format::default())]).unwrap(), + 180.0, + ) + .unwrap() + } + + fn cells(editor: &CanvasEditor) -> Vec> { + let ParagraphContent::Table(table) = &editor.active_outline().document.nodes()[0].content + else { + panic!() + }; + table + .rows + .iter() + .flat_map(|row| &row.cells) + .map(|cell| { + cell.paragraphs + .iter() + .map(|node| node.text().unwrap().text.text().to_owned()) + .collect() + }) + .collect() + } + + #[test] + fn tab_creates_splits_and_selects_native_cells() { + let mut engine = TextEngine::default(); + let mut editor = editor(&mut engine, "abcdef"); + let original = editor.active_outline().document.clone(); + editor + .select( + [TextPosition { + paragraph: 0, + offset: 3, + }; 2] + .into(), + ) + .unwrap(); + editor.tab(&mut engine, false).unwrap(); + assert_eq!(cells(&editor), [vec!["abc"], vec!["def"]]); + assert_eq!( + editor + .active_outline() + .document + .text_nodes() + .next() + .unwrap() + .id, + original.nodes()[0].id + ); + assert_eq!( + editor.selection().positions, + [TextPosition { + paragraph: 1, + offset: 0 + }; 2] + ); + editor.insert(&mut engine, "X").unwrap(); + editor.tab(&mut engine, false).unwrap(); + assert_eq!(cells(&editor), [vec!["abc"], vec!["X"], vec!["def"]]); + editor.tab(&mut engine, true).unwrap(); + assert_eq!( + editor.selection().positions, + [ + TextPosition { + paragraph: 1, + offset: 0 + }, + TextPosition { + paragraph: 1, + offset: 1 + } + ] + ); + editor.insert(&mut engine, "REPLACED").unwrap(); + assert_eq!(cells(&editor)[1], ["REPLACED"]); + editor.tab(&mut engine, true).unwrap(); + assert_eq!( + editor.selection().positions, + [ + TextPosition { + paragraph: 0, + offset: 0 + }, + TextPosition { + paragraph: 0, + offset: 3 + } + ] + ); + editor + .select( + [TextPosition { + paragraph: 0, + offset: 2, + }; 2] + .into(), + ) + .unwrap(); + editor.tab(&mut engine, true).unwrap(); + editor.insert(&mut engine, "!").unwrap(); + assert_eq!(cells(&editor)[0], ["ab!c"]); + editor.tab(&mut engine, false).unwrap(); + assert_eq!( + editor.selection().positions[1], + TextPosition { + paragraph: 1, + offset: 8 + } + ); + assert_eq!(editor.active_outline().layout.max_width, Some(180.0)); + for _ in 0..5 { + assert!(editor.undo(&mut engine).unwrap()); + } + assert_eq!(editor.active_outline().document, original); + for _ in 0..5 { + assert!(editor.redo(&mut engine).unwrap()); + } + assert_eq!( + cells(&editor), + [vec!["ab!c"], vec!["REPLACED"], vec!["def"]] + ); + } + + #[test] + fn adding_rows_preserves_nested_cells_and_undo() { + let mut engine = TextEngine::default(); + let mut outer = editor(&mut engine, ""); + outer.insert(&mut engine, "Alpha").unwrap(); + outer.tab(&mut engine, false).unwrap(); + outer.insert(&mut engine, "Beta").unwrap(); + outer.enter(&mut engine, false).unwrap(); + outer.insert(&mut engine, "Gamma").unwrap(); + outer.tab(&mut engine, false).unwrap(); + outer.insert(&mut engine, "Delta").unwrap(); + let mut inner = editor(&mut engine, ""); + inner.insert(&mut engine, "Nested").unwrap(); + inner.tab(&mut engine, false).unwrap(); + let mut source = outer.active_outline().snapshot(); + let ParagraphContent::Table(table) = &mut source.paragraphs[0].content else { + panic!() + }; + table.rows[1].cells[0].paragraphs = inner.active_outline().document.nodes().to_vec(); + for tab in [false, true] { + let mut editor = + CanvasEditor::from_outlines(&mut engine, vec![source.clone()], BTreeMap::new()) + .unwrap(); + let original = editor.active_outline().document.clone(); + let end = TextPosition { + paragraph: original.text_nodes().count() - 1, + offset: 5, + }; + editor.select([end; 2].into()).unwrap(); + if tab { + editor.tab(&mut engine, false).unwrap(); + } else { + editor.enter(&mut engine, false).unwrap(); + } + let added = editor.active_outline().document.clone(); + let ParagraphContent::Table(before) = &original.nodes()[0].content else { + panic!() + }; + let ParagraphContent::Table(after) = &added.nodes()[0].content else { + panic!() + }; + assert_eq!(after.rows.len(), 3); + assert_eq!(&after.rows[..2], &before.rows); + assert_eq!(after.columns, before.columns); + for cell in &after.rows[2].cells { + assert_eq!(cell.paragraphs.len(), 1); + assert!(cell.paragraphs[0].text().unwrap().text.text().is_empty()); + } + assert_eq!(editor.selection().positions[1].paragraph, end.paragraph + 1); + editor.insert(&mut engine, "New row").unwrap(); + assert_eq!( + editor + .active_outline() + .document + .paragraphs() + .nth(end.paragraph + 1) + .unwrap() + .text(), + "New row" + ); + assert!(editor.undo(&mut engine).unwrap()); + assert_eq!(editor.active_outline().document, added); + assert!(editor.undo(&mut engine).unwrap()); + assert_eq!(editor.active_outline().document, original); + assert_eq!(editor.selection().positions, [end; 2]); + assert!(editor.redo(&mut engine).unwrap()); + assert_eq!(editor.active_outline().document, added); + } + } + + #[test] + fn native_rows_exit_and_text_have_separate_undo_steps() { + let mut engine = TextEngine::default(); + let mut editor = editor(&mut engine, "Alpha"); + editor + .select( + [TextPosition { + paragraph: 0, + offset: 5, + }; 2] + .into(), + ) + .unwrap(); + editor.tab(&mut engine, false).unwrap(); + editor.insert(&mut engine, "Beta").unwrap(); + editor.enter(&mut engine, false).unwrap(); + assert_eq!( + editor.selection().positions, + [TextPosition { + paragraph: 2, + offset: 0 + }; 2] + ); + editor.insert(&mut engine, "Gamma").unwrap(); + editor.tab(&mut engine, false).unwrap(); + editor.insert(&mut engine, "Delta").unwrap(); + let two_rows = editor.active_outline().document.clone(); + editor.tab(&mut engine, false).unwrap(); + editor.insert(&mut engine, "Epsilon").unwrap(); + assert_eq!( + cells(&editor), + [ + vec!["Alpha"], + vec!["Beta"], + vec!["Gamma"], + vec!["Delta"], + vec!["Epsilon"], + vec![""] + ] + ); + editor.undo(&mut engine).unwrap(); + assert_eq!(cells(&editor)[4], [""]); + editor.undo(&mut engine).unwrap(); + assert_eq!(editor.active_outline().document, two_rows); + editor.redo(&mut engine).unwrap(); + editor.enter(&mut engine, false).unwrap(); + assert_eq!(cells(&editor).len(), 4); + assert_eq!(editor.active_outline().document.nodes().len(), 3); + assert_eq!( + editor.selection().positions, + [TextPosition { + paragraph: 5, + offset: 0 + }; 2] + ); + editor.insert(&mut engine, "Outside").unwrap(); + assert_eq!( + editor + .active_outline() + .document + .paragraphs() + .last() + .unwrap() + .text(), + "Outside" + ); + editor.undo(&mut engine).unwrap(); + editor.undo(&mut engine).unwrap(); + assert_eq!(cells(&editor).len(), 6); + } + + #[test] + fn enter_and_soft_break_stay_in_cells_until_the_final_end() { + let mut engine = TextEngine::default(); + let mut editor = editor(&mut engine, "Alpha"); + editor + .select( + [TextPosition { + paragraph: 0, + offset: 5, + }; 2] + .into(), + ) + .unwrap(); + editor.tab(&mut engine, false).unwrap(); + editor.insert(&mut engine, "Beta").unwrap(); + editor + .select( + [TextPosition { + paragraph: 1, + offset: 2, + }; 2] + .into(), + ) + .unwrap(); + editor.enter(&mut engine, false).unwrap(); + assert_eq!(cells(&editor), [vec!["Alpha"], vec!["Be", "ta"]]); + editor.enter(&mut engine, true).unwrap(); + assert_eq!(cells(&editor)[1], ["Be", "\u{000b}ta"]); + editor + .select( + [TextPosition { + paragraph: 0, + offset: 5, + }; 2] + .into(), + ) + .unwrap(); + editor.enter(&mut engine, false).unwrap(); + assert_eq!(cells(&editor)[0], ["Alpha", ""]); + } + + #[test] + fn paragraph_start_and_selection_tab_indent_without_creating_a_table() { + let mut engine = TextEngine::default(); + let mut editor = editor(&mut engine, "Alpha"); + editor.tab(&mut engine, false).unwrap(); + assert_eq!(editor.active_outline().document.nodes()[0].level, 2); + editor + .select( + [ + TextPosition { + paragraph: 0, + offset: 1, + }, + TextPosition { + paragraph: 0, + offset: 3, + }, + ] + .into(), + ) + .unwrap(); + let selection = editor.selection(); + editor.tab(&mut engine, false).unwrap(); + assert_eq!(editor.active_outline().document.nodes()[0].level, 3); + assert_eq!( + editor + .active_outline() + .document + .paragraphs() + .next() + .unwrap() + .text(), + "Alpha" + ); + assert_eq!(editor.selection(), selection); + editor.tab(&mut engine, true).unwrap(); + assert_eq!(editor.active_outline().document.nodes()[0].level, 2); + editor + .select( + [TextPosition { + paragraph: 0, + offset: 5, + }; 2] + .into(), + ) + .unwrap(); + editor.tab(&mut engine, false).unwrap(); + assert_eq!(editor.active_outline().document.nodes()[0].level, 2); + assert_eq!( + editor + .active_outline() + .document + .text_nodes() + .next() + .unwrap() + .level, + 1 + ); + assert_eq!(editor.active_outline().shaped.paragraphs[0].origin[0], 27.0); + } +} diff --git a/crates/canvas/src/gpu/canvas.wgsl b/crates/canvas/src/gpu/canvas.wgsl new file mode 100644 index 0000000000000000000000000000000000000000..46e482110fb6f54c8f07e38c10edb9db36351f91 --- /dev/null +++ b/crates/canvas/src/gpu/canvas.wgsl @@ -0,0 +1,87 @@ +struct Vertex { + @builtin(position) position: vec4, + @location(0) uv: vec2, + @location(1) color: vec4, + @location(2) local: vec2, + @location(3) @interpolate(flat) shape: vec4, + @location(4) @interpolate(flat) stroke: f32, +} + +@group(0) @binding(0) var atlas: texture_2d; +@group(0) @binding(1) var atlas_sampler: sampler; + +@vertex +fn vertex(@location(0) position: vec2, @location(1) uv: vec2, + @location(2) color: vec4, @location(3) local: vec2, + @location(4) shape: vec4, @location(5) stroke: f32) -> Vertex { + return Vertex(vec4(position, 0.0, 1.0), uv, color, local, shape, stroke); +} + +fn ellipse_arc(angle: f32, radius: vec2) -> f32 { + if radius.x == radius.y { + return angle * radius.x; + } + // Four-point Gauss-Legendre quadrature. + let nodes = vec4(-0.86113631, -0.33998104, 0.33998104, 0.86113631); + let weights = vec4(0.34785485, 0.65214515, 0.65214515, 0.34785485); + let half = angle * 0.5; + let scale = max(radius.x, radius.y); + var sum = 0.0; + for (var i = 0u; i < 4u; i += 1u) { + let t = half * (nodes[i] + 1.0); + sum += weights[i] * length((radius / scale) * vec2(sin(t), cos(t))); + } + return half * sum * scale; +} + +@fragment +fn fragment(input: Vertex) -> @location(0) vec4 { + var color = textureSample(atlas, atlas_sampler, input.uv) * input.color; + if input.shape.x > 0.0 && input.shape.y > 0.0 { + let q = abs(input.local) - input.shape.xy + input.shape.zw; + var distance: f32; + if input.shape.z == input.shape.w { + distance = length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - input.shape.z; + } else if all(q > vec2(0.0)) { + let radius = max(input.shape.zw, vec2(0.0001)); + let normalized = q / radius; + let k0 = length(normalized); + distance = k0 * (k0 - 1.0) / max(length(normalized / radius), 1e-20); + } else { + let edge = abs(input.local) - input.shape.xy; + distance = max(edge.x, edge.y); + } + var coverage = clamp(0.5 - distance, 0.0, 1.0); + let width = abs(input.stroke); + if width > 0.0 { + coverage *= clamp(distance + width + 0.5, 0.0, 1.0); + } + if input.stroke < 0.0 { + var radius = max(input.shape.zw - width * 0.5, vec2(0.0)); + if any(radius == vec2(0.0)) { + radius = vec2(0.0); + } + let straight = input.shape.xy - width * 0.5 - radius; + let p = abs(input.local); + let quarter = straight.x + straight.y + ellipse_arc(1.57079632679, radius); + var along: f32; + if p.y <= straight.y { + along = p.y; + } else if p.x <= straight.x || radius.x == 0.0 { + along = quarter - p.x; + } else { + let angle = atan2((p.y - straight.y) / radius.y, (p.x - straight.x) / radius.x); + along = straight.y + ellipse_arc(angle, radius); + } + if input.local.x < 0.0 { + along = select(2.0 * quarter - along, 2.0 * quarter + along, input.local.y < 0.0); + } else if input.local.y < 0.0 { + along = 4.0 * quarter - along; + } + let phase = fract(along / (4.0 * width)) * (4.0 * width); + coverage *= clamp(width + 0.5 - abs(phase - 2.0 * width), 0.0, 1.0); + } + color *= coverage; + } + return color; +} diff --git a/crates/canvas/src/gpu/mod.rs b/crates/canvas/src/gpu/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..827e63d7db7ed3151ac7f28d7ed6c9b3dec572c1 --- /dev/null +++ b/crates/canvas/src/gpu/mod.rs @@ -0,0 +1,1546 @@ +pub mod page; +#[cfg(test)] +mod profile; +mod tags; + +use crate::{ + layout::TextLayout, + outline::{ParagraphTag, TagIcon}, +}; +use bytemuck::{Pod, Zeroable}; +use image::ImageDecoder; +use parley::{PositionedLayoutItem, fontique::Blob}; +use std::{ + collections::{HashMap, HashSet}, + ops::Range, +}; +use swash::{ + FontRef, + scale::{Render, ScaleContext, Source, StrikeWith, image::Content}, + zeno::{Angle, Format, Transform, Vector}, +}; + +const ATLAS_SIZE: u32 = 2048; +const MAX_GLYPHS: usize = 8192; +const MAX_VERTICES: usize = 65_536; +const MAX_IMAGE_BYTES: u64 = 64 * 1024 * 1024; +const MAX_IMAGES: usize = 256; + +#[derive(Clone)] +pub struct RasterImage { + size: [u32; 2], + pixels: Blob, +} + +impl RasterImage { + pub fn decode(encoded: &[u8]) -> Result { + if encoded.len() as u64 > MAX_IMAGE_BYTES { + return Err(RenderError::ImageBudget); + } + let format = image::guess_format(encoded).map_err(RenderError::ImageDecode)?; + let mut reader = image::ImageReader::with_format(std::io::Cursor::new(encoded), format); + let mut limits = image::Limits::default(); + limits.max_image_width = Some(16_384); + limits.max_image_height = Some(16_384); + limits.max_alloc = Some(MAX_IMAGE_BYTES); + reader.limits(limits.clone()); + let mut decoder = reader.into_decoder().map_err(RenderError::ImageDecode)?; + let (width, height) = decoder.dimensions(); + if u64::from(width) * u64::from(height) * 4 > MAX_IMAGE_BYTES + || decoder.total_bytes() > MAX_IMAGE_BYTES + { + return Err(RenderError::ImageBudget); + } + limits + .reserve(decoder.total_bytes()) + .map_err(RenderError::ImageDecode)?; + decoder + .set_limits(limits) + .map_err(RenderError::ImageDecode)?; + let pixels = image::DynamicImage::from_decoder(decoder) + .map_err(RenderError::ImageDecode)? + .into_rgba8(); + Self::new([width, height], pixels.into_raw()) + } + + /// Immutable, straight-alpha sRGB RGBA pixels retain one cache identity across clones. + pub fn new(size: [u32; 2], mut pixels: Vec) -> Result { + let bytes = u64::from(size[0]) + .checked_mul(u64::from(size[1])) + .and_then(|v| v.checked_mul(4)) + .ok_or(RenderError::InvalidImage)?; + if size.contains(&0) || bytes != pixels.len() as u64 { + return Err(RenderError::InvalidImage); + } + if bytes > MAX_IMAGE_BYTES { + return Err(RenderError::ImageBudget); + } + // Premultiply in linear light before filtering; transparent RGB must not bleed at edges. + for pixel in pixels.chunks_exact_mut(4) { + let alpha = pixel[3]; + if alpha == 255 { + continue; + } + let linear = colorref(u32::from_le_bytes(pixel.try_into().unwrap())); + for (byte, value) in pixel[..3].iter_mut().zip(linear) { + let value = value * f32::from(alpha) / 255.0; + *byte = srgb_byte(value); + } + } + Ok(Self { + size, + pixels: pixels.into(), + }) + } +} + +struct CachedImage { + binding: wgpu::BindGroup, + bytes: u64, +} + +struct Batch { + vertices: Range, + image: Option, + scissor: [u32; 4], +} + +#[repr(C)] +#[derive(Clone, Copy, Pod, Zeroable)] +struct Vertex { + position: [f32; 2], + uv: [f32; 2], + color: [f32; 4], + local: [f32; 2], + shape: [f32; 4], + stroke: f32, +} + +#[derive(Hash, PartialEq, Eq)] +enum AtlasKey { + Text { + font: u64, + index: u32, + glyph: u16, + size: u32, + coords: Vec, + phase: [u8; 2], + embolden: bool, + skew: u32, + }, + Tag { + icon: TagIcon, + size: u32, + phase: [u8; 2], + }, +} + +#[derive(Clone, Copy)] +struct AtlasGlyph { + x: u32, + y: u32, + width: u32, + height: u32, + left: i32, + top: i32, + color: bool, +} + +#[derive(Clone, Copy)] +pub struct Viewport { + pub size: [u32; 2], + /// Physical pixels per document point. + pub scale: f32, + /// Physical pixel position of the document origin. + pub origin: [f32; 2], +} + +impl Viewport { + pub fn document_point(&self, point: [f32; 2]) -> [f32; 2] { + [ + (point[0] - self.origin[0]) / self.scale, + (point[1] - self.origin[1]) / self.scale, + ] + } + + fn visible_image_rect(&self, rect: [f32; 4]) -> Result, RenderError> { + let rect = [ + rect[0] * self.scale + self.origin[0], + rect[1] * self.scale + self.origin[1], + rect[2] * self.scale + self.origin[0], + rect[3] * self.scale + self.origin[1], + ]; + if rect.iter().any(|v| !v.is_finite()) || rect[2] < rect[0] || rect[3] < rect[1] { + return Err(RenderError::InvalidPrimitive); + } + Ok((rect[0] < self.size[0] as f32 + && rect[1] < self.size[1] as f32 + && rect[2] > 0.0 + && rect[3] > 0.0 + && rect[0] < rect[2] + && rect[1] < rect[3]) + .then_some(rect)) + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Stroke { + Solid(f32), + /// Dash and gap lengths are twice the stroke width. + Dashed(f32), +} + +/// Coordinates are document points; colors are linear RGBA. +pub enum Primitive<'a> { + Tag { + tag: &'a ParagraphTag, + origin: [f32; 2], + }, + Text { + layout: &'a TextLayout, + origin: [f32; 2], + /// Document-space paint bounds preserve the full shaping and caret advances. + clip: Option<[f32; 4]>, + }, + Rect { + rect: [f32; 4], + color: [f32; 4], + }, + RoundedRect { + rect: [f32; 4], + radius: [f32; 2], + /// None fills the shape; stroke widths are positive document points. + stroke: Option, + color: [f32; 4], + }, + Image { + image: &'a RasterImage, + rect: [f32; 4], + }, +} + +#[derive(Debug)] +pub enum RenderError { + AtlasFull, + FrameTooLarge, + UnreadableFont, + UnsupportedGlyph, + InvalidViewport, + InvalidPrimitive, + InvalidImage, + ImageBudget, + ImageTooLarge, + ImageDecode(image::ImageError), +} + +pub struct Renderer { + pub device: wgpu::Device, + pub queue: wgpu::Queue, + pipeline: wgpu::RenderPipeline, + image_pipeline: wgpu::RenderPipeline, + atlas: wgpu::Texture, + bind_group: wgpu::BindGroup, + image_sampler: wgpu::Sampler, + vertex_buffer: wgpu::Buffer, + vertices: Vec, + glyphs: HashMap>, + images: HashMap, + batches: Vec, + scaler: ScaleContext, + pen: [u32; 2], + row_height: u32, +} + +impl Renderer { + pub fn new(device: wgpu::Device, queue: wgpu::Queue, format: wgpu::TextureFormat) -> Self { + let shader = device.create_shader_module(wgpu::include_wgsl!("canvas.wgsl")); + let binding_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("Canvas texture"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("Canvas"), + bind_group_layouts: &[Some(&binding_layout)], + ..Default::default() + }); + let make_pipeline = |blend| { + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("Canvas"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vertex"), + compilation_options: Default::default(), + buffers: &[Some(wgpu::VertexBufferLayout { + array_stride: size_of::() as u64, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Float32x4, 3 => Float32x2, 4 => Float32x4, 5 => Float32], + })], + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fragment"), + compilation_options: Default::default(), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: Some(blend), + write_mask: wgpu::ColorWrites::ALL, + })], + }), + primitive: Default::default(), + depth_stencil: None, + multisample: Default::default(), + multiview_mask: None, + cache: None, + }) + }; + let pipeline = make_pipeline(wgpu::BlendState::ALPHA_BLENDING); + let image_pipeline = make_pipeline(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING); + let atlas = device.create_texture(&wgpu::TextureDescriptor { + label: Some("Glyph atlas"), + size: wgpu::Extent3d { + width: ATLAS_SIZE, + height: ATLAS_SIZE, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8UnormSrgb, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + let view = atlas.create_view(&Default::default()); + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + mag_filter: wgpu::FilterMode::Nearest, + min_filter: wgpu::FilterMode::Nearest, + ..Default::default() + }); + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Glyph atlas"), + layout: &pipeline.get_bind_group_layout(0), + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&sampler), + }, + ], + }); + let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Canvas vertices"), + size: (MAX_VERTICES * size_of::()) as u64, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let image_sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("Canvas image filtering"), + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + ..Default::default() + }); + queue.write_texture( + atlas.as_image_copy(), + &[255; 4], + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(4), + rows_per_image: Some(1), + }, + wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + ); + Self { + device, + queue, + pipeline, + image_pipeline, + atlas, + bind_group, + image_sampler, + vertex_buffer, + vertices: Vec::new(), + glyphs: HashMap::new(), + images: HashMap::new(), + batches: Vec::new(), + scaler: ScaleContext::with_max_entries(32), + pen: [1, 0], + row_height: 1, + } + } + + pub fn clear_glyph_cache(&mut self) { + self.glyphs.clear(); + self.pen = [1, 0]; + self.row_height = 1; + } + + pub fn draw( + &mut self, + target: &wgpu::TextureView, + viewport: Viewport, + primitives: &[Primitive<'_>], + ) -> Result<(), RenderError> { + if viewport.size.contains(&0) + || !viewport.scale.is_finite() + || viewport.scale <= 0.0 + || viewport.origin.iter().any(|v| !v.is_finite()) + { + return Err(RenderError::InvalidViewport); + } + let mut active_images = HashSet::new(); + let mut image_bytes = 0; + for primitive in primitives { + if let Primitive::Image { image, rect } = primitive + && viewport.visible_image_rect(*rect)?.is_some() + && active_images.insert(image.pixels.id()) + { + image_bytes += image.pixels.as_ref().len() as u64; + if image_bytes > MAX_IMAGE_BYTES || active_images.len() > MAX_IMAGES { + return Err(RenderError::ImageBudget); + } + } + } + for attempt in 0..2 { + self.vertices.clear(); + self.batches.clear(); + match self.prepare(viewport, primitives, &active_images) { + Err(RenderError::AtlasFull) if attempt == 0 => self.clear_glyph_cache(), + result => { + result?; + break; + } + } + } + self.queue + .write_buffer(&self.vertex_buffer, 0, bytemuck::cast_slice(&self.vertices)); + let mut encoder = self.device.create_command_encoder(&Default::default()); + { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("Canvas"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: target, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::WHITE), + store: wgpu::StoreOp::Store, + }, + })], + ..Default::default() + }); + pass.set_vertex_buffer(0, self.vertex_buffer.slice(..)); + for batch in &self.batches { + let [x, y, width, height] = batch.scissor; + pass.set_scissor_rect(x, y, width, height); + pass.set_pipeline(if batch.image.is_some() { + &self.image_pipeline + } else { + &self.pipeline + }); + let binding = batch + .image + .map(|id| &self.images[&id].binding) + .unwrap_or(&self.bind_group); + pass.set_bind_group(0, binding, &[]); + pass.draw(batch.vertices.clone(), 0..1); + } + } + self.queue.submit([encoder.finish()]); + Ok(()) + } + + fn prepare( + &mut self, + viewport: Viewport, + primitives: &[Primitive<'_>], + active_images: &HashSet, + ) -> Result<(), RenderError> { + for primitive in primitives { + let start = self.vertices.len() as u32; + let mut image_id = None; + let mut scissor = [0, 0, viewport.size[0], viewport.size[1]]; + match primitive { + Primitive::Tag { tag, origin } => { + let origin = [ + viewport.origin[0] + (origin[0] + tag.origin[0]) * viewport.scale, + viewport.origin[1] + (origin[1] + tag.origin[1]) * viewport.scale, + ]; + self.tag(Viewport { origin, ..viewport }, tag)?; + } + Primitive::Text { + layout, + origin, + clip, + } => { + let origin = [ + viewport.origin[0] + origin[0] * viewport.scale, + viewport.origin[1] + origin[1] * viewport.scale, + ]; + if origin.iter().any(|v| !v.is_finite()) { + return Err(RenderError::InvalidPrimitive); + } + if let Some(clip) = clip { + let Some(rect) = viewport.visible_image_rect(*clip)? else { + continue; + }; + let left = rect[0].max(0.0).floor() as u32; + let top = rect[1].max(0.0).floor() as u32; + let right = rect[2].min(viewport.size[0] as f32).ceil() as u32; + let bottom = rect[3].min(viewport.size[1] as f32).ceil() as u32; + scissor = [left, top, right - left, bottom - top]; + } + self.text(Viewport { origin, ..viewport }, layout)?; + } + Primitive::Rect { rect, color } => { + self.quad( + viewport, + [ + rect[0] * viewport.scale + viewport.origin[0], + rect[1] * viewport.scale + viewport.origin[1], + rect[2] * viewport.scale + viewport.origin[0], + rect[3] * viewport.scale + viewport.origin[1], + ], + [0.5 / ATLAS_SIZE as f32; 4], + *color, + )?; + } + Primitive::RoundedRect { + rect, + radius, + stroke, + color, + } => { + if radius + .iter() + .any(|value| !value.is_finite() || *value < 0.0) + { + return Err(RenderError::InvalidPrimitive); + } + let width = match stroke { + None => 0.0, + Some(Stroke::Solid(width) | Stroke::Dashed(width)) => { + if !width.is_finite() || *width <= 0.0 || width * viewport.scale == 0.0 + { + return Err(RenderError::InvalidPrimitive); + } + *width + } + }; + let rect = [ + rect[0] * viewport.scale + viewport.origin[0], + rect[1] * viewport.scale + viewport.origin[1], + rect[2] * viewport.scale + viewport.origin[0], + rect[3] * viewport.scale + viewport.origin[1], + ]; + self.quad(viewport, rect, [0.5 / ATLAS_SIZE as f32; 4], *color)?; + let half = [(rect[2] - rect[0]) * 0.5, (rect[3] - rect[1]) * 0.5]; + if !(4.0 * (half[0] + half[1])).is_finite() { + return Err(RenderError::InvalidPrimitive); + } + let radius = if radius.contains(&0.0) { + [0.0; 2] + } else { + std::array::from_fn(|axis| (radius[axis] * viewport.scale).min(half[axis])) + }; + let width = (width * viewport.scale).min(half[0].min(half[1])); + // The stroke sign selects its dash pattern at the shader boundary. + let width = if matches!(stroke, Some(Stroke::Dashed(_))) { + -width + } else { + width + }; + for vertex in &mut self.vertices[start as usize..] { + vertex.shape = [half[0], half[1], radius[0], radius[1]]; + vertex.stroke = width; + } + } + Primitive::Image { image, rect } => { + if let Some(rect) = viewport.visible_image_rect(*rect)? { + self.image(image, active_images)?; + image_id = Some(image.pixels.id()); + self.quad(viewport, rect, [0.0, 0.0, 1.0, 1.0], [1.0; 4])?; + } + } + } + let end = self.vertices.len() as u32; + if start != end { + if let Some(last) = self.batches.last_mut() + && last.image == image_id + && last.scissor == scissor + { + last.vertices.end = end; + } else { + self.batches.push(Batch { + vertices: start..end, + image: image_id, + scissor, + }); + } + } + } + Ok(()) + } + + fn image(&mut self, image: &RasterImage, active: &HashSet) -> Result<(), RenderError> { + if self.images.contains_key(&image.pixels.id()) { + return Ok(()); + } + if image + .size + .iter() + .any(|v| *v > self.device.limits().max_texture_dimension_2d) + { + return Err(RenderError::ImageTooLarge); + } + let bytes = image.pixels.as_ref().len() as u64; + while self.images.values().map(|i| i.bytes).sum::() + bytes > MAX_IMAGE_BYTES + || self.images.len() >= MAX_IMAGES + { + let id = self + .images + .keys() + .find(|id| !active.contains(id)) + .copied() + .ok_or(RenderError::ImageBudget)?; + self.images.remove(&id); + } + let size = wgpu::Extent3d { + width: image.size[0], + height: image.size[1], + depth_or_array_layers: 1, + }; + let texture = self.device.create_texture(&wgpu::TextureDescriptor { + label: Some("Canvas image"), + size, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8UnormSrgb, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + self.queue.write_texture( + texture.as_image_copy(), + image.pixels.as_ref(), + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(image.size[0] * 4), + rows_per_image: Some(image.size[1]), + }, + size, + ); + let binding = self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Canvas image"), + layout: &self.pipeline.get_bind_group_layout(0), + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView( + &texture.create_view(&Default::default()), + ), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&self.image_sampler), + }, + ], + }); + self.images + .insert(image.pixels.id(), CachedImage { binding, bytes }); + Ok(()) + } + + fn text(&mut self, viewport: Viewport, layout: &TextLayout) -> Result<(), RenderError> { + let scale = viewport.scale; + let origin = viewport.origin; + for (line, line_box) in layout.lines() { + let top = line_box.top * scale + origin[1]; + if top + line_box.height * scale < 0.0 || top > viewport.size[1] as f32 { + continue; + } + for item in line.items() { + let PositionedLayoutItem::GlyphRun(glyph_run) = item else { + continue; + }; + let run = glyph_run.run(); + let data = &run.font().font; + let size = run.font_size() * scale; + if !size.is_finite() || size > ATLAS_SIZE as f32 { + return Err(RenderError::AtlasFull); + } + let synthesis = run.synthesis(); + let coords: Vec = run + .normalized_coords() + .iter() + .map(|c| c.to_bits()) + .collect(); + let color = glyph_run.style().brush.color; + for glyph in glyph_run.positioned_glyphs() { + let x = glyph.x * scale + origin[0]; + let y = + (glyph.y + line_box.baseline - line.metrics().baseline) * scale + origin[1]; + // Quantization affects raster coverage only, never advances or line breaks. + let x = (x * 4.0).round() * 0.25; + let y = (y * 4.0).round() * 0.25; + let glyph_id = glyph + .id + .try_into() + .map_err(|_| RenderError::UnsupportedGlyph)?; + let phase = [((x - x.floor()) * 4.0) as u8, ((y - y.floor()) * 4.0) as u8]; + let key = AtlasKey::Text { + font: data.data.id(), + index: data.index, + glyph: glyph_id, + size: size.to_bits(), + coords: coords.clone(), + phase, + embolden: synthesis.embolden(), + skew: synthesis.skew().unwrap_or(0.0).to_bits(), + }; + let cached = if let Some(cached) = self.glyphs.get(&key) { + *cached + } else { + if self.glyphs.len() >= MAX_GLYPHS { + return Err(RenderError::AtlasFull); + } + let font = FontRef::from_index(data.data.as_ref(), data.index as usize) + .ok_or(RenderError::UnreadableFont)?; + let mut scaler = self + .scaler + .builder_with_id(font, [data.data.id(), u64::from(data.index)]) + .size(size) + .hint(false) + .normalized_coords(coords.iter().copied()) + .build(); + let mut render = Render::new(&[ + Source::ColorOutline(0), + Source::ColorBitmap(StrikeWith::BestFit), + Source::Outline, + ]); + // Swash rasterizes outlines in Y-up coordinates. + render.format(Format::Alpha).offset(Vector::new( + f32::from(phase[0]) * 0.25, + -f32::from(phase[1]) * 0.25, + )); + if synthesis.embolden() { + render.embolden(size / 24.0); + } + if let Some(skew) = synthesis.skew() { + render.transform(Some(Transform::skew( + Angle::from_degrees(skew), + Angle::from_degrees(0.0), + ))); + } + let image = render.render(&mut scaler, glyph_id); + let cached = if let Some(image) = + image.filter(|i| i.placement.width > 0 && i.placement.height > 0) + { + Some(self.upload(image)?) + } else { + None + }; + self.glyphs.insert(key, cached); + cached + }; + if let Some(glyph) = cached { + let mut left = x.floor() + glyph.left as f32; + let mut top = y.floor() - glyph.top as f32; + let mut width = glyph.width as f32; + let mut height = glyph.height as f32; + if glyph.color { + let line_top = line_box.top * scale + origin[1]; + let line_height = line_box.height * scale; + let ratio = (line_height / height).min(1.0); + left += width * (1.0 - ratio) * 0.5; + width *= ratio; + height *= ratio; + top = top + .clamp(line_top, (line_top + line_height - height).max(line_top)); + } + let atlas_size = ATLAS_SIZE as f32; + self.quad( + viewport, + [left, top, left + width, top + height], + [ + glyph.x as f32 / atlas_size, + glyph.y as f32 / atlas_size, + (glyph.x + glyph.width) as f32 / atlas_size, + (glyph.y + glyph.height) as f32 / atlas_size, + ], + if glyph.color { + [1.0; 4] + } else { + colorref(color) + }, + )?; + } + } + let metrics = run.font_metrics(); + for (decoration, offset, thickness) in [ + ( + &glyph_run.style().underline, + metrics.underline_offset, + metrics.underline_size, + ), + ( + &glyph_run.style().strikethrough, + metrics.strikethrough_offset, + metrics.strikethrough_size, + ), + ] { + if let Some(decoration) = decoration { + let x = glyph_run.offset() * scale + origin[0]; + let y = (line_box.baseline - decoration.offset.unwrap_or(offset)) * scale + + origin[1]; + self.quad( + viewport, + [ + x, + y, + x + glyph_run.advance() * scale, + y + (decoration.size.unwrap_or(thickness) * scale).max(1.0), + ], + [0.5 / ATLAS_SIZE as f32; 4], + colorref(decoration.brush.color), + )?; + } + } + } + } + Ok(()) + } + + fn upload(&mut self, image: swash::scale::image::Image) -> Result { + let p = image.placement; + if p.width + 2 > ATLAS_SIZE || p.height + 2 > ATLAS_SIZE { + return Err(RenderError::AtlasFull); + } + if self.pen[0] + p.width + 1 > ATLAS_SIZE { + self.pen[0] = 0; + self.pen[1] += self.row_height + 1; + self.row_height = 0; + } + if self.pen[1] + p.height + 1 > ATLAS_SIZE { + return Err(RenderError::AtlasFull); + } + let cached = AtlasGlyph { + x: self.pen[0], + y: self.pen[1], + width: p.width, + height: p.height, + left: p.left, + top: p.top, + color: image.content == Content::Color, + }; + let rgba = match image.content { + Content::Mask => image + .data + .iter() + .flat_map(|a| [255, 255, 255, *a]) + .collect(), + _ => image.data, + }; + self.queue.write_texture( + wgpu::TexelCopyTextureInfo { + origin: wgpu::Origin3d { + x: cached.x, + y: cached.y, + z: 0, + }, + ..self.atlas.as_image_copy() + }, + &rgba, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(p.width * 4), + rows_per_image: Some(p.height), + }, + wgpu::Extent3d { + width: p.width, + height: p.height, + depth_or_array_layers: 1, + }, + ); + self.pen[0] += p.width + 1; + self.row_height = self.row_height.max(p.height); + Ok(cached) + } + + fn tag(&mut self, viewport: Viewport, tag: &ParagraphTag) -> Result<(), RenderError> { + let size = ParagraphTag::SIZE * viewport.scale; + let [x, y] = viewport.origin.map(|v| (v * 4.0).round() * 0.25); + if !size.is_finite() || [x, y].iter().any(|v| !v.is_finite()) { + return Err(RenderError::InvalidPrimitive); + } + if x + size + 1.0 < 0.0 + || y + size + 1.0 < 0.0 + || x - 1.0 > viewport.size[0] as f32 + || y - 1.0 > viewport.size[1] as f32 + { + return Ok(()); + } + if size + 3.0 > ATLAS_SIZE as f32 { + return Err(RenderError::AtlasFull); + } + let phase = [((x - x.floor()) * 4.0) as u8, ((y - y.floor()) * 4.0) as u8]; + let key = AtlasKey::Tag { + icon: tag.icon, + size: size.to_bits(), + phase, + }; + let glyph = if let Some(Some(glyph)) = self.glyphs.get(&key) { + *glyph + } else { + if self.glyphs.len() >= MAX_GLYPHS { + return Err(RenderError::AtlasFull); + } + let glyph = self.upload(tags::rasterize(tag.icon, size, phase))?; + self.glyphs.insert(key, Some(glyph)); + glyph + }; + let left = x.floor(); + let top = y.floor(); + let atlas = ATLAS_SIZE as f32; + self.quad( + viewport, + [ + left, + top, + left + glyph.width as f32, + top + glyph.height as f32, + ], + [ + glyph.x as f32 / atlas, + glyph.y as f32 / atlas, + (glyph.x + glyph.width) as f32 / atlas, + (glyph.y + glyph.height) as f32 / atlas, + ], + [1.0, 1.0, 1.0, if tag.disabled { 0.45 } else { 1.0 }], + ) + } + + fn quad( + &mut self, + viewport: Viewport, + rect: [f32; 4], + uv: [f32; 4], + color: [f32; 4], + ) -> Result<(), RenderError> { + if rect.iter().chain(&color).any(|v| !v.is_finite()) + || rect[2] < rect[0] + || rect[3] < rect[1] + { + return Err(RenderError::InvalidPrimitive); + } + if rect[2] < 0.0 + || rect[3] < 0.0 + || rect[0] > viewport.size[0] as f32 + || rect[1] > viewport.size[1] as f32 + { + return Ok(()); + } + if self.vertices.len() + 6 > MAX_VERTICES { + return Err(RenderError::FrameTooLarge); + } + let x0 = rect[0] * 2.0 / viewport.size[0] as f32 - 1.0; + let x1 = rect[2] * 2.0 / viewport.size[0] as f32 - 1.0; + let y0 = 1.0 - rect[1] * 2.0 / viewport.size[1] as f32; + let y1 = 1.0 - rect[3] * 2.0 / viewport.size[1] as f32; + if [x0, x1, y0, y1].iter().any(|v| !v.is_finite()) { + return Err(RenderError::InvalidPrimitive); + } + let [hx, hy] = [(rect[2] - rect[0]) * 0.5, (rect[3] - rect[1]) * 0.5]; + for (position, uv, local) in [ + ([x0, y0], [uv[0], uv[1]], [-hx, -hy]), + ([x0, y1], [uv[0], uv[3]], [-hx, hy]), + ([x1, y1], [uv[2], uv[3]], [hx, hy]), + ([x0, y0], [uv[0], uv[1]], [-hx, -hy]), + ([x1, y1], [uv[2], uv[3]], [hx, hy]), + ([x1, y0], [uv[2], uv[1]], [hx, -hy]), + ] { + self.vertices.push(Vertex { + position, + uv, + color, + local, + shape: [0.0; 4], + stroke: 0.0, + }); + } + Ok(()) + } +} + +fn srgb_byte(value: f32) -> u8 { + let srgb = if value <= 0.0031308 { + value * 12.92 + } else { + 1.055 * value.powf(1.0 / 2.4) - 0.055 + }; + (srgb * 255.0).round() as u8 +} + +pub fn colorref(color: u32) -> [f32; 4] { + let linear = |byte: u32| { + let value = (byte & 255) as f32 / 255.0; + if value <= 0.04045 { + value / 12.92 + } else { + ((value + 0.055) / 1.055).powf(2.4) + } + }; + [linear(color), linear(color >> 8), linear(color >> 16), 1.0] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{layout::TextEngine, text::Paragraph}; + use onestore::document::Format as TextFormat; + use std::time::Duration; + + #[test] + fn decoding_preserves_pixels_and_rejects_large_or_truncated_images() { + let mut encoded = Vec::new(); + let mut encoder = png::Encoder::new(&mut encoded, 2, 1); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + let rgba = [255, 64, 0, 255, 0, 128, 255, 128]; + encoder + .write_header() + .unwrap() + .write_image_data(&rgba) + .unwrap(); + let decoded = RasterImage::decode(&encoded).unwrap(); + assert_eq!(decoded.size, [2, 1]); + assert_eq!(decoded.pixels.as_ref(), [255, 64, 0, 255, 0, 93, 188, 128]); + for end in 0..encoded.len() - 12 { + assert!(RasterImage::decode(&encoded[..end]).is_err(), "{end}"); + } + + let mut jpeg = Vec::new(); + image::codecs::jpeg::JpegEncoder::new(&mut jpeg) + .encode(&[255; 4 * 4 * 3], 4, 4, image::ExtendedColorType::Rgb8) + .unwrap(); + let decoded = RasterImage::decode(&jpeg).unwrap(); + assert_eq!(decoded.size, [4, 4]); + assert_eq!(decoded.pixels.as_ref(), [255; 4 * 4 * 4]); + + for size in [[8192, 8192], [16_385, 1]] { + let mut header = Vec::new(); + let mut encoder = png::Encoder::new(&mut header, size[0], size[1]); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + let mut writer = encoder.write_header().unwrap(); + writer.write_chunk(png::chunk::IDAT, &[]).unwrap(); + drop(writer); + let error = RasterImage::decode(&header).err().unwrap(); + if size[0] == 8192 { + assert!(matches!(error, RenderError::ImageBudget), "{error:?}"); + } else { + assert!( + matches!( + error, + RenderError::ImageDecode(image::ImageError::Limits(_)) + ), + "{error:?}" + ); + } + } + } + + #[test] + fn image_pixels_have_valid_dimensions_and_immutable_clone_identity() { + assert!(matches!( + RasterImage::new([0, 1], Vec::new()), + Err(RenderError::InvalidImage) + )); + assert!(matches!( + RasterImage::new([2, 2], vec![255; 15]), + Err(RenderError::InvalidImage) + )); + assert!(matches!( + RasterImage::new([u32::MAX; 2], Vec::new()), + Err(RenderError::InvalidImage) + )); + let image = RasterImage::new([1, 1], vec![1, 2, 3, 255]).unwrap(); + assert_eq!(image.pixels.id(), image.clone().pixels.id()); + } + + #[test] + #[ignore = "requires a native GPU adapter"] + fn eviction_and_repaint_reproduce_pixels() { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap(); + let (device, queue) = + pollster::block_on(adapter.request_device(&Default::default())).unwrap(); + let format = wgpu::TextureFormat::Rgba8UnormSrgb; + let target = device.create_texture(&wgpu::TextureDescriptor { + label: Some("Canvas readback test"), + size: wgpu::Extent3d { + width: 512, + height: 256, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let readback = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Canvas readback"), + size: 512 * 256 * 4, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let mut renderer = Renderer::new(device, queue, format); + let mut engine = TextEngine::default(); + let layout = engine + .layout( + &Paragraph::new( + "Hello 🌳\nCafé e\u{301} שלום".into(), + TextFormat { + underline: Some(true), + ..TextFormat::default() + }, + ), + 220.0, + ) + .unwrap(); + let viewport = Viewport { + size: [512, 256], + scale: 2.0, + origin: [24.0; 2], + }; + let mut captures = Vec::new(); + let image = RasterImage::new( + [2, 2], + vec![255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 0, 0, 0, 0], + ) + .unwrap(); + let transparent_edge = + RasterImage::new([2, 1], vec![255, 0, 0, 255, 0, 0, 255, 0]).unwrap(); + let tags = [ + TagIcon::CheckBox { checked: false }, + TagIcon::CheckBox { checked: true }, + TagIcon::Question, + TagIcon::Music, + ] + .map(|icon| ParagraphTag { + icon, + origin: [0.0; 2], + label: String::new(), + disabled: false, + }); + let phase_layout = engine + .layout( + &Paragraph::new( + "H".into(), + TextFormat { + font: Some("Arial".into()), + font_size: Some(11.0), + ..TextFormat::default() + }, + ), + 20.0, + ) + .unwrap(); + let mut primitives = vec![ + Primitive::Rect { + rect: [0.0, 0.0, 70.0, 14.0], + color: [0.55, 0.73, 1.0, 0.5], + }, + Primitive::Text { + clip: None, + layout: &layout, + origin: [0.0; 2], + }, + Primitive::Text { + clip: None, + layout: &layout, + origin: [8.0, 64.0], + }, + Primitive::Image { + image: &image, + rect: [128.0, 8.0, 160.0, 40.0], + }, + Primitive::Rect { + rect: [128.0, 8.0, 132.0, 12.0], + color: [0.0, 0.0, 1.0, 1.0], + }, + Primitive::Image { + image: &transparent_edge, + rect: [176.0, 8.0, 208.0, 40.0], + }, + Primitive::RoundedRect { + rect: [128.0, 48.0, 148.0, 68.0], + radius: [5.0, 10.0], + stroke: None, + color: [1.0, 0.0, 0.0, 1.0], + }, + Primitive::RoundedRect { + rect: [164.0, 48.0, 184.0, 68.0], + radius: [5.0; 2], + stroke: Some(Stroke::Solid(1.0)), + color: [0.0, 0.0, 1.0, 1.0], + }, + Primitive::RoundedRect { + rect: [200.0, 48.0, 240.0, 68.0], + radius: [3.0, 10.0], + stroke: Some(Stroke::Dashed(1.0)), + color: [0.0, 0.0, 1.0, 1.0], + }, + Primitive::Tag { + tag: &tags[0], + origin: [128.125, 80.25], + }, + Primitive::Tag { + tag: &tags[1], + origin: [148.125, 80.25], + }, + Primitive::Tag { + tag: &tags[2], + origin: [168.125, 80.25], + }, + Primitive::Tag { + tag: &tags[3], + origin: [188.125, 80.25], + }, + ]; + primitives.extend((0..9).map(|step| Primitive::Text { + clip: None, + layout: &phase_layout, + origin: [8.0 + 24.0 * step as f32, 96.0 + 0.125 * step as f32], + })); + for pass in 0..6 { + if pass == 2 { + renderer.clear_glyph_cache(); + renderer.images.clear(); + } else if pass == 3 { + renderer = Renderer::new(renderer.device.clone(), renderer.queue.clone(), format); + } + let clipping = [ + Primitive::Text { + layout: &layout, + origin: [0.0; 2], + clip: (pass == 5).then_some([2.5, 1.25, 30.75, 20.5]), + }, + Primitive::Rect { + rect: [40.0, 40.0, 70.0, 50.0], + color: [0.0, 0.0, 1.0, 1.0], + }, + ]; + renderer + .draw( + &target.create_view(&Default::default()), + viewport, + if pass < 4 { &primitives } else { &clipping }, + ) + .unwrap(); + let mut encoder = renderer.device.create_command_encoder(&Default::default()); + encoder.copy_texture_to_buffer( + target.as_image_copy(), + wgpu::TexelCopyBufferInfo { + buffer: &readback, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(512 * 4), + rows_per_image: Some(256), + }, + }, + wgpu::Extent3d { + width: 512, + height: 256, + depth_or_array_layers: 1, + }, + ); + renderer.queue.submit([encoder.finish()]); + let (sender, receiver) = std::sync::mpsc::channel(); + readback.map_async(wgpu::MapMode::Read, .., move |result| { + sender.send(result).unwrap(); + }); + renderer + .device + .poll(wgpu::PollType::Wait { + submission_index: None, + timeout: Some(Duration::from_secs(5)), + }) + .unwrap(); + receiver + .recv_timeout(Duration::from_secs(5)) + .unwrap() + .unwrap(); + captures.push(readback.get_mapped_range(..).unwrap().to_vec()); + readback.unmap(); + } + let centroids: Vec<_> = (0..9) + .map(|step| { + let mut weight = 0.0_f64; + let mut moment = 0.0_f64; + for y in 212..252 { + for x in (40 + 48 * step)..(72 + 48 * step) { + let value = captures[0][(y * 512 + x) * 4]; + let coverage = 1.0 - f64::from(colorref(u32::from(value))[0]); + weight += coverage; + moment += coverage * y as f64; + } + } + assert!(weight > 10.0, "phase sample {step} was not painted"); + moment / weight + }) + .collect(); + let pixel = |x: usize, y: usize| &captures[0][(y * 512 + x) * 4..(y * 512 + x) * 4 + 4]; + assert_eq!(pixel(300, 140), [255, 0, 0, 255]); + assert_eq!(pixel(280, 120), [255; 4]); + assert_eq!(pixel(282, 124), [255; 4]); + assert_eq!(pixel(281, 140), [255, 0, 0, 255]); + assert_eq!(pixel(372, 140), [255; 4]); + assert_eq!(pixel(372, 120), [0, 0, 255, 255]); + assert_eq!(pixel(352, 120), [255; 4]); + assert_eq!(pixel(424, 120), [255; 4]); + assert_eq!(pixel(464, 140), [255; 4]); + assert!((440..480).any(|x| pixel(x, 120)[0] < 40)); + assert!((440..480).any(|x| pixel(x, 120)[0] > 250)); + for x in 440..472 { + assert!(pixel(x, 120)[0].abs_diff(pixel(x + 8, 120)[0]) <= 1); + } + for (step, centroid) in centroids.iter().enumerate() { + let movement = centroid - centroids[0]; + assert!( + (movement - step as f64 * 0.25).abs() < 0.06, + "quarter-pixel step {step} moved the glyph by {movement} px" + ); + } + assert!(!renderer.glyphs.is_empty()); + assert!(renderer.glyphs.len() <= MAX_GLYPHS); + assert_eq!( + renderer + .glyphs + .keys() + .filter(|key| matches!(key, AtlasKey::Tag { .. })) + .count(), + 4 + ); + for x in [280, 320, 360, 400] { + let colored = (184..210) + .flat_map(|y| (x..x + 26).map(move |x| (y * 512 + x) * 4)) + .filter(|offset| captures[0][*offset..*offset + 3].iter().any(|v| *v < 180)) + .count(); + assert!(colored > 10, "tag at {x} was not painted"); + } + let count = renderer.glyphs.len(); + renderer + .draw( + &target.create_view(&Default::default()), + viewport, + &[Primitive::Tag { + tag: &tags[0], + origin: [10000.0; 2], + }], + ) + .unwrap(); + assert_eq!(renderer.glyphs.len(), count); + assert!( + captures[0] + .chunks_exact(4) + .filter(|pixel| pixel[0] < 200) + .count() + > 100 + ); + assert_eq!(captures[0], captures[1]); + assert_eq!(captures[0], captures[2]); + assert_eq!(captures[0], captures[3]); + assert_ne!(captures[4], captures[5]); + for y in 0..256 { + for x in 0..512 { + let offset = (y * 512 + x) * 4; + let expected = if ((29..86).contains(&x) && (26..65).contains(&y)) + || ((104..164).contains(&x) && (104..124).contains(&y)) + { + &captures[4][offset..offset + 4] + } else { + &[255; 4] + }; + assert_eq!( + &captures[5][offset..offset + 4], + expected, + "clipping at {x},{y}" + ); + } + } + for (x, y, color) in [ + (288, 48, [255, 0, 0, 255]), + (336, 96, [255; 4]), + (284, 44, [0, 0, 255, 255]), + ] { + let offset = (y * 512 + x) * 4; + assert_eq!(captures[0][offset..offset + 4], color); + } + let edge = &captures[0][(48 * 512 + 408) * 4..(48 * 512 + 408) * 4 + 4]; + assert_eq!(edge[0], 255, "{edge:?}"); + assert_eq!(edge[1], edge[2], "{edge:?}"); + assert!((180..=200).contains(&edge[1]), "{edge:?}"); + assert_eq!(renderer.images.len(), 2); + for primitive in [ + Primitive::RoundedRect { + rect: [-1e38, 0.0, 1e38, 1e38], + radius: [3.0; 2], + stroke: Some(Stroke::Dashed(1.0)), + color: [1.0; 4], + }, + Primitive::RoundedRect { + rect: [0.0, 0.0, 10.0, 10.0], + radius: [f32::NAN, 2.0], + stroke: None, + color: [1.0; 4], + }, + Primitive::RoundedRect { + rect: [0.0, 0.0, 10.0, 10.0], + radius: [2.0; 2], + stroke: Some(Stroke::Solid(0.0)), + color: [1.0; 4], + }, + Primitive::RoundedRect { + rect: [0.0, 0.0, 10.0, 10.0], + radius: [2.0; 2], + stroke: Some(Stroke::Dashed(f32::NAN)), + color: [1.0; 4], + }, + Primitive::Rect { + rect: [0.0, 0.0, f32::NAN, 1.0], + color: [1.0; 4], + }, + Primitive::Rect { + rect: [2.0, 0.0, 1.0, 1.0], + color: [1.0; 4], + }, + Primitive::Text { + clip: None, + layout: &layout, + origin: [f32::MAX; 2], + }, + Primitive::Text { + clip: Some([0.0, 0.0, f32::NAN, 1.0]), + layout: &layout, + origin: [0.0; 2], + }, + Primitive::Text { + clip: Some([2.0, 0.0, 1.0, 1.0]), + layout: &layout, + origin: [0.0; 2], + }, + ] { + assert!(matches!( + renderer.draw( + &target.create_view(&Default::default()), + viewport, + &[primitive] + ), + Err(RenderError::InvalidPrimitive) + )); + } + let too_many: Vec<_> = (0..=MAX_IMAGES) + .map(|_| RasterImage::new([1, 1], vec![255; 4]).unwrap()) + .collect(); + let primitives: Vec<_> = too_many + .iter() + .map(|image| Primitive::Image { + image, + rect: [0.0, 0.0, 1.0, 1.0], + }) + .collect(); + assert!(matches!( + renderer.draw( + &target.create_view(&Default::default()), + viewport, + &primitives + ), + Err(RenderError::ImageBudget) + )); + let primitives: Vec<_> = too_many + .iter() + .map(|image| Primitive::Image { + image, + rect: [-20.0, -20.0, -19.0, -19.0], + }) + .collect(); + renderer + .draw( + &target.create_view(&Default::default()), + viewport, + &primitives, + ) + .unwrap(); + assert_eq!(renderer.images.len(), 2); + let mut seen = HashSet::from([image.pixels.id(), transparent_edge.pixels.id()]); + let mut previous = None; + for _ in 0..5 { + let large = RasterImage::new([2048; 2], vec![255; 2048 * 2048 * 4]).unwrap(); + seen.insert(large.pixels.id()); + let mut primitives = vec![Primitive::Image { + image: &large, + rect: [0.0, 0.0, 16.0, 16.0], + }]; + if let Some(image) = &previous { + primitives.push(Primitive::Image { + image, + rect: [16.0, 0.0, 32.0, 16.0], + }); + } + renderer + .draw( + &target.create_view(&Default::default()), + viewport, + &primitives, + ) + .unwrap(); + assert!(renderer.images.contains_key(&large.pixels.id())); + if let Some(image) = &previous { + assert!(renderer.images.contains_key(&image.pixels.id())); + } + assert!(renderer.images.values().map(|i| i.bytes).sum::() <= MAX_IMAGE_BYTES); + previous = Some(large); + } + assert!(renderer.images.len() <= MAX_IMAGES); + assert!(seen.iter().any(|id| !renderer.images.contains_key(id))); + renderer.images.clear(); + renderer + .draw( + &target.create_view(&Default::default()), + viewport, + &[Primitive::Image { + image: &image, + rect: [128.0, 8.0, 160.0, 40.0], + }], + ) + .unwrap(); + assert_eq!(renderer.images.len(), 1); + assert!(renderer.images.contains_key(&image.pixels.id())); + } +} diff --git a/crates/canvas/src/gpu/page.rs b/crates/canvas/src/gpu/page.rs new file mode 100644 index 0000000000000000000000000000000000000000..53057607da4494f1108e3dc3e75dd8b84b41eb11 --- /dev/null +++ b/crates/canvas/src/gpu/page.rs @@ -0,0 +1,1553 @@ +use super::{Primitive, RasterImage, RenderError, colorref}; +use crate::editor::ReadOnlyObject; +use crate::editor::page::Content; +use crate::{ + date::DateField, + editor::{CanvasEditor, EditorError}, + layout::{LayoutError, TextEngine}, + page::Page, +}; +use std::fmt; + +/// Retained drawing data in the source page's coordinate system. +pub struct PageScene { + reference: Option>, + images: std::collections::BTreeMap, +} + +#[derive(Debug, PartialEq)] +pub enum SceneHit { + Outline(T), + Date(DateField), + ReadOnly(usize), +} + +fn outline_origin( + mut origin: [f32; 2], + title: Option, + editor: Option<&CanvasEditor>, +) -> Result<[f32; 2], SceneError> { + if let Some(id) = title { + let title = editor + .and_then(|editor| editor.visible_outlines().find(|outline| outline.id == id)) + .ok_or(SceneError::MissingOutline)?; + origin[1] += title.bounds().y1 as f32; + } + Ok(origin) +} + +#[derive(Debug)] +pub enum SceneError { + Layout(LayoutError), + Editor(EditorError), + MissingOutline, + Image(RenderError), + MissingImage, + InvalidGeometry, +} + +impl fmt::Display for SceneError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Layout(error) => error.fmt(f), + Self::Editor(error) => error.fmt(f), + Self::MissingOutline => f.write_str("An editable outline is missing from this page."), + Self::Image(error) => write!(f, "Image rendering failed: {error:?}"), + Self::MissingImage => f.write_str("An image is missing its data or dimensions."), + Self::InvalidGeometry => { + f.write_str("The page contains invalid object dimensions or positions.") + } + } + } +} +impl std::error::Error for SceneError {} +impl From for SceneError { + fn from(error: LayoutError) -> Self { + Self::Layout(error) + } +} + +impl From for SceneError { + fn from(error: EditorError) -> Self { + match error { + EditorError::InvalidGeometry => Self::InvalidGeometry, + error => Self::Editor(error), + } + } +} + +impl PageScene { + pub fn new(mut page: Page, engine: &mut TextEngine) -> Result { + let (objects, _, _) = + crate::editor::page::build(&mut page, engine, false).map_err(SceneError::from)?; + let images = Self::decode_images(&objects)?; + Ok(Self { + reference: Some(objects), + images, + }) + } + + pub fn from_page( + page: Page, + engine: &mut TextEngine, + ) -> Result<(Self, CanvasEditor), SceneError> { + let editor = CanvasEditor::from_page(page, engine).map_err(SceneError::from)?; + let images = Self::decode_images(&editor.objects)?; + Ok(( + Self { + reference: None, + images, + }, + editor, + )) + } + + fn decode_images( + objects: &[Content], + ) -> Result, SceneError> { + let mut images = std::collections::BTreeMap::new(); + let mut bytes = 0_u64; + for object in objects { + if let Content::Image(source) = object { + let image = + RasterImage::decode(source.bytes.as_deref().ok_or(SceneError::MissingImage)?) + .map_err(SceneError::Image)?; + bytes += image.pixels.as_ref().len() as u64; + if bytes > super::MAX_IMAGE_BYTES { + return Err(SceneError::Image(RenderError::ImageBudget)); + } + if images.insert(source.id, image).is_some() { + return Err(SceneError::InvalidGeometry); + } + } + } + Ok(images) + } + + fn objects<'a>( + &'a self, + editor: Option<&'a CanvasEditor>, + ) -> Result<&'a [Content], SceneError> { + self.reference + .as_deref() + .or_else(|| editor.map(|editor| editor.objects.as_slice())) + .ok_or(SceneError::MissingOutline) + } + + pub fn read_only<'a>( + &'a self, + editor: Option<&'a CanvasEditor>, + ) -> impl Iterator { + self.objects(editor) + .into_iter() + .flatten() + .filter_map(|object| match object { + Content::ReadOnly(object) => Some(object.as_ref()), + _ => None, + }) + } + + /// Bounds of noneditable content in page coordinates. + pub fn content_bounds<'a>( + &'a self, + editor: &'a CanvasEditor, + ) -> impl Iterator + 'a { + self.objects(Some(editor)) + .into_iter() + .flatten() + .filter_map(move |object| match object { + Content::Outline { + source, + layout, + below_title, + } => { + let origin = outline_origin( + [ + source.layout.x.unwrap_or(0.0), + source.layout.y.unwrap_or(0.0), + ], + *below_title, + Some(editor), + ) + .ok()?; + Some([ + origin[0], + origin[1], + origin[0] + layout.size[0], + origin[1] + layout.size[1], + ]) + } + Content::Date { below_title } => { + let date = editor.date()?; + let origin = outline_origin( + [ + date.source().layout.x.unwrap_or(0.0), + date.source().layout.y.unwrap_or(0.0), + ], + *below_title, + Some(editor), + ) + .ok()?; + let size = date.layout().size; + Some([ + origin[0], + origin[1], + origin[0] + size[0], + origin[1] + size[1], + ]) + } + Content::Image(source) => Some([ + source.layout.x.unwrap_or(0.0), + source.layout.y.unwrap_or(0.0), + source.layout.x.unwrap_or(0.0) + source.layout.max_width?, + source.layout.y.unwrap_or(0.0) + source.layout.max_height?, + ]), + Content::ReadOnly(object) => Some(object.rect()), + Content::Editable(_) => None, + }) + } + + pub fn date_fields( + &self, + editor: &CanvasEditor, + ) -> impl Iterator { + let mut fields = [None; 2]; + if let Some(date) = editor.date() + && let Some((origin, title)) = self + .objects(Some(editor)) + .into_iter() + .flatten() + .find_map(|object| match object { + Content::Date { below_title } => Some(( + [ + date.source().layout.x.unwrap_or(0.0), + date.source().layout.y.unwrap_or(0.0), + ], + *below_title, + )), + _ => None, + }) + && let Ok(origin) = outline_origin(origin, title, Some(editor)) + { + for (slot, (field, paragraph)) in fields.iter_mut().zip( + date.fields() + .map(|(field, _)| field) + .zip(&date.layout().paragraphs), + ) { + let x = origin[0] + paragraph.origin[0]; + let y = origin[1] + paragraph.origin[1]; + let width = paragraph + .text + .lines() + .map(|(line, _)| line.metrics().advance) + .fold(0.0_f32, f32::max); + *slot = Some(( + field, + [x, y, x + width.max(12.0), y + paragraph.text.height()], + )); + } + } + fields.into_iter().flatten() + } + + /// The point and outline hit callback use scene coordinates. + pub fn hit_test( + &self, + point: [f32; 2], + editor: Option<&CanvasEditor>, + mut outline: impl FnMut(onestore::ExGuid) -> Option, + ) -> Option> { + let mut readonly = self.read_only(editor).count(); + for object in self.objects(editor).ok()?.iter().rev() { + match object { + Content::Date { .. } => { + if let Some(editor) = editor + && let Some((field, _)) = self.date_fields(editor).find(|(_, rect)| { + (rect[0]..=rect[2]).contains(&point[0]) + && (rect[1]..=rect[3]).contains(&point[1]) + }) + { + return Some(SceneHit::Date(field)); + } + } + Content::Editable(id) => { + if let Some(hit) = outline(*id) { + return Some(SceneHit::Outline(hit)); + } + } + Content::ReadOnly(object) => { + readonly -= 1; + let [x0, y0, x1, y1] = object.rect(); + if (x0..=x1).contains(&point[0]) && (y0..=y1).contains(&point[1]) { + return Some(SceneHit::ReadOnly(readonly)); + } + } + Content::Outline { .. } | Content::Image(_) => {} + } + } + None + } + + /// The offset is in document points. + pub fn append_primitives<'a>( + &'a self, + primitives: &mut Vec>, + offset: [f32; 2], + ) -> Result<(), SceneError> { + self.append_primitives_with(primitives, offset, None, |_, _, _| { + Err(SceneError::MissingOutline) + }) + } + + pub fn append_primitives_with<'a, E: From>( + &'a self, + primitives: &mut Vec>, + offset: [f32; 2], + editor: Option<&'a CanvasEditor>, + mut outline: impl FnMut(onestore::ExGuid, [f32; 2], &mut Vec>) -> Result<(), E>, + ) -> Result<(), E> { + for content in self.objects(editor)? { + let origin = match content { + Content::Outline { + source, + below_title, + .. + } => outline_origin( + [ + source.layout.x.unwrap_or(0.0), + source.layout.y.unwrap_or(0.0), + ], + *below_title, + editor, + )?, + Content::Date { below_title } => { + let date = editor + .and_then(CanvasEditor::date) + .ok_or(SceneError::MissingOutline)?; + outline_origin( + [ + date.source().layout.x.unwrap_or(0.0), + date.source().layout.y.unwrap_or(0.0), + ], + *below_title, + editor, + )? + } + Content::Image(source) => [ + source.layout.x.unwrap_or(0.0), + source.layout.y.unwrap_or(0.0), + ], + Content::ReadOnly(object) => { + let [x0, y0, x1, y1] = object.rect(); + let rect = [ + x0 + offset[0], + y0 + offset[1], + x1 + offset[0], + y1 + offset[1], + ]; + primitives.push(Primitive::Rect { + rect, + color: colorref(0x00e4ddd6), + }); + primitives.push(Primitive::Rect { + rect: [rect[0] + 1.0, rect[1] + 1.0, rect[2] - 1.0, rect[3] - 1.0], + color: colorref(0x00faf7f3), + }); + primitives.push(Primitive::Text { + clip: None, + layout: &object.label, + origin: [rect[0] + 8.0, rect[1] + 8.0], + }); + continue; + } + Content::Editable(id) => { + outline(*id, offset, primitives)?; + continue; + } + }; + let object_origin = [origin[0] + offset[0], origin[1] + offset[1]]; + match content { + Content::Image(source) => primitives.push(Primitive::Image { + image: self + .images + .get(&source.id) + .ok_or(SceneError::MissingImage)?, + rect: [ + object_origin[0], + object_origin[1], + object_origin[0] + + source.layout.max_width.ok_or(SceneError::MissingImage)?, + object_origin[1] + + source.layout.max_height.ok_or(SceneError::MissingImage)?, + ], + }), + Content::Outline { .. } | Content::Date { .. } => { + let outline = match content { + Content::Outline { layout, .. } => layout, + _ => editor + .and_then(CanvasEditor::date) + .ok_or(SceneError::MissingOutline)? + .layout(), + }; + outline.append_table_primitives(primitives, object_origin); + outline.append_background_primitives(primitives, object_origin); + for (index, paragraph) in outline.paragraphs.iter().enumerate() { + let origin = [ + object_origin[0] + paragraph.origin[0], + object_origin[1] + paragraph.origin[1], + ]; + let clip = outline.paragraph_cell(index).map(|cell| { + let [left, top, right, bottom] = cell.text_bounds(); + [ + left + object_origin[0], + top + object_origin[1], + right + object_origin[0], + bottom + object_origin[1], + ] + }); + primitives.push(Primitive::Text { + clip, + layout: ¶graph.text, + origin, + }); + for (layout, origin) in ¶graph.markers { + primitives.push(Primitive::Text { + clip, + layout, + origin: [ + object_origin[0] + origin[0], + object_origin[1] + (origin[1] + paragraph.origin[1]), + ], + }); + } + for tag in ¶graph.tags { + primitives.push(Primitive::Tag { + tag, + origin: [object_origin[0], object_origin[1] + paragraph.origin[1]], + }); + } + } + } + Content::Editable(_) | Content::ReadOnly(_) => unreachable!(), + } + } + Ok(()) + } +} + +impl crate::outline::OutlineLayout { + pub fn append_background_primitives( + &self, + primitives: &mut Vec>, + origin: [f32; 2], + ) { + for (index, paragraph) in self.paragraphs.iter().enumerate() { + for (mut rect, color) in paragraph.text.backgrounds() { + rect.x0 += f64::from(paragraph.origin[0]); + rect.x1 += f64::from(paragraph.origin[0]); + rect.y0 += f64::from(paragraph.origin[1]); + rect.y1 += f64::from(paragraph.origin[1]); + if let Some(cell) = self.paragraph_cell(index) { + let Some(clipped) = cell.clip(rect) else { + continue; + }; + rect = clipped; + } + primitives.push(Primitive::Rect { + rect: [ + rect.x0 as f32 + origin[0], + rect.y0 as f32 + origin[1], + rect.x1 as f32 + origin[0], + rect.y1 as f32 + origin[1], + ], + color: colorref(color), + }); + } + } + } + + pub fn append_table_primitives(&self, primitives: &mut Vec>, origin: [f32; 2]) { + for table in &self.tables { + let (Some(first), Some(last)) = (table.cells.first(), table.cells.last()) else { + continue; + }; + if !table.borders { + continue; + } + let bounds = [first.rect[0], first.rect[1], last.rect[2], last.rect[3]]; + let color = colorref(0x00a3a3a3); + primitives.push(Primitive::RoundedRect { + rect: [ + bounds[0] + origin[0] - 0.375, + bounds[1] + origin[1] - 0.375, + bounds[2] + origin[0] + 0.375, + bounds[3] + origin[1] + 0.375, + ], + radius: [3.6; 2], + stroke: Some(super::Stroke::Solid(0.75)), + color, + }); + for cell in &table.cells { + let [left, top, right, bottom] = cell.rect; + if right < bounds[2] { + primitives.push(Primitive::Rect { + rect: [ + right + origin[0] - 0.375, + top + origin[1], + right + origin[0] + 0.375, + bottom + origin[1], + ], + color, + }); + } + if bottom < bounds[3] { + primitives.push(Primitive::Rect { + rect: [ + left + origin[0], + bottom + origin[1] - 0.375, + right + origin[0], + bottom + origin[1] + 0.375, + ], + color, + }); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + editor::TextOutline, + page::{Image, PageObject}, + text::Paragraph, + }; + use onestore::{ExGuid, document::Layout}; + use std::{collections::BTreeMap, sync::Arc}; + + #[test] + fn title_editing_keeps_identity_and_date_geometry_through_composition_and_undo() { + use crate::{document::TextDocument, page::Title}; + let mut engine = TextEngine::default(); + let mut fields = ["Header", "September 8, 2026"].map(|text| { + let document = + TextDocument::new(vec![Paragraph::new(text.into(), Default::default())]).unwrap(); + TextOutline::new(&mut engine, document, 468.0, [0.0; 2]) + .unwrap() + .snapshot() + }); + for field in &mut fields { + field.layout.max_width = None; + field.layout.width_set_by_user = None; + field.layout.max_height = Some(21.6); + } + fields[0].title = true; + fields[0].min_width = Some(162.0); + fields[1].layout.x = Some(2.0); + fields[1].layout.y = Some(3.0); + let id = fields[0].id; + let source = fields[0].paragraphs.clone(); + let page = Page { + created: Some(1), + title: "Header".into(), + margin_origin: [36.0, 14.4], + definitions: BTreeMap::new(), + objects: vec![PageObject::Title(Title { + date: Some(fields[1].id), + id: ExGuid::default(), + layout: Layout { + x: Some(10.0), + y: Some(20.0), + ..Default::default() + }, + outlines: fields.into(), + })], + }; + let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap(); + assert!(editor.active_outline().title); + assert_eq!(editor.active_outline().origin(), [46.0, 34.4]); + assert_eq!(editor.active_outline().layout().max_width, None); + let before = scene.content_bounds(&editor).collect::>(); + assert_eq!(before[0][0], 48.0); + assert!((before[0][1] - 62.6).abs() < 0.00001); + let text = "A long title with multiple wrapped lines ".repeat(10); + editor.select_all().unwrap(); + let end = text.encode_utf16().count() as u32; + editor.compose(&mut engine, text.clone(), end..end).unwrap(); + let wrapped = scene.content_bounds(&editor).collect::>(); + assert!(wrapped[0][1] > before[0][1] + 20.0); + assert_eq!( + wrapped[0][1], + editor.active_outline().bounds().y1 as f32 + 3.0 + 3.6 + ); + editor.cancel_composition(&mut engine).unwrap(); + assert_eq!(editor.active_outline().document().nodes(), source); + assert_eq!(scene.content_bounds(&editor).collect::>(), before); + editor.commit_text(&mut engine, text).unwrap(); + assert_eq!(scene.content_bounds(&editor).collect::>(), wrapped); + assert_eq!(editor.active_outline().id, id); + assert_eq!( + editor.active_outline().document().nodes()[0].id, + source[0].id + ); + editor.undo(&mut engine).unwrap(); + assert_eq!(editor.active_outline().document().nodes(), source); + assert_eq!(scene.content_bounds(&editor).collect::>(), before); + editor.redo(&mut engine).unwrap(); + editor.select_all().unwrap(); + editor.insert(&mut engine, "").unwrap(); + assert_eq!(editor.outlines().len(), 1); + assert!(editor.caret_outline().is_none()); + assert_eq!(editor.active_outline().id, id); + assert_eq!(scene.content_bounds(&editor).collect::>(), before); + assert!(editor.move_outline(id, [0.0; 2]).is_err()); + assert!(editor.resize(&mut engine, 90.0).is_err()); + editor.undo(&mut engine).unwrap(); + let primitives = paint_editor(&scene, &editor); + let Primitive::Text { origin, .. } = primitives.last().unwrap() else { + panic!() + }; + assert_eq!(origin[1], wrapped[0][1]); + let title = editor.active_outline().document().clone(); + editor + .change_date( + &mut engine, + 2, + ["A date label that wraps ".repeat(30), String::new()], + ) + .unwrap(); + let changed = scene.content_bounds(&editor).collect::>(); + assert_eq!(changed[0][1], wrapped[0][1]); + assert!(changed[0][3] > wrapped[0][3]); + let (field, rect) = scene.date_fields(&editor).next().unwrap(); + assert_eq!(field, DateField::Date); + assert_eq!(rect[1], changed[0][1]); + assert_eq!(rect[3], changed[0][3]); + editor.undo(&mut engine).unwrap(); + assert_eq!(editor.date().unwrap().timestamp(), 1); + assert_eq!(scene.content_bounds(&editor).collect::>(), wrapped); + assert_eq!(editor.active_outline().document(), &title); + } + + #[test] + #[ignore = "requires the native baseline section and Carlito via CANVAS_TEST_SECTION/CANVAS_TEST_SUBSTITUTE"] + fn native_title_metrics_and_exit() { + let bytes = std::fs::read(std::env::var_os("CANVAS_TEST_SECTION").unwrap()).unwrap(); + let store = onestore::Store::parse(&bytes).unwrap(); + let index = onestore::RevisionIndex::parse(&store).unwrap(); + let document = onestore::document::Document::parse(&index).unwrap(); + let page = Page::from_document(&document, "Baseline anchors").unwrap(); + let mut engine = TextEngine::default(); + engine + .register_substitute(parley::fontique::Blob::new(Arc::new( + std::fs::read(std::env::var_os("CANVAS_TEST_SUBSTITUTE").unwrap()).unwrap(), + ))) + .unwrap(); + let mut editor = CanvasEditor::from_page(page, &mut engine).unwrap(); + let title_id = editor + .outlines() + .iter() + .find(|outline| outline.title) + .unwrap() + .id; + for (text, lines, y) in [ + ("Header", 1, 86.4), + ( + "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", + 3, + 140.4, + ), + ] { + editor.focus_outline(title_id).unwrap(); + editor.select_all().unwrap(); + editor.insert(&mut engine, text).unwrap(); + let paragraph = editor.active_outline().layouts().next().unwrap().1; + assert_eq!(paragraph.text.lines().count(), lines); + assert!((paragraph.text.lines().next().unwrap().1.height - 20.751953).abs() < 0.001); + editor.leave_title(&mut engine).unwrap(); + assert_eq!(editor.caret_outline().unwrap().origin(), [36.0, y]); + editor.undo(&mut engine).unwrap(); + } + let date = editor.date().unwrap(); + let timestamp = date.timestamp(); + let source = date.source().paragraphs.clone(); + assert_eq!( + date.fields().map(|(field, _)| field).collect::>(), + [DateField::Date, DateField::Time] + ); + let positions = editor + .outlines() + .iter() + .map(TextOutline::origin) + .collect::>(); + editor + .change_date( + &mut engine, + timestamp + 2 * 86_400 * 10_000_000, + ["Wednesday, September 09, 2026".into(), "6:14 AM".into()], + ) + .unwrap(); + assert_eq!( + editor + .outlines() + .iter() + .map(TextOutline::origin) + .collect::>(), + positions + ); + assert_eq!(editor.date().unwrap().source().paragraphs[1], source[1]); + editor.undo(&mut engine).unwrap(); + assert_eq!(editor.date().unwrap().timestamp(), timestamp); + assert_eq!(editor.date().unwrap().source().paragraphs, source); + for (run_size, base_size, screen_y, paragraphs) in [ + (11.0, 11.0, 235.0, Some(2)), + (11.0, 11.0, 252.0, Some(3)), + (11.0, 11.0, 256.0, Some(3)), + (11.0, 11.0, 260.0, None), + (22.0, 11.0, 306.0, Some(3)), + (22.0, 22.0, 306.0, Some(2)), + (22.0, 22.0, 324.0, None), + ] { + let style = ExGuid { + n: 900, + ..Default::default() + }; + let format = onestore::document::Format { + font: Some("Calibri".into()), + font_size: Some(run_size), + ..Default::default() + }; + let mut nodes = crate::document::TextDocument::new(vec![Paragraph::new( + "Anchor0".into(), + format.clone(), + )]) + .unwrap() + .nodes() + .to_vec(); + nodes[0].style = Some(style); + let outline = TextOutline::new( + &mut engine, + crate::document::TextDocument::from_nodes(nodes).unwrap(), + 72.0, + [120.0, 90.0], + ) + .unwrap(); + let id = outline.id; + let source = outline.document().clone(); + let mut editor = CanvasEditor::from_text_outlines( + vec![outline], + BTreeMap::from([( + style, + crate::page::Definition { + kind: onestore::document::Kind::Style { + name: Some("p".into()), + }, + format: onestore::document::Format { + font_size: Some(base_size), + ..format + }, + }, + )]), + None, + ) + .unwrap(); + let point = [ + (220.0 - 48.0) * 0.75 - 120.0, + (screen_y - 83.0) * 0.75 - 90.0, + ]; + assert_eq!( + editor.select_below(&mut engine, id, point).unwrap(), + paragraphs.is_some(), + "run={run_size} base={base_size} y={screen_y}" + ); + assert_eq!(editor.outlines()[0].document(), &source); + if let Some(count) = paragraphs { + editor.insert(&mut engine, "X").unwrap(); + assert_eq!(editor.active_outline().document().nodes().len(), count); + assert_eq!(editor.active_outline().origin(), [120.0, 90.0]); + assert_eq!( + editor + .active_outline() + .document() + .paragraphs() + .last() + .unwrap() + .text(), + "X" + ); + assert!( + (editor + .active_outline() + .layouts() + .last() + .unwrap() + .1 + .text + .height() + - base_size * (12.207_031 / 10.0)) + .abs() + < 0.001 + ); + } + } + } + + #[test] + #[ignore = "requires the native baseline section and Carlito via CANVAS_TEST_SECTION/CANVAS_TEST_SUBSTITUTE"] + fn native_title_flow() { + use crate::{document::TextDocument, page::Image}; + let bytes = std::fs::read(std::env::var_os("CANVAS_TEST_SECTION").unwrap()).unwrap(); + let store = onestore::Store::parse(&bytes).unwrap(); + let index = onestore::RevisionIndex::parse(&store).unwrap(); + let document = onestore::document::Document::parse(&index).unwrap(); + let mut engine = TextEngine::default(); + engine + .register_substitute(parley::fontique::Blob::new(Arc::new( + std::fs::read(std::env::var_os("CANVAS_TEST_SUBSTITUTE").unwrap()).unwrap(), + ))) + .unwrap(); + 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"; + for (font_size, text, x, y, expected) in [ + (17.0, long, 120.0, 90.0, 116.18992), + (17.0, long, 120.0, 110.0, 116.18992), + (17.0, long, 120.0, 150.0, 150.0), + (17.0, long, 120.0, 73.8, 73.8), + (17.0, long, 120.0, 74.0, 116.18992), + (17.0, long, 120.0, 40.0, 40.0), + (17.0, long, 540.0, 90.0, 116.18992), + (17.0, long, 550.0, 90.0, 90.0), + (17.0, long, 600.0, 90.0, 90.0), + (17.0, long, 0.0, 90.0, 116.18992), + (17.0, long, -50.0, 90.0, 116.18992), + (17.0, long, -100.0, 90.0, 90.0), + (17.0, "Header\u{000b}Second", 120.0, 90.0, 95.43796), + (17.0, "Header\u{000b}Second", 250.0, 90.0, 95.43796), + (17.0, "Header\u{000b}Second", 540.0, 90.0, 95.43796), + (17.0, "Header\u{000b}Second", 550.0, 90.0, 90.0), + (11.0, long, 120.0, 60.0, 60.0), + (11.0, long, 120.0, 70.0, 80.78945), + (22.0, long, 120.0, 80.0, 80.0), + (22.0, long, 120.0, 90.0, 134.5003), + ] { + let mut page = Page::from_document(&document, "Baseline anchors").unwrap(); + page.objects + .retain(|object| matches!(object, PageObject::Title(_))); + let PageObject::Title(title) = &mut page.objects[0] else { + unreachable!() + }; + let title = title + .outlines + .iter_mut() + .find(|outline| outline.title) + .unwrap(); + title.paragraphs[0].text_mut().unwrap().text = Paragraph::new( + "Header".into(), + onestore::document::Format { + font: Some("Calibri".into()), + font_size: Some(font_size), + ..Default::default() + }, + ); + let body = TextOutline::new( + &mut engine, + TextDocument::new(vec![Paragraph::new("Anchor".into(), Default::default())]) + .unwrap(), + 72.0, + [x, y], + ) + .unwrap() + .snapshot(); + let id = body.id; + page.objects.push(PageObject::Outline(body)); + let mut editor = CanvasEditor::from_page(page, &mut engine).unwrap(); + editor.select_all().unwrap(); + editor.insert(&mut engine, text).unwrap(); + let position = |editor: &CanvasEditor| { + editor + .outlines() + .iter() + .find(|outline| outline.id == id) + .unwrap() + .origin() + }; + assert_eq!(position(&editor)[0], x); + assert!( + (position(&editor)[1] - expected).abs() < 0.002, + "({x}, {y}) after {text}: {:?}, expected {expected}", + position(&editor) + ); + editor.select_all().unwrap(); + editor.insert(&mut engine, "Header").unwrap(); + assert!((position(&editor)[1] - expected).abs() < 0.002); + editor.undo(&mut engine).unwrap(); + editor.undo(&mut engine).unwrap(); + assert_eq!(position(&editor), [x, y]); + editor.redo(&mut engine).unwrap(); + assert!((position(&editor)[1] - expected).abs() < 0.002); + } + for trigger in [true, false] { + let mut page = Page::from_document(&document, "Baseline anchors").unwrap(); + let image_bytes = page + .objects + .iter() + .find_map(|object| match object { + PageObject::Image(image) => image.bytes.clone(), + _ => None, + }) + .unwrap(); + page.objects + .retain(|object| matches!(object, PageObject::Title(_))); + for (x, y, background) in [ + (120.0, 90.0, !trigger), + (600.0, 150.0, false), + (600.0, 90.0, true), + (120.0, 60.0, false), + ] { + page.objects.push(PageObject::Image(Image { + id: crate::text::new_id().unwrap(), + bytes: Some(image_bytes.clone()), + layout: Layout { + x: Some(x), + y: Some(y), + max_width: Some(72.0), + max_height: Some(80.0), + ..Default::default() + }, + alt: None, + background, + })); + } + let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap(); + let images = |editor: &CanvasEditor| { + let mut primitives = Vec::new(); + scene + .append_primitives_with::( + &mut primitives, + [0.0; 2], + Some(editor), + |_, _, _| Ok(()), + ) + .unwrap(); + primitives + .into_iter() + .filter_map(|primitive| match primitive { + Primitive::Image { image, rect } => Some((image.pixels.id(), rect)), + _ => None, + }) + .collect::>() + }; + let before = images(&editor); + editor.select_all().unwrap(); + editor.insert(&mut engine, "Header").unwrap(); + editor.select_all().unwrap(); + editor.insert(&mut engine, long).unwrap(); + let after = images(&editor); + for (index, ((old_id, old), (new_id, new))) in before.iter().zip(&after).enumerate() { + assert_eq!(old_id, new_id); + let delta = if trigger && index < 2 { 26.18992 } else { 0.0 }; + assert_eq!([old[0], old[2]], [new[0], new[2]]); + assert!((old[1] + delta - new[1]).abs() < 0.002); + assert!((old[3] + delta - new[3]).abs() < 0.002); + assert!(scene.content_bounds(&editor).any(|bounds| bounds == *new)); + } + editor.undo(&mut engine).unwrap(); + assert_eq!(images(&editor), before); + editor.redo(&mut engine).unwrap(); + assert_eq!(images(&editor), after); + } + } + + #[test] + fn title_exit_places_an_uncommitted_body_caret_below_the_date() { + use crate::{document::TextDocument, page::Title}; + let mut engine = TextEngine::default(); + let fields = + [("Header", 20.751953), ("Date\u{000b}Time", 12.207031)].map(|(text, line_spacing)| { + let document = TextDocument::new(vec![Paragraph::new( + text.into(), + onestore::document::Format { + font: Some("Arial".into()), + font_size: Some(8.0), + line_spacing: Some(line_spacing), + ..Default::default() + }, + )]) + .unwrap(); + TextOutline::new(&mut engine, document, 468.0, [0.0; 2]) + .unwrap() + .snapshot() + }); + let mut fields = Vec::from(fields); + fields[0].title = true; + fields[0].min_width = Some(162.0); + let title_id = fields[0].id; + let page = Page { + created: None, + title: "Header".into(), + margin_origin: [36.0, 14.4], + definitions: BTreeMap::new(), + objects: vec![PageObject::Title(Title { + date: None, + id: ExGuid::default(), + layout: Layout::default(), + outlines: fields, + })], + }; + let mut editor = CanvasEditor::from_page(page, &mut engine).unwrap(); + let original = editor.active_outline().document().clone(); + editor.leave_title(&mut engine).unwrap(); + assert_eq!(editor.caret_outline().unwrap().origin(), [36.0, 86.4]); + assert_eq!(editor.outlines().len(), 1); + assert!(!editor.undo(&mut engine).unwrap()); + editor.insert(&mut engine, "body").unwrap(); + let body_id = editor.active_outline().id; + editor.focus_outline(title_id).unwrap(); + editor.leave_title(&mut engine).unwrap(); + assert_eq!(editor.active_outline().id, body_id); + assert!(editor.caret_outline().is_none()); + editor.undo(&mut engine).unwrap(); + assert_eq!(editor.outlines().len(), 1); + editor.focus_outline(title_id).unwrap(); + editor.select_all().unwrap(); + editor + .insert(&mut engine, "Header\u{000b}Second\u{000b}Third") + .unwrap(); + assert_eq!(editor.active_outline().document().nodes().len(), 1); + assert_eq!( + editor + .active_outline() + .layouts() + .next() + .unwrap() + .1 + .text + .lines() + .count(), + 3 + ); + editor.leave_title(&mut engine).unwrap(); + assert_eq!(editor.caret_outline().unwrap().origin(), [36.0, 140.4]); + assert_eq!(editor.outlines().len(), 1); + editor.undo(&mut engine).unwrap(); + assert_eq!(editor.active_outline().id, title_id); + assert_eq!(editor.active_outline().document(), &original); + } + + #[test] + fn reverse_hit_order_keeps_read_only_focus_indices() { + let objects = [0.0, 20.0] + .into_iter() + .map(|position| { + PageObject::Unsupported(crate::page::Unsupported { + id: ExGuid { + guid: [position as u8; 16], + n: 1, + }, + jcid: 0xdead, + layout: Layout { + x: Some(position), + y: Some(position), + max_width: Some(160.0), + max_height: Some(80.0), + ..Default::default() + }, + }) + }) + .collect(); + let page = Page { + created: None, + title: String::new(), + margin_origin: [0.0; 2], + definitions: BTreeMap::new(), + objects, + }; + let scene = PageScene::new(page, &mut TextEngine::default()).unwrap(); + assert_eq!( + scene.hit_test::<()>([30.0, 30.0], None, |_| unreachable!()), + Some(SceneHit::ReadOnly(1)) + ); + assert_eq!( + scene.hit_test::<()>([5.0, 5.0], None, |_| unreachable!()), + Some(SceneHit::ReadOnly(0)) + ); + assert_eq!( + scene.hit_test::<()>([300.0, 300.0], None, |_| unreachable!()), + None + ); + } + + #[test] + fn unsupported_tags_and_titles_leave_supported_outlines_editable() { + use crate::{ + document::TextDocument, + page::{Definition, Outline, Title}, + }; + use onestore::document::{Kind, Tag}; + for title in [false, true] { + let definition = ExGuid { + guid: [9; 16], + n: 1, + }; + let make_outline = |text: &str| { + let document = + TextDocument::new(vec![Paragraph::new(text.into(), Default::default())]) + .unwrap(); + Outline { + title: false, + min_width: None, + id: ExGuid { + guid: [u8::from(text == "Editable") + 1; 16], + n: 1, + }, + layout: Layout { + max_width: Some(200.0), + ..Default::default() + }, + indents: vec![18.0, 0.0], + paragraphs: document.nodes().to_vec(), + unsupported: Vec::new(), + } + }; + let mut tagged = make_outline("Tagged"); + tagged.paragraphs[0].tags.push(Tag { + definition: Some(definition), + status: 1, + action_type: None, + created: None, + completed: None, + start: None, + due: None, + task_id: None, + extra_set: 0, + }); + let unsupported = if title { + PageObject::Title(Title { + date: None, + id: ExGuid::default(), + layout: Layout { + x: Some(10.0), + y: Some(20.0), + ..Default::default() + }, + outlines: vec![tagged], + }) + } else { + PageObject::Outline(tagged) + }; + let page = Page { + created: None, + title: String::new(), + margin_origin: [36.0, 14.0], + objects: vec![unsupported, PageObject::Outline(make_outline("Editable"))], + definitions: BTreeMap::from([( + definition, + Definition { + kind: Kind::TagDefinition { + shape: Some(999), + label: Some("Unknown icon".into()), + color: None, + highlight: None, + action_type: None, + }, + format: Default::default(), + }, + )]), + }; + let mut engine = TextEngine::default(); + let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap(); + assert_eq!(editor.outlines().len(), 1); + let readonly = scene.read_only(Some(&editor)).next().unwrap(); + assert_eq!(scene.read_only(Some(&editor)).count(), 1); + if title { + assert_eq!(&readonly.rect()[..2], &[46.0, 34.0]); + } + editor.insert(&mut engine, "Still ").unwrap(); + assert_eq!( + editor + .active_outline() + .document() + .paragraphs() + .next() + .unwrap() + .text(), + "Still Editable" + ); + editor.undo(&mut engine).unwrap(); + assert_eq!( + editor + .active_outline() + .document() + .paragraphs() + .next() + .unwrap() + .text(), + "Editable" + ); + } + } + + #[test] + fn unsupported_sources_stay_owned_and_read_only_across_edit_and_undo() { + use crate::{ + document::TextDocument, + page::{Outline, Unsupported}, + }; + let document = TextDocument::new(vec![Paragraph::new( + "preserved text".into(), + Default::default(), + )]) + .unwrap(); + let original = document.nodes().to_vec(); + let unknown = Unsupported { + id: ExGuid { + guid: [8; 16], + n: 1, + }, + jcid: 0xdead, + layout: Layout::default(), + }; + let page = Page { + created: None, + title: String::new(), + margin_origin: [0.0; 2], + definitions: BTreeMap::new(), + objects: vec![ + PageObject::Outline(Outline { + title: false, + min_width: None, + id: ExGuid { + guid: [7; 16], + n: 1, + }, + layout: Layout { + x: Some(50.0), + y: Some(70.0), + max_width: Some(200.0), + ..Default::default() + }, + indents: vec![18.0, 0.0], + paragraphs: original.clone(), + unsupported: vec![unknown.clone()], + }), + PageObject::Image(Image { + id: ExGuid::default(), + layout: Layout { + y: Some(150.0), + ..Default::default() + }, + bytes: None, + alt: Some("missing diagram".into()), + background: false, + }), + PageObject::Unsupported(unknown.clone()), + ], + }; + let mut engine = TextEngine::default(); + let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap(); + assert_eq!(scene.read_only(Some(&editor)).count(), 3); + assert!(editor.outlines().is_empty()); + assert!(editor.caret_outline().is_some()); + assert!( + editor.active_outline().origin()[0] + > scene + .read_only(Some(&editor)) + .map(|object| object.rect()[2]) + .fold(0.0_f32, f32::max) + ); + let labels: Vec<_> = scene + .read_only(Some(&editor)) + .map(|object| object.label.id()) + .collect(); + for edit in [true, false] { + if edit { + editor.insert(&mut engine, "new annotation").unwrap(); + } else { + editor.undo(&mut engine).unwrap(); + } + assert_eq!(editor.caret_outline().is_none(), edit); + assert_eq!(editor.outlines().len(), usize::from(edit)); + let readonly: Vec<_> = scene.read_only(Some(&editor)).collect(); + let PageObject::Outline(outline) = &readonly[0].source else { + panic!() + }; + assert_eq!(outline.paragraphs, original); + assert_eq!( + outline.unsupported.as_slice(), + std::slice::from_ref(&unknown) + ); + let PageObject::Image(image) = &readonly[1].source else { + panic!() + }; + assert_eq!(image.alt.as_deref(), Some("missing diagram")); + assert!(image.bytes.is_none()); + let PageObject::Unsupported(source) = &readonly[2].source else { + panic!() + }; + assert_eq!(source, &unknown); + assert_eq!( + readonly + .iter() + .map(|object| object.label.id()) + .collect::>(), + labels + ); + let primitives = paint_editor(&scene, &editor); + assert_eq!(primitives.len(), 9); + assert_eq!( + primitives + .iter() + .filter(|p| matches!(p, Primitive::Text { .. })) + .count(), + 3 + ); + } + assert_eq!( + editor + .active_outline() + .document() + .paragraphs() + .next() + .unwrap() + .text(), + "" + ); + } + + fn paint_editor<'a>(scene: &'a PageScene, editor: &'a CanvasEditor) -> Vec> { + let mut primitives = Vec::new(); + scene + .append_primitives_with( + &mut primitives, + [0.0; 2], + Some(editor), + |id, offset, primitives| { + let outline = editor + .outlines() + .iter() + .find(|outline| outline.id == id) + .ok_or(SceneError::MissingOutline)?; + for (_, paragraph) in outline.layouts() { + primitives.push(Primitive::Text { + clip: None, + layout: ¶graph.text, + origin: [ + outline.origin()[0] + paragraph.origin[0] + offset[0], + outline.origin()[1] + paragraph.origin[1] + offset[1], + ], + }); + } + Ok::<_, SceneError>(()) + }, + ) + .unwrap(); + primitives + } + + #[test] + fn editable_outlines_keep_paint_order_and_images_across_edit_and_undo() { + use crate::{document::TextDocument, page::Outline, text::Paragraph}; + let outline = |x, text: &str| { + let document = + TextDocument::new(vec![Paragraph::new(text.into(), Default::default())]).unwrap(); + PageObject::Outline(Outline { + title: false, + min_width: None, + id: ExGuid { + guid: [x as u8; 16], + n: 1, + }, + layout: Layout { + x: Some(x), + max_width: Some(180.0), + width_set_by_user: Some(true), + ..Default::default() + }, + indents: vec![18.0, 0.0], + paragraphs: document.nodes().to_vec(), + unsupported: Vec::new(), + }) + }; + let mut bytes = Vec::new(); + { + let mut encoder = png::Encoder::new(&mut bytes, 1, 1); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + encoder + .write_header() + .unwrap() + .write_image_data(&[17, 34, 51, 255]) + .unwrap(); + } + let page = || Page { + created: None, + title: String::new(), + margin_origin: [0.0; 2], + definitions: BTreeMap::new(), + objects: vec![ + outline(2.0, "first"), + PageObject::Image(Image { + id: ExGuid::default(), + layout: Layout { + x: Some(5.0), + y: Some(7.0), + max_width: Some(40.0), + max_height: Some(30.0), + ..Default::default() + }, + bytes: Some(Arc::from(bytes.clone())), + alt: None, + background: false, + }), + outline(50.0, "last"), + ], + }; + let mut engine = TextEngine::default(); + let reference = PageScene::new(page(), &mut engine).unwrap(); + let (scene, mut editor) = PageScene::from_page(page(), &mut engine).unwrap(); + let mut reference_primitives = Vec::new(); + reference + .append_primitives(&mut reference_primitives, [0.0; 2]) + .unwrap(); + let image_id; + { + let actual = paint_editor(&scene, &editor); + assert_eq!(actual.len(), 3); + for (actual, expected) in actual.iter().zip(&reference_primitives) { + match (actual, expected) { + ( + Primitive::Text { + clip: None, + layout: a, + origin: x, + }, + Primitive::Text { + clip: None, + layout: b, + origin: y, + }, + ) => { + assert_eq!(x, y); + assert_eq!( + a.lines().next().unwrap().0.metrics().advance, + b.lines().next().unwrap().0.metrics().advance + ); + } + ( + Primitive::Image { image: a, rect: x }, + Primitive::Image { image: b, rect: y }, + ) => { + assert_eq!(x, y); + assert_eq!(a.pixels.as_ref(), b.pixels.as_ref()); + } + _ => panic!("paint order changed"), + } + } + let Primitive::Image { image, .. } = &actual[1] else { + panic!() + }; + image_id = image.pixels.id(); + } + let original = editor.active_outline().document().clone(); + editor.insert(&mut engine, "new words ").unwrap(); + { + let actual = paint_editor(&scene, &editor); + let Primitive::Text { layout, .. } = &actual[0] else { + panic!() + }; + let Primitive::Text { layout: before, .. } = &reference_primitives[0] else { + panic!() + }; + assert!( + layout.lines().next().unwrap().0.metrics().advance + > before.lines().next().unwrap().0.metrics().advance + ); + let Primitive::Image { image, .. } = &actual[1] else { + panic!() + }; + assert_eq!(image.pixels.id(), image_id); + } + editor.undo(&mut engine).unwrap(); + assert_eq!(editor.active_outline().document(), &original); + let actual = paint_editor(&scene, &editor); + let Primitive::Text { layout, .. } = &actual[0] else { + panic!() + }; + let Primitive::Text { layout: before, .. } = &reference_primitives[0] else { + panic!() + }; + assert_eq!( + layout.lines().next().unwrap().0.metrics().advance, + before.lines().next().unwrap().0.metrics().advance + ); + assert!(matches!( + scene.append_primitives(&mut Vec::new(), [0.0; 2]), + Err(SceneError::MissingOutline) + )); + } + + #[test] + fn scene_owns_decoded_images_and_reuses_identity_across_translated_frames() { + let mut encoded = Vec::new(); + { + let mut encoder = png::Encoder::new(&mut encoded, 1, 1); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + encoder + .write_header() + .unwrap() + .write_image_data(&[255, 0, 0, 255]) + .unwrap(); + } + let page = |width| Page { + created: None, + title: String::new(), + margin_origin: [0.0; 2], + definitions: BTreeMap::new(), + objects: vec![PageObject::Image(Image { + id: ExGuid::default(), + layout: Layout { + x: Some(2.0), + y: Some(5.0), + max_width: Some(width), + max_height: Some(4.0), + ..Default::default() + }, + bytes: Some(Arc::from(encoded.clone())), + alt: None, + background: false, + })], + }; + let mut engine = TextEngine::default(); + let scene = PageScene::new(page(3.0), &mut engine).unwrap(); + for invalid in [f32::NAN, f32::INFINITY, -1.0, 0.0] { + assert!(matches!( + PageScene::new(page(invalid), &mut engine), + Err(SceneError::InvalidGeometry) + )); + } + let mut primitives = Vec::new(); + scene.append_primitives(&mut primitives, [0.0; 2]).unwrap(); + scene + .append_primitives(&mut primitives, [10.0, -20.0]) + .unwrap(); + let [ + Primitive::Image { + image: first, + rect: a, + }, + Primitive::Image { + image: second, + rect: b, + }, + ] = primitives.as_slice() + else { + panic!() + }; + assert_eq!(*a, [2.0, 5.0, 5.0, 9.0]); + assert_eq!(*b, [12.0, -15.0, 15.0, -11.0]); + assert_eq!(first.pixels.id(), second.pixels.id()); + assert_eq!(first.pixels.as_ref(), [255, 0, 0, 255]); + } +} diff --git a/crates/canvas/src/gpu/profile.rs b/crates/canvas/src/gpu/profile.rs new file mode 100644 index 0000000000000000000000000000000000000000..6c1b91e56957eeaba563875511294d3175d93c33 --- /dev/null +++ b/crates/canvas/src/gpu/profile.rs @@ -0,0 +1,151 @@ +use super::*; +use crate::{layout::TextEngine, page::Page, text::Paragraph}; +use std::{ + sync::Arc, + time::{Duration, Instant}, +}; + +#[test] +#[ignore = "manual release GPU timing and cache occupancy probe"] +fn renderer_cost() { + let mut engine = TextEngine::default(); + if let Some(font) = std::env::var_os("CANVAS_TEST_SUBSTITUTE") { + engine + .register_substitute(Blob::new(Arc::new(std::fs::read(font).unwrap()))) + .unwrap(); + } + let source = std::env::var_os("CANVAS_TEST_SECTION").map(|path| std::fs::read(path).unwrap()); + let title = std::env::var("CANVAS_TEST_PAGE").unwrap_or_default(); + let mut scene = source + .as_ref() + .map(|bytes| load_scene(bytes, &title, &mut engine)); + let layout = source.is_none().then(|| { + engine + .layout( + &Paragraph::new( + "A paragraph with café, trees 🌳 and a few words.\n".repeat(2000), + Default::default(), + ), + 360.0, + ) + .unwrap() + }); + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap(); + eprintln!("canvas_gpu_adapter\t{:?}", adapter.get_info()); + let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap(); + let size = [1386, 759]; + let format = wgpu::TextureFormat::Rgba8UnormSrgb; + let target = device.create_texture(&wgpu::TextureDescriptor { + label: Some("Canvas profile target"), + size: wgpu::Extent3d { + width: size[0], + height: size[1], + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + let view = target.create_view(&Default::default()); + let mut renderer = Renderer::new(device, queue, format); + eprintln!( + "canvas_gpu_allocated_bytes\tatlas\t{}\tvertex_buffer\t{}\ttarget\t{}", + u64::from(renderer.atlas.width()) * u64::from(renderer.atlas.height()) * 4, + renderer.vertex_buffer.size(), + u64::from(size[0]) * u64::from(size[1]) * 4 + ); + for phase in [ + "warm", + "pan", + "zoom", + "evict", + "offscreen_x", + "offscreen_y", + "reopen", + ] { + if phase == "reopen" && source.is_none() { + continue; + } + if phase.starts_with("offscreen") { + renderer.clear_glyph_cache(); + renderer.images.clear(); + } + for sample in 0..32 { + if phase == "evict" { + renderer.clear_glyph_cache(); + renderer.images.clear(); + } + let mut viewport = Viewport { + size, + scale: 96.0 / 72.0, + origin: [0.0; 2], + }; + match phase { + "pan" => viewport.origin[1] = -(sample as f32 * 63.25), + "zoom" => viewport.scale *= [0.5, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0][sample % 8], + "offscreen_x" => viewport.origin[0] = 1_000_000.0, + "offscreen_y" => viewport.origin[1] = 1_000_000.0, + "reopen" => { + scene = Some(load_scene(source.as_ref().unwrap(), &title, &mut engine)); + } + _ => {} + } + let mut primitives = Vec::new(); + if let Some(scene) = &scene { + scene.append_primitives(&mut primitives, [0.0; 2]).unwrap(); + } else { + primitives.push(Primitive::Text { + clip: None, + layout: layout.as_ref().unwrap(), + origin: [36.0, 90.0], + }); + } + let start = Instant::now(); + renderer.draw(&view, viewport, &primitives).unwrap(); + let submitted = start.elapsed().as_nanos(); + renderer + .device + .poll(wgpu::PollType::Wait { + submission_index: None, + timeout: Some(Duration::from_secs(10)), + }) + .unwrap(); + let completed = start.elapsed().as_nanos(); + let image_bytes: u64 = renderer.images.values().map(|image| image.bytes).sum(); + let atlas_texels: u64 = renderer + .glyphs + .values() + .flatten() + .map(|glyph| u64::from(glyph.width) * u64::from(glyph.height)) + .sum(); + assert!(renderer.glyphs.len() <= MAX_GLYPHS); + assert!(renderer.images.len() <= MAX_IMAGES && image_bytes <= MAX_IMAGE_BYTES); + assert!(renderer.vertices.len() <= MAX_VERTICES); + eprintln!( + "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{}", + renderer.glyphs.len(), + renderer.images.len(), + renderer.vertices.len(), + renderer.batches.len(), + renderer.vertices.capacity() * size_of::(), + renderer.batches.capacity() * size_of::(), + renderer.glyphs.capacity(), + renderer.images.capacity() + ); + } + } +} + +fn load_scene(bytes: &[u8], title: &str, engine: &mut TextEngine) -> page::PageScene { + let store = onestore::Store::parse(bytes).unwrap(); + let index = onestore::RevisionIndex::parse(&store).unwrap(); + page::PageScene::new( + Page::from_document(&onestore::document::Document::parse(&index).unwrap(), title).unwrap(), + engine, + ) + .unwrap() +} diff --git a/crates/canvas/src/gpu/tags.rs b/crates/canvas/src/gpu/tags.rs new file mode 100644 index 0000000000000000000000000000000000000000..c68f4b8554b09fd97df2463acca67e6fbcb0e381 --- /dev/null +++ b/crates/canvas/src/gpu/tags.rs @@ -0,0 +1,132 @@ +use crate::outline::TagIcon; +use swash::{ + scale::image::{Content, Image}, + zeno::{Mask, Placement, Transform, Vector}, +}; + +pub(crate) fn rasterize(icon: TagIcon, size: f32, phase: [u8; 2]) -> Image { + let (source, overlay) = match icon { + TagIcon::CheckBox { checked } => ( + include_str!("../../assets/tags/checkbox.svg"), + checked.then_some(include_str!("../../assets/tags/checkmark.svg")), + ), + TagIcon::Question => (include_str!("../../assets/tags/question.svg"), None), + TagIcon::Music => (include_str!("../../assets/tags/music.svg"), None), + }; + let width = (size + 1.0).ceil() as u32; + let mut pixels = vec![[0.0_f32; 4]; (width * width) as usize]; + for source in std::iter::once(source).chain(overlay) { + let svg = roxmltree::Document::parse(source).expect("Bundled tag SVG must be valid"); + assert_eq!(svg.root_element().attribute("viewBox"), Some("0 0 16 16")); + for path in svg.descendants().filter(|node| node.has_tag_name("path")) { + let gradient = path + .attribute("fill") + .unwrap() + .strip_prefix("url(#") + .unwrap() + .strip_suffix(')') + .unwrap(); + let gradient = svg + .descendants() + .find(|node| node.attribute("id") == Some(gradient)) + .unwrap(); + let mut stops = gradient + .children() + .filter(|node| node.has_tag_name("stop")) + .map(|node| { + let color = u32::from_str_radix( + node.attribute("stop-color") + .unwrap() + .strip_prefix('#') + .unwrap(), + 16, + ) + .unwrap(); + let [_, r, g, b] = color.to_be_bytes(); + super::colorref(u32::from_le_bytes([r, g, b, 0])) + }); + let top = stops.next().unwrap(); + let bottom = stops.next().unwrap(); + assert!(stops.next().is_none()); + let (mask, _) = Mask::new(path.attribute("d").unwrap()) + .transform(Some(Transform::scale(size / 16.0, size / 16.0))) + .render_offset(Vector::new(phase[0] as f32 * 0.25, phase[1] as f32 * 0.25)) + .size(width, width) + .render(); + for (index, (pixel, coverage)) in pixels.iter_mut().zip(mask).enumerate() { + let alpha = f32::from(coverage) / 255.0; + let y = ((index / width as usize) as f32 / size).min(1.0); + for channel in 0..3 { + let color = top[channel] + (bottom[channel] - top[channel]) * y; + pixel[channel] = color * alpha + pixel[channel] * (1.0 - alpha); + } + pixel[3] = alpha + pixel[3] * (1.0 - alpha); + } + } + } + let data = pixels + .into_iter() + .flat_map(|pixel| { + let alpha = pixel[3]; + let channel = |value: f32| { + let value = if alpha > 0.0 { value / alpha } else { 0.0 }; + super::srgb_byte(value) + }; + [ + channel(pixel[0]), + channel(pixel[1]), + channel(pixel[2]), + (alpha * 255.0).round() as u8, + ] + }) + .collect(); + Image { + content: Content::Color, + placement: Placement { + left: 0, + top: 0, + width, + height: width, + }, + data, + ..Image::default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tag_masks_keep_transparency_and_checked_state_across_scales() { + for size in [8.0, 16.0, 32.0, 57.5] { + for phase in [[0, 0], [1, 2], [3, 3]] { + let mut masks = Vec::new(); + for icon in [ + TagIcon::CheckBox { checked: false }, + TagIcon::CheckBox { checked: true }, + TagIcon::Question, + TagIcon::Music, + ] { + let image = rasterize(icon, size, phase); + assert_eq!( + image.data.len(), + (image.placement.width * image.placement.height * 4) as usize + ); + assert!(image.data.chunks_exact(4).any(|p| p[3] > 0)); + assert!(image.data.chunks_exact(4).any(|p| p[3] < 255)); + assert!( + image + .data + .chunks_exact(4) + .filter(|p| p[3] == 0) + .all(|p| p[..3] == [0; 3]) + ); + masks.push(image.data); + } + assert_ne!(masks[0], masks[1]); + assert!(masks[1].chunks_exact(4).any(|p| p[0] > p[2] && p[3] > 0)); + } + } + } +} diff --git a/crates/canvas/src/layout.rs b/crates/canvas/src/layout.rs index edd98de7beb967d04adcd690a65a04f0c2ba7ea3..94219cc111f07f832d5875785d814acf131bcf09 100644 --- a/crates/canvas/src/layout.rs +++ b/crates/canvas/src/layout.rs @@ -9,7 +9,10 @@ use std::{ collections::BTreeSet, fmt, ops::Range, - sync::atomic::{AtomicU64, Ordering}, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, }; #[derive(Default)] @@ -33,9 +36,10 @@ pub struct LineBox { pub height: f32, } +#[derive(Clone)] pub struct TextLayout { id: u64, - pub(crate) shaped: Layout, + pub(crate) shaped: Arc>, lines: Vec, } @@ -112,7 +116,11 @@ impl TextEngine { Ok(target) } - pub fn layout(&mut self, paragraph: &Paragraph, width: f32) -> Result { + fn shape( + &mut self, + paragraph: &Paragraph, + width: f32, + ) -> Result, LayoutError> { if !width.is_finite() || width <= 0.0 { return Err(LayoutError::InvalidWidth); } @@ -121,6 +129,8 @@ impl TextEngine { .context .ranged_builder(&mut self.fonts, text, 1.0, false); builder.push_default(StyleProperty::OverflowWrap(OverflowWrap::BreakWord)); + // OneNote 2010 uses unkerned advances for text and table widths. + builder.push_default(StyleProperty::FontFeatures(r#""kern" 0"#.into())); let mut start = 0; for (index, span) in paragraph.spans().iter().enumerate() { let format = &span.format; @@ -161,6 +171,43 @@ impl TextEngine { let mut shaped = builder.build(text); shaped.break_all_lines(Some(width)); shaped.align(Alignment::Start, AlignmentOptions::default()); + Ok(shaped) + } + + fn font_extents( + &self, + run: parley::layout::Run<'_, TextBrush>, + ) -> Result<(f32, f32), LayoutError> { + let data = &run.font().font; + let font = FontRef::from_index(data.data.as_ref(), data.index) + .map_err(|_| LayoutError::InvalidFontMetrics)?; + Ok(if self.arial_substitutes.contains(&data.data.id()) { + // Arimo's hhea extents match Arial's Windows extents; omit hhea line gap. + let head = font.head().map_err(|_| LayoutError::InvalidFontMetrics)?; + let hhea = font.hhea().map_err(|_| LayoutError::InvalidFontMetrics)?; + let scale = run.font_size() / f32::from(head.units_per_em()); + ( + f32::from(hhea.ascender().to_i16()) * scale, + -f32::from(hhea.descender().to_i16()) * scale, + ) + } else if let (Ok(head), Ok(os2)) = (font.head(), font.os2()) + && head.units_per_em() != 0 + && (os2.us_win_ascent() != 0 || os2.us_win_descent() != 0) + { + let scale = run.font_size() / f32::from(head.units_per_em()); + ( + f32::from(os2.us_win_ascent()) * scale, + f32::from(os2.us_win_descent()) * scale, + ) + } else { + let metrics = run.font_metrics(); + (metrics.ascent, metrics.descent) + }) + } + + pub fn layout(&mut self, paragraph: &Paragraph, width: f32) -> Result { + let shaped = self.shape(paragraph, width)?; + let text = paragraph.text(); let mut lines = Vec::with_capacity(shaped.len()); let mut top = 0.0_f64; let mut source_end = 0; @@ -189,27 +236,25 @@ impl TextEngine { let data = &run.font().font; let font = FontRef::from_index(data.data.as_ref(), data.index) .map_err(|_| LayoutError::InvalidFontMetrics)?; - let (a, d) = if self.arial_substitutes.contains(&data.data.id()) { - // Arimo's hhea extents match Arial's Windows extents; omit hhea line gap. - let head = font.head().map_err(|_| LayoutError::InvalidFontMetrics)?; - let hhea = font.hhea().map_err(|_| LayoutError::InvalidFontMetrics)?; - let scale = run.font_size() / f32::from(head.units_per_em()); - ( - f32::from(hhea.ascender().to_i16()) * scale, - -f32::from(hhea.descender().to_i16()) * scale, - ) - } else if let (Ok(head), Ok(os2)) = (font.head(), font.os2()) - && head.units_per_em() != 0 - && (os2.us_win_ascent() != 0 || os2.us_win_descent() != 0) - { - let scale = run.font_size() / f32::from(head.units_per_em()); - ( - f32::from(os2.us_win_ascent()) * scale, - f32::from(os2.us_win_descent()) * scale, - ) + // Color glyphs fit the source font's line box instead of shifting annotation rows. + let (a, d) = if font.colr().is_ok() || font.sbix().is_ok() || font.cbdt().is_ok() { + let format = ¶graph + .spans() + .iter() + .find(|span| span.end > run.text_range().start) + .unwrap_or_else(|| paragraph.spans().last().unwrap()) + .format; + let sample = + self.shape(&Paragraph::new("Mg".into(), format.clone()), f32::MAX)?; + sample.lines().flat_map(|line| line.runs()).try_fold( + (0.0_f32, 0.0_f32), + |(a, d), run| { + let (next_a, next_d) = self.font_extents(run)?; + Ok::<_, LayoutError>((a.max(next_a), d.max(next_d))) + }, + )? } else { - let metrics = run.font_metrics(); - (metrics.ascent, metrics.descent) + self.font_extents(run)? }; if !a.is_finite() || !d.is_finite() || a < 0.0 || d < 0.0 { return Err(LayoutError::InvalidFontMetrics); @@ -239,7 +284,11 @@ impl TextEngine { let id = NEXT_ID .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |id| id.checked_add(1)) .expect("Text layout identities exhausted"); - Ok(TextLayout { id, shaped, lines }) + Ok(TextLayout { + id, + shaped: Arc::new(shaped), + lines, + }) } } @@ -303,6 +352,59 @@ impl TextLayout { Cursor::from_byte_index(&self.shaped, byte, affinity) } + pub(crate) fn word_cursor(&self, cursor: Cursor, backward: bool) -> Cursor { + let mut current = cursor; + let mut visited = BTreeSet::new(); + loop { + if !visited.insert((current.index(), current.affinity() == Affinity::Upstream)) { + // Parley visual cursors can cycle at soft-wrapped bidi boundaries. + let [left, right] = cursor.visual_clusters(&self.shaped); + let rtl = if backward { + left.or(right) + } else { + right.or(left) + } + .is_some_and(|cluster| cluster.is_rtl()); + return if backward != rtl { + cursor.previous_logical_word(&self.shaped) + } else { + cursor.next_logical_word(&self.shaped) + }; + } + let next = if backward { + current.previous_visual(&self.shaped) + } else { + current.next_visual(&self.shaped) + }; + if next == current { + return current; + } + current = next; + let [Some(left), Some(right)] = current.visual_clusters(&self.shaped) else { + return current; + }; + let boundary = if left.is_rtl() { + left.is_word_boundary() + && if backward { + left.is_space_or_nbsp() + || (right.is_word_boundary() && !right.is_space_or_nbsp()) + } else { + !left.is_space_or_nbsp() + } + } else { + right.is_word_boundary() + && if backward { + !right.is_space_or_nbsp() + } else { + !left.is_space_or_nbsp() + } + }; + if boundary { + return current; + } + } + } + pub fn hit_test(&self, x: f32, y: f32) -> Cursor { let index = self .lines @@ -348,6 +450,30 @@ mod tests { use super::*; use onestore::document::Format; + #[test] + #[ignore = "requires Arial and CANVAS_TEST_SUBSTITUTE pointing to Carlito"] + fn native_unkerned_advances() { + let mut engine = TextEngine::default(); + engine + .register_substitute(parley::fontique::Blob::new(Arc::new( + std::fs::read(std::env::var_os("CANVAS_TEST_SUBSTITUTE").unwrap()).unwrap(), + ))) + .unwrap(); + for (font, expected) in [("Calibri", 149.63843), ("Arial", 184.58305)] { + let paragraph = Paragraph::new( + "AVATAR AVATAR SECOND OFFICE".into(), + onestore::document::Format { + font: Some(font.into()), + font_size: Some(11.0), + ..Default::default() + }, + ); + let layout = engine.layout(¶graph, 400.0).unwrap(); + assert_eq!(layout.lines().count(), 1); + assert!((layout.lines().next().unwrap().0.metrics().advance - expected).abs() < 0.001); + } + } + #[test] fn rejected_substitute_leaves_font_selection_unchanged() { let mut engine = TextEngine::default(); @@ -592,6 +718,33 @@ mod tests { assert!(caret.height() > 0.0); } + #[cfg(target_os = "macos")] + #[test] + fn color_emoji_preserve_text_baselines_and_respect_explicit_font_size() { + let mut engine = TextEngine::default(); + for size in [11.0, 22.0] { + let format = Format { + font_size: Some(size), + ..Default::default() + }; + let base = engine + .layout(&Paragraph::new("Mg".into(), format.clone()), 1000.0) + .unwrap(); + for text in ["🌳", "👩‍👩‍👧‍👦", "before 🌳 after", "Mg\n🌳\nMg"] { + let layout = engine + .layout(&Paragraph::new(text.into(), format.clone()), 1000.0) + .unwrap(); + for (_, line) in layout.lines() { + assert_eq!(line.height, base.lines[0].height, "{text}"); + assert!( + (line.baseline - line.top - base.lines[0].baseline).abs() < 0.0001, + "{text}" + ); + } + } + } + } + #[test] fn bidi_hit_testing_stays_on_grapheme_boundaries() { let graphemes = [ diff --git a/crates/canvas/src/lib.rs b/crates/canvas/src/lib.rs index d377fd5bc30b9c913b9ff35842edfae2368e40db..5e48695d65c481f198f8a98e647b50cf28ab0d03 100644 --- a/crates/canvas/src/lib.rs +++ b/crates/canvas/src/lib.rs @@ -4,3 +4,7 @@ pub mod layout; pub mod outline; pub mod page; pub mod text; + +pub mod date; +#[cfg(feature = "gpu")] +pub mod gpu; diff --git a/crates/canvas/src/outline.rs b/crates/canvas/src/outline.rs index 77e3c7254d20e12462a050cd84a3a21f03571a41..64b9d6bf3ac8208852e0c6286827142ccc168f25 100644 --- a/crates/canvas/src/outline.rs +++ b/crates/canvas/src/outline.rs @@ -1,6 +1,7 @@ use crate::{ + document::{DocumentEdit, edited_nodes}, layout::{LayoutError, TextEngine, TextLayout}, - page::{Definition, Outline, PageParagraph, Title}, + page::{Definition, Outline, PageParagraph, ParagraphContent, Table, Title}, text::{Paragraph, TextProjection}, }; use onestore::{ @@ -9,11 +10,55 @@ use onestore::{ }; use std::collections::{BTreeMap, BTreeSet}; +pub(crate) const TITLE_WIDTH: f32 = 468.0; + +#[derive(Clone)] pub struct OutlineLayout { pub paragraphs: Vec, + pub tables: Vec, pub size: [f32; 2], } +#[derive(Clone)] +pub struct TableLayout { + pub id: ExGuid, + /// Cells are in paragraph order with disjoint visible ranges. + pub cells: Vec, + pub borders: bool, +} + +#[derive(Clone)] +pub struct CellLayout { + pub id: ExGuid, + pub rect: [f32; 4], + /// Visible paragraph indices, including paragraphs in nested tables. + pub paragraphs: std::ops::Range, +} + +impl CellLayout { + /// Native ink gutters extend beyond the cell borders. + pub fn text_bounds(&self) -> [f32; 4] { + [ + self.rect[0] - 2.7, + self.rect[1], + self.rect[2] + 4.62, + self.rect[3], + ] + } + + pub fn clip(&self, rect: parley::BoundingBox) -> Option { + let [left, top, right, bottom] = self.text_bounds().map(f64::from); + let rect = parley::BoundingBox::new( + rect.x0.max(left), + rect.y0.max(top), + rect.x1.min(right), + rect.y1.min(bottom), + ); + (rect.width() > 0.0 && rect.height() > 0.0).then_some(rect) + } +} + +#[derive(Clone)] pub struct ParagraphLayout { pub id: ExGuid, pub origin: [f32; 2], @@ -44,7 +89,51 @@ impl ParagraphTag { pub const SIZE: f32 = 12.0; } +pub(crate) fn indentation(level: u32, indents: &[f32], width: f32) -> Result { + if indents.len() < 2 || indents.iter().any(|v| !v.is_finite() || *v < 0.0) || level == 0 { + return Err(LayoutError::InvalidIndentation); + } + let known = (level as usize).min(indents.len() - 1); + let indent = (indents[1..=known] + .iter() + .map(|v| f64::from(*v)) + .sum::() + + f64::from(level - known as u32) * f64::from(*indents.last().unwrap())) + as f32; + if !indent.is_finite() || indent >= width { + return Err(LayoutError::InvalidIndentation); + } + Ok(indent) +} + +fn spacing( + bottom: &mut f64, + previous: &mut Option, + format: &Format, +) -> Result { + let before = format.space_before.unwrap_or(0.0); + let after = format.space_after.unwrap_or(0.0); + if [before, after].iter().any(|v| !v.is_finite() || *v < 0.0) { + return Err(LayoutError::InvalidSpacing); + } + if let Some(previous) = previous { + *bottom += f64::from(previous.max(before)); + } + *previous = Some(after); + Ok(*bottom as f32) +} + impl ParagraphLayout { + fn size(&self) -> [f32; 2] { + [ + self.origin[0] + self.text.shaped.width(), + self.markers + .iter() + .map(|(layout, _)| layout.height()) + .fold(self.text.height(), f32::max), + ] + } + pub(crate) fn shape( engine: &mut TextEngine, paragraph: &PageParagraph, @@ -55,27 +144,10 @@ impl ParagraphLayout { if !width.is_finite() || width <= 0.0 { return Err(LayoutError::InvalidWidth); } - if indents.len() < 2 - || indents.iter().any(|v| !v.is_finite() || *v < 0.0) - || paragraph.level == 0 - { - return Err(LayoutError::InvalidIndentation); - } - if !paragraph.unsupported.is_empty() { - return Err(LayoutError::UnsupportedContent); - } - let known = (paragraph.level as usize).min(indents.len() - 1); - let indent = (indents[1..=known] - .iter() - .map(|v| f64::from(*v)) - .sum::() - + f64::from(paragraph.level - known as u32) * f64::from(*indents.last().unwrap())) - as f32; - if !indent.is_finite() || indent >= width { - return Err(LayoutError::InvalidIndentation); - } - let projection = paragraph - .combined_text() + let indent = indentation(paragraph.level, indents, width)?; + let source = paragraph.text().ok_or(LayoutError::UnsupportedContent)?; + let projection = source + .text .project() .map_err(|_| LayoutError::InvalidSourceRange)?; let format = &projection.text().spans()[0].format; @@ -122,11 +194,7 @@ impl ParagraphLayout { } text.minimum_line_height(format.line_spacing.unwrap_or(0.0))?; let mut tags = Vec::new(); - for tag in paragraph - .tags - .iter() - .chain(paragraph.text.iter().flat_map(|text| &text.tags)) - { + for tag in paragraph.tags.iter().chain(&source.tags) { let Some(Definition { kind: Kind::TagDefinition { @@ -172,9 +240,11 @@ impl ParagraphLayout { } } -pub(crate) fn visible_paragraphs(nodes: &[PageParagraph]) -> impl Iterator { +pub(crate) fn visible_paragraphs<'a>( + nodes: impl Iterator, +) -> impl Iterator { let mut hidden = BTreeSet::new(); - nodes.iter().filter(move |paragraph| { + nodes.filter(move |paragraph| { let invisible = paragraph .parent .is_some_and(|parent| hidden.contains(&parent)); @@ -192,38 +262,17 @@ pub(crate) fn arrange<'a>( ) -> Result<(Vec, [f32; 2]), LayoutError> { let mut origins = Vec::new(); let mut bottom = 0.0_f64; - let mut previous_after = 0.0_f32; + let mut previous_after = None; let mut content_width = 0.0_f32; for paragraph in paragraphs { let format = ¶graph.projection.text().spans()[0].format; - let before = format.space_before.unwrap_or(0.0); - let after = format.space_after.unwrap_or(0.0); - if [before, after].iter().any(|v| !v.is_finite() || *v < 0.0) { - return Err(LayoutError::InvalidSpacing); - } - if !origins.is_empty() { - bottom += f64::from(previous_after.max(before)); - } - origins.push(bottom as f32); - bottom += f64::from( - paragraph - .markers - .iter() - .map(|(m, _)| m.height()) - .fold(paragraph.text.height(), f32::max), - ); - previous_after = after; + origins.push(spacing(&mut bottom, &mut previous_after, format)?); + let size = paragraph.size(); + bottom += f64::from(size[1]); if !(bottom as f32).is_finite() { return Err(LayoutError::InvalidSpacing); } - content_width = content_width.max( - paragraph.origin[0] - + paragraph - .text - .lines() - .map(|(line, _)| line.metrics().advance) - .fold(0.0, f32::max), - ); + content_width = content_width.max(size[0]); } let height = bottom as f32; if !height.is_finite() || !content_width.is_finite() { @@ -243,6 +292,192 @@ pub(crate) fn arrange<'a>( } impl OutlineLayout { + /// Innermost table cell containing a visible paragraph index. + pub fn paragraph_cell(&self, index: usize) -> Option<&CellLayout> { + self.tables.iter().rev().find_map(|table| { + let cell = table + .cells + .partition_point(|cell| cell.paragraphs.end <= index); + table + .cells + .get(cell) + .filter(|cell| cell.paragraphs.contains(&index)) + }) + } + + fn append(&mut self, mut child: Self, origin: [f32; 2]) { + for paragraph in &mut child.paragraphs { + paragraph.origin[0] += origin[0]; + paragraph.origin[1] += origin[1]; + for (_, marker) in &mut paragraph.markers { + marker[0] += origin[0]; + } + for tag in &mut paragraph.tags { + tag.origin[0] += origin[0]; + } + } + for table in &mut child.tables { + for cell in &mut table.cells { + cell.paragraphs.start += self.paragraphs.len(); + cell.paragraphs.end += self.paragraphs.len(); + for (value, offset) in cell.rect.iter_mut().zip(origin.into_iter().cycle()) { + *value += offset; + } + } + } + self.paragraphs.extend(child.paragraphs); + self.tables.extend(child.tables); + } + + pub(crate) fn flow<'a>( + nodes: impl Iterator, + indents: &[f32], + width: f32, + fixed_width: bool, + depth: usize, + edit: Option<&DocumentEdit>, + shape: &mut impl FnMut(&PageParagraph, f32, &[f32]) -> Result, + ) -> Result { + if !width.is_finite() || width <= 0.0 { + return Err(LayoutError::InvalidWidth); + } + if depth > 64 { + return Err(LayoutError::UnsupportedContent); + } + let mut result = Self { + paragraphs: Vec::new(), + tables: Vec::new(), + size: [36.0, 0.0], + }; + let mut bottom = 0.0; + let mut previous = None; + for node in visible_paragraphs(nodes) { + match &node.content { + ParagraphContent::Text(_) => { + let mut paragraph = shape(node, width, indents)?; + let y = spacing( + &mut bottom, + &mut previous, + ¶graph.projection.text().spans()[0].format, + )?; + let size = paragraph.size(); + paragraph.origin[1] = y; + result.paragraphs.push(paragraph); + result.size[0] = result.size[0].max(size[0]); + bottom += f64::from(size[1]); + } + ParagraphContent::Table(table) => { + let x = indentation(node.level, indents, width)?; + let y = spacing(&mut bottom, &mut previous, &node.format)?; + let child = Self::table(table, depth + 1, edit, shape)?; + result.size[0] = result.size[0].max(x + child.size[0]); + bottom += f64::from(child.size[1]); + result.append(child, [x, y]); + } + ParagraphContent::Unsupported(_) => return Err(LayoutError::UnsupportedContent), + } + if !(bottom as f32).is_finite() || !result.size[0].is_finite() { + return Err(LayoutError::InvalidSpacing); + } + } + result.size[1] = bottom as f32; + result.size[0] = if fixed_width { width } else { result.size[0] }.max(result.table_width()); + Ok(result) + } + + pub(crate) fn table_width(&self) -> f32 { + // Stored column widths extend 1.77pt past the final cell border. + self.tables + .iter() + .filter_map(|table| table.cells.last()) + .map(|cell| cell.rect[2] + 1.77) + .fold(0.0, f32::max) + } + + fn table( + table: &Table, + depth: usize, + edit: Option<&DocumentEdit>, + shape: &mut impl FnMut(&PageParagraph, f32, &[f32]) -> Result, + ) -> Result { + let widths = edit.and_then(|edit| edit.columns.get(&table.id)); + if widths.is_some_and(|widths| widths.len() != table.columns.len()) { + return Err(LayoutError::InvalidWidth); + } + let width = + |index: usize| widths.map_or(table.columns[index].width, |widths| widths[index]); + if table.columns.is_empty() + || table.rows.is_empty() + || table + .columns + .iter() + .enumerate() + .any(|(index, _)| !width(index).is_finite() || width(index) < 36.0) + { + return Err(LayoutError::InvalidWidth); + } + if !table.tags.is_empty() { + return Err(LayoutError::UnsupportedContent); + } + let mut result = Self { + paragraphs: Vec::new(), + tables: vec![TableLayout { + id: table.id, + cells: Vec::new(), + borders: table.borders.unwrap_or(true), + }], + size: [ + (0..table.columns.len()) + .map(|index| width(index) + 4.98) + .sum::() + - 1.83, + 3.54, + ], + }; + let mut y = 0.0; + for row in &table.rows { + if row.cells.len() != table.columns.len() { + return Err(LayoutError::InvalidWidth); + } + let start = result.tables[0].cells.len(); + let mut x = 0.0; + let mut height = 0.0_f32; + for (index, cell) in row.cells.iter().enumerate() { + let width = width(index); + if cell.paragraphs.is_empty() || !cell.unsupported.is_empty() { + return Err(LayoutError::UnsupportedContent); + } + let child = Self::flow( + edited_nodes(&cell.paragraphs, Some(cell.id), edit), + &cell.indents, + width, + false, + depth, + edit, + shape, + )?; + height = height.max(child.size[1]); + let paragraph_start = result.paragraphs.len(); + result.append(child, [x, y + 3.54]); + result.tables[0].cells.push(CellLayout { + id: cell.id, + rect: [x - 3.6, y + 1.86, x + width + 1.38, 0.0], + paragraphs: paragraph_start..result.paragraphs.len(), + }); + x += width + 4.98; + } + y += height + 4.98; + for cell in &mut result.tables[0].cells[start..] { + cell.rect[3] = y + 1.86; + } + } + result.size[1] += y; + if result.size.iter().any(|v| !v.is_finite()) { + return Err(LayoutError::InvalidSpacing); + } + Ok(result) + } + pub(crate) fn new( mut paragraphs: Vec, width: f32, @@ -255,7 +490,11 @@ impl OutlineLayout { for (paragraph, y) in paragraphs.iter_mut().zip(origins) { paragraph.origin[1] = y; } - Ok(Self { paragraphs, size }) + Ok(Self { + paragraphs, + tables: Vec::new(), + size, + }) } } @@ -273,7 +512,7 @@ impl Outline { self.layout_with_width(engine, definitions, width) } - fn layout_with_width( + pub(crate) fn layout_with_width( &self, engine: &mut TextEngine, definitions: &BTreeMap, @@ -285,7 +524,24 @@ impl Outline { if !self.unsupported.is_empty() { return Err(LayoutError::UnsupportedContent); } - let paragraphs = visible_paragraphs(&self.paragraphs) + if self + .paragraphs + .iter() + .any(|node| matches!(node.content, ParagraphContent::Table(_))) + { + return OutlineLayout::flow( + self.paragraphs.iter(), + &self.indents, + width, + self.layout.width_set_by_user == Some(true), + 0, + None, + &mut |node, width, indents| { + ParagraphLayout::shape(engine, node, width, indents, definitions) + }, + ); + } + let paragraphs = visible_paragraphs(self.paragraphs.iter()) .map(|p| ParagraphLayout::shape(engine, p, width, &self.indents, definitions)) .collect::>()?; OutlineLayout::new( @@ -316,7 +572,7 @@ impl Title { .layout .reserved_width .or(outline.layout.max_width) - .unwrap_or(f32::MAX); + .unwrap_or(TITLE_WIDTH); let layout = outline.layout_with_width(engine, definitions, width)?; let origin = [ outline.layout.x.unwrap_or(0.0), @@ -326,7 +582,7 @@ impl Title { if origin.iter().any(|v| !v.is_finite()) || !height.is_finite() || height < 0.0 { return Err(LayoutError::InvalidSpacing); } - bottom = origin[1] + layout.size[1].max(height); + bottom = origin[1] + layout.size[1].max(height) + if outline.title { 3.6 } else { 0.0 }; if !bottom.is_finite() { return Err(LayoutError::InvalidSpacing); } @@ -354,18 +610,281 @@ mod tests { }), level, format: Format::default(), - text: vec![TextObject { + content: crate::page::ParagraphContent::Text(TextObject { + date_field: None, id: ExGuid { n: n + 100, ..ExGuid::default() }, text: Paragraph::new(text.into(), Format::default()), tags: Vec::new(), - }], - unsupported: Vec::new(), + }), lists: Vec::new(), tags: Vec::new(), collapsed: false, + style: None, + } + } + + fn table(rows: &[&[&str]], widths: &[f32], mut n: u32) -> PageParagraph { + use crate::page::{TableCell, TableColumn, TableRow}; + let mut id = || { + n += 1; + ExGuid { + n, + ..ExGuid::default() + } + }; + let mut node = paragraph(id().n, "", 1, None); + node.content = ParagraphContent::Table(Table { + id: id(), + columns: widths + .iter() + .map(|width| TableColumn { + width: *width, + locked: false, + }) + .collect(), + rows: rows + .iter() + .map(|row| TableRow { + id: id(), + cells: row + .iter() + .map(|text| TableCell { + id: id(), + layout: Layout::default(), + indents: vec![18.0, 0.0, 27.0], + shading: None, + paragraphs: vec![paragraph(id().n, text, 1, None)], + unsupported: Vec::new(), + }) + .collect(), + }) + .collect(), + borders: Some(true), + layout: Layout::default(), + tags: Vec::new(), + }); + node + } + + #[test] + fn table_rows_align_cells_and_expand_for_wrapped_text() { + let mut engine = TextEngine::default(); + let mut outline = Outline { + id: ExGuid::default(), + title: false, + min_width: None, + layout: Layout { + max_width: Some(300.0), + ..Layout::default() + }, + indents: vec![18.0, 0.0, 27.0], + paragraphs: vec![ + paragraph(1, "Before", 1, None), + table( + &[ + &[ + "A long paragraph that wraps inside a single table cell", + "B", + ], + &["C", "D"], + ], + &[72.0, 48.0], + 1000, + ), + paragraph(2, "After", 1, None), + ], + unsupported: Vec::new(), + }; + let definitions = BTreeMap::new(); + let layout = outline.layout(&mut engine, &definitions).unwrap(); + assert_eq!(layout.paragraphs.len(), 6); + assert_eq!(layout.tables.len(), 1); + let cells = &layout.tables[0].cells; + assert_eq!(cells.len(), 4); + assert_eq!( + cells + .iter() + .map(|cell| cell.paragraphs.clone()) + .collect::>(), + [1..2, 2..3, 3..4, 4..5] + ); + assert!(layout.paragraph_cell(0).is_none()); + assert_eq!(layout.paragraph_cell(4).unwrap().id, cells[3].id); + assert!(layout.paragraph_cell(5).is_none()); + let paragraphs = &layout.paragraphs; + assert!(paragraphs[1].text.lines().count() > 1); + assert_eq!(paragraphs[2].text.lines().count(), 1); + assert_eq!(paragraphs[1].origin[1], paragraphs[2].origin[1]); + assert_eq!(paragraphs[3].origin[1], paragraphs[4].origin[1]); + assert_eq!(cells[0].rect[3], cells[1].rect[3]); + assert_eq!(cells[0].rect[3], cells[2].rect[1]); + assert!((cells[0].rect[2] - cells[1].rect[0]).abs() < 0.00001); + assert_eq!(paragraphs[1].origin[0], 0.0); + assert_eq!(paragraphs[2].origin[0], 76.98); + assert!(paragraphs[3].origin[1] > paragraphs[1].origin[1] + paragraphs[1].text.height()); + assert!(paragraphs[5].origin[1] > cells[3].rect[3]); + assert_eq!( + layout.size[1], + paragraphs[5].origin[1] + paragraphs[5].text.height() + ); + let ParagraphContent::Table(source) = &outline.paragraphs[1].content else { + panic!() + }; + assert_eq!(layout.tables[0].id, source.id); + assert_eq!( + cells.iter().map(|c| c.id).collect::>(), + source + .rows + .iter() + .flat_map(|row| row.cells.iter().map(|c| c.id)) + .collect::>() + ); + + outline.layout.width_set_by_user = Some(true); + let fixed = outline.layout(&mut engine, &definitions).unwrap(); + assert_eq!(fixed.size, [300.0, layout.size[1]]); + assert_eq!(fixed.paragraphs[2].origin, paragraphs[2].origin); + let ParagraphContent::Table(source) = &mut outline.paragraphs[1].content else { + panic!() + }; + source.columns[0].width = 160.0; + let widened = outline.layout(&mut engine, &definitions).unwrap(); + assert!(widened.size[1] < fixed.size[1]); + assert_eq!(widened.paragraphs[2].origin[0], 164.98); + } + + #[test] + fn nested_table_layout_translates_cells_and_respects_collapsed_children() { + let mut outer = table(&[&["Left", "Right"]], &[160.0, 72.0], 1000); + let mut inner = table(&[&["Nested", "Cell"]], &[48.0, 48.0], 2000); + inner.level = 2; + let ParagraphContent::Table(source) = &mut outer.content else { + panic!() + }; + let hidden = paragraph( + 42, + "Hidden descendant", + 2, + Some(source.rows[0].cells[0].paragraphs[0].id.n), + ); + source.rows[0].cells[0].paragraphs[0].collapsed = true; + source.rows[0].cells[0].paragraphs.push(hidden); + source.rows[0].cells[0].paragraphs.push(inner); + let mut engine = TextEngine::default(); + let layout = OutlineLayout::flow( + [&outer].into_iter(), + &[18.0, 0.0, 27.0], + 300.0, + false, + 0, + None, + &mut |node, width, indents| { + ParagraphLayout::shape(&mut engine, node, width, indents, &BTreeMap::new()) + }, + ) + .unwrap(); + assert_eq!(layout.tables.len(), 2); + assert_eq!(layout.tables[0].cells[0].paragraphs, 0..3); + assert_eq!(layout.tables[0].cells[1].paragraphs, 3..4); + assert_eq!(layout.tables[1].cells[0].paragraphs, 1..2); + assert_eq!(layout.tables[1].cells[1].paragraphs, 2..3); + assert_eq!( + layout.paragraph_cell(1).unwrap().id, + layout.tables[1].cells[0].id + ); + assert_eq!( + layout + .paragraphs + .iter() + .map(|p| p.projection.text().text()) + .collect::>(), + ["Left", "Nested", "Cell", "Right"] + ); + assert_eq!(layout.paragraphs[1].origin[0], 27.0); + assert_eq!(layout.tables[1].cells[0].rect[0], 27.0 - 3.6); + assert!(layout.tables[1].cells[0].rect[1] > layout.tables[0].cells[0].rect[1]); + assert!(layout.tables[1].cells[0].rect[3] < layout.tables[0].cells[0].rect[3]); + assert_eq!( + layout.paragraphs[0].origin[1], + layout.paragraphs[3].origin[1] + ); + } + + #[test] + #[ignore = "requires CANVAS_TEST_SECTION native Tab capture and CANVAS_TEST_SUBSTITUTE Carlito font"] + fn native_table_layout() { + use crate::page::{Page, PageObject}; + use onestore::{RevisionIndex, Store, document::Document}; + let bytes = std::fs::read(std::env::var_os("CANVAS_TEST_SECTION").unwrap()).unwrap(); + let store = Store::parse(&bytes).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let mut engine = TextEngine::default(); + engine + .register_substitute(parley::fontique::Blob::new(std::sync::Arc::new( + std::fs::read(std::env::var_os("CANVAS_TEST_SUBSTITUTE").unwrap()).unwrap(), + ))) + .unwrap(); + let page = Page::from_document(&document, "rows").unwrap(); + let outline = page + .objects + .iter() + .find_map(|object| match object { + PageObject::Outline(outline) => Some(outline), + _ => None, + }) + .unwrap(); + let layout = outline.layout(&mut engine, &page.definitions).unwrap(); + assert!((layout.size[0] - 84.6).abs() < 0.001); + assert!((layout.size[1] - 58.76315).abs() < 0.001); + for (paragraph, expected) in layout.paragraphs.iter().zip([ + [0.0, 3.54], + [44.34, 3.54], + [0.0, 21.947714], + [44.34, 21.947714], + [0.0, 40.355427], + [44.34, 40.355427], + ]) { + assert_eq!(paragraph.text.lines().count(), 1); + for (actual, expected) in paragraph.origin.into_iter().zip(expected) { + assert!((actual - expected).abs() < 0.001); + } + } + assert_eq!(layout.tables.len(), 1); + assert_eq!(layout.tables[0].cells.len(), 6); + for (title, size) in [ + ("soft-break", [82.350006, 35.375435]), + ("fixed", [180.0, 21.947721]), + ] { + let page = Page::from_document(&document, title).unwrap(); + let outline = page + .objects + .iter() + .find_map(|object| match object { + PageObject::Outline(outline) => Some(outline), + _ => None, + }) + .unwrap(); + let layout = outline.layout(&mut engine, &page.definitions).unwrap(); + for (actual, expected) in layout.size.into_iter().zip(size) { + assert!( + (actual - expected).abs() < 0.001, + "{title}: {actual} != {expected}" + ); + } + assert_eq!(layout.tables.len(), 1); + assert_eq!(layout.tables[0].cells.len(), 2); + assert_eq!( + layout + .paragraphs + .iter() + .map(|p| p.text.lines().count()) + .max(), + Some(if title == "soft-break" { 2 } else { 1 }) + ); } } @@ -382,7 +901,7 @@ mod tests { let plain = ParagraphLayout::shape(&mut engine, &node, 120.0, &[18.0, 0.0, 27.0], &definitions) .unwrap(); - node.text[0].tags.push(Tag { + node.text_mut().unwrap().tags.push(Tag { definition: Some(id), status: 3, action_type: None, @@ -465,14 +984,17 @@ mod tests { } #[test] - fn title_accepts_intrinsic_width_and_keeps_date_after_wrapped_title() { + fn title_uses_a_default_wrap_limit_and_keeps_date_after_wrapped_title() { let mut title = Title { + date: None, id: ExGuid::default(), layout: Layout::default(), outlines: ["A title with enough words to wrap", "A date"] .into_iter() .enumerate() .map(|(index, text)| Outline { + title: index == 0, + min_width: None, id: ExGuid { n: index as u32, ..ExGuid::default() @@ -495,11 +1017,19 @@ mod tests { )); let layouts = title.layout(&mut engine, &definitions).unwrap(); assert_eq!(layouts[0].1.paragraphs[0].text.lines().count(), 1); - assert_eq!(layouts[1].0, [0.0, 21.6]); + assert_eq!(layouts[1].0, [0.0, 21.6 + 3.6]); + title.outlines[0].paragraphs[0].text_mut().unwrap().text = Paragraph::new( + "A title with enough words to wrap ".repeat(12), + Format::default(), + ); + let layouts = title.layout(&mut engine, &definitions).unwrap(); + assert!(layouts[0].1.paragraphs[0].text.lines().count() > 1); + assert!(layouts[0].1.size[0] <= 468.0); + assert_eq!(layouts[1].0[1], layouts[0].1.size[1] + 3.6); title.outlines[0].layout.max_width = Some(70.0); let layouts = title.layout(&mut engine, &definitions).unwrap(); assert!(layouts[0].1.paragraphs[0].text.lines().count() > 1); - assert_eq!(layouts[1].0[1], layouts[0].1.size[1]); + assert_eq!(layouts[1].0[1], layouts[0].1.size[1] + 3.6); for value in [f32::NAN, f32::INFINITY, -1.0] { title.outlines[0].layout.max_height = Some(value); assert!(matches!( @@ -518,7 +1048,7 @@ mod tests { #[test] fn collapses_descendants_and_uses_the_larger_adjacent_spacing() { let mut first = paragraph(1, "First", 1, None); - first.text[0].text = Paragraph::new( + first.text_mut().unwrap().text = Paragraph::new( "First".into(), Format { space_after: Some(4.0), @@ -526,7 +1056,7 @@ mod tests { }, ); let mut second = paragraph(2, "Second", 2, Some(1)); - second.text[0].text = Paragraph::new( + second.text_mut().unwrap().text = Paragraph::new( "Second".into(), Format { space_before: Some(7.0), @@ -536,6 +1066,8 @@ mod tests { ); second.collapsed = true; let mut outline = Outline { + title: false, + min_width: None, id: ExGuid::default(), layout: Layout { max_width: Some(300.0), @@ -591,6 +1123,8 @@ mod tests { ); long.lists.push(marker_id); let outline = Outline { + title: false, + min_width: None, id: ExGuid::default(), layout: Layout { max_width: Some(110.0), diff --git a/crates/canvas/src/page.rs b/crates/canvas/src/page.rs index 9b88c15888615202425bd9789906eefd1973941c..29f333fa77ea6159337a51085f42f20b9de1bcae 100644 --- a/crates/canvas/src/page.rs +++ b/crates/canvas/src/page.rs @@ -1,3 +1,4 @@ +use crate::date::DateField; use crate::text::Paragraph; use onestore::{ ExGuid, @@ -10,6 +11,8 @@ use std::{ pub struct Page { pub title: String, + /// FILETIME ticks from the page's TopologyCreationTimeStamp. + pub created: Option, pub margin_origin: [f32; 2], pub objects: Vec, pub definitions: BTreeMap, @@ -27,14 +30,46 @@ pub enum PageObject { Unsupported(Unsupported), } +impl PageObject { + pub(crate) fn id(&self) -> ExGuid { + match self { + Self::Outline(value) => value.id, + Self::Title(value) => value.id, + Self::Image(value) => value.id, + Self::Unsupported(value) => value.id, + } + } + pub(crate) fn layout(&self) -> &Layout { + match self { + Self::Outline(value) => &value.layout, + Self::Title(value) => &value.layout, + Self::Image(value) => &value.layout, + Self::Unsupported(value) => &value.layout, + } + } + + pub(crate) fn layout_mut(&mut self) -> &mut Layout { + match self { + Self::Outline(value) => &mut value.layout, + Self::Title(value) => &mut value.layout, + Self::Image(value) => &mut value.layout, + Self::Unsupported(value) => &mut value.layout, + } + } +} + pub struct Title { pub id: ExGuid, + pub date: Option, pub layout: Layout, pub outlines: Vec, } +#[derive(Clone)] pub struct Outline { pub id: ExGuid, + pub title: bool, + pub min_width: Option, pub layout: Layout, pub indents: Vec, pub paragraphs: Vec, @@ -46,17 +81,57 @@ pub struct PageParagraph { pub id: ExGuid, pub parent: Option, pub level: u32, + pub style: Option, pub format: Format, - pub text: Vec, - pub unsupported: Vec, + pub content: ParagraphContent, pub lists: Vec, pub tags: Vec, pub collapsed: bool, } +#[derive(Clone, Debug, PartialEq)] +pub enum ParagraphContent { + Text(TextObject), + Table(Table), + Unsupported(Unsupported), +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Table { + pub id: ExGuid, + pub columns: Vec, + pub rows: Vec, + pub borders: Option, + pub layout: Layout, + pub tags: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct TableColumn { + pub width: f32, + pub locked: bool, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct TableRow { + pub id: ExGuid, + pub cells: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct TableCell { + pub id: ExGuid, + pub layout: Layout, + pub indents: Vec, + pub shading: Option, + pub paragraphs: Vec, + pub unsupported: Vec, +} + #[derive(Clone, Debug, PartialEq)] pub struct TextObject { pub id: ExGuid, + pub date_field: Option, pub text: Paragraph, pub tags: Vec, } @@ -101,20 +176,6 @@ impl Page { pub fn from_revision(revision: &Revision<'_>, id: ExGuid) -> Result { let invalid = |message| onestore::Error { offset: 0, message }; - let default_format = Format::default(); - let style = |id: Option| -> Result<&Format, onestore::Error> { - let Some(id) = id else { - return Ok(&default_format); - }; - let node = revision - .nodes - .get(&id) - .ok_or_else(|| invalid("Missing canvas paragraph style"))?; - if !matches!(node.kind, Kind::Style { .. }) { - return Err(invalid("Canvas paragraph style has the wrong type")); - } - Ok(&node.format) - }; let root = revision .nodes .get(&id) @@ -129,6 +190,22 @@ impl Page { }; let mut page = Self { title: page_title(revision, id).unwrap_or_default().to_owned(), + created: revision + .roots + .get(&2) + .and_then(|id| revision.nodes.get(id)) + .filter(|node| matches!(node.kind, Kind::Metadata { .. })) + .and_then(|node| node.extra.first()) + .and_then(|fields| fields.iter().find(|field| field.id == 0x18001c65)) + .map(|field| { + let onestore::document::FieldValue::Bytes(bytes) = field.value else { + return Err(invalid("The page creation date is damaged")); + }; + Ok(u64::from_le_bytes(bytes.try_into().map_err(|_| { + invalid("The page creation date is damaged") + })?)) + }) + .transpose()?, margin_origin: [ margin_origin_x.unwrap_or(0.0), margin_origin_y.unwrap_or(0.0), @@ -160,168 +237,64 @@ impl Page { let index = page.objects.len(); page.objects.push(PageObject::Title(Title { id, + date: None, layout: node.layout.clone(), outlines: Vec::new(), })); roots.extend(node.children.iter().rev().map(|id| (*id, Some(index)))); } Kind::Outline { indents } => { + let fields = node.extra.first().map(Vec::as_slice).unwrap_or_default(); + if let Some(index) = title_index + && fields.iter().any(|field| { + field.id == 0x88001cb5 + && matches!(field.value, onestore::document::FieldValue::NoData) + }) + { + let PageObject::Title(title) = &mut page.objects[index] else { + unreachable!() + }; + if title.date.replace(id).is_some() { + return Err(invalid("The page has more than one date field")); + } + } + let min_width = fields + .iter() + .find(|field| field.id == 0x14001cec) + .map(|field| { + let onestore::document::FieldValue::Bytes(bytes) = field.value else { + return Err(invalid("Invalid canvas minimum outline width")); + }; + let width = + f32::from_le_bytes(bytes.try_into().map_err(|_| { + invalid("Invalid canvas minimum outline width") + })?) * 36.0; + if !width.is_finite() || width <= 0.0 { + return Err(invalid("Invalid canvas minimum outline width")); + } + Ok(width) + }) + .transpose()?; let mut outline = Outline { id, + title: fields.iter().any(|field| { + field.id == 0x88001cb4 + && matches!(field.value, onestore::document::FieldValue::NoData) + }), + min_width, layout: node.layout.clone(), indents: indents.clone(), paragraphs: Vec::new(), unsupported: Vec::new(), }; - let mut pending: Vec<_> = node - .children - .iter() - .rev() - .map(|id| { - ( - *id, - None, - u32::from(node.child_level.unwrap_or(0)), - node.format.clone(), - ) - }) - .collect(); - while let Some((id, parent, level, inherited)) = pending.pop() { - if !seen.insert(id) { - return Err(invalid("Repeated canvas outline object")); - } - let node = revision - .nodes - .get(&id) - .ok_or_else(|| invalid("Missing canvas outline object"))?; - let next_level = level - .checked_add(u32::from(node.child_level.unwrap_or(0))) - .ok_or_else(|| invalid("Canvas outline level overflow"))?; - match &node.kind { - Kind::Paragraph { - lists, - paragraph_style, - collapse_state, - } => { - let format = node - .format - .inherit(style(*paragraph_style)?) - .inherit(&inherited); - let mut text = Vec::new(); - let mut unsupported = Vec::new(); - for id in &node.content { - let content = revision.nodes.get(id).ok_or_else(|| { - invalid("Missing canvas paragraph content") - })?; - if let Kind::RichText { - paragraph_style, .. - } = &content.kind - { - let runs = revision.text_runs(*id)?; - let text_content = if runs.is_empty() { - Paragraph::new( - String::new(), - content - .format - .inherit(style(*paragraph_style)?) - .inherit(&format), - ) - } else { - Paragraph::from_runs(runs.into_iter().map(|run| { - (run.text.to_owned(), run.format.inherit(&format)) - })) - }; - text.push(TextObject { - id: *id, - text: text_content, - tags: content.tags.clone(), - }); - } else { - unsupported.push(Unsupported { - id: *id, - jcid: content.jcid, - layout: content.layout.clone(), - }); - } - } - for (id, is_list) in lists.iter().map(|id| (id, true)).chain( - node.tags - .iter() - .chain(text.iter().flat_map(|t| &t.tags)) - .filter_map(|tag| tag.definition.as_ref()) - .map(|id| (id, false)), - ) { - let definition = revision.nodes.get(id).ok_or_else(|| { - invalid("Missing canvas list or tag definition") - })?; - let kind = match &definition.kind { - Kind::List { - font, - format, - restart, - bullet, - } if is_list => Kind::List { - font: font.clone(), - format: format.clone(), - restart: *restart, - bullet: *bullet, - }, - Kind::TagDefinition { - label, - action_type, - shape, - color, - highlight, - } if !is_list => Kind::TagDefinition { - label: label.clone(), - action_type: *action_type, - shape: *shape, - color: *color, - highlight: *highlight, - }, - _ => { - return Err(invalid( - "Canvas list or tag definition has the wrong type", - )); - } - }; - page.definitions.insert( - *id, - Definition { - kind, - format: definition.format.clone(), - }, - ); - } - outline.paragraphs.push(PageParagraph { - id, - parent, - level, - format: format.clone(), - text, - unsupported, - lists: lists.clone(), - tags: node.tags.clone(), - collapsed: *collapse_state == Some(1), - }); - pending.extend( - node.children.iter().rev().map(|child| { - (*child, Some(id), next_level, format.clone()) - }), - ); - } - Kind::OutlineGroup => { - pending.extend(node.children.iter().rev().map(|child| { - (*child, parent, next_level, node.format.inherit(&inherited)) - })) - } - _ => outline.unsupported.push(Unsupported { - id, - jcid: node.jcid, - layout: node.layout.clone(), - }), - } - } + (outline.paragraphs, outline.unsupported) = read_paragraphs( + revision, + node, + &node.format, + &mut page.definitions, + &mut seen, + 0, + )?; if let Some(index) = title_index { let PageObject::Title(title) = &mut page.objects[index] else { unreachable!() @@ -368,6 +341,335 @@ impl Page { } } +fn read_paragraphs( + revision: &Revision<'_>, + container: &onestore::document::Element<'_>, + inherited: &Format, + definitions: &mut BTreeMap, + seen: &mut BTreeSet, + depth: usize, +) -> Result<(Vec, Vec), onestore::Error> { + let invalid = |message| onestore::Error { offset: 0, message }; + let default_format = Format::default(); + let style = |id: Option| -> Result<&Format, onestore::Error> { + let Some(id) = id else { + return Ok(&default_format); + }; + let node = revision + .nodes + .get(&id) + .ok_or_else(|| invalid("Missing canvas paragraph style"))?; + if !matches!(node.kind, Kind::Style { .. }) { + return Err(invalid("Canvas paragraph style has the wrong type")); + } + Ok(&node.format) + }; + + let mut paragraphs = Vec::new(); + let mut unsupported = Vec::new(); + let mut pending: Vec<_> = container + .children + .iter() + .rev() + .map(|id| { + ( + *id, + None, + u32::from(container.child_level.unwrap_or(0)), + inherited.clone(), + ) + }) + .collect(); + while let Some((id, parent, level, inherited)) = pending.pop() { + if !seen.insert(id) { + return Err(invalid("Repeated canvas outline object")); + } + let node = revision + .nodes + .get(&id) + .ok_or_else(|| invalid("Missing canvas outline object"))?; + let next_level = level + .checked_add(u32::from(node.child_level.unwrap_or(0))) + .ok_or_else(|| invalid("Canvas outline level overflow"))?; + match &node.kind { + Kind::Paragraph { + lists, + paragraph_style, + collapse_state, + } => { + let format = node + .format + .inherit(style(*paragraph_style)?) + .inherit(&inherited); + let mut base_style = *paragraph_style; + let [content_id] = node.content.as_slice() else { + return Err(invalid("A paragraph must contain one content object")); + }; + if !seen.insert(*content_id) { + return Err(invalid("Repeated canvas paragraph content")); + } + let content = revision + .nodes + .get(content_id) + .ok_or_else(|| invalid("Missing canvas paragraph content"))?; + let content = if let Kind::RichText { + paragraph_style, .. + } = &content.kind + { + base_style = paragraph_style.or(base_style); + let runs = revision.text_runs(*content_id)?; + let text_content = if runs.is_empty() { + Paragraph::new( + String::new(), + content + .format + .inherit(style(*paragraph_style)?) + .inherit(&format), + ) + } else { + Paragraph::from_runs( + runs.into_iter() + .map(|run| (run.text.to_owned(), run.format.inherit(&format))), + ) + }; + ParagraphContent::Text(TextObject { + id: *content_id, + date_field: content + .extra + .first() + .map(Vec::as_slice) + .unwrap_or_default() + .iter() + .filter(|field| { + matches!(field.value, onestore::document::FieldValue::NoData) + }) + .filter_map(|field| match field.id { + 0x88001cb5 => Some(DateField::Date), + 0x88001c87 => Some(DateField::Time), + _ => None, + }) + .try_fold(None, |previous, field| { + if previous.is_some() { + Err(invalid("The page date field has conflicting roles")) + } else { + Ok(Some(field)) + } + })?, + text: text_content, + tags: content.tags.clone(), + }) + } else if matches!(content.kind, Kind::Table { .. }) { + ParagraphContent::Table(read_table( + revision, + *content_id, + &content.format.inherit(&format), + definitions, + seen, + depth + 1, + )?) + } else { + ParagraphContent::Unsupported(Unsupported { + id: *content_id, + jcid: content.jcid, + layout: content.layout.clone(), + }) + }; + for (id, is_list) in lists.iter().map(|id| (id, true)).chain( + node.tags + .iter() + .chain(match &content { + ParagraphContent::Text(text) => text.tags.as_slice(), + ParagraphContent::Table(table) => table.tags.as_slice(), + ParagraphContent::Unsupported(_) => &[], + }) + .filter_map(|tag| tag.definition.as_ref()) + .map(|id| (id, false)), + ) { + let definition = revision + .nodes + .get(id) + .ok_or_else(|| invalid("Missing canvas list or tag definition"))?; + let kind = match &definition.kind { + Kind::List { + font, + format, + restart, + bullet, + } if is_list => Kind::List { + font: font.clone(), + format: format.clone(), + restart: *restart, + bullet: *bullet, + }, + Kind::TagDefinition { + label, + action_type, + shape, + color, + highlight, + } if !is_list => Kind::TagDefinition { + label: label.clone(), + action_type: *action_type, + shape: *shape, + color: *color, + highlight: *highlight, + }, + _ => { + return Err(invalid( + "Canvas list or tag definition has the wrong type", + )); + } + }; + definitions.insert( + *id, + Definition { + kind, + format: definition.format.clone(), + }, + ); + } + if let Some(id) = base_style { + let definition = revision + .nodes + .get(&id) + .ok_or_else(|| invalid("Missing canvas paragraph style"))?; + let Kind::Style { name } = &definition.kind else { + return Err(invalid("Canvas paragraph style has the wrong type")); + }; + definitions.entry(id).or_insert_with(|| Definition { + kind: Kind::Style { name: name.clone() }, + format: definition.format.clone(), + }); + } + paragraphs.push(PageParagraph { + id, + parent, + level, + style: base_style, + format: format.clone(), + content, + lists: lists.clone(), + tags: node.tags.clone(), + collapsed: *collapse_state == Some(1), + }); + pending.extend( + node.children + .iter() + .rev() + .map(|child| (*child, Some(id), next_level, format.clone())), + ); + } + Kind::OutlineGroup => pending.extend( + node.children + .iter() + .rev() + .map(|child| (*child, parent, next_level, node.format.inherit(&inherited))), + ), + _ => unsupported.push(Unsupported { + id, + jcid: node.jcid, + layout: node.layout.clone(), + }), + } + } + Ok((paragraphs, unsupported)) +} + +fn read_table( + revision: &Revision<'_>, + id: ExGuid, + inherited: &Format, + definitions: &mut BTreeMap, + seen: &mut BTreeSet, + depth: usize, +) -> Result { + let invalid = |message| onestore::Error { offset: 0, message }; + if depth > 64 { + return Err(invalid("Tables are nested too deeply")); + } + let node = &revision.nodes[&id]; + let Kind::Table { + rows, + columns, + widths, + locked, + borders, + } = &node.kind + else { + unreachable!() + }; + if rows.map(|n| n as usize) != Some(node.children.len()) + || node.children.is_empty() + || columns.map(|n| n as usize) != Some(widths.len()) + || widths.is_empty() + || (!locked.is_empty() && locked.len() != widths.len()) + || widths.iter().any(|w| !w.is_finite() || *w < 36.0) + { + return Err(invalid("Table dimensions are inconsistent")); + } + let mut table_rows = Vec::new(); + for row_id in &node.children { + if !seen.insert(*row_id) { + return Err(invalid("Repeated canvas table row")); + } + let row = revision + .nodes + .get(row_id) + .ok_or_else(|| invalid("Missing canvas table row"))?; + if !matches!(row.kind, Kind::Row) || row.children.len() != widths.len() { + return Err(invalid("Table row dimensions are inconsistent")); + } + let mut cells = Vec::new(); + for cell_id in &row.children { + if !seen.insert(*cell_id) { + return Err(invalid("Repeated canvas table cell")); + } + let cell = revision + .nodes + .get(cell_id) + .ok_or_else(|| invalid("Missing canvas table cell"))?; + if cell.children.is_empty() { + return Err(invalid("A table cell has no paragraphs")); + } + let Kind::Cell { shading, indents } = &cell.kind else { + return Err(invalid("A table row contains an invalid cell")); + }; + let (paragraphs, unsupported) = read_paragraphs( + revision, + cell, + &cell.format.inherit(inherited), + definitions, + seen, + depth, + )?; + cells.push(TableCell { + id: *cell_id, + layout: cell.layout.clone(), + indents: indents.clone(), + shading: *shading, + paragraphs, + unsupported, + }); + } + table_rows.push(TableRow { id: *row_id, cells }); + } + Ok(Table { + id, + columns: widths + .iter() + .enumerate() + .map(|(index, &width)| TableColumn { + width, + locked: locked.get(index).copied().unwrap_or(false), + }) + .collect(), + rows: table_rows, + borders: *borders, + layout: node.layout.clone(), + tags: node.tags.clone(), + }) +} + fn page_title<'a>(revision: &'a Revision<'_>, id: ExGuid) -> Option<&'a str> { revision .roots @@ -393,18 +695,17 @@ fn page_title<'a>(revision: &'a Revision<'_>, id: ExGuid) -> Option<&'a str> { } impl PageParagraph { - pub fn combined_text(&self) -> Paragraph { - if self.text.is_empty() { - return Paragraph::new(String::new(), self.format.clone()); + pub fn text(&self) -> Option<&TextObject> { + match &self.content { + ParagraphContent::Text(text) => Some(text), + _ => None, + } + } + pub fn text_mut(&mut self) -> Option<&mut TextObject> { + match &mut self.content { + ParagraphContent::Text(text) => Some(text), + _ => None, } - Paragraph::from_runs(self.text.iter().flat_map(|object| { - let mut start = 0; - object.text.spans().iter().map(move |span| { - let text = object.text.text()[start..span.end].to_owned(); - start = span.end; - (text, span.format.clone()) - }) - })) } } @@ -499,6 +800,278 @@ mod tests { } } + fn table_revision() -> Revision<'static> { + let mut source = revision(); + source.nodes.get_mut(&id(3)).unwrap().content = vec![id(7)]; + let mut table = element(Kind::Table { + rows: Some(1), + columns: Some(2), + widths: vec![37.11, 99.0], + locked: vec![false, true], + borders: Some(false), + }); + table.children = vec![id(8)]; + let mut row = element(Kind::Row); + row.children = vec![id(9), id(10)]; + source.nodes.insert(id(7), table); + source.nodes.insert(id(8), row); + for (cell, paragraph, text) in [(9, 11, 4), (10, 12, 13)] { + let mut node = element(Kind::Cell { + shading: Some(0x00ffff), + indents: vec![18.0, 0.0, 27.0, 27.0], + }); + node.child_level = Some(1); + node.layout.max_width = Some(268.8); + node.children = vec![id(paragraph)]; + if cell == 10 { + node.format.font_size = Some(13.0); + } + source.nodes.insert(id(cell), node); + let mut node = element(Kind::Paragraph { + lists: Vec::new(), + paragraph_style: None, + collapse_state: None, + }); + node.content = vec![id(text)]; + source.nodes.insert(id(paragraph), node); + } + source.nodes.insert( + id(13), + element(Kind::RichText { + text: "cd".into(), + runs: vec![TextRun { + start: 0, + end: 2, + format: None, + extra_set: None, + }], + paragraph_style: None, + boilerplate: false, + }), + ); + source + } + + #[test] + fn table_import_owns_cells_and_inherits_format_across_containers() { + let page = { + let source = table_revision(); + let before = serde_json::to_vec(&source).unwrap(); + let page = Page::from_revision(&source, id(1)).unwrap(); + assert_eq!(serde_json::to_vec(&source).unwrap(), before); + page + }; + let PageObject::Outline(outline) = &page.objects[0] else { + panic!() + }; + let ParagraphContent::Table(table) = &outline.paragraphs[0].content else { + panic!() + }; + assert_eq!(table.id, id(7)); + assert_eq!(table.borders, Some(false)); + assert_eq!( + table.columns, + [ + TableColumn { + width: 37.11, + locked: false + }, + TableColumn { + width: 99.0, + locked: true + } + ] + ); + assert_eq!(table.rows[0].id, id(8)); + for (index, cell) in table.rows[0].cells.iter().enumerate() { + assert_eq!(cell.id, id(9 + index as u32)); + assert_eq!(cell.layout.max_width, Some(268.8)); + assert_eq!(cell.indents, [18.0, 0.0, 27.0, 27.0]); + assert_eq!(cell.shading, Some(0x00ffff)); + let paragraph = &cell.paragraphs[0]; + assert_eq!(paragraph.parent, None); + assert_eq!(paragraph.level, 1); + assert_eq!(paragraph.id, id(11 + index as u32)); + let text = paragraph.text().unwrap(); + assert_eq!(text.id, if index == 0 { id(4) } else { id(13) }); + assert_eq!(text.text.text(), if index == 0 { "ab" } else { "cd" }); + assert_eq!( + text.text.spans()[0].format.font_size, + Some(if index == 0 { 12.0 } else { 13.0 }) + ); + assert_eq!(text.text.spans()[0].format.bold, Some(false)); + } + } + + #[test] + fn absent_column_locks_use_the_specified_unlocked_default() { + let mut source = table_revision(); + let Kind::Table { locked, .. } = &mut source.nodes.get_mut(&id(7)).unwrap().kind else { + panic!() + }; + locked.clear(); + let page = Page::from_revision(&source, id(1)).unwrap(); + let PageObject::Outline(outline) = &page.objects[0] else { + panic!() + }; + let ParagraphContent::Table(table) = &outline.paragraphs[0].content else { + panic!() + }; + assert!(table.columns.iter().all(|column| !column.locked)); + } + + #[test] + fn table_import_rejects_aliases_cycles_and_inconsistent_dimensions() { + for case in 0..7 { + let mut source = table_revision(); + match case { + 0 => source.nodes.get_mut(&id(8)).unwrap().children[1] = id(9), + 1 => source.nodes.get_mut(&id(11)).unwrap().content = vec![id(7)], + 2 => source.nodes.get_mut(&id(12)).unwrap().content = vec![id(4)], + 3 => source.nodes.get_mut(&id(3)).unwrap().content.push(id(4)), + 4 => source.nodes.get_mut(&id(9)).unwrap().children.clear(), + 5 => { + let Kind::Table { columns, .. } = + &mut source.nodes.get_mut(&id(7)).unwrap().kind + else { + panic!() + }; + *columns = Some(3); + } + _ => { + let Kind::Table { widths, .. } = + &mut source.nodes.get_mut(&id(7)).unwrap().kind + else { + panic!() + }; + widths[0] = f32::INFINITY; + } + } + assert!(Page::from_revision(&source, id(1)).is_err(), "case {case}"); + } + } + + #[test] + fn nested_tables_import_with_a_bounded_depth() { + for levels in [1, 62, 65] { + let mut source = table_revision(); + let mut paragraph = id(11); + for depth in 0..levels { + let base = 20 + depth * 4; + source.nodes.get_mut(¶graph).unwrap().content = vec![id(base)]; + let mut table = element(Kind::Table { + rows: Some(1), + columns: Some(1), + widths: vec![72.0], + locked: vec![true], + borders: Some(true), + }); + table.children = vec![id(base + 1)]; + let mut row = element(Kind::Row); + row.children = vec![id(base + 2)]; + let mut cell = element(Kind::Cell { + shading: None, + indents: vec![18.0, 0.0, 27.0, 27.0], + }); + cell.child_level = Some(1); + cell.children = vec![id(base + 3)]; + let mut child = element(Kind::Paragraph { + lists: Vec::new(), + paragraph_style: None, + collapse_state: None, + }); + child.content = vec![id(4)]; + source.nodes.extend([ + (id(base), table), + (id(base + 1), row), + (id(base + 2), cell), + (id(base + 3), child), + ]); + paragraph = id(base + 3); + } + assert_eq!(Page::from_revision(&source, id(1)).is_ok(), levels < 64); + } + } + + #[test] + #[ignore = "requires CANVAS_TEST_SECTION pointing to the native Tab table capture"] + fn native_table_import() { + use onestore::{RevisionIndex, Store}; + let page = { + let bytes = std::fs::read(std::env::var_os("CANVAS_TEST_SECTION").unwrap()).unwrap(); + let store = Store::parse(&bytes).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + for (space, page) in document.pages().unwrap() { + let space = &document.spaces[&space]; + let revision = &space.revisions[&space.contexts[&ExGuid::default()]]; + Page::from_revision(revision, page).unwrap(); + } + Page::from_document(&document, "rows").unwrap() + }; + let table = page + .objects + .iter() + .find_map(|object| match object { + PageObject::Outline(outline) => { + outline.paragraphs.iter().find_map(|p| match &p.content { + ParagraphContent::Table(table) => Some(table), + _ => None, + }) + } + _ => None, + }) + .unwrap(); + assert_eq!(table.rows.len(), 3); + assert_eq!(table.columns.len(), 2); + assert!(table.columns.iter().all(|c| !c.locked)); + let cells: Vec<_> = table.rows.iter().flat_map(|row| &row.cells).collect(); + let text: Vec<_> = cells + .iter() + .map(|cell| cell.paragraphs[0].text().unwrap().text.text()) + .collect(); + assert_eq!(text, ["Alpha", "Beta", "Gamma", "Delta", "Epsilon", ""]); + assert!(cells.iter().all(|c| c.indents == [18.0, 0.0, 27.0, 27.0])); + use crate::document::{TextDocument, TextPosition}; + let outline = page + .objects + .iter() + .find_map(|object| match object { + PageObject::Outline(outline) + if outline + .paragraphs + .iter() + .any(|p| matches!(p.content, ParagraphContent::Table(_))) => + { + Some(outline) + } + _ => None, + }) + .unwrap(); + let mut document = TextDocument::from_nodes(outline.paragraphs.clone()).unwrap(); + let original = document.clone(); + for (index, cell) in cells.iter().enumerate() { + let position = TextPosition { + paragraph: index, + offset: 0, + }; + let edit = document + .replace( + position..position, + vec![ + Paragraph::new("🧊".into(), Format::default()), + Paragraph::new("text".into(), Format::default()), + ], + ) + .unwrap(); + assert_eq!(edit.container, Some(cell.id)); + let undo = document.apply(edit).unwrap(); + assert_eq!(document.paragraphs().nth(index).unwrap().text(), "🧊"); + document.apply(undo).unwrap(); + assert_eq!(document, original); + } + } + #[test] fn owns_page_content_and_resolves_style_before_ancestor_defaults() { let page = { @@ -539,15 +1112,104 @@ mod tests { assert!(paragraph.collapsed); assert_eq!(paragraph.level, 1); assert_eq!(paragraph.parent, None); - let text = paragraph.combined_text(); + assert_eq!(paragraph.style, Some(id(5))); + assert!(matches!(&page.definitions[&id(5)].kind, + Kind::Style { name: Some(name) } if name == "Body")); + assert_eq!(page.definitions[&id(5)].format.font_size, Some(12.0)); + let text = ¶graph.text().unwrap().text; assert_eq!(text.text(), "ab"); assert_eq!(text.spans()[0].format.font_size, Some(12.0)); assert_eq!(text.spans()[0].format.bold, Some(false)); - assert_eq!(paragraph.text[0].tags[0].created, Some(123)); + assert_eq!(paragraph.text().unwrap().tags[0].created, Some(123)); assert!(matches!(&page.definitions[&id(7)].kind, Kind::TagDefinition { label: Some(label), shape: Some(3), .. } if label == "To Do")); } + #[test] + fn creation_time_comes_from_page_metadata_and_date_role_from_the_title_child() { + use onestore::document::{Field, FieldValue}; + let mut source = revision(); + source.nodes.get_mut(&id(1)).unwrap().created = Some(7); + let bytes = 134_333_468_649_123_456_u64.to_le_bytes(); + source.nodes.get_mut(&id(6)).unwrap().extra = vec![vec![Field { + id: 0x18001c65, + value: FieldValue::Bytes(&bytes), + }]]; + source.nodes.get_mut(&id(1)).unwrap().children.clear(); + source.nodes.get_mut(&id(1)).unwrap().structure.push(id(7)); + let mut title = element(Kind::Title); + title.children.push(id(2)); + source.nodes.insert(id(7), title); + source.nodes.get_mut(&id(2)).unwrap().extra = vec![vec![Field { + id: 0x88001cb5, + value: FieldValue::NoData, + }]]; + source.nodes.get_mut(&id(4)).unwrap().extra = vec![vec![Field { + id: 0x88001c87, + value: FieldValue::NoData, + }]]; + let page = Page::from_revision(&source, id(1)).unwrap(); + assert_eq!(page.created, Some(134_333_468_649_123_456)); + let PageObject::Title(title) = &page.objects[0] else { + panic!() + }; + assert_eq!(title.date, Some(id(2))); + assert_eq!( + title.outlines[0].paragraphs[0].text().unwrap().date_field, + Some(DateField::Time) + ); + source.nodes.get_mut(&id(6)).unwrap().extra[0][0].value = FieldValue::Bytes(&bytes[..7]); + assert!(Page::from_revision(&source, id(1)).is_err()); + source.nodes.get_mut(&id(6)).unwrap().extra.clear(); + assert_eq!(Page::from_revision(&source, id(1)).unwrap().created, None); + } + + #[test] + fn title_role_and_minimum_width_come_from_root_properties() { + use onestore::document::{Field, FieldValue}; + let mut source = revision(); + source.nodes.get_mut(&id(1)).unwrap().children.clear(); + source.nodes.get_mut(&id(1)).unwrap().structure.push(id(7)); + let mut title = element(Kind::Title); + title.children.push(id(2)); + source.nodes.insert(id(7), title); + source.nodes.get_mut(&id(2)).unwrap().extra = vec![vec![ + Field { + id: 0x88001cb4, + value: FieldValue::NoData, + }, + Field { + id: 0x14001cec, + value: FieldValue::Bytes(&[0, 0, 0x90, 0x40]), + }, + ]]; + let page = Page::from_revision(&source, id(1)).unwrap(); + let PageObject::Title(title) = &page.objects[0] else { + panic!() + }; + assert!(title.outlines[0].title); + assert_eq!(title.outlines[0].min_width, Some(162.0)); + source.nodes.get_mut(&id(2)).unwrap().extra[0][1].value = + FieldValue::Bytes(&[0, 0, 0x80, 0x7f]); + assert!(Page::from_revision(&source, id(1)).is_err()); + source.nodes.get_mut(&id(2)).unwrap().extra = vec![ + vec![Field { + id: 0x08001cb4, + value: FieldValue::NoData, + }], + vec![Field { + id: 0x88001cb4, + value: FieldValue::NoData, + }], + ]; + let page = Page::from_revision(&source, id(1)).unwrap(); + let PageObject::Title(title) = &page.objects[0] else { + panic!() + }; + assert!(!title.outlines[0].title); + assert_eq!(title.outlines[0].min_width, None); + } + #[test] fn preserves_paint_order_nested_parents_and_owned_image_payloads() { let bytes = vec![1, 2, 3, 4]; @@ -577,11 +1239,21 @@ mod tests { let mut group = element(Kind::OutlineGroup); group.children.push(id(12)); group.child_level = Some(1); - let child = element(Kind::Paragraph { + let mut child = element(Kind::Paragraph { lists: Vec::new(), paragraph_style: None, collapse_state: None, }); + child.content.push(id(13)); + source.nodes.insert( + id(13), + element(Kind::RichText { + text: "".into(), + runs: Vec::new(), + paragraph_style: None, + boilerplate: false, + }), + ); source.nodes.extend([ (id(7), image), (id(8), file), @@ -651,7 +1323,7 @@ mod tests { panic!() }; assert_eq!( - outline.paragraphs[0].combined_text().spans()[0] + outline.paragraphs[0].text().unwrap().text.spans()[0] .format .font_size, Some(12.0) diff --git a/crates/canvas/src/text.rs b/crates/canvas/src/text.rs index db28f0565cd1fe767196c00277155d3416ce68c5..b0e5abe7b5665336130a231927bdab7f9423a6f8 100644 --- a/crates/canvas/src/text.rs +++ b/crates/canvas/src/text.rs @@ -16,12 +16,14 @@ pub struct Paragraph { spans: Vec, } +#[derive(Clone)] pub struct TextProjection { text: Paragraph, spans: Vec, source_len: u32, } +#[derive(Clone)] struct ProjectedSpan { visible: Range, source_start: u32, @@ -148,7 +150,7 @@ impl Paragraph { .map_err(|_| EditError::TextTooLong)?; let source_end = source.checked_add(length).ok_or(EditError::TextTooLong)?; if span.format.hidden != Some(true) { - runs.push((fragment.to_owned(), span.format.clone())); + runs.push((fragment.replace('\u{000b}', "\n"), span.format.clone())); if length > 0 { if let Some(last) = spans.last_mut() && last.source_start + (last.visible.end - last.visible.start) == source @@ -356,6 +358,27 @@ mod tests { } } + #[test] + fn soft_breaks_keep_source_offsets_and_formatting() { + let source = Paragraph::from_runs([("A\u{000b}".into(), regular()), ("🌳".into(), bold())]); + let projection = source.project().unwrap(); + assert_eq!(source.text(), "A\u{000b}🌳"); + assert_eq!(projection.text().text(), "A\n🌳"); + assert_eq!(projection.text().spans(), source.spans()); + assert_eq!( + projection.source_boundaries().collect::>(), + [(0, 0), (1, 1), (2, 2), (6, 4)] + ); + for offset in [0, 1, 2, 4] { + assert_eq!(projection.visible_offset(offset), Ok(offset)); + assert_eq!( + projection.source_offset(offset, Affinity::Downstream), + Ok(offset) + ); + } + assert!(projection.visible_offset(3).is_err()); + } + #[test] fn hidden_fields_keep_source_offsets_at_both_sides_of_a_gap() { let hidden = Format { diff --git a/crates/snowbound/Cargo.toml b/crates/snowbound/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..af56d3e3b13831eda5cc080cad7565e1532b4ff5 --- /dev/null +++ b/crates/snowbound/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "snowbound" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +canvas = { path = "../canvas", features = ["gpu"] } +onestore = { path = "../onestore" } +pollster = "0.4" +wgpu.workspace = true +parley.workspace = true +winit = "0.30.13" +arboard = { version = "3.6.1", default-features = false } +accesskit = "0.25.0" +accesskit_winit = { version = "0.34.0", default-features = false, features = ["rwh_06"] } +objc2 = "0.5.2" +objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSAlert", "NSButton", "NSControl", "NSView", "NSResponder", "NSWindow", "NSApplication", "NSEvent", "NSColor", "NSColorSpace", "NSDatePicker", "NSDatePickerCell"] } +objc2-foundation = { version = "0.2.2", default-features = false, features = ["NSString", "NSThread", "NSAttributedString", "NSDate", "NSCalendar", "NSDateFormatter", "NSLocale", "NSTimeZone"] } + +[dev-dependencies] +accesskit_consumer = "0.39.0" diff --git a/crates/snowbound/src/accessibility.rs b/crates/snowbound/src/accessibility.rs new file mode 100644 index 0000000000000000000000000000000000000000..c520a7a9d0a0e519ad572388e19919dc91f077b7 --- /dev/null +++ b/crates/snowbound/src/accessibility.rs @@ -0,0 +1,1467 @@ +use accesskit::{ + Action, Affine, Node, NodeId, Rect, Role, TextDirection, TextSelection, TreeId, TreeInfo, + TreeUpdate, +}; +use canvas::gpu::Viewport; +use canvas::{ + date::DateField, + document::TextPosition, + editor::{CanvasEditor, Selection, TextOutline}, + text::EditError, +}; +use parley::Affinity; +use std::{collections::HashMap, ops::Range}; + +pub const ROOT: NodeId = NodeId(0); + +struct Run { + outline: onestore::ExGuid, + id: NodeId, + node: Node, + positions: Vec, +} + +#[derive(Hash, PartialEq, Eq)] +struct ParagraphKey { + layout: u64, + source: usize, + next: Option, + origin: [u32; 2], +} + +pub struct Accessibility { + outlines: Vec<(onestore::ExGuid, NodeId)>, + runs: Vec, + paragraphs: HashMap>, + read_only: Vec, + dates: Vec<(DateField, NodeId)>, + next_id: u64, +} + +impl Default for Accessibility { + fn default() -> Self { + Self { + outlines: Vec::new(), + runs: Vec::new(), + paragraphs: HashMap::new(), + read_only: Vec::new(), + dates: Vec::new(), + next_id: 1, + } + } +} + +impl Accessibility { + /// Retire published runs without recycling their IDs. + pub fn deactivate(&mut self) { + self.runs.clear(); + self.paragraphs.clear(); + self.outlines.clear(); + self.read_only.clear(); + self.dates.clear(); + } + + pub fn append_page_fields( + &mut self, + update: &mut TreeUpdate, + scene: Option<&(canvas::gpu::page::PageScene, [f32; 2])>, + editor: &CanvasEditor, + viewport: Viewport, + focus: Option, + ) { + let mut count = 0; + if let Some((scene, offset)) = scene { + for (index, object) in scene.read_only(Some(editor)).enumerate() { + if index == self.read_only.len() { + let id = self.allocate(); + self.read_only.push(id); + } + let id = self.read_only[index]; + let mut node = Node::new(Role::Label); + node.set_value(object.message); + node.set_read_only(); + node.add_action(Action::Focus); + if focus == Some(index) { + update.focus = id; + } + let [x0, y0, x1, y1] = object.rect(); + let x = |v| f64::from((v + offset[0]) * viewport.scale + viewport.origin[0]); + let y = |v| f64::from((v + offset[1]) * viewport.scale + viewport.origin[1]); + node.set_bounds(Rect::new(x(x0), y(y0), x(x1), y(y1))); + update + .nodes + .iter_mut() + .find(|(id, _)| *id == ROOT) + .unwrap() + .1 + .push_child(id); + update.nodes.push((id, node)); + count += 1; + } + } + self.read_only.truncate(count); + let mut date_count = 0; + if let Some((scene, offset)) = scene { + for (index, (field, rect)) in scene.date_fields(editor).enumerate() { + if index == self.dates.len() { + let id = self.allocate(); + self.dates.push((field, id)); + } + self.dates[index].0 = field; + let id = self.dates[index].1; + let mut node = Node::new(Role::Button); + node.set_label(crate::DATE_LABELS[field as usize]); + node.set_value( + editor.date().unwrap().source().paragraphs[index] + .text() + .unwrap() + .text + .text(), + ); + node.add_action(Action::Click); + let x = |v| f64::from((v + offset[0]) * viewport.scale + viewport.origin[0]); + let y = |v| f64::from((v + offset[1]) * viewport.scale + viewport.origin[1]); + node.set_bounds(Rect::new(x(rect[0]), y(rect[1]), x(rect[2]), y(rect[3]))); + update + .nodes + .iter_mut() + .find(|(id, _)| *id == ROOT) + .unwrap() + .1 + .push_child(id); + update.nodes.push((id, node)); + date_count += 1; + } + } + self.dates.truncate(date_count); + } + + fn allocate(&mut self) -> NodeId { + let id = NodeId(self.next_id); + self.next_id = self + .next_id + .checked_add(1) + .expect("Accessibility node IDs exhausted"); + id + } + + pub fn read_only_for_node(&self, node: NodeId) -> Option { + self.read_only.iter().position(|id| *id == node) + } + + pub fn date_for_node(&self, node: NodeId) -> Option { + self.dates + .iter() + .find(|(_, id)| *id == node) + .map(|(field, _)| *field) + } + + pub fn outline_for_node(&self, node: NodeId) -> Option { + self.outlines + .iter() + .find(|(_, id)| *id == node) + .map(|(outline, _)| *outline) + } + + pub fn update( + &mut self, + editor: &CanvasEditor, + viewport: Viewport, + title: &str, + preview: Option<(onestore::ExGuid, [f32; 2])>, + ) -> Result { + let mut runs = Vec::new(); + let mut outlines = Vec::new(); + let mut paragraph_ranges = HashMap::new(); + for outline in editor.visible_outlines().chain(editor.caret_outline()) { + let field = self + .outlines + .iter() + .find(|(id, _)| *id == outline.id) + .map(|(_, node)| *node) + .unwrap_or_else(|| self.allocate()); + outlines.push((outline.id, field)); + let mut paragraphs = outline.layouts().peekable(); + while let Some((paragraph, shaped)) = paragraphs.next() { + let layout = &shaped.text; + let origin = shaped.origin; + let key = ParagraphKey { + layout: layout.id(), + source: paragraph, + next: paragraphs.peek().map(|(next, _)| *next), + origin: origin.map(f32::to_bits), + }; + let start = runs.len(); + if let Some(range) = self.paragraphs.get(&key) { + runs.extend( + self.runs[range.clone()] + .iter() + .map(|run| (run.outline, run.node.clone(), run.positions.clone())), + ); + paragraph_ranges.insert(key, start..runs.len()); + continue; + } + let projection = &shaped.projection; + let text = projection.text().text(); + let boundaries: Vec<_> = projection.source_boundaries().collect(); + let source_position = |byte| -> Result { + let index = boundaries + .binary_search_by_key(&byte, |(byte, _)| *byte) + .map_err(|_| EditError::InvalidRange)?; + Ok(TextPosition { + paragraph, + offset: boundaries[index].1, + }) + }; + let line_count = layout.lines().count(); + for (line_index, (line, bounds)) in layout.lines().enumerate() { + let line_start = runs.len(); + let mut x = line.metrics().offset; + let mut logical_runs = Vec::new(); + for run in line.runs() { + let advance = run.advance(); + logical_runs.push((run, x)); + x += advance; + } + logical_runs.sort_by_key(|(run, _)| run.text_range().start); + for (run, x) in logical_runs { + // AccessKit stores each character's byte length in a u8. + let mut characters = Vec::new(); + for cluster in run.clusters() { + let range = cluster.text_range(); + if range.is_empty() || range.start == text.len() { + continue; + } + let cluster_text = + text.get(range.clone()).ok_or(EditError::InvalidRange)?; + if range.len() <= u8::MAX as usize { + characters.push(( + range, + cluster.advance(), + cluster.is_word_boundary(), + )); + } else { + for (index, (offset, ch)) in cluster_text.char_indices().enumerate() + { + characters.push(( + range.start + offset..range.start + offset + ch.len_utf8(), + if index == 0 { cluster.advance() } else { 0.0 }, + index == 0 && cluster.is_word_boundary(), + )); + } + } + } + let mut advance = 0.0; + for chunk in characters.chunks(256) { + let width: f32 = chunk.iter().map(|(_, width, _)| width).sum(); + let left = x + if run.is_rtl() { + run.advance() - advance - width + } else { + advance + }; + let mut node = Node::new(Role::TextRun); + node.set_bounds(Rect::new( + f64::from(left + origin[0]), + f64::from(bounds.top + origin[1]), + f64::from(left + width + origin[0]), + f64::from(bounds.top + bounds.height + origin[1]), + )); + node.set_text_direction(if run.is_rtl() { + TextDirection::RightToLeft + } else { + TextDirection::LeftToRight + }); + node.set_font_size(run.font_size()); + let start = chunk[0].0.start; + let end = chunk.last().unwrap().0.end; + node.set_value(text.get(start..end).ok_or(EditError::InvalidRange)?); + node.set_character_lengths( + chunk + .iter() + .map(|(range, _, _)| u8::try_from(range.len()).unwrap()) + .collect::>(), + ); + let mut offset = 0.0; + node.set_character_positions( + chunk + .iter() + .map(|(_, width, _)| { + let position = offset; + offset += width; + position + }) + .collect::>(), + ); + node.set_character_widths( + chunk.iter().map(|(_, width, _)| *width).collect::>(), + ); + node.set_word_starts( + chunk + .iter() + .enumerate() + .filter_map(|(index, (_, _, word))| word.then_some(index as u8)) + .collect::>(), + ); + let positions = chunk + .iter() + .map(|(range, _, _)| source_position(range.start)) + .chain(std::iter::once(source_position(end))) + .collect::, _>>()?; + runs.push((outline.id, node, positions)); + advance += width; + } + } + // Empty lines still need a text position for selection and insertion. + if runs.len() == line_start { + let mut node = Node::new(Role::TextRun); + node.set_value(""); + node.set_character_lengths(Vec::::new()); + node.set_character_positions(Vec::::new()); + node.set_character_widths(Vec::::new()); + node.set_text_direction(TextDirection::LeftToRight); + node.set_bounds(Rect::new( + f64::from(origin[0]), + f64::from(bounds.top + origin[1]), + f64::from(origin[0]), + f64::from(bounds.top + bounds.height + origin[1]), + )); + runs.push(( + outline.id, + node, + vec![source_position(bounds.source.start)?], + )); + } + if line_index + 1 == line_count + && let Some((next, _)) = paragraphs.peek() + { + let (_, node, source_positions) = runs.last_mut().unwrap(); + let mut value = node.value().unwrap().to_owned(); + value.push('\n'); + node.set_value(value); + let mut lengths = node.character_lengths().to_vec(); + lengths.push(1); + node.set_character_lengths(lengths); + if let Some(rect) = node.bounds() { + let mut positions = + node.character_positions().unwrap_or_default().to_vec(); + positions.push(rect.width() as f32); + node.set_character_positions(positions); + let mut widths = node.character_widths().unwrap_or_default().to_vec(); + widths.push(4.0); + node.set_character_widths(widths); + } + source_positions.push(TextPosition { + paragraph: *next, + offset: 0, + }); + } + } + if !shaped.tags.is_empty() { + let descriptions = shaped + .tags + .iter() + .map(|tag| { + use canvas::outline::TagIcon; + let fallback = match tag.icon { + TagIcon::CheckBox { .. } => "To do", + TagIcon::Question => "Question", + TagIcon::Music => "Music", + }; + let label = if tag.label.is_empty() { + fallback + } else { + &tag.label + }; + let state = match tag.icon { + TagIcon::CheckBox { checked: true } => ", completed", + TagIcon::CheckBox { checked: false } => ", incomplete", + _ => "", + }; + format!( + "{label}{state}{}", + if tag.disabled { ", disabled" } else { "" } + ) + }) + .collect::>() + .join("; "); + runs[start].1.set_description(descriptions); + } + paragraph_ranges.insert(key, start..runs.len()); + } + } + let mut runs: Vec<_> = runs + .into_iter() + .enumerate() + .map(|(index, (outline, mut node, positions))| { + node.clear_next_on_line(); + node.clear_previous_on_line(); + let id = match self.runs.get(index) { + Some(old) + if old.outline == outline + && old.positions == positions + && old.node.value() == node.value() => + { + old.id + } + _ => self.allocate(), + }; + Run { + outline, + id, + node, + positions, + } + }) + .collect(); + for index in 1..runs.len() { + if runs[index - 1].outline == runs[index].outline + && runs[index - 1].node.bounds().map(|r| r.y0) + == runs[index].node.bounds().map(|r| r.y0) + { + let (before, after) = runs.split_at_mut(index); + before[index - 1].node.set_next_on_line(after[0].id); + after[0].node.set_previous_on_line(before[index - 1].id); + } + } + self.runs = runs; + self.paragraphs = paragraph_ranges; + self.outlines = outlines; + let mut root = Node::new(Role::Window); + root.set_label(title); + root.set_children(self.outlines.iter().map(|(_, id)| *id).collect::>()); + root.set_bounds(Rect::new( + 0.0, + 0.0, + viewport.size[0] as f64, + viewport.size[1] as f64, + )); + let mut nodes = vec![(ROOT, root)]; + let mut focus = ROOT; + for (index, (outline, (_, id))) in editor + .visible_outlines() + .chain(editor.caret_outline()) + .zip(&self.outlines) + .enumerate() + { + let mut field = Node::new(Role::MultilineTextInput); + field.set_label(if outline.title { + "Page title".into() + } else if editor + .caret_outline() + .is_some_and(|caret| caret.id == outline.id) + { + "Text input".into() + } else { + format!("Text outline {}", index + 1) + }); + field.add_action(Action::Focus); + field.add_action(Action::SetTextSelection); + field.add_action(Action::ReplaceSelectedText); + field.add_action(Action::SetValue); + let runs = self.runs.iter().filter(|run| run.outline == outline.id); + field.set_value( + runs.clone() + .filter_map(|run| run.node.value()) + .collect::(), + ); + field.set_children(runs.map(|run| run.id).collect::>()); + let origin = preview + .filter(|(id, _)| *id == outline.id) + .map(|(_, origin)| origin) + .unwrap_or_else(|| outline.origin()); + field.set_transform(Affine::new([ + f64::from(viewport.scale), + 0.0, + 0.0, + f64::from(viewport.scale), + f64::from(viewport.origin[0]) + f64::from(origin[0]) * f64::from(viewport.scale), + f64::from(viewport.origin[1]) + f64::from(origin[1]) * f64::from(viewport.scale), + ])); + let bounds = outline.bounds(); + field.set_bounds(Rect::new(0.0, 0.0, bounds.width(), bounds.height())); + if outline.id == editor.active_outline().id { + let Selection { + positions: [anchor, caret], + affinities, + } = editor.selection(); + field.set_text_selection(TextSelection { + anchor: self.position(outline, anchor, affinities[0])?, + focus: self.position(outline, caret, affinities[1])?, + }); + focus = *id; + } + nodes.push((*id, field)); + } + nodes.extend(self.runs.iter().map(|run| (run.id, run.node.clone()))); + Ok(TreeUpdate { + nodes, + tree: Some(TreeInfo::new(ROOT)), + tree_id: TreeId::ROOT, + focus, + }) + } + + fn position( + &self, + outline: &TextOutline, + mut position: TextPosition, + affinity: Affinity, + ) -> Result { + let shaped = outline.paragraph_layout(position.paragraph)?; + let projection = &shaped.projection; + let layout = &shaped.text; + let origin = shaped.origin; + let cursor = layout.cursor( + projection + .text() + .byte_offset(projection.visible_offset(position.offset)?)?, + affinity, + ); + position.offset = projection.source_offset( + projection.text().utf16_offset(cursor.index())?, + Affinity::Downstream, + )?; + let caret = layout.caret(cursor, 0.0); + let mut positions = self + .runs + .iter() + .filter(|run| { + run.outline == outline.id + && run + .node + .bounds() + .is_some_and(|rect| rect.y0 == f64::from(caret.y0 as f32 + origin[1])) + }) + .filter_map(|run| { + run.positions + .iter() + .position(|p| *p == position) + .map(|character_index| accesskit::TextPosition { + node: run.id, + character_index, + }) + }); + match affinity { + Affinity::Upstream => positions.next(), + Affinity::Downstream => positions.next_back(), + } + .ok_or(EditError::InvalidRange) + } + + pub fn selection( + &self, + outline: onestore::ExGuid, + selection: TextSelection, + ) -> Result { + let position = |position: accesskit::TextPosition| { + let run = self + .runs + .iter() + .find(|run| run.outline == outline && run.id == position.node) + .ok_or(EditError::InvalidRange)?; + let source = *run + .positions + .get(position.character_index) + .ok_or(EditError::InvalidRange)?; + let affinity = if position.character_index > 0 + && position.character_index + 1 == run.positions.len() + { + Affinity::Upstream + } else { + Affinity::Downstream + }; + Ok::<_, EditError>((source, affinity)) + }; + let (anchor, anchor_affinity) = position(selection.anchor)?; + let (focus, focus_affinity) = position(selection.focus)?; + Ok(Selection { + positions: [anchor, focus], + affinities: [anchor_affinity, focus_affinity], + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use canvas::{document::TextDocument, layout::TextEngine, text::Paragraph}; + use onestore::document::Format; + + #[test] + fn title_flow_updates_read_only_hit_and_accessibility_bounds() { + use canvas::page::{Page, PageObject, Title, Unsupported}; + let mut engine = TextEngine::default(); + let mut title = TextOutline::new( + &mut engine, + TextDocument::new(vec![Paragraph::new( + "Header".into(), + Format { + font: Some("Arial".into()), + font_size: Some(8.0), + line_spacing: Some(20.0), + ..Default::default() + }, + )]) + .unwrap(), + 468.0, + [0.0; 2], + ) + .unwrap() + .snapshot(); + title.title = true; + let page = Page { + title: "Header".into(), + created: None, + margin_origin: [36.0, 14.4], + definitions: Default::default(), + objects: vec![ + PageObject::Title(Title { + id: Default::default(), + date: None, + layout: Default::default(), + outlines: vec![title], + }), + PageObject::Unsupported(Unsupported { + id: Default::default(), + jcid: 0xdead, + layout: onestore::document::Layout { + x: Some(120.0), + y: Some(80.0), + max_width: Some(200.0), + max_height: Some(60.0), + ..Default::default() + }, + }), + ], + }; + let (scene, mut editor) = + canvas::gpu::page::PageScene::from_page(page, &mut engine).unwrap(); + let scene = (scene, [30.0, 40.0]); + let viewport = Viewport { + size: [800, 600], + origin: [10.0, -20.0], + scale: 2.0, + }; + let mut access = Accessibility::default(); + let mut identity = None; + for phase in 0..4 { + match phase { + 1 => { + editor.select_all().unwrap(); + editor.insert(&mut engine, "Header\u{000b}Second\u{000b}Third\u{000b}Fourth\u{000b}Fifth\u{000b}Sixth").unwrap(); + } + 2 => { + editor.undo(&mut engine).unwrap(); + } + 3 => { + editor.redo(&mut engine).unwrap(); + } + _ => {} + } + let mut update = access.update(&editor, viewport, "Test", None).unwrap(); + access.append_page_fields(&mut update, Some(&scene), &editor, viewport, Some(0)); + let (id, node) = update + .nodes + .iter() + .find(|(_, node)| node.role() == Role::Label) + .unwrap(); + if let Some(identity) = identity { + assert_eq!(*id, identity); + } + identity = Some(*id); + let y = if phase % 2 == 0 { 80.0 } else { 145.92 }; + let expected = Rect::new( + 310.0, + f64::from((y + 40.0) * 2.0 - 20.0), + 710.0, + f64::from((y + 100.0) * 2.0 - 20.0), + ); + let bounds = node.bounds().unwrap(); + assert_eq!(bounds.x0, expected.x0); + assert_eq!(bounds.x1, expected.x1); + assert!((bounds.y0 - expected.y0).abs() < 0.001); + assert!((bounds.y1 - expected.y1).abs() < 0.001); + assert_eq!( + crate::page_hit_test(&editor, Some(&scene), [155.0, y + 45.0], 1.0), + Some(crate::Hit::ReadOnly(0)) + ); + if phase % 2 == 1 { + assert_ne!( + crate::page_hit_test(&editor, Some(&scene), [155.0, 125.0], 1.0), + Some(crate::Hit::ReadOnly(0)) + ); + } + } + } + + #[test] + fn read_only_objects_keep_accessibility_identity_through_edits_and_view_changes() { + use canvas::gpu::page::PageScene; + use canvas::page::{Page, PageObject, Unsupported}; + let page = Page { + created: None, + title: String::new(), + margin_origin: [0.0; 2], + definitions: Default::default(), + objects: vec![PageObject::Unsupported(Unsupported { + id: Default::default(), + jcid: 0xdead, + layout: onestore::document::Layout { + x: Some(10.0), + y: Some(20.0), + max_width: Some(200.0), + max_height: Some(60.0), + ..Default::default() + }, + })], + }; + let mut engine = TextEngine::default(); + let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap(); + let scene = (scene, [30.0, 40.0]); + let mut access = Accessibility::default(); + let mut identity = None; + for scale in [1.0, 2.0, 0.5] { + editor.insert(&mut engine, "annotation ").unwrap(); + let viewport = Viewport { + size: [800, 600], + origin: [-200.0, 10.0], + scale, + }; + let mut update = access.update(&editor, viewport, "Test", None).unwrap(); + access.append_page_fields(&mut update, Some(&scene), &editor, viewport, None); + let (id, node) = update + .nodes + .iter() + .find(|(_, node)| node.role() == Role::Label) + .unwrap(); + if let Some(identity) = identity { + assert_eq!(*id, identity); + } + identity = Some(*id); + assert_eq!(node.value(), Some("Unsupported content\nRead-only")); + assert!(node.is_read_only()); + assert!(node.supports_action(Action::Focus)); + for action in [ + Action::SetValue, + Action::ReplaceSelectedText, + Action::SetTextSelection, + ] { + assert!(!node.supports_action(action)); + } + assert_eq!( + node.bounds(), + Some(Rect::new( + f64::from(40.0 * scale - 200.0), + f64::from(60.0 * scale + 10.0), + f64::from(240.0 * scale - 200.0), + f64::from(120.0 * scale + 10.0) + )) + ); + assert_eq!(access.outline_for_node(*id), None); + let tree = accesskit_consumer::Tree::new(update, true); + assert_eq!( + tree.state().focus().unwrap().document_range().text(), + "annotation " + ); + let mut focused = access.update(&editor, viewport, "Test", None).unwrap(); + access.append_page_fields(&mut focused, Some(&scene), &editor, viewport, Some(0)); + assert_eq!(focused.focus, identity.unwrap()); + assert_eq!(access.read_only_for_node(focused.focus), Some(0)); + let tree = accesskit_consumer::Tree::new(focused, true); + assert_eq!( + tree.state().focus().unwrap().value().as_deref(), + Some("Unsupported content\nRead-only") + ); + editor.undo(&mut engine).unwrap(); + } + access.deactivate(); + assert!(access.read_only.is_empty()); + } + + #[test] + fn tag_descriptions_preserve_plain_text_and_survive_edit_undo_and_cache_reuse() { + use canvas::page::{Definition, Outline}; + use onestore::{ + ExGuid, + document::{Kind, Layout, Tag}, + }; + use std::collections::BTreeMap; + let text = TextDocument::new( + ["task one", "task two", "question", "music"] + .into_iter() + .map(|text| Paragraph::new(text.into(), Format::default())) + .collect(), + ) + .unwrap(); + let mut nodes = text.nodes().to_vec(); + let mut definitions = BTreeMap::new(); + for (index, (shape, label, status)) in [ + (3, Some("Rehearsal"), 0), + (3, Some("Rehearsal"), 1), + (15, None, 1), + (121, None, 3), + ] + .into_iter() + .enumerate() + { + let id = ExGuid { + n: index as u32 + 500, + ..ExGuid::default() + }; + definitions.insert( + id, + Definition { + kind: Kind::TagDefinition { + label: label.map(str::to_owned), + action_type: None, + shape: Some(shape), + color: None, + highlight: None, + }, + format: Format::default(), + }, + ); + nodes[index].text_mut().unwrap().tags.push(Tag { + definition: Some(id), + action_type: None, + status, + created: None, + completed: None, + start: None, + due: None, + task_id: None, + extra_set: 0, + }); + } + let mut engine = TextEngine::default(); + let mut editor = CanvasEditor::from_outlines( + &mut engine, + vec![Outline { + title: false, + min_width: None, + id: ExGuid { + n: 900, + ..ExGuid::default() + }, + layout: Layout { + max_width: Some(120.0), + ..Layout::default() + }, + indents: vec![18.0, 0.0], + paragraphs: nodes, + unsupported: Vec::new(), + }], + definitions, + ) + .unwrap(); + let mut access = Accessibility::default(); + let viewport = Viewport { + size: [800, 600], + origin: [48.0; 2], + scale: 2.0, + }; + for step in 0..4 { + if step == 2 { + editor.insert(&mut engine, "new ").unwrap(); + } + if step == 3 { + editor.undo(&mut engine).unwrap(); + } + let update = access.update(&editor, viewport, "Test", None).unwrap(); + assert_eq!( + update + .nodes + .iter() + .filter_map(|(_, n)| n.description()) + .collect::>(), + [ + "Rehearsal, incomplete", + "Rehearsal, completed", + "Question", + "Music, disabled" + ] + ); + let tree = accesskit_consumer::Tree::new(update, true); + let text = tree.state().focus().unwrap().document_range().text(); + assert_eq!( + text, + if step == 2 { + "new task one\ntask two\nquestion\nmusic" + } else { + "task one\ntask two\nquestion\nmusic" + } + ); + } + } + + #[test] + fn collapsed_paragraphs_keep_visible_accessibility_ranges_in_source_coordinates() { + let document = TextDocument::new( + [ + "root", + "folded", + "hidden", + "last", + "folded tail", + "hidden tail", + ] + .into_iter() + .map(|text| Paragraph::new(text.into(), Format::default())) + .collect(), + ) + .unwrap(); + let mut nodes = document.nodes().to_vec(); + for (child, parent, level) in [(1, 0, 2), (2, 1, 3), (4, 3, 2), (5, 4, 3)] { + nodes[child].parent = Some(nodes[parent].id); + nodes[child].level = level; + } + nodes[1].collapsed = true; + nodes[4].collapsed = true; + let mut engine = TextEngine::default(); + let mut editor = + CanvasEditor::new(&mut engine, TextDocument::from_nodes(nodes).unwrap(), 180.0) + .unwrap(); + let mut access = Accessibility::default(); + let viewport = Viewport { + size: [900, 700], + scale: 2.0, + origin: [13.0, 19.0], + }; + let update = access.update(&editor, viewport, "Test", None).unwrap(); + let tree = accesskit_consumer::Tree::new(update, true); + let field = tree.state().focus().unwrap(); + assert_eq!( + field.document_range().text(), + "root\nfolded\nlast\nfolded tail" + ); + assert!( + access + .runs + .iter() + .flat_map(|run| &run.positions) + .all(|position| ![2, 5].contains(&position.paragraph)) + ); + let source = access + .selection( + editor.active_outline().id, + field.document_range().to_text_selection(), + ) + .unwrap(); + assert_eq!( + source.positions[0], + TextPosition { + paragraph: 0, + offset: 0 + } + ); + assert_eq!( + source.positions[1], + TextPosition { + paragraph: 4, + offset: 11 + } + ); + editor.select(source).unwrap(); + let update = access.update(&editor, viewport, "Test", None).unwrap(); + let tree = accesskit_consumer::Tree::new(update, true); + assert_eq!( + tree.state() + .focus() + .unwrap() + .text_selection() + .unwrap() + .text(), + "root\nfolded\nlast\nfolded tail" + ); + editor + .select( + [TextPosition { + paragraph: 4, + offset: 0, + }; 2] + .into(), + ) + .unwrap(); + access.update(&editor, viewport, "Test", None).unwrap(); + let caret = editor.caret(1.0).unwrap(); + assert_eq!(caret.x0, 27.0); + let position = access + .position( + editor.active_outline(), + editor.selection().positions[1], + Affinity::Downstream, + ) + .unwrap(); + let run = access + .runs + .iter() + .find(|run| run.id == position.node) + .unwrap(); + assert_eq!(run.node.bounds().unwrap().x0, 27.0); + assert_eq!(run.node.bounds().unwrap().y0, caret.y0); + } + + #[test] + fn consumer_reads_unicode_blank_paragraphs_and_hidden_fields() { + let cases = [ + "", + "a\n\nשלום\nend", + "café e\u{301} 🌳 👩‍👩‍👧‍👦 words wrap here", + "\n\n", + "a\nb\n", + ]; + let mut engine = TextEngine::default(); + for text in cases + .into_iter() + .map(str::to_owned) + .chain([format!("a{}", "\u{301}".repeat(300)), "x".repeat(600)]) + { + for width in [24.0, 480.0, 10000.0] { + let document = TextDocument::new( + text.split('\n') + .map(|line| { + Paragraph::from_runs([ + ( + String::from("secret"), + Format { + hidden: Some(true), + ..Format::default() + }, + ), + (line.to_owned(), Format::default()), + ]) + }) + .collect(), + ) + .unwrap(); + let mut editor = CanvasEditor::new(&mut engine, document, width).unwrap(); + let mut access = Accessibility::default(); + let viewport = Viewport { + size: [800, 600], + origin: [48.0; 2], + scale: 2.0, + }; + let update = access.update(&editor, viewport, "Test", None).unwrap(); + for (_, node) in &update.nodes { + if node.role() == Role::TextRun { + assert_eq!( + node.character_lengths() + .iter() + .map(|n| *n as usize) + .sum::(), + node.value().unwrap().len() + ); + if let Some(positions) = node.character_positions() { + assert_eq!(positions.len(), node.character_lengths().len()); + } + if let Some(widths) = node.character_widths() { + assert_eq!(widths.len(), node.character_lengths().len()); + } + } + } + let tree = accesskit_consumer::Tree::new(update, true); + let field = tree.state().focus().unwrap(); + assert_eq!(field.document_range().text(), text, "width {width}"); + let selection = field.document_range().to_text_selection(); + let source_selection = access + .selection(editor.active_outline().id, selection) + .unwrap(); + editor.select(source_selection).unwrap(); + let selected = access.update(&editor, viewport, "Test", None).unwrap(); + let tree = accesskit_consumer::Tree::new(selected, true); + assert_eq!( + tree.state() + .focus() + .unwrap() + .text_selection() + .unwrap() + .text(), + text + ); + let stable_ids: Vec<_> = access.runs.iter().map(|run| run.id).collect(); + access + .update( + &editor, + Viewport { + scale: 3.0, + ..viewport + }, + "Test", + None, + ) + .unwrap(); + assert_eq!( + stable_ids, + access.runs.iter().map(|run| run.id).collect::>() + ); + access.deactivate(); + access.update(&editor, viewport, "Test", None).unwrap(); + assert!( + access + .selection(editor.active_outline().id, selection) + .is_err() + ); + let selection = access + .update(&editor, viewport, "Test", None) + .unwrap() + .nodes[1] + .1 + .text_selection() + .copied() + .unwrap(); + editor.insert(&mut engine, "replacement").unwrap(); + access.update(&editor, viewport, "Test", None).unwrap(); + assert!( + access + .selection(editor.active_outline().id, selection) + .is_err() + ); + editor.undo(&mut engine).unwrap(); + let update = access.update(&editor, viewport, "Test", None).unwrap(); + let tree = accesskit_consumer::Tree::new(update, true); + assert_eq!(tree.state().focus().unwrap().document_range().text(), text); + } + } + } + + #[test] + fn caret_geometry_uses_visible_offsets_and_canvas_line_metrics() { + let mut engine = TextEngine::default(); + let document = TextDocument::new(vec![ + Paragraph::from_runs([ + ("abc ".into(), Format::default()), + ( + "hidden field".into(), + Format { + hidden: Some(true), + ..Format::default() + }, + ), + ("e\u{301} 🌳 words wrap here".into(), Format::default()), + ]), + Paragraph::new("".into(), Format::default()), + Paragraph::new("abc שלום def عالم end".into(), Format::default()), + Paragraph::new("last\nline\n".into(), Format::default()), + ]) + .unwrap(); + let positions: Vec<_> = document + .paragraphs() + .enumerate() + .flat_map(|(paragraph, text)| { + text.text() + .char_indices() + .map(|(byte, _)| text.utf16_offset(byte).unwrap()) + .chain(std::iter::once( + text.utf16_offset(text.text().len()).unwrap(), + )) + .map(move |offset| TextPosition { paragraph, offset }) + }) + .collect(); + let mut editor = CanvasEditor::new(&mut engine, document, 48.0).unwrap(); + let mut access = Accessibility::default(); + let viewport = Viewport { + size: [800, 600], + origin: [31.0, -57.0], + scale: 2.5, + }; + for (position, affinity) in positions.into_iter().flat_map(|position| { + [Affinity::Upstream, Affinity::Downstream].map(|affinity| (position, affinity)) + }) { + editor + .select(Selection { + positions: [position; 2], + affinities: [affinity; 2], + }) + .unwrap(); + let caret = editor.caret(0.0).unwrap(); + let update = access.update(&editor, viewport, "Test", None).unwrap(); + let tree = accesskit_consumer::Tree::new(update, true); + let field = tree.state().focus().unwrap(); + assert_eq!( + field.document_range().text(), + "abc e\u{301} 🌳 words wrap here\n\nabc שלום def عالم end\nlast\nline\n" + ); + let rects = field.text_selection().unwrap().bounding_boxes(); + assert_eq!(rects.len(), 1, "{position:?}"); + let rect = rects[0]; + for (actual, expected) in [ + (rect.x0, caret.x0 * 2.5 + 31.0), + (rect.y0, caret.y0 * 2.5 - 57.0), + (rect.y1, caret.y1 * 2.5 - 57.0), + ] { + assert!( + (actual - expected).abs() < 0.0001, + "{position:?} {affinity:?}: {rect:?} vs {caret:?}" + ); + } + let selection = access + .selection( + editor.active_outline().id, + field.text_selection().unwrap().to_text_selection(), + ) + .unwrap(); + editor.select(selection).unwrap(); + assert_eq!( + editor.caret(0.0).unwrap(), + caret, + "{position:?} {affinity:?}: AX selection moved the caret" + ); + } + } + #[test] + fn keyboard_and_accessibility_preserve_visual_caret_at_wraps() { + use canvas::editor::Movement; + let mut engine = TextEngine::default(); + let document = TextDocument::new(vec![ + Paragraph::new( + "first words wrap here and continue".into(), + Format::default(), + ), + Paragraph::new("abc שלום def عالم end".into(), Format::default()), + Paragraph::new("last\nline".into(), Format::default()), + ]) + .unwrap(); + let mut editor = CanvasEditor::new(&mut engine, document, 72.0).unwrap(); + let mut access = Accessibility::default(); + let viewport = Viewport { + size: [800, 600], + origin: [0.0; 2], + scale: 1.0, + }; + for movement in [ + Movement::LineEnd, + Movement::Right, + Movement::Left, + Movement::Down, + Movement::LineStart, + Movement::LineEnd, + Movement::Down, + Movement::Down, + Movement::Down, + Movement::LineEnd, + Movement::Left, + Movement::Left, + Movement::Right, + Movement::LineStart, + Movement::Up, + Movement::Right, + Movement::LineEnd, + ] { + editor.move_selection(&mut engine, movement, false).unwrap(); + let caret = editor.caret(0.0).unwrap(); + let tree = accesskit_consumer::Tree::new( + access.update(&editor, viewport, "Test", None).unwrap(), + true, + ); + let field = tree.state().focus().unwrap(); + let selection = field.text_selection().unwrap(); + let rects = selection.bounding_boxes(); + assert_eq!(rects.len(), 1); + let rect = rects[0]; + for (actual, expected) in [ + (rect.x0, caret.x0), + (rect.y0, caret.y0), + (rect.y1, caret.y1), + ] { + assert!( + (actual - expected).abs() < 0.0001, + "{movement:?}: {rect:?} vs {caret:?}" + ); + } + let source_selection = access + .selection(editor.active_outline().id, selection.to_text_selection()) + .unwrap(); + editor.select(source_selection).unwrap(); + let restored = editor.caret(0.0).unwrap(); + assert_eq!( + restored, caret, + "{movement:?}: AX selection moved the caret" + ); + } + } + #[test] + fn cached_runs_match_reconstruction_through_editing_and_view_changes() { + let mut engine = TextEngine::default(); + let document = TextDocument::new( + [ + "one two three", + "", + "אבג 日本 👩🏽‍💻 e\u{301}", + "last paragraph", + ] + .into_iter() + .map(|text| Paragraph::new(text.into(), Format::default())) + .collect(), + ) + .unwrap(); + let mut editor = CanvasEditor::new(&mut engine, document, 72.0).unwrap(); + let mut cached = Accessibility::default(); + let mut rebuilt = Accessibility::default(); + let mut compare = |editor: &CanvasEditor, step: usize| { + let viewport = Viewport { + size: [800, 600], + origin: [step as f32 * 0.125, -17.25], + scale: 0.75 + (step % 5) as f32 * 0.5, + }; + let preview = step + .is_multiple_of(2) + .then_some((editor.active_outline().id, [13.5, -7.25])); + for repeat in 0..2 { + rebuilt.paragraphs.clear(); + let actual = cached.update(editor, viewport, "Test", preview).unwrap(); + let expected = rebuilt.update(editor, viewport, "Test", preview).unwrap(); + assert_eq!(actual.nodes, expected.nodes, "step {step}, repeat {repeat}"); + assert_eq!(actual.focus, expected.focus); + let tree = accesskit_consumer::Tree::new(actual, true); + let field = tree.state().focus().unwrap(); + let selection = field.document_range().to_text_selection(); + assert_eq!( + cached + .selection(editor.active_outline().id, selection) + .unwrap(), + rebuilt + .selection(editor.active_outline().id, selection) + .unwrap() + ); + let count = editor.outlines().iter().map(|o| o.layouts().count()).sum(); + assert_eq!(cached.paragraphs.len(), count); + } + }; + compare(&editor, 0); + let mut seed = 0x923e_u64; + let mut next = || { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + (seed >> 32) as usize + }; + for step in 1..=160 { + let id = editor.outlines()[next() % editor.outlines().len()].id; + editor.focus_outline(id).unwrap(); + let positions: Vec<_> = editor + .active_outline() + .document() + .paragraphs() + .enumerate() + .flat_map(|(paragraph, text)| { + (0..=text.text().len()).filter_map(move |byte| { + text.utf16_offset(byte) + .ok() + .map(|offset| TextPosition { paragraph, offset }) + }) + }) + .collect(); + let anchor = positions[next() % positions.len()]; + let focus = if step % 3 == 0 { + positions[next() % positions.len()] + } else { + anchor + }; + editor.select([anchor, focus].into()).unwrap(); + match step % 10 { + 0 => editor.insert(&mut engine, "\n").unwrap(), + 1 => editor.insert(&mut engine, "abc 👩🏽‍💻 שלום e\u{301}").unwrap(), + 2 => { + editor.delete(&mut engine, true).unwrap(); + } + 3 => editor + .resize(&mut engine, 48.0 + (step % 7) as f32 * 31.25) + .unwrap(), + 4 => { + editor + .create_outline(&mut engine, [step as f32, -5.0], 97.0) + .unwrap(); + editor.insert(&mut engine, "annotation").unwrap(); + } + 5 => { + editor.undo(&mut engine).unwrap(); + } + 6 => { + editor.redo(&mut engine).unwrap(); + } + 7 => editor.move_outline(id, [17.5, step as f32]).unwrap(), + 8 => { + editor.compose(&mut engine, "仮\n名".into(), 1..1).unwrap(); + compare(&editor, step); + editor.cancel_composition(&mut engine).unwrap(); + } + _ => {} + } + compare(&editor, step); + } + while editor.undo(&mut engine).unwrap() { + compare(&editor, 161); + } + while editor.redo(&mut engine).unwrap() { + compare(&editor, 162); + } + } + #[test] + fn outlines_have_independent_accessibility_identity_and_selection() { + let mut engine = TextEngine::default(); + let document = + TextDocument::new(vec![Paragraph::new("lyrics".into(), Format::default())]).unwrap(); + let mut editor = CanvasEditor::new(&mut engine, document, 240.0).unwrap(); + let first = editor.active_outline().id; + let second = editor + .create_outline(&mut engine, [300.0, 40.0], 120.0) + .unwrap(); + editor.insert(&mut engine, "annotation 🌳").unwrap(); + let mut access = Accessibility::default(); + let viewport = Viewport { + size: [1000, 720], + scale: 2.0, + origin: [48.0; 2], + }; + let update = access.update(&editor, viewport, "Test", None).unwrap(); + let fields: Vec<_> = update + .nodes + .iter() + .filter(|(_, node)| node.role() == Role::MultilineTextInput) + .map(|(id, node)| (*id, node.value().unwrap().to_owned())) + .collect(); + assert_eq!( + fields + .iter() + .map(|(_, text)| text.as_str()) + .collect::>(), + ["lyrics", "annotation 🌳"] + ); + assert_ne!(fields[0].0, fields[1].0); + assert_eq!(access.outline_for_node(fields[0].0), Some(first)); + assert_eq!(access.outline_for_node(fields[1].0), Some(second)); + let tree = accesskit_consumer::Tree::new(update, true); + let field = tree.state().focus().unwrap(); + assert_eq!(field.document_range().text(), "annotation 🌳"); + let selection = field.document_range().to_text_selection(); + assert!(access.selection(first, selection).is_err()); + let source_selection = access.selection(second, selection).unwrap(); + editor.select(source_selection).unwrap(); + let preview = access + .update(&editor, viewport, "Test", Some((second, [400.0, 50.0]))) + .unwrap(); + let preview_tree = accesskit_consumer::Tree::new(preview, true); + let preview_field = preview_tree.state().focus().unwrap(); + let rect = preview_field.document_range().bounding_boxes()[0]; + assert_eq!(rect.x0, 848.0); + assert_eq!(rect.y0, 148.0); + assert_eq!(editor.active_outline().origin(), [300.0, 40.0]); + editor.move_outline(second, [400.0, 50.0]).unwrap(); + access.update(&editor, viewport, "Test", None).unwrap(); + assert_eq!(access.outline_for_node(fields[1].0), Some(second)); + assert!(access.selection(second, selection).is_ok()); + editor.undo(&mut engine).unwrap(); + editor.undo(&mut engine).unwrap(); + editor.undo(&mut engine).unwrap(); + let update = access.update(&editor, viewport, "Test", None).unwrap(); + assert_eq!( + update + .nodes + .iter() + .filter(|(_, node)| node.role() == Role::MultilineTextInput) + .count(), + 1 + ); + assert_eq!(access.outline_for_node(fields[1].0), None); + assert!(access.selection(second, selection).is_err()); + assert_eq!(access.outline_for_node(fields[0].0), Some(first)); + } +} diff --git a/crates/snowbound/src/macos.rs b/crates/snowbound/src/macos.rs new file mode 100644 index 0000000000000000000000000000000000000000..3e642ff0895ee3e5442b9d732edfd510d22fcee0 --- /dev/null +++ b/crates/snowbound/src/macos.rs @@ -0,0 +1,377 @@ +use canvas::date::DateField; +use objc2::{ + ClassType, DeclaredClass, + declare::ClassBuilder, + declare_class, msg_send, msg_send_id, mutability, + rc::{Allocated, Retained}, + runtime::{AnyClass, AnyObject, Sel}, + sel, +}; +use objc2_app_kit::{ + NSAlert, NSAlertFirstButtonReturn, NSAlertSecondButtonReturn, NSApplication, NSColor, + NSColorSpace, NSDatePicker, NSDatePickerElementFlags, NSDatePickerStyle, NSEvent, NSEventType, +}; +use objc2_foundation::{ + MainThreadMarker, NSAttributedString, NSCalendar, NSCalendarUnit, NSDate, NSDateFormatter, + NSDateFormatterStyle, NSRange, NSString, +}; +use std::{cell::Cell, sync::OnceLock}; +use winit::{ + error::EventLoopError, + event_loop::{EventLoop, EventLoopProxy}, + raw_window_handle::{HasWindowHandle, RawWindowHandle}, + window::Window, +}; + +static QUIT: OnceLock> = OnceLock::new(); +static INPUT_CLASS: OnceLock<&'static AnyClass> = OnceLock::new(); +thread_local! { static IN_KEY_DOWN: Cell = const { Cell::new(false) }; } + +unsafe extern "C" fn key_down(view: &AnyObject, _: Sel, event: &NSEvent) { + let previous = IN_KEY_DOWN.replace(true); + unsafe { + let superclass = INPUT_CLASS.get().unwrap().superclass().unwrap(); + let _: () = msg_send![super(view, superclass), keyDown: event]; + } + IN_KEY_DOWN.set(previous); +} + +unsafe extern "C" fn insert_text(view: &AnyObject, _: Sel, text: &AnyObject, range: NSRange) { + unsafe { + let marked: bool = msg_send![view, hasMarkedText]; + if IN_KEY_DOWN.get() || marked { + let superclass = INPUT_CLASS.get().unwrap().superclass().unwrap(); + let _: () = + msg_send![super(view, superclass), insertText: text replacementRange: range]; + } else { + // NSTextInputClient supplies either NSString or NSAttributedString. + let attributed: bool = msg_send![text, isKindOfClass: NSAttributedString::class()]; + let string: Retained = if attributed { + msg_send_id![text, string] + } else { + msg_send_id![text, copy] + }; + if let Some(proxy) = QUIT.get() { + let _ = proxy.send_event(crate::UserEvent::InsertText(string.to_string())); + } + } + } +} + +/// Install before AccessKit subclasses the same view, preserving its restoration chain. +pub fn install_text_input(window: &Window) { + MainThreadMarker::new().expect("Text input belongs to the main thread"); + let RawWindowHandle::AppKit(handle) = + window.window_handle().expect("Live AppKit window").as_raw() + else { + unreachable!() + }; + unsafe { + let view = &*handle.ns_view.as_ptr().cast::(); + let class = INPUT_CLASS.get_or_init(|| { + let mut class = ClassBuilder::new("SnowboundTextInputView", view.class()) + .expect("Unique text input class"); + class.add_method(sel!(keyDown:), key_down as unsafe extern "C" fn(_, _, _)); + class.add_method( + sel!(insertText:replacementRange:), + insert_text as unsafe extern "C" fn(_, _, _, _), + ); + class.register() + }); + assert_eq!(class.superclass(), Some(view.class())); + assert_eq!(class.instance_size(), view.class().instance_size()); + // The subclass adds no ivars, so the existing allocation remains valid. + AnyObject::set_class(view, class); + } +} + +pub fn double_click_interval() -> std::time::Duration { + std::time::Duration::from_secs_f64(unsafe { NSEvent::doubleClickInterval() }) +} + +pub fn show_character_palette() { + let mtm = MainThreadMarker::new().expect("Text input belongs to the main thread"); + NSApplication::sharedApplication(mtm).orderFrontCharacterPalette(None); +} + +pub fn edit_date( + timestamp: u64, + field: DateField, +) -> Result, &'static str> { + let mtm = MainThreadMarker::new().expect("Date controls belong to the main thread"); + unsafe { + let calendar = NSCalendar::currentCalendar(); + let picker = NSDatePicker::new(mtm); + picker.setDatePickerStyle(NSDatePickerStyle::TextFieldAndStepper); + picker.setDatePickerElements(match field { + DateField::Date => NSDatePickerElementFlags::NSDatePickerElementFlagYearMonthDay, + DateField::Time => NSDatePickerElementFlags::NSDatePickerElementFlagHourMinute, + }); + picker.setPresentsCalendarOverlay(true); + picker.setCalendar(Some(&calendar)); + picker.setTimeZone(Some(&calendar.timeZone())); + picker.setDateValue(&NSDate::dateWithTimeIntervalSince1970( + (timestamp / 10_000_000) as f64 - 11_644_473_600.0, + )); + picker.setMinDate(Some(&NSDate::dateWithTimeIntervalSince1970( + -11_644_473_600.0, + ))); + let label = NSString::from_str(crate::DATE_LABELS[field as usize]); + let _: () = msg_send![&picker, setAccessibilityLabel: &*label]; + picker.sizeToFit(); + let alert = NSAlert::new(mtm); + alert.setMessageText(&NSString::from_str(match field { + DateField::Date => "Change Page Date", + DateField::Time => "Change Page Time", + })); + alert.setAccessoryView(Some(&picker)); + alert.addButtonWithTitle(&NSString::from_str("Apply")); + alert.addButtonWithTitle(&NSString::from_str("Cancel")); + alert.layout(); + alert.window().makeFirstResponder(Some(&picker)); + if alert.runModal() != NSAlertFirstButtonReturn { + return Ok(None); + } + alert.window().makeFirstResponder(None); + merge_date(timestamp, &picker.dateValue(), field, &calendar).map(Some) + } +} + +fn merge_date( + timestamp: u64, + selected: &NSDate, + field: DateField, + calendar: &NSCalendar, +) -> Result<(u64, [String; 2]), &'static str> { + unsafe { + let original = NSDate::dateWithTimeIntervalSince1970( + (timestamp / 10_000_000) as f64 - 11_644_473_600.0, + ); + let units = NSCalendarUnit::Era + | NSCalendarUnit::Year + | NSCalendarUnit::Month + | NSCalendarUnit::Day + | NSCalendarUnit::Hour + | NSCalendarUnit::Minute + | NSCalendarUnit::Second; + let components = calendar.components_fromDate(units, &original); + let changed = calendar.components_fromDate(units, selected); + match field { + DateField::Date => { + components.setEra(changed.era()); + components.setYear(changed.year()); + components.setMonth(changed.month()); + components.setDay(changed.day()); + } + DateField::Time => { + components.setHour(changed.hour()); + components.setMinute(changed.minute()); + } + } + let date = calendar + .dateFromComponents(&components) + .ok_or("Choose another date or time.")?; + let seconds = date.timeIntervalSince1970() + 11_644_473_600.0; + let outside_range = "This date is outside the notebook's supported range."; + if !seconds.is_finite() || seconds < 0.0 || seconds > (u64::MAX / 10_000_000) as f64 { + return Err(outside_range); + } + // NSDate cannot retain FILETIME's subsecond precision at this epoch distance. + let updated = (seconds.round() as u64) + .checked_mul(10_000_000) + .and_then(|value| value.checked_add(timestamp % 10_000_000)) + .ok_or(outside_range)?; + let formatter = NSDateFormatter::new(); + formatter.setCalendar(Some(calendar)); + formatter.setTimeZone(Some(&calendar.timeZone())); + formatter.setDateStyle(NSDateFormatterStyle::NSDateFormatterFullStyle); + let date_text = formatter.stringFromDate(&date).to_string(); + formatter.setDateStyle(NSDateFormatterStyle::NSDateFormatterNoStyle); + formatter.setTimeStyle(NSDateFormatterStyle::NSDateFormatterShortStyle); + Ok(( + updated, + [date_text, formatter.stringFromDate(&date).to_string()], + )) + } +} + +#[cfg(test)] +mod date_tests { + use super::*; + use objc2_foundation::{NSCalendarIdentifierGregorian, NSTimeZone}; + + #[test] + fn calendar_changes_preserve_hidden_components_and_filetime_fraction() { + unsafe { + let calendar = NSCalendar::initWithCalendarIdentifier( + NSCalendar::alloc(), + NSCalendarIdentifierGregorian, + ) + .unwrap(); + calendar.setTimeZone( + &NSTimeZone::timeZoneWithName(&NSString::from_str("America/Los_Angeles")).unwrap(), + ); + for (original, selected, field, expected) in [ + ( + 1_788_786_864_u64, + 1_789_023_599.0, + DateField::Date, + 1_788_959_664_u64, + ), + (1_788_786_864, 978_413_702.0, DateField::Time, 1_788_842_124), + ( + 1_709_259_645, + 1_740_772_800.0, + DateField::Date, + 1_740_795_645, + ), + ( + 1_772_879_424, + 1_772_996_400.0, + DateField::Date, + 1_772_965_824, + ), + ] { + let ticks = (original + 11_644_473_600) * 10_000_000 + 9_123_456; + let (updated, text) = merge_date( + ticks, + &NSDate::dateWithTimeIntervalSince1970(selected), + field, + &calendar, + ) + .unwrap(); + assert_eq!( + updated, + (expected + 11_644_473_600) * 10_000_000 + 9_123_456 + ); + assert!(text.iter().all(|value| !value.is_empty())); + } + } + } +} + +pub fn configure_presentation(surface: &wgpu::Surface<'_>) { + MainThreadMarker::new().expect("Layer presentation belongs to the main thread"); + // Retain wgpu's surface ownership while synchronizing presentation with AppKit resize transactions. + if let Some(surface) = unsafe { surface.as_hal::() } { + surface + .render_layer() + .lock() + .setPresentsWithTransaction(true); + } +} + +pub fn text_colors() -> [[f32; 4]; 2] { + unsafe { + let space = NSColorSpace::sRGBColorSpace(); + [ + NSColor::textInsertionPointColor(), + NSColor::selectedTextBackgroundColor(), + ] + .map(|color| { + let color = color + .colorUsingColorSpace(&space) + .expect("System text colors convert to sRGB"); + let [r, g, b] = [ + color.redComponent(), + color.greenComponent(), + color.blueComponent(), + ] + .map(|value| { + let value = value as f32; + if value <= 0.04045 { + value / 12.92 + } else { + ((value + 0.055) / 1.055).powf(2.4) + } + }); + [r, g, b, color.alphaComponent() as f32] + }) + } +} + +declare_class!( + struct CanvasApplication; + + unsafe impl ClassType for CanvasApplication { + type Super = NSApplication; + type Mutability = mutability::MainThreadOnly; + const NAME: &'static str = "SnowboundApplication"; + } + + impl DeclaredClass for CanvasApplication { + type Ivars = (); + } + + unsafe impl CanvasApplication { + #[method_id(init)] + fn init(this: Allocated) -> Retained { + let this = this.set_ivars(()); + unsafe { msg_send_id![super(this), init] } + } + + #[method(terminate:)] + fn terminate(&self, _sender: Option<&AnyObject>) { + if let Some(proxy) = QUIT.get() { let _ = proxy.send_event(crate::UserEvent::Quit); } + } + + // NSAlert consumes Escape before it reaches cancelOperation:. + #[method(sendEvent:)] + fn send_event(&self, event: &NSEvent) { + unsafe { + if event.r#type() == NSEventType::KeyDown + && self.modalWindow().is_some() + && event.charactersIgnoringModifiers().is_some_and(|text| text.to_string() == "\u{1b}") + { + self.stopModal(); + } else { + let _: () = msg_send![super(self), sendEvent: event]; + } + } + } + } +); + +pub fn event_loop() -> Result, EventLoopError> { + MainThreadMarker::new().expect("AppKit must start on the main thread"); + // Create the application's own subclass before Winit obtains the singleton. + let _: Retained = + unsafe { msg_send_id![CanvasApplication::class(), sharedApplication] }; + let event_loop = EventLoop::with_user_event().build()?; + QUIT.set(event_loop.create_proxy()) + .expect("Only one application event loop is created"); + Ok(event_loop) +} + +pub fn discard_changes() -> bool { + let mtm = MainThreadMarker::new().expect("Window events run on the main thread"); + // The alert and its strings stay on AppKit's main thread for the modal call. + unsafe { + let alert = NSAlert::new(mtm); + alert.setMessageText(&NSString::from_str("Discard this page?")); + alert.setInformativeText(&NSString::from_str( + "This temporary page has no saved copy. Closing it will discard your edits.", + )); + alert.addButtonWithTitle(&NSString::from_str("Keep Editing")); + alert.addButtonWithTitle(&NSString::from_str("Discard Changes")); + alert.runModal() == NSAlertSecondButtonReturn + } +} + +/// End AppKit preedit after a canvas action commits or cancels the composition. +pub fn clear_marked_text(window: &Window) { + MainThreadMarker::new().expect("Text input belongs to the main thread"); + let RawWindowHandle::AppKit(handle) = + window.window_handle().expect("Live AppKit window").as_raw() + else { + unreachable!() + }; + // Winit owns this view and implements the NSTextInputClient selectors. + unsafe { + let view = &*handle.ns_view.as_ptr().cast::(); + let marked: bool = msg_send![view, hasMarkedText]; + if marked { + let _: () = msg_send![view, unmarkText]; + } + } +} diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs new file mode 100644 index 0000000000000000000000000000000000000000..f76bb29d024367cb9082cbbbc7e75501f985b042 --- /dev/null +++ b/crates/snowbound/src/main.rs @@ -0,0 +1,3126 @@ +mod accessibility; +mod macos; +#[cfg(test)] +mod profile; +mod scroll; + +use canvas::gpu::{Primitive, Renderer, Stroke, Viewport, page::PageScene}; +use canvas::{ + date::DateField, + document::TextDocument, + editor::{ + CanvasEditor, DEFAULT_OUTLINE_WIDTH, Movement, Selection, SelectionUnit, TextOutline, + }, + layout::TextEngine, + page::Page, + text::Paragraph, +}; +use onestore::document::Format; +use std::{ + error::Error, + path::PathBuf, + sync::Arc, + time::{Duration, Instant}, +}; +use winit::{ + application::ApplicationHandler, + dpi::{LogicalSize, PhysicalPosition, PhysicalSize}, + event::{ElementState, Ime, MouseButton, MouseScrollDelta, WindowEvent}, + event_loop::{ActiveEventLoop, ControlFlow, EventLoopProxy}, + keyboard::{Key, ModifiersState, NamedKey}, + window::{Window, WindowId}, +}; + +const HANDLE_HEIGHT: f32 = 6.75; +const DATE_LABELS: [&str; 2] = ["Page date", "Page time"]; + +#[derive(Debug)] +enum UserEvent { + Quit, + InsertText(String), + Accessibility(accesskit_winit::Event), +} + +impl From for UserEvent { + fn from(event: accesskit_winit::Event) -> Self { + Self::Accessibility(event) + } +} + +fn trace_input(event: &impl std::fmt::Debug) { + if std::env::var_os("SNOWBOUND_TRACE_INPUT").is_some() { + eprintln!("Input {:?}: {event:?}", std::time::SystemTime::now()); + } +} + +fn read_only_shortcut(key: &Key, modifiers: ModifiersState) -> bool { + key == &Key::Named(NamedKey::Escape) + || (modifiers.control_key() && key == &Key::Named(NamedKey::Tab)) + || (modifiers.super_key() + && matches!(key, Key::Character(value) if matches!(value.as_str(), "+" | "=" | "-" | "0") || (modifiers.shift_key() && value.eq_ignore_ascii_case("n")))) +} + +struct App { + proxy: EventLoopProxy, + input: Option, + substitutes: Vec, + state: Option, + startup_error: Option>, +} + +enum Input { + Notes { + document: TextDocument, + width: f32, + reference: Option, + }, + Page(Page), +} + +enum Drag { + Text { + anchor: Selection, + unit: SelectionUnit, + }, + Scrollbar { + axis: usize, + grab: f32, + }, + Resize { + outline: Option>, + grab: f32, + }, + Outline { + id: onestore::ExGuid, + grab: [f32; 2], + pending_press: Option<[f32; 2]>, + }, +} + +#[derive(Clone, Copy)] +enum PointerFeedback<'a> { + Hover(onestore::ExGuid), + Move(onestore::ExGuid, [f32; 2]), + Resize(&'a TextOutline), +} + +struct State { + window: Arc, + instance: wgpu::Instance, + surface: wgpu::Surface<'static>, + config: wgpu::SurfaceConfiguration, + renderer: Renderer, + engine: TextEngine, + editor: CanvasEditor, + initial: Vec<(onestore::ExGuid, TextDocument)>, + initial_layouts: Vec<(onestore::ExGuid, onestore::document::Layout)>, + initial_date: Option, + scene: Option<(PageScene, [f32; 2])>, + viewport: Viewport, + display_scale: f32, + pointer: [f32; 2], + pointer_inside: bool, + last_click: Option<(Instant, [f32; 2], u8)>, + drag: Option, + read_only_focus: Option, + modifiers: ModifiersState, + focused: bool, + occluded: bool, + caret: bool, + blink_at: Instant, + clipboard: arboard::Clipboard, + access_adapter: accesskit_winit::Adapter, + accessibility: accessibility::Accessibility, +} + +impl State { + fn edit_date(&mut self, field: DateField) -> Result<(), Box> { + let Some(date) = self.editor.date() else { + return Ok(()); + }; + let timestamp = date.timestamp(); + self.editor.finish_composition(); + macos::clear_marked_text(&self.window); + self.drag = None; + if let Some((timestamp, text)) = macos::edit_date(timestamp, field)? { + self.editor.change_date(&mut self.engine, timestamp, text)?; + } + self.changed() + } + + async fn new( + event_loop: &ActiveEventLoop, + proxy: EventLoopProxy, + input: Input, + substitutes: &[PathBuf], + ) -> Result> { + let window = Arc::new( + event_loop.create_window( + Window::default_attributes() + .with_visible(false) + .with_title(match &input { + Input::Notes { + reference: Some(page), + .. + } => format!("{} · Reference and temporary notes", page.title), + Input::Page(page) => format!("{} · Temporary page", page.title), + Input::Notes { + reference: None, .. + } => "Untitled · Temporary page".into(), + }) + .with_inner_size(LogicalSize::new(1000.0, 720.0)), + )?, + ); + macos::install_text_input(&window); + let access_adapter = + accesskit_winit::Adapter::with_event_loop_proxy(event_loop, &window, proxy); + window.set_visible(true); + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_with_display_handle( + Box::new(window.clone()), + )); + let surface = instance.create_surface(window.clone())?; + macos::configure_presentation(&surface); + let adapter = instance + .request_adapter(&wgpu::RequestAdapterOptions { + compatible_surface: Some(&surface), + ..Default::default() + }) + .await?; + let (device, queue) = adapter.request_device(&Default::default()).await?; + let size = window.inner_size(); + let config = surface + .get_default_config(&adapter, size.width, size.height) + .ok_or("No supported canvas surface")?; + surface.configure(&device, &config); + let renderer = Renderer::new(device, queue, config.format); + let mut engine = TextEngine::default(); + for path in substitutes { + let target = engine + .register_substitute(parley::fontique::Blob::new(Arc::new(std::fs::read(path)?)))?; + eprintln!("Using {} for {target}", path.display()); + } + let (editor, scene) = match input { + Input::Notes { + document, + width, + reference, + } => { + let editor = CanvasEditor::new(&mut engine, document, width)?; + let scene = reference + .map(|page| { + PageScene::new(page, &mut engine).map(|scene| (scene, [width + 24.0, 0.0])) + }) + .transpose()?; + (editor, scene) + } + Input::Page(page) => { + let (scene, editor) = PageScene::from_page(page, &mut engine)?; + (editor, Some((scene, [0.0; 2]))) + } + }; + let initial_date = editor.date().map(|date| date.timestamp()); + let initial_layouts = editor + .object_layouts() + .map(|(id, layout)| (id, layout.clone())) + .collect(); + let initial = editor + .outlines() + .iter() + .map(|outline| (outline.id, outline.document().clone())) + .collect(); + let dpr = window.scale_factor() as f32; + window.set_ime_allowed(true); + window.set_cursor(winit::window::CursorIcon::Text); + window.request_redraw(); + eprintln!("Canvas GPU: {:?}; scale factor {dpr}", adapter.get_info()); + Ok(Self { + window, + instance, + surface, + config, + renderer, + engine, + editor, + initial, + initial_date, + initial_layouts, + scene, + viewport: Viewport { + size: [size.width, size.height], + scale: dpr * 96.0 / 72.0, + origin: [48.0 * dpr; 2], + }, + display_scale: dpr, + pointer: [0.0; 2], + pointer_inside: false, + last_click: None, + drag: None, + read_only_focus: None, + modifiers: ModifiersState::empty(), + focused: true, + occluded: false, + caret: true, + blink_at: Instant::now() + Duration::from_millis(500), + clipboard: arboard::Clipboard::new()?, + access_adapter, + accessibility: accessibility::Accessibility::default(), + }) + } + + fn preview(&self) -> Option<(onestore::ExGuid, [f32; 2])> { + let Drag::Outline { + id, + grab, + pending_press, + } = self.drag.as_ref()? + else { + return None; + }; + let origin = self + .editor + .outlines() + .iter() + .find(|outline| outline.id == *id)? + .origin(); + if pending_press.is_some() { + return Some((*id, origin)); + } + let point = self.viewport.document_point(self.pointer); + let position = [point[0] - grab[0], point[1] - grab[1]]; + Some(( + *id, + if self.modifiers.alt_key() { + position + } else { + snap_to_grid(position) + }, + )) + } + + fn set_read_only_focus(&mut self, index: Option) { + if self.read_only_focus != index { + self.editor.finish_composition(); + self.drag = None; + self.read_only_focus = index; + self.window.set_ime_allowed(index.is_none()); + } + } + + fn changed(&mut self) -> Result<(), Box> { + self.scroll().clamp(&mut self.viewport); + self.caret = true; + self.blink_at = Instant::now() + Duration::from_millis(500); + let mut rect = self.editor.caret(1.0)?; + let origin = self + .preview() + .map(|(_, origin)| origin) + .unwrap_or_else(|| self.editor.active_outline().origin()); + rect.x0 += f64::from(origin[0]); + rect.y0 += f64::from(origin[1]); + rect.x1 += f64::from(origin[0]); + rect.y1 += f64::from(origin[1]); + let v = self.viewport; + self.window.set_ime_cursor_area( + PhysicalPosition::new( + rect.x0 * f64::from(v.scale) + f64::from(v.origin[0]), + rect.y0 * f64::from(v.scale) + f64::from(v.origin[1]), + ), + PhysicalSize::new( + (rect.width() * f64::from(v.scale)).max(1.0), + (rect.height() * f64::from(v.scale)).max(1.0), + ), + ); + self.update_accessibility()?; + if self.editor.active_outline().title { + let title = self + .editor + .active_outline() + .document() + .paragraphs() + .next() + .unwrap() + .text() + .split(['\u{000b}', '\n', '\r']) + .next() + .unwrap(); + let title = if title.trim().is_empty() { + "Untitled" + } else { + title + }; + let title = format!("{title} · Temporary page"); + if self.window.title() != title { + self.window.set_title(&title); + } + } + self.window.request_redraw(); + Ok(()) + } + + fn update_accessibility(&mut self) -> Result<(), Box> { + let mut error = None; + let preview = self.preview(); + self.access_adapter.update_if_active(|| { + match self.accessibility.update( + &self.editor, + self.viewport, + &self.window.title(), + preview, + ) { + Ok(mut update) => { + self.accessibility.append_page_fields( + &mut update, + self.scene.as_ref(), + &self.editor, + self.viewport, + self.read_only_focus, + ); + update + } + Err(failure) => { + error = Some(failure); + self.accessibility.deactivate(); + let mut root = accesskit::Node::new(accesskit::Role::Window); + root.set_label(self.window.title()); + accesskit::TreeUpdate { + nodes: vec![(accessibility::ROOT, root)], + tree: Some(accesskit::TreeInfo::new(accessibility::ROOT)), + tree_id: accesskit::TreeId::ROOT, + focus: accessibility::ROOT, + } + } + } + }); + if let Some(error) = error { + return Err(error.into()); + } + Ok(()) + } + + fn access_action(&mut self, request: accesskit::ActionRequest) -> Result<(), Box> { + trace_input(&request); + use accesskit::{Action, ActionData}; + if request.target_tree != accesskit::TreeId::ROOT { + return Ok(()); + } + if let Some(field) = self.accessibility.date_for_node(request.target_node) { + if request.action == Action::Click { + self.edit_date(field)?; + } + return Ok(()); + } + if let Some(index) = self.accessibility.read_only_for_node(request.target_node) { + if request.action == Action::Focus { + self.set_read_only_focus(Some(index)); + self.window.focus_window(); + self.reveal_focus()?; + self.changed()?; + } + return Ok(()); + } + let Some(outline) = self.accessibility.outline_for_node(request.target_node) else { + return Ok(()); + }; + match (request.action, request.data) { + (Action::Focus, _) => { + self.editor.focus_outline(outline)?; + self.window.focus_window(); + } + (Action::SetTextSelection, Some(ActionData::SetTextSelection(selection))) => { + let selection = self.accessibility.selection(outline, selection)?; + self.editor.focus_outline(outline)?; + self.editor.select(selection)?; + trace_input(&self.editor.selection()); + } + (Action::SetValue, Some(ActionData::Value(text))) => { + self.editor.focus_outline(outline)?; + self.editor.select_all()?; + self.editor.commit_text(&mut self.engine, text.into())?; + } + (Action::ReplaceSelectedText, Some(ActionData::Value(text))) => { + self.editor.focus_outline(outline)?; + self.editor.commit_text(&mut self.engine, text.into())?; + } + _ => return Ok(()), + } + self.set_read_only_focus(None); + self.reveal_focus()?; + self.changed() + } + + fn reveal_focus(&mut self) -> Result<(), Box> { + if self.viewport.size.contains(&0) { + return Ok(()); + } + let rect = if let Some(index) = self.read_only_focus { + let (scene, offset) = self.scene.as_ref().unwrap(); + let [x0, y0, x1, y1] = scene + .read_only(Some(&self.editor)) + .nth(index) + .unwrap() + .rect(); + parley::BoundingBox { + x0: f64::from(x0 + offset[0]), + y0: f64::from(y0 + offset[1]), + x1: f64::from(x1 + offset[0]), + y1: f64::from(y1 + offset[1]), + } + } else { + let mut rect = self.editor.caret(1.0)?; + let origin = self.editor.active_outline().origin(); + rect.x0 += f64::from(origin[0]); + rect.x1 += f64::from(origin[0]); + rect.y0 += f64::from(origin[1]); + rect.y1 += f64::from(origin[1]); + rect + }; + let outline = self.editor.active_outline().bounds(); + for (axis, (mut start, mut end)) in [(rect.x0, rect.x1), (rect.y0, rect.y1)] + .into_iter() + .enumerate() + { + let size = f64::from(self.viewport.size[axis]); + let margin = f64::from(16.0 * self.display_scale).min(size * 0.25); + let (outline_start, outline_end) = + [(outline.x0, outline.x1), (outline.y0, outline.y1)][axis]; + let outline_start = outline_start.min(start); + let outline_end = outline_end.max(end); + if self.read_only_focus.is_none() + && (outline_end - outline_start) * f64::from(self.viewport.scale) + <= size - margin * 2.0 + { + start = outline_start; + end = outline_end; + } + let start = + start * f64::from(self.viewport.scale) + f64::from(self.viewport.origin[axis]); + let end = end * f64::from(self.viewport.scale) + f64::from(self.viewport.origin[axis]); + let shift = if start < margin { + margin - start + } else if end > size - margin { + size - margin - end + } else { + 0.0 + }; + self.viewport.origin[axis] += shift as f32; + } + Ok(()) + } + + fn zoom(&mut self, factor: f32) { + let point = self.viewport.document_point(self.pointer); + let dpr = self.window.scale_factor() as f32; + self.viewport.scale = (self.viewport.scale * factor).clamp(dpr / 3.0, dpr * 16.0 / 3.0); + self.viewport.origin = [ + self.pointer[0] - point[0] * self.viewport.scale, + self.pointer[1] - point[1] * self.viewport.scale, + ]; + } + + fn scroll(&self) -> scroll::Scroll { + let editable = self + .editor + .visible_outlines() + .chain(self.editor.caret_outline()) + .map(|outline| { + let rect = outline.bounds(); + let offset = self + .scene + .as_ref() + .filter(|_| self.editor.has_page_outline(outline.id)) + .map(|(_, offset)| *offset) + .unwrap_or([0.0; 2]); + [ + rect.x0 as f32 + offset[0], + rect.y0 as f32 + offset[1], + rect.x1 as f32 + offset[0], + rect.y1 as f32 + offset[1], + ] + }); + let fixed = self.scene.iter().flat_map(|(scene, offset)| { + scene.content_bounds(&self.editor).map(|rect| { + [ + rect[0] + offset[0], + rect[1] + offset[1], + rect[2] + offset[0], + rect[3] + offset[1], + ] + }) + }); + scroll::Scroll::new(self.viewport, editable.chain(fixed)) + } + + fn draw(&mut self) -> Result<(), Box> { + trace_input(&("Draw", self.viewport.origin, self.viewport.scale)); + if self.occluded || self.viewport.size.contains(&0) { + return Ok(()); + } + let mut reconfigure = false; + let frame = match self.surface.get_current_texture() { + wgpu::CurrentSurfaceTexture::Success(frame) => frame, + wgpu::CurrentSurfaceTexture::Suboptimal(frame) => { + reconfigure = true; + frame + } + wgpu::CurrentSurfaceTexture::Outdated => { + self.surface.configure(&self.renderer.device, &self.config); + self.window.request_redraw(); + return Ok(()); + } + wgpu::CurrentSurfaceTexture::Lost => { + self.surface = self.instance.create_surface(self.window.clone())?; + macos::configure_presentation(&self.surface); + self.surface.configure(&self.renderer.device, &self.config); + self.window.request_redraw(); + return Ok(()); + } + wgpu::CurrentSurfaceTexture::Timeout => { + trace_input(&"Surface timeout"); + return Ok(()); + } + wgpu::CurrentSurfaceTexture::Occluded => { + trace_input(&"Surface occluded"); + return Ok(()); + } + wgpu::CurrentSurfaceTexture::Validation => { + return Err("Canvas surface validation failed".into()); + } + }; + let mut primitives = page_primitives( + &self.editor, + self.scene.as_ref(), + match &self.drag { + Some(Drag::Resize { + outline: Some(outline), + .. + }) => Some(PointerFeedback::Resize(outline)), + _ => self + .preview() + .map(|(id, origin)| PointerFeedback::Move(id, origin)) + .or_else(|| { + if !self.pointer_inside { + return None; + } + match page_hit_test( + &self.editor, + self.scene.as_ref(), + self.viewport.document_point(self.pointer), + self.display_scale / self.viewport.scale, + ) { + Some( + Hit::Text { id, .. } + | Hit::Handle { id, .. } + | Hit::Resize { id, .. }, + ) => Some(PointerFeedback::Hover(id)), + _ => None, + } + }), + }, + self.read_only_focus, + self.caret + && self.focused + && !matches!(self.drag, Some(Drag::Outline { .. } | Drag::Resize { .. })), + self.viewport.scale, + self.display_scale, + )?; + self.scroll() + .append(self.viewport, self.display_scale, &mut primitives); + self.renderer + .draw( + &frame.texture.create_view(&Default::default()), + self.viewport, + &primitives, + ) + .map_err(|error| format!("Canvas drawing failed: {error:?}"))?; + self.window.pre_present_notify(); + self.renderer.queue.present(frame); + trace_input(&"Present submitted"); + if reconfigure { + self.surface.configure(&self.renderer.device, &self.config); + } + Ok(()) + } + + fn key(&mut self, key: &Key, text: Option<&str>) -> Result<(), Box> { + let shift = self.modifiers.shift_key(); + let command = self.modifiers.super_key(); + let option = self.modifiers.alt_key(); + if self.read_only_focus.is_some() { + if !read_only_shortcut(key, self.modifiers) { + return Ok(()); + } + if key == &Key::Named(NamedKey::Escape) { + self.set_read_only_focus(None); + self.reveal_focus()?; + return self.changed(); + } + } + if command && self.modifiers.control_key() && key == &Key::Named(NamedKey::Space) { + macos::show_character_palette(); + return Ok(()); + } + if matches!(self.drag, Some(Drag::Outline { .. } | Drag::Resize { .. })) { + if matches!( + key, + Key::Named( + NamedKey::Alt + | NamedKey::AltGraph + | NamedKey::Control + | NamedKey::Shift + | NamedKey::Super + | NamedKey::Meta + ) + ) { + return Ok(()); + } + self.drag = None; + if key == &Key::Named(NamedKey::Escape) { + return self.changed(); + } + } + if command && option { + let delta = match key { + Key::Named(NamedKey::ArrowLeft) => Some([-1.0, 0.0]), + Key::Named(NamedKey::ArrowRight) => Some([1.0, 0.0]), + Key::Named(NamedKey::ArrowUp) => Some([0.0, -1.0]), + Key::Named(NamedKey::ArrowDown) => Some([0.0, 1.0]), + _ => None, + }; + if let Some(delta) = delta { + let outline = self.editor.active_outline(); + let origin = outline.origin(); + let step = if shift { 10.0 } else { 1.0 }; + self.editor.move_outline( + outline.id, + [origin[0] + delta[0] * step, origin[1] + delta[1] * step], + )?; + self.reveal_focus()?; + return self.changed(); + } + } + if self.modifiers.control_key() && key == &Key::Named(NamedKey::Tab) { + let outlines = self.editor.outlines(); + let count = outlines.len() + + self + .scene + .as_ref() + .map_or(0, |(scene, _)| scene.read_only(Some(&self.editor)).count()); + if count == 0 { + return self.changed(); + } + let index = self.read_only_focus.map_or_else( + || { + outlines + .iter() + .position(|outline| outline.id == self.editor.active_outline().id) + }, + |index| Some(outlines.len() + index), + ); + let next = index.map_or(if shift { count - 1 } else { 0 }, |index| { + if shift { + (index + count - 1) % count + } else { + (index + 1) % count + } + }); + if next < outlines.len() { + self.editor.focus_outline(outlines[next].id)?; + self.set_read_only_focus(None); + } else { + self.set_read_only_focus(Some(next - outlines.len())); + } + self.reveal_focus()?; + return self.changed(); + } + if command && let Key::Character(key) = key { + match key.to_lowercase().as_str() { + "n" if shift => { + let position = if let Some(index) = self.read_only_focus { + let (scene, offset) = self.scene.as_ref().unwrap(); + let rect = scene + .read_only(Some(&self.editor)) + .nth(index) + .unwrap() + .rect(); + [rect[2] + offset[0] + 24.0, rect[1] + offset[1]] + } else { + let bounds = self.editor.active_outline().bounds(); + [bounds.x1 as f32 + 24.0, bounds.y0 as f32] + }; + self.editor.place_caret( + &mut self.engine, + snap_to_grid(position), + DEFAULT_OUTLINE_WIDTH, + )?; + self.set_read_only_focus(None); + } + "a" => self.editor.select_all()?, + "z" => { + if shift { + self.editor.redo(&mut self.engine)?; + } else { + self.editor.undo(&mut self.engine)?; + } + } + "c" | "x" => { + let [anchor, focus] = self.editor.selection().positions; + let selected = self + .editor + .active_outline() + .document() + .slice(anchor.min(focus)..anchor.max(focus))?; + let text = selected + .iter() + .map(|paragraph| { + paragraph + .project() + .map(|projection| projection.text().text().to_owned()) + }) + .collect::, _>>()? + .join("\n"); + if !text.is_empty() { + self.clipboard.set_text(text)?; + if key.eq_ignore_ascii_case("x") { + self.editor.insert(&mut self.engine, "")?; + } + } + } + "v" => { + let text = self.clipboard.get_text()?; + self.editor.commit_text(&mut self.engine, text)?; + } + "+" | "=" => { + self.zoom(1.1); + return self.changed(); + } + "-" => { + self.zoom(1.0 / 1.1); + return self.changed(); + } + "0" => { + self.zoom((self.display_scale * 96.0 / 72.0) / self.viewport.scale); + return self.changed(); + } + _ => return Ok(()), + } + } else { + let movement = match key { + Key::Named(NamedKey::ArrowLeft) => Some(if command { + Movement::LineStart + } else if option { + Movement::WordLeft + } else { + Movement::Left + }), + Key::Named(NamedKey::ArrowRight) => Some(if command { + Movement::LineEnd + } else if option { + Movement::WordRight + } else { + Movement::Right + }), + Key::Named(NamedKey::ArrowUp) => Some(if command { + Movement::DocumentStart + } else if option { + Movement::ParagraphStart + } else { + Movement::Up + }), + Key::Named(NamedKey::ArrowDown) => Some(if command { + Movement::DocumentEnd + } else if option { + Movement::ParagraphEnd + } else { + Movement::Down + }), + Key::Named(NamedKey::Home) if shift => Some(Movement::DocumentStart), + Key::Named(NamedKey::End) if shift => Some(Movement::DocumentEnd), + Key::Character(key) if self.modifiers.control_key() && !option => { + match key.as_str() { + "a" => Some(Movement::LineStart), + "e" => Some(Movement::LineEnd), + "b" => Some(Movement::Left), + "f" => Some(Movement::Right), + "p" => Some(Movement::Up), + "n" => Some(Movement::Down), + _ => None, + } + } + _ => None, + }; + if let Some(movement) = movement { + self.editor + .move_selection(&mut self.engine, movement, shift)?; + } else if self.editor.marked_range().is_none() { + match key { + Key::Named(NamedKey::Backspace) if command || option => { + self.editor.delete_to( + &mut self.engine, + if command { + Movement::LineStart + } else { + Movement::WordLeft + }, + )?; + } + Key::Named(NamedKey::Delete) if command || option => { + self.editor.delete_to( + &mut self.engine, + if command { + Movement::LineEnd + } else { + Movement::WordRight + }, + )?; + } + Key::Named(NamedKey::Backspace) => { + self.editor.delete(&mut self.engine, true)?; + } + Key::Named(NamedKey::Delete) => { + self.editor.delete(&mut self.engine, false)?; + } + Key::Named(NamedKey::Home | NamedKey::End) => { + let limits = self.scroll(); + self.viewport.origin[1] = -if key == &Key::Named(NamedKey::Home) { + limits.min[1] + } else { + limits.max[1] + }; + return self.changed(); + } + Key::Character(key) if self.modifiers.control_key() && !option => { + match key.as_str() { + "h" => { + self.editor.delete(&mut self.engine, true)?; + } + "d" => { + self.editor.delete(&mut self.engine, false)?; + } + "k" if !self + .editor + .delete_to(&mut self.engine, Movement::LineEnd)? => + { + self.editor.delete(&mut self.engine, false)?; + } + _ => {} + } + } + Key::Named(NamedKey::Enter) => { + self.editor.enter(&mut self.engine, shift)?; + } + Key::Named(NamedKey::Tab) => { + self.editor.tab(&mut self.engine, shift)?; + } + _ if !command && !self.modifiers.control_key() => { + if let Some(text) = text + .filter(|text| !text.is_empty() && !text.chars().any(char::is_control)) + { + self.editor.insert(&mut self.engine, text)?; + } + } + _ => {} + } + } else if key == &Key::Named(NamedKey::Escape) { + self.editor.cancel_composition(&mut self.engine)?; + } + } + self.reveal_focus()?; + self.changed() + } +} + +impl App { + fn close(&self, event_loop: &ActiveEventLoop) { + if self.state.as_ref().is_none_or(|state| { + state + .editor + .caret_outline() + .is_none_or(TextOutline::is_empty) + && state.initial_date == state.editor.date().map(|date| date.timestamp()) + && state + .initial_layouts + .iter() + .map(|(id, layout)| (*id, layout)) + .eq(state.editor.object_layouts()) + && state + .initial + .iter() + .map(|(id, document)| (id, document)) + .eq(state + .editor + .outlines() + .iter() + .map(|outline| (&outline.id, outline.document()))) + }) || macos::discard_changes() + { + event_loop.exit(); + } + } +} + +impl ApplicationHandler for App { + fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) { + let event = match event { + UserEvent::InsertText(text) => { + if let Some(state) = &mut self.state + && state.read_only_focus.is_none() + && !text.is_empty() + && !text.chars().any(char::is_control) + { + let result = (|| -> Result<(), Box> { + state.editor.commit_text(&mut state.engine, text)?; + state.reveal_focus()?; + state.changed() + })(); + if let Err(error) = result { + eprintln!("{error}"); + } + } + return; + } + UserEvent::Quit => { + self.close(event_loop); + return; + } + UserEvent::Accessibility(event) => event, + }; + let Some(state) = &mut self.state else { + return; + }; + if event.window_id != state.window.id() { + return; + } + let was_marked = state.editor.marked_range().is_some(); + let result = match event.window_event { + accesskit_winit::WindowEvent::InitialTreeRequested => state.update_accessibility(), + accesskit_winit::WindowEvent::ActionRequested(request) => state.access_action(request), + accesskit_winit::WindowEvent::AccessibilityDeactivated => { + state.accessibility.deactivate(); + Ok(()) + } + }; + if was_marked && state.editor.marked_range().is_none() { + macos::clear_marked_text(&state.window); + } + if let Err(error) = result { + eprintln!("{error}"); + } + } + + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + if self.state.is_some() { + return; + } + match pollster::block_on(State::new( + event_loop, + self.proxy.clone(), + self.input.take().unwrap(), + &self.substitutes, + )) { + Ok(state) => self.state = Some(state), + Err(error) => { + self.startup_error = Some(error); + event_loop.exit(); + } + } + } + + fn window_event(&mut self, event_loop: &ActiveEventLoop, _: WindowId, event: WindowEvent) { + if matches!( + event, + WindowEvent::KeyboardInput { .. } + | WindowEvent::Ime(_) + | WindowEvent::MouseInput { .. } + | WindowEvent::CursorMoved { .. } + | WindowEvent::ModifiersChanged(_) + | WindowEvent::Focused(_) + ) { + trace_input(&event); + } + if let Some(state) = &mut self.state { + state.access_adapter.process_event(&state.window, &event); + } + if matches!(event, WindowEvent::CloseRequested) { + self.close(event_loop); + return; + } + let Some(state) = &mut self.state else { + return; + }; + let was_marked = + !matches!(event, WindowEvent::Ime(_)) && state.editor.marked_range().is_some(); + let result = (|| -> Result<(), Box> { + match event { + WindowEvent::RedrawRequested => state.draw()?, + WindowEvent::Resized(size) => { + state.viewport.size = [size.width, size.height]; + if size.width > 0 && size.height > 0 { + state.config.width = size.width; + state.config.height = size.height; + state + .surface + .configure(&state.renderer.device, &state.config); + state.changed()?; + } + } + WindowEvent::ScaleFactorChanged { scale_factor, .. } => { + let scale = scale_factor as f32; + let ratio = scale / state.display_scale; + state.viewport.scale *= ratio; + state.viewport.origin[0] *= ratio; + state.viewport.origin[1] *= ratio; + state.display_scale = scale; + state.renderer.clear_glyph_cache(); + state.changed()?; + } + WindowEvent::Focused(focused) => { + state.focused = focused; + if !focused { + state.drag = None; + } + state.changed()?; + } + WindowEvent::Occluded(occluded) => { + trace_input(&("Window occluded", occluded)); + state.occluded = occluded; + if !occluded { + state.changed()?; + } + } + WindowEvent::ModifiersChanged(modifiers) => { + state.modifiers = modifiers.state(); + if matches!(state.drag, Some(Drag::Outline { .. })) { + state.changed()?; + } + } + WindowEvent::CursorLeft { .. } => { + state.pointer_inside = false; + state.window.request_redraw(); + } + WindowEvent::CursorMoved { position, .. } => { + state.pointer_inside = true; + state.pointer = [position.x as f32, position.y as f32]; + let hit = page_hit_test( + &state.editor, + state.scene.as_ref(), + state.viewport.document_point(state.pointer), + state.display_scale / state.viewport.scale, + ); + let scrollbar = state + .scroll() + .hit_test(state.viewport, state.display_scale, state.pointer) + .is_some(); + state.window.set_cursor(match (&state.drag, hit) { + (Some(Drag::Scrollbar { .. }), _) => winit::window::CursorIcon::Default, + (None, _) if scrollbar => winit::window::CursorIcon::Default, + (Some(Drag::Resize { .. }), _) | (None, Some(Hit::Resize { .. })) => { + winit::window::CursorIcon::EwResize + } + (Some(Drag::Outline { .. }), _) | (None, Some(Hit::Handle { .. })) => { + winit::window::CursorIcon::Move + } + (None, Some(Hit::Date(_))) => winit::window::CursorIcon::Pointer, + (None, Some(Hit::ReadOnly(_))) => winit::window::CursorIcon::Default, + _ => winit::window::CursorIcon::Text, + }); + match &mut state.drag { + Some(Drag::Scrollbar { axis, grab }) => { + let (axis, grab) = (*axis, *grab); + state.scroll().drag( + &mut state.viewport, + state.display_scale, + axis, + state.pointer[axis], + grab, + ); + state.changed()?; + } + Some(Drag::Text { anchor, unit }) => { + let point = state.viewport.document_point(state.pointer); + let origin = state.editor.active_outline().origin(); + let target = state.editor.selection_at( + point[0] - origin[0], + point[1] - origin[1], + *unit, + )?; + state + .editor + .select(drag_selection(*anchor, target, *unit))?; + state.changed()?; + } + Some(Drag::Outline { pending_press, .. }) => { + if pending_press.is_some_and(|press| { + (0..2).any(|axis| { + (state.pointer[axis] - press[axis]).abs() + > 2.0 * state.display_scale + }) + }) { + *pending_press = None; + } + state.changed()?; + } + Some(Drag::Resize { outline, grab }) => { + let point = state.viewport.document_point(state.pointer); + let width = + (point[0] - state.editor.active_outline().origin()[0] - *grab) + .max(36.0); + if outline.is_some() + || (width - state.editor.active_outline().bounds().width() as f32) + .abs() + * state.viewport.scale + > 2.0 * state.display_scale + { + *outline = Some(Box::new( + state.editor.preview_resize(&mut state.engine, width)?, + )); + state.changed()?; + } + } + None => state.window.request_redraw(), + } + } + WindowEvent::MouseInput { + state: button_state, + button: MouseButton::Left, + .. + } => { + let point = state.viewport.document_point(state.pointer); + if button_state == ElementState::Pressed { + let now = Instant::now(); + let count = state + .last_click + .filter(|(time, point, _)| { + now.duration_since(*time) <= macos::double_click_interval() + && (0..2).all(|axis| { + (point[axis] - state.pointer[axis]).abs() + <= 4.0 * state.display_scale + }) + }) + .map_or(1, |(_, _, count)| (count % 3) + 1); + state.last_click = Some((now, state.pointer, count)); + let scrollbar = state + .scroll() + .hit_test(state.viewport, state.display_scale, state.pointer) + .map(|(axis, grab)| Drag::Scrollbar { axis, grab }); + if scrollbar.is_some() { + state.drag = scrollbar; + } else { + match page_hit_test( + &state.editor, + state.scene.as_ref(), + point, + state.display_scale / state.viewport.scale, + ) { + Some(Hit::Date(field)) => state.edit_date(field)?, + Some(Hit::ReadOnly(index)) => { + state.set_read_only_focus(Some(index)) + } + Some(Hit::Handle { id, grab }) => { + state.set_read_only_focus(None); + state.editor.focus_outline(id)?; + state.drag = Some(Drag::Outline { + id, + grab, + pending_press: Some(state.pointer), + }); + } + Some(Hit::Resize { id, grab }) => { + state.set_read_only_focus(None); + state.editor.focus_outline(id)?; + state.drag = Some(Drag::Resize { + outline: None, + grab, + }); + } + Some(Hit::Text { id, point }) => { + let extend = state.modifiers.shift_key() + && state.read_only_focus.is_none() + && id == state.editor.active_outline().id; + state.set_read_only_focus(None); + state.editor.focus_outline(id)?; + let previous = state.editor.selection(); + state.editor.select_below(&mut state.engine, id, point)?; + let unit = match count { + 2 => SelectionUnit::Word, + 3 => SelectionUnit::Paragraph, + _ => SelectionUnit::Grapheme, + }; + let selection = + state.editor.selection_at(point[0], point[1], unit)?; + let selection = if extend { + Selection { + positions: [ + previous.positions[0], + selection.positions[1], + ], + affinities: [ + previous.affinities[0], + selection.affinities[1], + ], + } + } else { + selection + }; + state.editor.select(selection)?; + state.drag = Some(Drag::Text { + anchor: selection, + unit, + }); + } + None => { + let position = [ + point[0], + point[1] - 7.0 * state.display_scale / state.viewport.scale, + ]; + let position = if state.modifiers.alt_key() { + position + } else { + snap_to_grid(position) + }; + state.editor.place_caret( + &mut state.engine, + position, + DEFAULT_OUTLINE_WIDTH, + )?; + state.set_read_only_focus(None); + state.drag = Some(Drag::Text { + anchor: state.editor.selection(), + unit: SelectionUnit::Grapheme, + }); + } + } + } + } else { + let preview = state.preview(); + if let Some(Drag::Resize { + outline: Some(outline), + .. + }) = state.drag.take() + { + state + .editor + .resize(&mut state.engine, outline.bounds().width() as f32)?; + } + if let Some((id, origin)) = preview { + state.editor.move_outline(id, origin)?; + } + } + state.changed()?; + } + WindowEvent::MouseWheel { delta, .. } => { + let dpr = state.window.scale_factor() as f32; + let delta = match delta { + MouseScrollDelta::LineDelta(x, y) => [x * 32.0 * dpr, y * 32.0 * dpr], + MouseScrollDelta::PixelDelta(p) => [p.x as f32, p.y as f32], + }; + if state.modifiers.super_key() { + state.zoom((delta[1] * 0.005).exp()); + } else { + state.viewport.origin[0] += delta[0]; + state.viewport.origin[1] += delta[1]; + } + state.changed()?; + } + WindowEvent::KeyboardInput { event, .. } + if event.state == ElementState::Pressed => + { + state.key(&event.logical_key, event.text.as_deref())?; + } + WindowEvent::Ime(_) if state.read_only_focus.is_some() => {} + WindowEvent::Ime(Ime::Preedit(text, cursor)) => { + if text.is_empty() { + state.editor.cancel_composition(&mut state.engine)?; + } else { + let (start, end) = cursor.unwrap_or((text.len(), text.len())); + let start: u32 = text + .get(..start) + .ok_or("IME range splits UTF-8")? + .encode_utf16() + .count() + .try_into()?; + let end: u32 = text + .get(..end) + .ok_or("IME range splits UTF-8")? + .encode_utf16() + .count() + .try_into()?; + state.editor.compose(&mut state.engine, text, start..end)?; + } + state.reveal_focus()?; + state.changed()?; + } + WindowEvent::Ime(Ime::Commit(text)) => { + state.editor.commit_text(&mut state.engine, text)?; + state.reveal_focus()?; + state.changed()?; + } + WindowEvent::Ime(Ime::Disabled) => { + state.editor.cancel_composition(&mut state.engine)?; + state.reveal_focus()?; + state.changed()?; + } + _ => {} + } + Ok(()) + })(); + if was_marked && state.editor.marked_range().is_none() { + macos::clear_marked_text(&state.window); + } + if let Err(error) = result { + eprintln!("{error}"); + } + } + + fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { + let Some(state) = &mut self.state else { + return; + }; + let [anchor, focus] = state.editor.selection().positions; + if state.focused && !state.occluded && state.read_only_focus.is_none() && anchor == focus { + let now = Instant::now(); + if now >= state.blink_at { + state.caret = !state.caret; + state.blink_at = now + Duration::from_millis(500); + state.window.request_redraw(); + } + event_loop.set_control_flow(ControlFlow::WaitUntil(state.blink_at)); + } else { + event_loop.set_control_flow(ControlFlow::Wait); + } + } +} + +#[derive(Debug, PartialEq)] +enum Hit { + Date(DateField), + Resize { + id: onestore::ExGuid, + grab: f32, + }, + Text { + id: onestore::ExGuid, + point: [f32; 2], + }, + Handle { + id: onestore::ExGuid, + grab: [f32; 2], + }, + ReadOnly(usize), +} + +fn page_hit_test( + editor: &CanvasEditor, + scene: Option<&(PageScene, [f32; 2])>, + point: [f32; 2], + pixel: f32, +) -> Option { + let hit = |outline: &TextOutline, offset: [f32; 2], below: bool| { + if below { + let outline = editor + .outlines() + .iter() + .find(|source| source.id == outline.id)?; + let local = [ + point[0] - offset[0] - outline.origin()[0], + point[1] - offset[1] - outline.origin()[1], + ]; + return outline.contains_extension(local).then_some(Hit::Text { + id: outline.id, + point: local, + }); + } + let (bounds, body_top) = outline_chrome(outline, pixel); + let local = [point[0] - offset[0], point[1] - offset[1]]; + let [x, y] = local; + if x >= bounds[0] && x <= bounds[2] && y >= bounds[1] && y < body_top { + if x >= bounds[2] - 9.0 * pixel { + return Some(Hit::Resize { + id: outline.id, + grab: point[0] - outline.bounds().x1 as f32, + }); + } + return Some(Hit::Handle { + id: outline.id, + grab: [ + point[0] - outline.origin()[0], + point[1] - outline.origin()[1], + ], + }); + } + if (x >= bounds[0] && x <= bounds[2] && y >= body_top && y <= bounds[3]) + || outline.layouts().any(|(_, paragraph)| { + paragraph.tags.iter().any(|tag| { + let origin = outline.origin(); + let x = origin[0] + tag.origin[0]; + let y = origin[1] + paragraph.origin[1] + tag.origin[1]; + let size = canvas::outline::ParagraphTag::SIZE; + (x..=x + size).contains(&local[0]) && (y..=y + size).contains(&local[1]) + }) + }) + { + return Some(Hit::Text { + id: outline.id, + point: [ + local[0] - outline.origin()[0], + local[1] - outline.origin()[1], + ], + }); + } + None + }; + let pass = |below| { + if let Some(hit) = editor + .visible_outlines() + .rev() + .filter(|outline| scene.is_none() || !editor.has_page_outline(outline.id)) + .find_map(|outline| hit(outline, [0.0; 2], below)) + { + return Some(hit); + } + let (scene, offset) = scene?; + match scene.hit_test( + [point[0] - offset[0], point[1] - offset[1]], + Some(editor), + |id| { + editor + .visible_outlines() + .find(|outline| outline.id == id) + .and_then(|outline| hit(outline, *offset, below)) + }, + )? { + canvas::gpu::page::SceneHit::Outline(hit) => Some(hit), + canvas::gpu::page::SceneHit::Date(field) => Some(Hit::Date(field)), + canvas::gpu::page::SceneHit::ReadOnly(index) => Some(Hit::ReadOnly(index)), + } + }; + pass(false).or_else(|| pass(true)) +} + +fn page_primitives<'a>( + editor: &'a CanvasEditor, + scene: Option<&'a (PageScene, [f32; 2])>, + preview: Option>, + read_only_focus: Option, + show_caret: bool, + scale: f32, + display_scale: f32, +) -> Result>, Box> { + let mut primitives = Vec::new(); + let paint = |id, offset: [f32; 2], primitives: &mut Vec<_>| { + let outline = editor + .visible_outlines() + .find(|outline| outline.id == id) + .ok_or(canvas::gpu::page::SceneError::MissingOutline)?; + let outline = match preview { + Some(PointerFeedback::Resize(resized)) if resized.id == outline.id => resized, + _ => outline, + }; + let origin = match preview { + Some(PointerFeedback::Move(id, origin)) if id == outline.id => origin, + _ => outline.origin(), + }; + if (read_only_focus.is_none() && outline.id == editor.active_outline().id) + || matches!(preview, Some(PointerFeedback::Hover(id) | PointerFeedback::Move(id, _)) if id == outline.id) + || matches!(preview, Some(PointerFeedback::Resize(resized)) if resized.id == outline.id) + { + append_outline_chrome( + outline, + [origin[0] + offset[0], origin[1] + offset[1]], + display_scale / scale, + primitives, + ); + } + append_outline( + (read_only_focus.is_none() + && outline.id == editor.active_outline().id + && !matches!(preview, Some(PointerFeedback::Resize(_)))) + .then_some(editor), + outline, + [origin[0] + offset[0], origin[1] + offset[1]], + show_caret, + scale, + display_scale / scale, + primitives, + ) + }; + if let Some((scene, origin)) = scene { + scene.append_primitives_with(&mut primitives, *origin, Some(editor), &paint)?; + } + for outline in editor + .visible_outlines() + .filter(|outline| scene.is_none() || !editor.has_page_outline(outline.id)) + { + paint(outline.id, [0.0; 2], &mut primitives)?; + } + if read_only_focus.is_none() + && let Some(outline) = editor.caret_outline() + { + append_outline( + Some(editor), + outline, + outline.origin(), + show_caret, + scale, + display_scale / scale, + &mut primitives, + )?; + } + if let Some(index) = read_only_focus { + let (scene, offset) = scene.unwrap(); + let [x0, y0, x1, y1] = scene.read_only(Some(editor)).nth(index).unwrap().rect(); + let [x0, y0, x1, y1] = [ + x0 + offset[0], + y0 + offset[1], + x1 + offset[0], + y1 + offset[1], + ]; + let border = 2.0 / scale; + for rect in [ + [x0, y0, x1, y0 + border], + [x0, y1 - border, x1, y1], + [x0, y0, x0 + border, y1], + [x1 - border, y0, x1, y1], + ] { + primitives.push(Primitive::Rect { + rect, + color: [0.25, 0.45, 0.7, 1.0], + }); + } + } + Ok(primitives) +} + +fn drag_selection(anchor: Selection, target: Selection, unit: SelectionUnit) -> Selection { + if unit == SelectionUnit::Grapheme { + return Selection { + positions: [anchor.positions[0], target.positions[1]], + affinities: [anchor.affinities[0], target.affinities[1]], + }; + } + let backwards = target.positions[0] < anchor.positions[0].min(anchor.positions[1]); + let start = usize::from((anchor.positions[0] > anchor.positions[1]) != backwards); + let end = usize::from((target.positions[0] > target.positions[1]) == backwards); + Selection { + positions: [anchor.positions[start], target.positions[end]], + affinities: [anchor.affinities[start], target.affinities[end]], + } +} + +fn snap_to_grid(point: [f32; 2]) -> [f32; 2] { + std::array::from_fn(|axis| { + let offset = [0.0, 14.4][axis]; + let cell = (point[axis] - offset) / 18.0; + let nearest = cell.round(); + // Native midpoints remain free; account for the source coordinate's float precision. + if ((cell - nearest).abs() - 0.5).abs() <= f32::EPSILON * cell.abs().max(1.0) { + point[axis] + } else { + nearest * 18.0 + offset + } + }) +} + +fn outline_chrome(outline: &TextOutline, pixel: f32) -> ([f32; 4], f32) { + let bounds = outline.bounds(); + // Native chrome combines page-scaled gutters with a fixed screen inset. + let inset = 5.0 * pixel; + let body_top = bounds.y0 as f32 - inset; + if outline.title { + let (_, paragraph) = outline.layouts().last().unwrap(); + let bottom = outline.origin()[1] + paragraph.origin[1] + paragraph.text.height(); + return ( + [ + bounds.x0 as f32 - inset - 6.0 * pixel, + body_top, + bounds.x1 as f32 + inset - 2.0 * pixel, + bottom + inset, + ], + body_top, + ); + } + ( + [ + bounds.x0 as f32 - 7.5 - inset, + body_top - HANDLE_HEIGHT, + bounds.x1 as f32 + inset, + bounds.y1 as f32 + HANDLE_HEIGHT + inset, + ], + body_top, + ) +} + +fn append_outline_chrome( + outline: &TextOutline, + origin: [f32; 2], + pixel: f32, + primitives: &mut Vec>, +) { + let [x, y] = origin; + let (bounds, body_top) = outline_chrome(outline, pixel); + let [dx, dy] = [x - outline.origin()[0], y - outline.origin()[1]]; + let [left, top, right, bottom] = [ + bounds[0] + dx, + bounds[1] + dy, + bounds[2] + dx, + bounds[3] + dy, + ]; + let body_top = body_top + dy; + if outline.title { + primitives.push(Primitive::RoundedRect { + rect: [left, top, right, bottom], + radius: [6.0 * pixel, (bottom - top) * 0.5], + stroke: Some(Stroke::Dashed(pixel)), + color: canvas::gpu::colorref(0x007f7f7f), + }); + return; + } + primitives.push(Primitive::RoundedRect { + rect: [left, top, right, body_top], + radius: [3.0 * pixel; 2], + stroke: None, + color: canvas::gpu::colorref(0x00e8ebed), + }); + primitives.push(Primitive::RoundedRect { + rect: [right - 9.0 * pixel, top, right, body_top], + radius: [3.0 * pixel; 2], + stroke: None, + color: canvas::gpu::colorref(0x00e5dee7), + }); + primitives.push(Primitive::RoundedRect { + rect: [left, top, right, bottom], + radius: [3.0 * pixel; 2], + stroke: Some(Stroke::Solid(pixel)), + color: canvas::gpu::colorref(0x00d9cfd8), + }); + let middle = (top + body_top) * 0.5; + for offset in [-3.0, 0.0, 3.0] { + let center = (left + right) * 0.5 + offset * pixel; + primitives.push(Primitive::RoundedRect { + rect: [ + center - pixel * 0.5, + middle - pixel * 0.5, + center + pixel * 0.5, + middle + pixel * 0.5, + ], + radius: [pixel * 0.5; 2], + stroke: None, + color: canvas::gpu::colorref(0x00b4a5b4), + }); + } + for column in [0.0, 1.0, 2.0] { + let half = (column + 0.5) * pixel; + for x in [ + right - (8.0 - column) * pixel, + right - (2.0 + column) * pixel, + ] { + primitives.push(Primitive::Rect { + rect: [x, middle - half, x + pixel, middle + half], + color: canvas::gpu::colorref(0x00b4a5b4), + }); + } + } +} + +fn append_outline<'a>( + editor: Option<&'a CanvasEditor>, + outline: &'a TextOutline, + origin: [f32; 2], + show_caret: bool, + scale: f32, + pixel: f32, + primitives: &mut Vec>, +) -> Result<(), Box> { + let [x, y] = origin; + outline.shaped().append_table_primitives(primitives, origin); + outline + .shaped() + .append_background_primitives(primitives, origin); + if let Some(editor) = editor { + let [_, selection_color] = macos::text_colors(); + for rect in editor.selection_rects()? { + primitives.push(Primitive::Rect { + rect: [ + rect.x0 as f32 + x, + rect.y0 as f32 + y, + rect.x1 as f32 + x, + rect.y1 as f32 + y, + ], + color: selection_color, + }); + } + } + for (index, (_, paragraph)) in outline.layouts().enumerate() { + let layout = ¶graph.text; + let paragraph_origin = paragraph.origin; + let clip = outline.shaped().paragraph_cell(index).map(|cell| { + let [left, top, right, bottom] = cell.text_bounds(); + [left + x, top + y, right + x, bottom + y] + }); + primitives.push(Primitive::Text { + clip, + layout, + origin: [x + paragraph_origin[0], y + paragraph_origin[1]], + }); + for (layout, origin) in ¶graph.markers { + primitives.push(Primitive::Text { + clip, + layout, + origin: [x + origin[0], y + (origin[1] + paragraph_origin[1])], + }); + } + for tag in ¶graph.tags { + primitives.push(Primitive::Tag { + tag, + origin: [x, y + paragraph_origin[1]], + }); + } + } + if let Some(editor) = editor { + for rect in editor.marked_rects()? { + primitives.push(Primitive::Rect { + rect: [ + rect.x0 as f32 + x, + rect.y1 as f32 + y - 1.0 / scale, + rect.x1 as f32 + x, + rect.y1 as f32 + y, + ], + color: [0.0, 0.0, 0.0, 1.0], + }); + } + let [anchor, focus] = editor.selection().positions; + if show_caret && anchor == focus { + let rect = editor.caret(2.0 * pixel)?; + primitives.push(Primitive::RoundedRect { + rect: [ + rect.x0 as f32 + x, + rect.y0 as f32 + y, + rect.x1 as f32 + x, + rect.y1 as f32 + y, + ], + radius: [pixel; 2], + stroke: None, + color: macos::text_colors()[0], + }); + } + } + Ok(()) +} + +fn main() -> Result<(), Box> { + let mut args = std::env::args_os().skip(1); + let mut positional = Vec::new(); + let mut substitutes = Vec::new(); + let mut reference = None; + let mut editable = false; + while let Some(arg) = args.next() { + if arg == "--substitute-font" { + substitutes.push(PathBuf::from( + args.next() + .ok_or("Provide a font file after --substitute-font.")?, + )); + } else if arg == "--reference" || arg == "--page" { + if reference.is_some() { + return Err("Only one page can be opened.".into()); + } + editable = arg == "--page"; + let path = args + .next() + .ok_or("Provide a section file and page title after the page option.")?; + let title = args + .next() + .ok_or("Provide a page title after the section file.")?; + let bytes = std::fs::read(path)?; + let store = onestore::Store::parse(&bytes)?; + let index = onestore::RevisionIndex::parse(&store)?; + reference = Some(Page::from_document( + &onestore::document::Document::parse(&index)?, + title + .to_str() + .ok_or("The page title must be valid Unicode.")?, + )?); + } else { + positional.push(arg); + } + } + if editable && !positional.is_empty() { + return Err( + "Use --page with a section file and page title, without a text file or width.".into(), + ); + } + if positional.len() > 2 { + return Err( + "Usage: snowbound [TEXT_FILE] [WIDTH_POINTS] [--reference SECTION PAGE_TITLE | --page SECTION PAGE_TITLE] [--substitute-font FONT_FILE]..." + .into(), + ); + } + let text = positional + .first() + .map(std::fs::read_to_string) + .transpose()? + .unwrap_or_default(); + let width = positional + .get(1) + .map(|value| value.to_string_lossy().parse::()) + .transpose()? + .unwrap_or(if reference.is_some() { + DEFAULT_OUTLINE_WIDTH + } else { + 480.0 + }); + let input = if editable { + Input::Page(reference.unwrap()) + } else { + Input::Notes { + document: TextDocument::new( + text.split('\n') + .map(|line| Paragraph::new(line.to_owned(), Format::default())) + .collect(), + )?, + width, + reference, + } + }; + let event_loop = macos::event_loop()?; + let mut app = App { + proxy: event_loop.create_proxy(), + input: Some(input), + substitutes, + state: None, + startup_error: None, + }; + event_loop.run_app(&mut app)?; + app.startup_error.map_or(Ok(()), Err) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn date_buttons_keep_accessibility_identity_and_match_mouse_hits_after_reflow() { + let mut engine = TextEngine::default(); + let mut fields = [vec!["Header"], vec!["Monday", "6:14 AM"]].map(|text| { + TextOutline::new( + &mut engine, + TextDocument::new( + text.into_iter() + .map(|text| Paragraph::new(text.into(), Default::default())) + .collect(), + ) + .unwrap(), + 468.0, + [0.0; 2], + ) + .unwrap() + .snapshot() + }); + fields[0].title = true; + let page = Page { + title: "Header".into(), + created: Some(1), + margin_origin: [36.0, 14.4], + definitions: Default::default(), + objects: vec![canvas::page::PageObject::Title(canvas::page::Title { + id: onestore::ExGuid::default(), + date: Some(fields[1].id), + layout: Default::default(), + outlines: fields.into(), + })], + }; + let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap(); + let scene = (scene, [20.0, 40.0]); + let viewport = Viewport { + size: [1000, 700], + scale: 2.0, + origin: [-30.0, -50.0], + }; + let mut access = accessibility::Accessibility::default(); + let mut identities = Vec::new(); + for phase in 0..3 { + if phase == 1 { + editor + .insert(&mut engine, &"A wrapped title ".repeat(25)) + .unwrap(); + } + if phase == 2 { + editor + .change_date(&mut engine, 2, ["Wednesday".into(), "8:40 AM".into()]) + .unwrap(); + } + let mut update = access.update(&editor, viewport, "Header", None).unwrap(); + access.append_page_fields(&mut update, Some(&scene), &editor, viewport, None); + let buttons: Vec<_> = update + .nodes + .iter() + .filter(|(_, node)| node.role() == accesskit::Role::Button) + .collect(); + assert_eq!(buttons.len(), 2); + for (index, (id, node)) in buttons.iter().enumerate() { + let field = [DateField::Date, DateField::Time][index]; + assert_eq!(access.date_for_node(*id), Some(field)); + assert!(node.supports_action(accesskit::Action::Click)); + let rect = node.bounds().unwrap(); + let point = viewport.document_point([ + ((rect.x0 + rect.x1) * 0.5) as f32, + ((rect.y0 + rect.y1) * 0.5) as f32, + ]); + assert_eq!( + page_hit_test(&editor, Some(&scene), point, 0.5), + Some(Hit::Date(field)) + ); + if phase == 0 { + identities.push(*id); + } else { + assert_eq!(*id, identities[index]); + } + } + if phase == 2 { + assert_eq!(buttons[1].1.value(), Some("8:40 AM")); + } + accesskit_consumer::Tree::new(update, true); + } + } + + #[test] + fn extension_hits_yield_to_objects_and_keep_their_source_coordinate_frame() { + let mut engine = TextEngine::default(); + let upper = TextOutline::new( + &mut engine, + TextDocument::new(vec![Paragraph::new("Upper".into(), Default::default())]).unwrap(), + 240.0, + [36.0, 90.0], + ) + .unwrap(); + let bottom = upper.bounds().y1 as f32; + let id = upper.id; + let lower = TextOutline::new( + &mut engine, + TextDocument::new(vec![Paragraph::new("Lower".into(), Default::default())]).unwrap(), + 240.0, + [36.0, bottom + 20.0], + ) + .unwrap(); + let lower_id = lower.id; + let source = upper.snapshot(); + let mut editor = + CanvasEditor::from_text_outlines(vec![lower, upper], Default::default(), None).unwrap(); + assert!( + matches!(page_hit_test(&editor, None, [60.0, bottom + 22.0], 0.75), Some(Hit::Text { id, .. }) if id == lower_id) + ); + editor.move_outline(lower_id, [600.0, 500.0]).unwrap(); + for pixel in [0.375, 0.75, 1.5] { + let hit = page_hit_test(&editor, None, [60.0, bottom + 26.0], pixel).unwrap(); + assert!( + matches!(hit, Hit::Text { id: hit_id, point } if hit_id == id && (point[1] - (bottom + 26.0 - 90.0)).abs() < 0.001) + ); + assert_eq!( + page_hit_test(&editor, None, [60.0, bottom + 30.0], pixel), + None + ); + } + let page = canvas::page::Page { + title: String::new(), + created: None, + margin_origin: [0.0; 2], + definitions: Default::default(), + objects: vec![canvas::page::PageObject::Outline(source)], + }; + let (scene, editor) = PageScene::from_page(page, &mut engine).unwrap(); + let offset = [50.0, 300.0]; + assert!( + 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) + ); + } + + #[test] + fn title_chrome_selects_text_and_exposes_a_named_editable_field() { + let mut engine = TextEngine::default(); + let mut source = TextOutline::new( + &mut engine, + TextDocument::new(vec![Paragraph::new( + "Header".into(), + Format { + font_size: Some(8.0), + line_spacing: Some(20.751953), + ..Default::default() + }, + )]) + .unwrap(), + 468.0, + [36.0, 14.4], + ) + .unwrap() + .snapshot(); + source.title = true; + source.min_width = Some(162.0); + source.layout.max_width = None; + source.layout.width_set_by_user = None; + source.layout.max_height = Some(21.6); + let outline = TextOutline::from_outline(&mut engine, &source, &Default::default()).unwrap(); + let editor = + CanvasEditor::from_text_outlines(vec![outline], Default::default(), None).unwrap(); + assert_eq!(editor.active_outline().bounds().width(), 162.0); + assert_eq!(editor.active_outline().snapshot().min_width, Some(162.0)); + let (native_rect, _) = outline_chrome(editor.active_outline(), 0.75); + for (axis, (actual, expected)) in native_rect + .iter() + .zip([85.0, 98.0, 315.0, 134.0]) + .enumerate() + { + let origin = if axis % 2 == 0 { 48.0 } else { 83.0 }; + assert!((actual / 0.75 + origin - expected).abs() < 1.5); + } + let id = editor.active_outline().id; + let (rect, _) = outline_chrome(editor.active_outline(), 1.0); + for point in [ + [rect[0] + 1.0, rect[1] + 1.0], + [rect[2] - 1.0, rect[1] + 1.0], + [40.0, 20.0], + ] { + assert!( + matches!(page_hit_test(&editor, None, point, 1.0), Some(Hit::Text { id: hit, .. }) if hit == id) + ); + } + let primitives = page_primitives(&editor, None, None, None, false, 1.0, 1.0).unwrap(); + let borders = primitives + .iter() + .filter_map(|p| match p { + Primitive::RoundedRect { stroke, .. } => Some(*stroke), + _ => None, + }) + .collect::>(); + assert_eq!(borders, [Some(Stroke::Dashed(1.0))]); + 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]))); + let mut access = accessibility::Accessibility::default(); + let update = access + .update( + &editor, + Viewport { + size: [800, 600], + scale: 1.0, + origin: [0.0; 2], + }, + "Header", + None, + ) + .unwrap(); + assert!(update.nodes.iter().any(|(_, node)| node.role() + == accesskit::Role::MultilineTextInput + && node.label() == Some("Page title"))); + } + + #[test] + fn provisional_lines_share_one_drawn_hit_tested_and_accessible_outline() { + let mut engine = TextEngine::default(); + let mut editor = CanvasEditor::new( + &mut engine, + TextDocument::new(vec![Paragraph::new("body".into(), Format::default())]).unwrap(), + 240.0, + ) + .unwrap(); + let id = editor.active_outline().id; + for _ in 0..2 { + editor + .move_selection(&mut engine, Movement::Down, false) + .unwrap(); + } + let primitives = page_primitives(&editor, None, None, None, true, 1.0, 1.0).unwrap(); + assert_eq!( + primitives + .iter() + .filter(|p| matches!(p, Primitive::Text { .. })) + .count(), + 3 + ); + let caret = editor.caret(1.0).unwrap(); + assert!( + 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) + ); + let mut access = accessibility::Accessibility::default(); + let update = access + .update( + &editor, + Viewport { + size: [800, 600], + scale: 1.0, + origin: [0.0; 2], + }, + "Test", + None, + ) + .unwrap(); + let fields = update + .nodes + .iter() + .filter(|(_, node)| node.role() == accesskit::Role::MultilineTextInput) + .collect::>(); + assert_eq!(fields.len(), 1); + assert_eq!(fields[0].1.value(), Some("body\n\n")); + assert_eq!(editor.outlines()[0].document().nodes().len(), 1); + editor + .place_caret(&mut engine, [300.0, 200.0], 240.0) + .unwrap(); + assert_eq!( + editor + .visible_outlines() + .next() + .unwrap() + .document() + .nodes() + .len(), + 1 + ); + } + + #[test] + fn chrome_follows_focus_hover_and_drag_without_changing_hit_geometry() { + let mut engine = TextEngine::default(); + let outlines = [[0.0, 0.0], [220.0, 0.0]] + .into_iter() + .map(|origin| { + TextOutline::new( + &mut engine, + TextDocument::new(vec![Paragraph::new("text".into(), Format::default())]) + .unwrap(), + 100.0, + origin, + ) + .unwrap() + }) + .collect(); + let editor = CanvasEditor::from_text_outlines(outlines, Default::default(), None).unwrap(); + let second = editor.outlines()[1].id; + let borders = |feedback| { + page_primitives(&editor, None, feedback, None, false, 1.0, 1.0) + .unwrap() + .into_iter() + .filter_map(|p| match p { + Primitive::RoundedRect { + rect, + stroke: Some(Stroke::Solid(1.0)), + .. + } => Some(rect), + _ => None, + }) + .collect::>() + }; + assert_eq!(borders(None).len(), 1); + let hovered = borders(Some(PointerFeedback::Hover(second))); + assert_eq!(hovered.len(), 2); + let moved = borders(Some(PointerFeedback::Move(second, [240.0, 50.0]))); + assert_eq!(moved[1][0] - hovered[1][0], 20.0); + assert_eq!(moved[1][1] - hovered[1][1], 50.0); + assert!( + matches!(page_hit_test(&editor, None, [225.0, 5.0], 1.0), Some(Hit::Text { id, .. }) if id == second) + ); + } + + #[test] + fn grouped_drag_keeps_the_initial_word_when_reversing_direction() { + let selection = |start, end| { + Selection::from([start, end].map(|offset| canvas::document::TextPosition { + paragraph: 0, + offset, + })) + }; + let anchor = selection(6, 10); + assert_eq!( + drag_selection(anchor, selection(11, 16), SelectionUnit::Word).positions, + selection(6, 16).positions + ); + assert_eq!( + drag_selection(anchor, selection(0, 5), SelectionUnit::Word).positions, + selection(10, 0).positions + ); + assert_eq!( + drag_selection(anchor, anchor, SelectionUnit::Word).positions, + anchor.positions + ); + let anchor = selection(10, 3); + assert_eq!( + drag_selection(anchor, selection(7, 7), SelectionUnit::Grapheme).positions, + selection(10, 7).positions + ); + } + + #[test] + fn native_grid_matches_drag_offsets_midpoints_and_zoom() { + for (pixels, points) in [ + (5.0, 0.0), + (6.0, 0.0), + (7.0, 0.0), + (8.0, 0.0), + (11.0, 0.0), + (12.0, 9.0), + (13.0, 18.0), + (17.0, 18.0), + (18.0, 18.0), + (19.0, 18.0), + (25.0, 18.0), + (37.0, 36.0), + (-6.0, 0.0), + (-7.0, 0.0), + (-12.0, -9.0), + (-13.0, -18.0), + (-19.0, -18.0), + ] { + let origin = [486.0, 230.40001]; + let proposed = origin.map(|v| v + pixels * 72.0 / 96.0); + let result = snap_to_grid(proposed); + for axis in 0..2 { + assert!((result[axis] - origin[axis] - points).abs() < 0.00004); + } + } + for pixels in [6.0, 7.0, 12.0, 13.0, 19.0] { + let result = snap_to_grid([491.25 + pixels * 0.75, 235.65 + pixels * 0.75]); + assert!((result[0] - 504.0).abs() < 0.00004); + assert!((result[1] - 248.4).abs() < 0.00004); + } + for (scale, delta, expected) in [ + (96.0 / 72.0, [27.0, 31.0], [54.0, 104.4]), + (96.0 / 72.0, [13.0, 17.0], [54.0, 104.4]), + (192.0 / 72.0, [13.0, 17.0], [36.0, 104.4]), + ] { + for dpr in [1.0, 2.0] { + let origin = [36.0, 90.0]; + let proposed = + std::array::from_fn(|axis| origin[axis] + delta[axis] * dpr / (scale * dpr)); + let result = snap_to_grid(proposed); + for axis in 0..2 { + assert!((result[axis] - expected[axis]).abs() < 0.00004); + } + } + } + for point in [[9.0, 5.4], [-9.0, -12.6], [423.0, 239.4]] { + assert_eq!(snap_to_grid(point), point); + } + } + + #[test] + fn native_fresh_click_grid_uses_screen_offset_at_both_zoom_levels() { + for (x, y, scale, expected) in [ + (900.0, 500.0, 96.0 / 72.0, [639.0, 302.4]), + (901.0, 500.0, 96.0 / 72.0, [648.0, 302.4]), + (906.0, 500.0, 96.0 / 72.0, [648.0, 302.4]), + (913.0, 500.0, 96.0 / 72.0, [648.0, 302.4]), + (900.0, 501.0, 96.0 / 72.0, [639.0, 302.4]), + (900.0, 506.0, 96.0 / 72.0, [639.0, 320.4]), + (900.0, 520.0, 96.0 / 72.0, [639.0, 320.4]), + (900.0, 500.0, 192.0 / 72.0, [324.0, 158.4]), + ] { + for dpr in [1.0, 2.0] { + let viewport = Viewport { + size: [(1600.0 * dpr) as u32, (900.0 * dpr) as u32], + origin: [48.0 * dpr, 83.0 * dpr], + scale: scale * dpr, + }; + let point = viewport.document_point([x * dpr, y * dpr]); + let result = snap_to_grid([point[0], point[1] - 7.0 * dpr / viewport.scale]); + for axis in 0..2 { + assert!((result[axis] - expected[axis]).abs() < 0.00004); + } + } + } + } + + #[test] + fn blank_caret_and_composition_have_accessible_text_without_outline_chrome() { + let mut engine = TextEngine::default(); + let mut editor = CanvasEditor::new( + &mut engine, + TextDocument::new(vec![Paragraph::new(String::new(), Default::default())]).unwrap(), + 240.0, + ) + .unwrap(); + editor + .place_caret(&mut engine, [120.0, 100.0], 240.0) + .unwrap(); + let mut access = accessibility::Accessibility::default(); + let viewport = Viewport { + size: [1000, 720], + scale: 96.0 / 72.0, + origin: [48.0; 2], + }; + let initial = access.update(&editor, viewport, "Page", None).unwrap(); + let id = editor.active_outline().id; + assert_eq!(access.outline_for_node(initial.focus), Some(id)); + let rectangles = |editor: &CanvasEditor| { + page_primitives(editor, None, None, None, true, viewport.scale, 1.0) + .unwrap() + .into_iter() + .filter_map(|p| match p { + Primitive::Rect { rect, color } + | Primitive::RoundedRect { rect, color, .. } => Some((rect, color)), + _ => None, + }) + .collect::>() + }; + assert_eq!(rectangles(&editor).len(), 1); + assert_eq!( + page_hit_test(&editor, None, [120.0, 100.0], 1.0 / viewport.scale), + None + ); + editor.compose(&mut engine, "に".into(), 1..1).unwrap(); + assert!(editor.outlines().is_empty()); + assert!( + rectangles(&editor) + .iter() + .all(|(_, color)| *color == [0.0, 0.0, 0.0, 1.0] + || *color == macos::text_colors()[0]) + ); + let preedit = access.update(&editor, viewport, "Page", None).unwrap(); + assert_eq!(preedit.focus, initial.focus); + editor.commit_text(&mut engine, "日本".into()).unwrap(); + let committed = access.update(&editor, viewport, "Page", None).unwrap(); + assert_eq!(committed.focus, initial.focus); + assert!(rectangles(&editor).len() > 1); + editor.undo(&mut engine).unwrap(); + assert!(editor.outlines().is_empty()); + assert_eq!(rectangles(&editor).len(), 1); + let empty = access.update(&editor, viewport, "Page", None).unwrap(); + assert_eq!(empty.focus, committed.focus); + assert_eq!(access.outline_for_node(committed.focus), Some(id)); + editor.redo(&mut engine).unwrap(); + editor.select_all().unwrap(); + editor.delete(&mut engine, true).unwrap(); + assert!(editor.outlines().is_empty()); + assert_eq!(rectangles(&editor).len(), 1); + let retired = access.update(&editor, viewport, "Page", None).unwrap(); + assert_eq!(retired.focus, committed.focus); + assert_eq!(access.outline_for_node(retired.focus), Some(id)); + editor.undo(&mut engine).unwrap(); + assert!(rectangles(&editor).len() > 1); + let restored = access.update(&editor, viewport, "Page", None).unwrap(); + assert_eq!(restored.focus, committed.focus); + editor.redo(&mut engine).unwrap(); + assert_eq!(rectangles(&editor).len(), 1); + assert!(editor.caret_outline().unwrap().is_empty()); + editor.insert(&mut engine, " ").unwrap(); + assert!(editor.caret_outline().unwrap().is_empty()); + editor.compose(&mut engine, "に".into(), 1..1).unwrap(); + assert!(!editor.caret_outline().unwrap().is_empty()); + } + + #[test] + fn table_glyphs_highlights_and_selection_share_cell_paint_bounds() { + let mut engine = TextEngine::default(); + let mut editor = CanvasEditor::new( + &mut engine, + TextDocument::new(vec![Paragraph::new( + "M".into(), + Format { + font_size: Some(130.0), + highlight: Some(0xffff), + ..Default::default() + }, + )]) + .unwrap(), + 180.0, + ) + .unwrap(); + editor + .move_selection(&mut engine, Movement::LineEnd, false) + .unwrap(); + editor.tab(&mut engine, false).unwrap(); + editor.insert(&mut engine, "R").unwrap(); + editor.tab(&mut engine, true).unwrap(); + let mut primitives = Vec::new(); + append_outline( + Some(&editor), + editor.active_outline(), + [24.0, 48.0], + false, + 1.0, + 1.0, + &mut primitives, + ) + .unwrap(); + let bounds = editor.active_outline().shaped().tables[0] + .cells + .iter() + .map(|cell| { + let [left, top, right, bottom] = cell.text_bounds(); + [left + 24.0, top + 48.0, right + 24.0, bottom + 48.0] + }) + .collect::>(); + let text = primitives + .iter() + .filter_map(|primitive| match primitive { + Primitive::Text { clip, .. } => Some(clip.unwrap()), + _ => None, + }) + .collect::>(); + assert_eq!(text, bounds); + let highlights = primitives + .iter() + .filter_map(|primitive| match primitive { + Primitive::Rect { rect, color } if *color == canvas::gpu::colorref(0xffff) => { + Some(*rect) + } + _ => None, + }) + .collect::>(); + assert_eq!(highlights.len(), 2); + for (rect, bounds) in highlights.iter().zip(&bounds) { + assert_eq!(rect[2], bounds[2]); + assert!(rect[0] >= bounds[0] && rect[1] >= bounds[1] && rect[3] <= bounds[3]); + } + let selection = primitives + .iter() + .find_map(|primitive| match primitive { + Primitive::Rect { rect, color } if *color == macos::text_colors()[1] => Some(*rect), + _ => None, + }) + .unwrap(); + assert_eq!(selection[2], bounds[0][2]); + assert!(editor.caret(1.0).unwrap().x0 + 24.0 > f64::from(bounds[0][2])); + } + + #[test] + fn editable_tables_paint_borders_before_selection_and_cell_text() { + let mut engine = TextEngine::default(); + let mut editor = CanvasEditor::new( + &mut engine, + TextDocument::new(vec![Paragraph::new("Left".into(), Format::default())]).unwrap(), + 180.0, + ) + .unwrap(); + editor + .move_selection(&mut engine, Movement::LineEnd, false) + .unwrap(); + editor.tab(&mut engine, false).unwrap(); + editor.insert(&mut engine, "Right").unwrap(); + editor.tab(&mut engine, true).unwrap(); + let mut primitives = Vec::new(); + append_outline( + Some(&editor), + editor.active_outline(), + [24.0, 48.0], + false, + 1.0, + 1.0, + &mut primitives, + ) + .unwrap(); + let color = canvas::gpu::colorref(0x00a3a3a3); + assert!(matches!(primitives[0], Primitive::RoundedRect { + stroke: Some(canvas::gpu::Stroke::Solid(0.75)), color: actual, .. + } if actual == color)); + assert!(matches!(primitives[1], Primitive::Rect { color: actual, .. } if actual == color)); + assert!( + matches!(primitives[2], Primitive::Rect { color, .. } if color == macos::text_colors()[1]) + ); + let painted = primitives + .iter() + .filter_map(|primitive| match primitive { + Primitive::Text { layout, origin, .. } => Some((layout.id(), *origin)), + _ => None, + }) + .collect::>(); + let expected = editor + .active_outline() + .layouts() + .map(|(_, paragraph)| { + ( + paragraph.text.id(), + [paragraph.origin[0] + 24.0, paragraph.origin[1] + 48.0], + ) + }) + .collect::>(); + assert_eq!(painted, expected); + assert_eq!(painted.len(), 2); + } + + #[test] + fn resize_preview_paints_reflowed_text_without_changing_the_editor() { + let mut engine = TextEngine::default(); + let editor = CanvasEditor::new( + &mut engine, + TextDocument::new(vec![Paragraph::new( + "A paragraph with enough words to wrap when the resize handle moves inward.".into(), + Format::default(), + )]) + .unwrap(), + 240.0, + ) + .unwrap(); + let original = editor.active_outline().snapshot(); + let original_layout = editor + .active_outline() + .paragraph_layout(0) + .unwrap() + .text + .id(); + let resized = editor.preview_resize(&mut engine, 72.0).unwrap(); + let primitives = page_primitives( + &editor, + None, + Some(PointerFeedback::Resize(&resized)), + None, + false, + 96.0 / 72.0, + 1.0, + ) + .unwrap(); + let painted = primitives + .iter() + .find_map(|primitive| match primitive { + Primitive::Text { layout, .. } => Some(*layout), + _ => None, + }) + .unwrap(); + assert_eq!(painted.id(), resized.paragraph_layout(0).unwrap().text.id()); + assert!( + painted.lines().count() + > editor + .active_outline() + .paragraph_layout(0) + .unwrap() + .text + .lines() + .count() + ); + assert_eq!(editor.active_outline().layout(), &original.layout); + assert_eq!( + editor + .active_outline() + .paragraph_layout(0) + .unwrap() + .text + .id(), + original_layout + ); + assert!(primitives.iter().any( + |primitive| matches!(primitive, Primitive::RoundedRect { stroke, .. } if stroke.is_some()) + )); + } + + #[test] + fn native_chrome_geometry_preserves_text_and_matches_hit_regions_across_zoom_and_dpi() { + let mut engine = TextEngine::default(); + let format = Format { + font: Some("Arial".into()), + font_size: Some(11.0), + ..Default::default() + }; + let mut editor = CanvasEditor::new(&mut engine, TextDocument::new(vec![ + Paragraph::new("HAMBURGEFONTS abcdefghijklmnopqrstuvwxyz 0123456789 HAMBURGEFONTS abcdefghijklmnopqrstuvwxyz 0123456789 HAMBURGEFONTS abcdefghijklmnopqrstuvwxyz 0123456789".into(), format.clone()), + Paragraph::new("Second paragraph abcdefghijklmnopqrstuvwxyz.".into(), format), + ]).unwrap(), 220.0).unwrap(); + let id = editor.active_outline().id; + editor.move_outline(id, [36.0, 90.0]).unwrap(); + let document = editor.active_outline().document().clone(); + let layout_ids: Vec<_> = editor + .active_outline() + .layouts() + .map(|(_, p)| p.text.id()) + .collect(); + for (zoom, expected) in [ + (0.5, [62.0, 134.0, 223.0, 219.0]), + (1.0, [81.0, 189.0, 394.0, 348.0]), + (1.5, [101.0, 245.0, 565.0, 479.0]), + (2.0, [120.0, 300.0, 736.0, 608.0]), + ] { + for dpr in [1.0, 2.0] { + let scale = zoom * (96.0 / 72.0) * dpr; + let pixel = dpr / scale; + let (frame, body_top) = outline_chrome(editor.active_outline(), pixel); + for (index, coordinate) in frame.iter().enumerate() { + let origin = if index % 2 == 0 { 48.0 } else { 83.0 }; + let screen = coordinate * scale / dpr + origin; + assert!( + (screen - expected[index]).abs() <= 1.5, + "zoom={zoom} dpr={dpr} edge={index}: {screen} vs {}", + expected[index] + ); + } + let header = [36.0, (frame[1] + body_top) / 2.0]; + assert!( + matches!(page_hit_test(&editor, None, header, pixel), Some(Hit::Handle {id: hit, ..}) if hit == id) + ); + assert!(matches!( + page_hit_test(&editor, None, [frame[2] - 4.0 * pixel, header[1]], pixel), + Some(Hit::Resize { id: hit, .. }) if hit == id + )); + let padding = [frame[0] + pixel, 90.0]; + assert!( + matches!(page_hit_test(&editor, None, padding, pixel), Some(Hit::Text {id: hit, ..}) if hit == id) + ); + assert_eq!( + page_hit_test(&editor, None, [frame[0] - pixel, 90.0], pixel), + None + ); + let primitives = + page_primitives(&editor, None, None, None, false, scale, dpr).unwrap(); + assert!( + matches!(&primitives[0], Primitive::RoundedRect {rect, ..} if *rect == [frame[0], frame[1], frame[2], body_top]) + ); + assert!( + primitives.iter().any( + |p| matches!(p, Primitive::Text {origin, ..} if *origin == [36.0, 90.0]) + ) + ); + } + } + assert_eq!(editor.active_outline().document(), &document); + assert_eq!( + editor + .active_outline() + .layouts() + .map(|(_, p)| p.text.id()) + .collect::>(), + layout_ids + ); + } + + #[test] + fn a_later_text_body_takes_priority_over_an_earlier_drag_handle() { + let mut engine = TextEngine::default(); + let mut editor = CanvasEditor::new( + &mut engine, + TextDocument::new(vec![Paragraph::new("first".into(), Default::default())]).unwrap(), + 240.0, + ) + .unwrap(); + let first = editor.active_outline().id; + editor.move_outline(first, [0.0, 20.0]).unwrap(); + assert_eq!( + page_hit_test(&editor, None, [5.0, 12.0], 1.0), + Some(Hit::Handle { + id: first, + grab: [5.0, -8.0] + }) + ); + let second = editor + .create_outline(&mut engine, [0.0, 10.0], 240.0) + .unwrap(); + assert_eq!( + page_hit_test(&editor, None, [5.0, 12.0], 1.0), + Some(Hit::Text { + id: second, + point: [5.0, 2.0] + }) + ); + editor.undo(&mut engine).unwrap(); + assert_eq!( + page_hit_test(&editor, None, [5.0, 12.0], 1.0), + Some(Hit::Handle { + id: first, + grab: [5.0, -8.0] + }) + ); + } + + #[test] + fn overlapping_objects_follow_paint_order_through_creation_movement_and_undo() { + use canvas::page::{Outline, PageObject, Unsupported}; + for readonly_on_top in [false, true] { + let mut engine = TextEngine::default(); + let document = TextDocument::new(vec![Paragraph::new( + "Imported text".into(), + Default::default(), + )]) + .unwrap(); + let id = onestore::ExGuid { + guid: [5; 16], + n: 1, + }; + let mut objects = vec![ + PageObject::Outline(Outline { + id, + title: false, + min_width: None, + layout: onestore::document::Layout { + x: Some(30.0), + y: Some(40.0), + max_width: Some(180.0), + ..Default::default() + }, + indents: vec![18.0, 0.0], + paragraphs: document.nodes().to_vec(), + unsupported: Vec::new(), + }), + PageObject::Unsupported(Unsupported { + id: Default::default(), + jcid: 0xdead, + layout: onestore::document::Layout { + x: Some(20.0), + y: Some(20.0), + max_width: Some(200.0), + max_height: Some(100.0), + ..Default::default() + }, + }), + ]; + if !readonly_on_top { + objects.reverse(); + } + let page = Page { + created: None, + title: String::new(), + margin_origin: [0.0; 2], + definitions: Default::default(), + objects, + }; + let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap(); + let scene = (scene, [0.0; 2]); + let imported_layout = editor + .active_outline() + .layouts() + .next() + .unwrap() + .1 + .text + .id(); + let texts: Vec<_> = page_primitives(&editor, Some(&scene), None, None, false, 1.0, 1.0) + .unwrap() + .into_iter() + .filter_map(|p| match p { + Primitive::Text { layout, .. } => Some(layout.id()), + _ => None, + }) + .collect(); + assert_eq!(texts.len(), 2); + assert_eq!(texts[usize::from(!readonly_on_top)], imported_layout); + let expected = if readonly_on_top { + Hit::ReadOnly(0) + } else { + Hit::Text { + id, + point: [10.0, 5.0], + } + }; + assert_eq!( + page_hit_test(&editor, Some(&scene), [40.0, 45.0], 1.0), + Some(expected) + ); + let header = page_hit_test(&editor, Some(&scene), [40.0, 32.0], 1.0); + if readonly_on_top { + assert_eq!(header, Some(Hit::ReadOnly(0))); + } else { + assert_eq!( + header, + Some(Hit::Handle { + id, + grab: [10.0, -8.0] + }) + ); + } + let annotation = editor + .create_outline(&mut engine, [30.0, 40.0], 180.0) + .unwrap(); + assert_eq!( + page_hit_test(&editor, Some(&scene), [40.0, 45.0], 1.0), + Some(Hit::Text { + id: annotation, + point: [10.0, 5.0] + }) + ); + assert_eq!( + page_hit_test(&editor, Some(&scene), [40.0, 32.0], 1.0), + Some(Hit::Handle { + id: annotation, + grab: [10.0, -8.0] + }) + ); + let texts: Vec<_> = page_primitives(&editor, Some(&scene), None, None, false, 1.0, 1.0) + .unwrap() + .into_iter() + .filter_map(|p| match p { + Primitive::Text { layout, .. } => Some(layout.id()), + _ => None, + }) + .collect(); + assert_eq!( + *texts.last().unwrap(), + editor + .active_outline() + .layouts() + .next() + .unwrap() + .1 + .text + .id() + ); + editor.move_outline(annotation, [500.0, 500.0]).unwrap(); + assert!( + !matches!(page_hit_test(&editor, Some(&scene), [40.0,45.0], 1.0), Some(Hit::Text { id, .. }) if id == annotation) + ); + editor.undo(&mut engine).unwrap(); + assert_eq!( + page_hit_test(&editor, Some(&scene), [40.0, 45.0], 1.0), + Some(Hit::Text { + id: annotation, + point: [10.0, 5.0] + }) + ); + editor.undo(&mut engine).unwrap(); + assert_eq!(editor.outlines().len(), 1); + assert_eq!(editor.active_outline().document(), &document); + assert_eq!( + page_hit_test(&editor, Some(&scene), [10.0, 10.0], 1.0), + None + ); + } + } + + #[test] + fn read_only_focus_accepts_navigation_without_edit_shortcuts() { + for modifiers in [ + ModifiersState::empty(), + ModifiersState::SHIFT, + ModifiersState::ALT, + ModifiersState::SUPER, + ModifiersState::SUPER | ModifiersState::SHIFT, + ModifiersState::SUPER | ModifiersState::ALT, + ] { + for key in [ + Key::Character("a".into()), + Key::Character("v".into()), + Key::Character("c".into()), + Key::Character("x".into()), + Key::Character("z".into()), + Key::Character("🌳".into()), + Key::Named(NamedKey::Backspace), + Key::Named(NamedKey::Delete), + Key::Named(NamedKey::Enter), + Key::Named(NamedKey::ArrowLeft), + Key::Named(NamedKey::ArrowRight), + Key::Named(NamedKey::ArrowUp), + Key::Named(NamedKey::ArrowDown), + ] { + assert!( + !read_only_shortcut(&key, modifiers), + "{key:?} {modifiers:?}" + ); + } + } + for (key, modifiers) in [ + (Key::Named(NamedKey::Escape), ModifiersState::empty()), + (Key::Named(NamedKey::Tab), ModifiersState::CONTROL), + ( + Key::Named(NamedKey::Tab), + ModifiersState::CONTROL | ModifiersState::SHIFT, + ), + ( + Key::Character("N".into()), + ModifiersState::SUPER | ModifiersState::SHIFT, + ), + (Key::Character("+".into()), ModifiersState::SUPER), + (Key::Character("0".into()), ModifiersState::SUPER), + (Key::Character("-".into()), ModifiersState::SUPER), + ] { + assert!(read_only_shortcut(&key, modifiers)); + } + assert!(!read_only_shortcut( + &Key::Character("n".into()), + ModifiersState::SUPER + )); + assert!(!read_only_shortcut( + &Key::Named(NamedKey::Tab), + ModifiersState::empty() + )); + } + + #[test] + fn read_only_focus_retires_text_overlays_and_draws_a_scaled_focus_border() { + use canvas::page::{PageObject, Unsupported}; + let mut engine = TextEngine::default(); + let mut editor = CanvasEditor::new( + &mut engine, + TextDocument::new(vec![Paragraph::new("Draft".into(), Default::default())]).unwrap(), + 240.0, + ) + .unwrap(); + let page = Page { + created: None, + title: String::new(), + margin_origin: [0.0; 2], + definitions: Default::default(), + objects: vec![PageObject::Unsupported(Unsupported { + id: Default::default(), + jcid: 0xdead, + layout: onestore::document::Layout { + x: Some(100.0), + y: Some(80.0), + max_width: Some(180.0), + max_height: Some(80.0), + ..Default::default() + }, + })], + }; + let scene = (PageScene::new(page, &mut engine).unwrap(), [20.0, 30.0]); + let original = editor.active_outline().document().clone(); + let rectangles = |primitives: Vec>| { + primitives + .into_iter() + .filter_map(|p| match p { + Primitive::Rect { rect, color } + | Primitive::RoundedRect { rect, color, .. } => Some((rect, color)), + _ => None, + }) + .collect::>() + }; + for scale in [1.0, 2.0] { + let text = rectangles( + page_primitives(&editor, Some(&scene), None, None, true, scale, 1.0).unwrap(), + ); + assert!( + text.iter() + .any(|(_, color)| *color == macos::text_colors()[0]) + ); + let selected = rectangles( + page_primitives(&editor, Some(&scene), None, Some(0), true, scale, 1.0).unwrap(), + ); + assert!( + !selected + .iter() + .any(|(_, color)| *color == macos::text_colors()[0]) + ); + assert_eq!( + selected, + rectangles( + page_primitives(&editor, Some(&scene), None, Some(0), false, scale, 1.0) + .unwrap() + ) + ); + assert_eq!( + selected.last().unwrap().0, + [300.0 - 2.0 / scale, 110.0, 300.0, 190.0] + ); + } + editor.select_all().unwrap(); + let text = rectangles( + page_primitives(&editor, Some(&scene), None, None, false, 1.0, 1.0).unwrap(), + ); + assert!( + text.iter() + .any(|(_, color)| *color == macos::text_colors()[1]) + ); + let selected = rectangles( + page_primitives(&editor, Some(&scene), None, Some(0), true, 1.0, 1.0).unwrap(), + ); + assert!( + !selected + .iter() + .any(|(_, color)| *color == macos::text_colors()[1]) + ); + assert_eq!(editor.active_outline().document(), &original); + } + + #[test] + #[ignore = "requires CANVAS_TEST_SECTION and CANVAS_TEST_PAGE private fixture inputs"] + fn imported_page_widgets_and_accessibility_follow_edits() { + let bytes = std::fs::read(std::env::var_os("CANVAS_TEST_SECTION").unwrap()).unwrap(); + let page = { + let store = onestore::Store::parse(&bytes).unwrap(); + let index = onestore::RevisionIndex::parse(&store).unwrap(); + Page::from_document( + &onestore::document::Document::parse(&index).unwrap(), + &std::env::var("CANVAS_TEST_PAGE").unwrap(), + ) + .unwrap() + }; + drop(bytes); + let mut engine = TextEngine::default(); + if let Some(font) = std::env::var_os("CANVAS_TEST_SUBSTITUTE") { + engine + .register_substitute(parley::fontique::Blob::new(Arc::new( + std::fs::read(font).unwrap(), + ))) + .unwrap(); + } + let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap(); + let scene = (scene, [0.0; 2]); + let viewport = Viewport { + size: [1000, 720], + scale: 96.0 / 72.0, + origin: [48.0; 2], + }; + let mut tag_hits = 0; + for outline in editor.outlines() { + for (_, paragraph) in outline.layouts() { + for tag in ¶graph.tags { + let point = [ + outline.origin()[0] + + tag.origin[0] + + canvas::outline::ParagraphTag::SIZE / 2.0, + outline.origin()[1] + + paragraph.origin[1] + + tag.origin[1] + + canvas::outline::ParagraphTag::SIZE / 2.0, + ]; + assert!( + matches!(page_hit_test(&editor, Some(&scene), point, 1.0), Some(Hit::Text { id, .. }) if id == outline.id), + "Tag center {point:?} did not target outline {:?}", + outline.id + ); + tag_hits += 1; + } + } + } + eprintln!("Verified {tag_hits} imported tag-gutter targets"); + let mut access = accessibility::Accessibility::default(); + let tags = |editor: &CanvasEditor| { + page_primitives(editor, Some(&scene), None, None, true, viewport.scale, 1.0) + .unwrap() + .into_iter() + .filter_map(|primitive| match primitive { + Primitive::Tag { tag, origin } => Some((tag.clone(), origin)), + _ => None, + }) + .collect::>() + }; + let original_tags = tags(&editor); + let ids: Vec<_> = editor.outlines().iter().map(|outline| outline.id).collect(); + for id in ids { + editor.focus_outline(id).unwrap(); + let original = editor.active_outline().document().clone(); + editor + .select( + [canvas::document::TextPosition { + paragraph: 0, + offset: 0, + }; 2] + .into(), + ) + .unwrap(); + let before = access.update(&editor, viewport, "Test", None).unwrap(); + let before = accesskit_consumer::Tree::new(before, true); + let before = before.state().focus().unwrap().document_range().text(); + editor.insert(&mut engine, "CANVAS CHECK ").unwrap(); + { + let update = access.update(&editor, viewport, "Test", None).unwrap(); + let tree = accesskit_consumer::Tree::new(update, true); + assert_eq!( + tree.state().focus().unwrap().document_range().text(), + format!("CANVAS CHECK {before}") + ); + let primitives = + page_primitives(&editor, Some(&scene), None, None, true, viewport.scale, 1.0) + .unwrap(); + assert!(!primitives.is_empty()); + let edited_tags = tags(&editor); + assert_eq!(edited_tags.len(), original_tags.len()); + for ((edited, _), (original, _)) in edited_tags.iter().zip(&original_tags) { + assert_eq!(edited.icon, original.icon); + assert_eq!(edited.label, original.label); + assert_eq!(edited.disabled, original.disabled); + } + } + editor.undo(&mut engine).unwrap(); + assert_eq!(editor.active_outline().document(), &original); + assert_eq!(tags(&editor), original_tags); + let update = access.update(&editor, viewport, "Test", None).unwrap(); + let tree = accesskit_consumer::Tree::new(update, true); + assert_eq!( + tree.state().focus().unwrap().document_range().text(), + before + ); + } + eprintln!( + "Rendered and restored {} imported outline widgets and accessibility fields", + editor.outlines().len() + ); + } +} diff --git a/crates/snowbound/src/profile.rs b/crates/snowbound/src/profile.rs new file mode 100644 index 0000000000000000000000000000000000000000..ea29586944886d46bbee7421277376f7391c5ee8 --- /dev/null +++ b/crates/snowbound/src/profile.rs @@ -0,0 +1,192 @@ +use super::*; +use canvas::document::TextPosition; +use std::{hint::black_box, process::Command}; + +fn timed(case: &str, phase: &str, sample: usize, operation: impl FnOnce() -> T) -> T { + let start = Instant::now(); + let result = operation(); + eprintln!( + "canvas_profile\t{case}\t{phase}\t{sample}\t{}", + start.elapsed().as_nanos() + ); + result +} + +fn resident(case: &str, phase: &str, sample: usize) { + let output = Command::new("/bin/ps") + .args(["-o", "rss=", "-p", &std::process::id().to_string()]) + .output() + .unwrap(); + assert!(output.status.success()); + let kib: u64 = std::str::from_utf8(&output.stdout) + .unwrap() + .trim() + .parse() + .unwrap(); + eprintln!("canvas_rss_kib\t{case}\t{phase}\t{sample}\t{kib}"); +} + +fn layout_ids(editor: &CanvasEditor) -> Vec { + editor + .outlines() + .iter() + .flat_map(|outline| outline.layouts().map(|(_, paragraph)| paragraph.text.id())) + .collect() +} + +fn editing_cost( + case: &str, + engine: &mut TextEngine, + editor: &mut CanvasEditor, + scene: Option<&(PageScene, [f32; 2])>, +) { + let viewport = Viewport { + size: [1000, 720], + scale: 96.0 / 72.0, + origin: [48.0; 2], + }; + let initial: Vec<_> = timed(case, "opening_snapshot", 0, || { + editor + .outlines() + .iter() + .map(|outline| outline.document().clone()) + .collect() + }); + let mut access = accessibility::Accessibility::default(); + let update = timed(case, "ax_first", 0, || { + access.update(editor, viewport, "Profile", None).unwrap() + }); + eprintln!("canvas_count\t{case}\tax_nodes\t0\t{}", update.nodes.len()); + drop(update); + let count: usize = editor + .outlines() + .iter() + .map(|outline| outline.document().nodes().len()) + .sum(); + eprintln!("canvas_count\t{case}\tparagraphs\t0\t{count}"); + for sample in 0..32 { + let index = sample % editor.outlines().len(); + let id = editor.outlines()[index].id; + editor.focus_outline(id).unwrap(); + let paragraph = if sample % 2 == 0 { + 0 + } else { + editor.active_outline().layouts().last().unwrap().0 + }; + editor + .select( + [TextPosition { + paragraph, + offset: 0, + }; 2] + .into(), + ) + .unwrap(); + let original_layouts = layout_ids(editor); + timed(case, "insert", sample, || { + editor.insert(engine, "x").unwrap() + }); + let edited_layouts = layout_ids(editor); + let changed = original_layouts + .iter() + .zip(&edited_layouts) + .filter(|(a, b)| a != b) + .count(); + assert_eq!(changed, 1); + eprintln!("canvas_count\t{case}\tchanged_layouts\t{sample}\t{changed}"); + let primitives = timed(case, "frame_primitives", sample, || { + let primitives = + page_primitives(editor, scene, None, None, true, viewport.scale, 1.0).unwrap(); + black_box(&primitives); + primitives.len() + }); + eprintln!("canvas_count\t{case}\tprimitives\t{sample}\t{primitives}"); + timed(case, "ax_insert", sample, || { + black_box(access.update(editor, viewport, "Profile", None).unwrap()); + }); + assert_eq!(edited_layouts, layout_ids(editor)); + timed(case, "undo", sample, || { + assert!(editor.undo(engine).unwrap()) + }); + timed(case, "ax_undo", sample, || { + black_box(access.update(editor, viewport, "Profile", None).unwrap()); + }); + assert_eq!(editor.active_outline().document(), &initial[index]); + } + resident(case, "after_edits", 0); +} + +#[test] +#[ignore = "manual release canvas pipeline timing probe"] +fn canvas_pipeline_cost() { + if let Some(path) = std::env::var_os("CANVAS_TEST_SECTION") { + let bytes = timed("native", "read", 0, || std::fs::read(path).unwrap()); + let title = std::env::var("CANVAS_TEST_PAGE").unwrap(); + let mut engine = timed("native", "engine", 0, TextEngine::default); + if let Some(font) = std::env::var_os("CANVAS_TEST_SUBSTITUTE") { + timed("native", "font", 0, || { + engine + .register_substitute(parley::fontique::Blob::new(Arc::new( + std::fs::read(font).unwrap(), + ))) + .unwrap() + }); + } + let cycles = std::env::var("CANVAS_PROFILE_CYCLES") + .map(|value| value.parse::().unwrap()) + .unwrap_or(16); + assert!(cycles > 0); + for cycle in 0..cycles { + let page = timed("native", "parse", cycle, || { + let store = onestore::Store::parse(&bytes).unwrap(); + let index = onestore::RevisionIndex::parse(&store).unwrap(); + Page::from_document( + &onestore::document::Document::parse(&index).unwrap(), + &title, + ) + .unwrap() + }); + let (scene, mut editor) = timed("native", "scene", cycle, || { + PageScene::from_page(page, &mut engine).unwrap() + }); + let scene = (scene, [0.0; 2]); + if cycle == 0 { + editing_cost("native", &mut engine, &mut editor, Some(&scene)); + } else { + black_box( + page_primitives(&editor, Some(&scene), None, None, false, 96.0 / 72.0, 1.0) + .unwrap(), + ); + } + resident("native", "open", cycle); + drop(editor); + drop(scene); + resident("native", "closed", cycle); + } + } else { + for count in [100, 1_000, 5_000] { + let case = format!("synthetic_{count}"); + let mut engine = timed(&case, "engine", 0, TextEngine::default); + let document = timed(&case, "document", 0, || { + TextDocument::new( + (0..count) + .map(|_| { + Paragraph::new( + "A paragraph of plain text with several words.".into(), + Format::default(), + ) + }) + .collect(), + ) + .unwrap() + }); + let mut editor = timed(&case, "scene", 0, || { + CanvasEditor::new(&mut engine, document, 480.0).unwrap() + }); + editing_cost(&case, &mut engine, &mut editor, None); + drop(editor); + drop(engine); + resident(&case, "closed", 0); + } + } +} diff --git a/crates/snowbound/src/scroll.rs b/crates/snowbound/src/scroll.rs new file mode 100644 index 0000000000000000000000000000000000000000..f36116a9dfe8918b093b1fe8f7a5d4c41653496e --- /dev/null +++ b/crates/snowbound/src/scroll.rs @@ -0,0 +1,133 @@ +use canvas::gpu::{Primitive, Viewport}; + +pub struct Scroll { + pub min: [f32; 2], + pub max: [f32; 2], +} + +impl Scroll { + pub fn new(viewport: Viewport, bounds: impl Iterator) -> Self { + let mut min = [0.0_f32; 2]; + let mut max = [0.0_f32; 2]; + for rect in bounds { + for axis in 0..2 { + min[axis] = min[axis].min(rect[axis]); + max[axis] = max[axis].max(rect[axis + 2]); + } + } + for axis in 0..2 { + min[axis] = (min[axis] - 36.0) * viewport.scale; + max[axis] = + ((max[axis] + 36.0) * viewport.scale - viewport.size[axis] as f32).max(min[axis]); + } + Self { min, max } + } + + pub fn clamp(&self, viewport: &mut Viewport) { + for axis in 0..2 { + viewport.origin[axis] = -(-viewport.origin[axis]).clamp(self.min[axis], self.max[axis]); + } + } + + pub fn thumb(&self, viewport: Viewport, dpr: f32, axis: usize) -> Option<[f32; 4]> { + let range = self.max[axis] - self.min[axis]; + let length = viewport.size[axis] as f32; + let track = length - 20.0 * dpr; + if range <= 0.0 || track <= 0.0 { + return None; + } + let size = (track * length / (length + range)) + .max(28.0 * dpr) + .min(track); + let position = (-viewport.origin[axis] - self.min[axis]) / range; + let start = 4.0 * dpr + position.clamp(0.0, 1.0) * (track - size); + let cross = viewport.size[1 - axis] as f32 - 4.0 * dpr; + Some(if axis == 0 { + [start, cross - 6.0 * dpr, start + size, cross] + } else { + [cross - 6.0 * dpr, start, cross, start + size] + }) + } + + pub fn drag(&self, viewport: &mut Viewport, dpr: f32, axis: usize, pointer: f32, grab: f32) { + let Some(rect) = self.thumb(*viewport, dpr, axis) else { + return; + }; + let travel = viewport.size[axis] as f32 - 20.0 * dpr - (rect[axis + 2] - rect[axis]); + if travel > 0.0 { + let fraction = ((pointer - grab - 4.0 * dpr) / travel).clamp(0.0, 1.0); + viewport.origin[axis] = + -(self.min[axis] + fraction * (self.max[axis] - self.min[axis])); + } + } + + pub fn hit_test(&self, viewport: Viewport, dpr: f32, point: [f32; 2]) -> Option<(usize, f32)> { + (0..2).find_map(|axis| { + let rect = self.thumb(viewport, dpr, axis)?; + let inset = 3.0 * dpr; + ((rect[0] - inset..=rect[2] + inset).contains(&point[0]) + && (rect[1] - inset..=rect[3] + inset).contains(&point[1])) + .then_some((axis, point[axis] - rect[axis])) + }) + } + + pub fn append(&self, viewport: Viewport, dpr: f32, primitives: &mut Vec>) { + for axis in 0..2 { + if let Some(rect) = self.thumb(viewport, dpr, axis) { + let start = viewport.document_point([rect[0], rect[1]]); + let end = viewport.document_point([rect[2], rect[3]]); + primitives.push(Primitive::RoundedRect { + rect: [start[0], start[1], end[0], end[1]], + radius: [3.0 * dpr / viewport.scale; 2], + stroke: None, + color: [0.18, 0.18, 0.18, 0.45], + }); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bounds_include_negative_objects_and_clamp_both_ends() { + let mut viewport = Viewport { + size: [800, 600], + scale: 2.0, + origin: [9999.0; 2], + }; + let scroll = Scroll::new(viewport, [[-80.0, -20.0, 1000.0, 1200.0]].into_iter()); + scroll.clamp(&mut viewport); + assert_eq!(viewport.origin, [232.0, 112.0]); + viewport.origin = [-9999.0; 2]; + scroll.clamp(&mut viewport); + assert_eq!(viewport.origin, [-1272.0, -1872.0]); + } + + #[test] + fn small_page_has_no_scrollbars_and_thumb_drag_reaches_endpoints() { + for dpr in [1.0, 2.0] { + let mut viewport = Viewport { + size: [(800.0 * dpr) as u32, (600.0 * dpr) as u32], + scale: dpr * 4.0 / 3.0, + origin: [48.0 * dpr; 2], + }; + let scroll = Scroll::new(viewport, [[0.0, 0.0, 72.0, 14.0]].into_iter()); + assert!(scroll.thumb(viewport, dpr, 0).is_none()); + assert!(scroll.thumb(viewport, dpr, 1).is_none()); + let scroll = Scroll::new(viewport, [[0.0, 0.0, 2000.0, 2000.0]].into_iter()); + for axis in 0..2 { + scroll.drag(&mut viewport, dpr, axis, -1000.0, 3.0 * dpr); + assert_eq!(-viewport.origin[axis], scroll.min[axis]); + let rect = scroll.thumb(viewport, dpr, axis).unwrap(); + assert!((rect[axis] - 4.0 * dpr).abs() < 0.001); + scroll.drag(&mut viewport, dpr, axis, 10000.0, 3.0 * dpr); + assert_eq!(-viewport.origin[axis], scroll.max[axis]); + let rect = scroll.thumb(viewport, dpr, axis).unwrap(); + assert!((rect[axis + 2] - (viewport.size[axis] as f32 - 16.0 * dpr)).abs() < 0.001); + } + } + } +} diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index b9d31cbf9dd30911ac2111eca1dd0915891fcef6..bcfecae555fff835c0a7b8b762ac5346638d1fbe 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -77,6 +77,18 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "canvas" +version = "0.1.0" +dependencies = [ + "getrandom", + "onestore", + "parley", + "serde", + "serde_json", + "skrifa", +] + [[package]] name = "cbc" version = "0.2.1" @@ -616,18 +628,6 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -[[package]] -name = "one-canvas" -version = "0.1.0" -dependencies = [ - "getrandom", - "onestore", - "parley", - "serde", - "serde_json", - "skrifa", -] - [[package]] name = "onestore" version = "0.1.0" @@ -649,9 +649,9 @@ dependencies = [ name = "onestore-fuzz" version = "0.0.0" dependencies = [ + "canvas", "libfuzzer-sys", "md5", - "one-canvas", "onestore", "onestore-offline", "serde_json", diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 454335c70963c67f74c9b4d6521b9b1b5f51deae..00c4de0ddd9894ba72b924aaccf55d65112e33de 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -11,7 +11,7 @@ cargo-fuzz = true libfuzzer-sys = "0.4" onestore = { path = "../crates/onestore", features = ["protected"] } onestore-offline = { path = "../crates/onestore-offline" } -one-canvas = { path = "../crates/canvas" } +canvas = { path = "../crates/canvas" } tempfile = "3" md5 = "0.8.1" serde_json = "1" diff --git a/fuzz/fuzz_targets/canvas_editor.rs b/fuzz/fuzz_targets/canvas_editor.rs index 03d46ed3003c03ddc471df9ffd397b8c1be70578..3c83c28a2b578e68f6222b5791deaad8afba3981 100644 --- a/fuzz/fuzz_targets/canvas_editor.rs +++ b/fuzz/fuzz_targets/canvas_editor.rs @@ -1,13 +1,14 @@ #![no_main] -use libfuzzer_sys::fuzz_target; -use one_canvas::{ +use canvas::{ + date::PageDate, document::{TextDocument, TextPosition}, - editor::{CanvasEditor, TextOutline}, + editor::{CanvasEditor, Movement, SelectionUnit, TextOutline}, layout::TextEngine, text::Paragraph, }; +use libfuzzer_sys::fuzz_target; use onestore::document::Format; -use std::cell::RefCell; +use std::{cell::RefCell, collections::BTreeMap}; thread_local! { static ENGINE: RefCell = RefCell::new(TextEngine::default()); @@ -15,18 +16,244 @@ thread_local! { fuzz_target!(|input: &[u8]| { ENGINE.with_borrow_mut(|engine| { - let mut width = - [1.0, 10.0, 72.0, 240.0][usize::from(input.first().copied().unwrap_or(0)) % 4]; + if input.first().is_some_and(|byte| byte & 32 != 0) { + let document = TextDocument::new(vec![Paragraph::new( + "ALPHA".into(), + Format { + font_size: (input[0] & 64 != 0).then_some(130.0), + ..Default::default() + }, + )]) + .unwrap(); + let mut editor = CanvasEditor::new(engine, document, 180.0).unwrap(); + for step in input.chunks_exact(4).take(24) { + let positions = editor + .active_outline() + .document() + .paragraphs() + .enumerate() + .flat_map(|(paragraph, text)| { + text.text() + .char_indices() + .map(move |(byte, _)| TextPosition { + paragraph, + offset: text.utf16_offset(byte).unwrap(), + }) + .chain(std::iter::once(TextPosition { + paragraph, + offset: text.utf16_offset(text.text().len()).unwrap(), + })) + }) + .collect::>(); + let focus = positions[usize::from(step[0]) % positions.len()]; + let anchor = if step[3] & 16 == 0 { + focus + } else { + positions[usize::from(step[3]) % positions.len()] + }; + editor.select([anchor, focus].into()).unwrap(); + let before = editor.active_outline().document().clone(); + let stored = editor + .outlines() + .iter() + .map(|outline| (outline.id, outline.document().clone())) + .collect::>(); + let selection = editor.selection(); + let result = match step[1] % 10 { + 0 => editor.tab(engine, false), + 1 => editor.tab(engine, true), + 2 => editor.enter(engine, false), + 3 => editor.enter(engine, true), + 4 => editor.insert( + engine, + ["x", "🧊", "left\nright", "العربية"][usize::from(step[2]) % 4], + ), + 5 => editor.delete(engine, step[2] & 1 == 0).map(|_| ()), + 6 => editor.indent(engine, step[2] & 1 == 0).map(|_| ()), + 7 => editor + .move_selection( + engine, + [ + Movement::Left, + Movement::Right, + Movement::Up, + Movement::Down, + ][usize::from(step[2]) % 4], + step[3] & 32 != 0, + ) + .inspect_err(|error| { + assert!(!matches!( + error, + canvas::editor::EditorError::Edit( + canvas::text::EditError::InvalidStructure + ) + )); + }), + 8 => { + let unit = [ + SelectionUnit::Grapheme, + SelectionUnit::Word, + SelectionUnit::Paragraph, + ][usize::from(step[2]) % 3]; + editor + .selection_at( + f32::from(step[2]) - 32.0, + f32::from(step[3]) - 32.0, + unit, + ) + .and_then(|selection| editor.select(selection)) + .map_err(Into::into) + } + _ => editor.compose(engine, "🧊\n中".into(), 4..4).and_then(|_| { + if step[3] & 64 != 0 { + editor.cancel_composition(engine).map(|_| ()) + } else { + editor.commit_text(engine, "done".into()) + } + }), + }; + if result.is_err() { + assert_eq!(editor.active_outline().document(), &before); + assert_eq!(editor.selection(), selection); + assert_eq!( + editor + .outlines() + .iter() + .map(|outline| (outline.id, outline.document().clone())) + .collect::>(), + stored + ); + continue; + } + let after = editor.active_outline().document().clone(); + let after_selection = editor.selection(); + let committed = editor + .outlines() + .iter() + .map(|outline| (outline.id, outline.document().clone())) + .collect::>(); + if committed != stored { + assert!(editor.undo(engine).unwrap()); + assert_eq!( + editor + .outlines() + .iter() + .map(|outline| (outline.id, outline.document().clone())) + .collect::>(), + stored + ); + assert_eq!(editor.active_outline().document(), &before); + assert_eq!(editor.selection(), selection); + assert!(editor.redo(engine).unwrap()); + assert_eq!( + editor + .outlines() + .iter() + .map(|outline| (outline.id, outline.document().clone())) + .collect::>(), + committed + ); + assert_eq!(editor.active_outline().document(), &after); + assert_eq!(editor.selection(), after_selection); + } + } + return; + } + let width = [1.0, 10.0, 72.0, 240.0][usize::from(input.first().copied().unwrap_or(0)) % 4]; let document = TextDocument::new( ["office e\u{301} 👩🏽‍💻", "العربية 日本"] .map(|text| Paragraph::new(text.into(), Format::default())) .into(), ) .unwrap(); - let mut editor = CanvasEditor::new(engine, document, width).unwrap(); + let position = [ + [0.0, 179.9, 180.0, 270.0][usize::from(input.first().copied().unwrap_or(0) >> 2) % 4], + 158.4, + ]; + let mut outline = TextOutline::new(engine, document, width, position).unwrap(); + if input.first().is_some_and(|byte| byte & 128 != 0) { + let mut source = outline.snapshot(); + source.title = true; + source.min_width = Some(162.0); + source.layout.max_width = None; + source.layout.width_set_by_user = None; + outline = TextOutline::from_outline(engine, &source, &BTreeMap::new()).unwrap(); + } + let date = if input.first().is_some_and(|byte| byte & 64 != 0) { + let document = TextDocument::new( + ["Monday", "6:14 AM"] + .map(|text| Paragraph::new(text.into(), Format::default())) + .into(), + ) + .unwrap(); + let source = TextOutline::new(engine, document, 468.0, [0.0; 2]) + .unwrap() + .snapshot(); + Some(PageDate::new(100, source, engine, &BTreeMap::new()).unwrap()) + } else { + None + }; + let mut editor = if outline.title { + let mut fields = vec![outline.snapshot()]; + let date_id = date.as_ref().map(|date| date.source().id); + if let Some(date) = &date { + fields.push(date.source().clone()); + } + let mut objects = vec![canvas::page::PageObject::Title(canvas::page::Title { + id: onestore::ExGuid::default(), + layout: Default::default(), + date: date_id, + outlines: fields, + })]; + for (index, (x, y, background)) in [ + (120.0, 70.0, false), + (600.0, 180.0, false), + (120.0, 70.0, true), + ] + .into_iter() + .enumerate() + { + objects.push(canvas::page::PageObject::Image(canvas::page::Image { + id: onestore::ExGuid { + n: index.try_into().unwrap(), + ..Default::default() + }, + layout: onestore::document::Layout { + x: Some(x), + y: Some(position[1] + y), + max_width: Some(72.0), + max_height: Some(40.0), + ..Default::default() + }, + bytes: Some(std::sync::Arc::from(&b"deferred decoding"[..])), + background, + alt: None, + })); + } + CanvasEditor::from_page( + canvas::page::Page { + title: String::new(), + created: date.map(|date| date.timestamp()), + margin_origin: [0.0; 2], + definitions: BTreeMap::new(), + objects, + }, + engine, + ) + .unwrap() + } else { + CanvasEditor::from_text_outlines(vec![outline], BTreeMap::new(), date).unwrap() + }; + let layouts = |editor: &CanvasEditor| { + editor + .object_layouts() + .map(|(id, layout)| (id, layout.clone())) + .collect::>() + }; for step in input.chunks_exact(8).take(32) { + let width = editor.active_outline().wrap_width(); if step[6] & 128 != 0 { - editor.place_caret(engine, [0.0; 2], width).unwrap(); + editor.place_caret(engine, position, width).unwrap(); } let mut positions = Vec::new(); let mut source = String::new(); @@ -47,6 +274,7 @@ fuzz_target!(|input: &[u8]| { let (focus, b) = positions[usize::from(step[1]) % positions.len()]; editor.select([anchor, focus].into()).unwrap(); let before = editor.active_outline().document().clone(); + let layouts_before = layouts(&editor); let selection = editor.selection(); let text = [ "", @@ -54,7 +282,7 @@ fuzz_target!(|input: &[u8]| { "e\u{301}", "👩🏽‍💻", "\n", - "left\nright", + "left\nright\u{000b}soft", "العربية", "日本", ][usize::from(step[2]) % 8]; @@ -69,6 +297,7 @@ fuzz_target!(|input: &[u8]| { assert!(editor.cancel_composition(engine).unwrap()); assert_eq!(editor.active_outline().document(), &before); assert_eq!(editor.selection(), selection); + assert_eq!(layouts(&editor), layouts_before); } let format = Format { font_size: Some(10.0 + f32::from(step[4] % 8)), @@ -96,14 +325,20 @@ fuzz_target!(|input: &[u8]| { source ); let after = editor.active_outline().document().clone(); + let layouts_after = layouts(&editor); let after_selection = editor.selection(); assert!(editor.undo(engine).unwrap()); assert_eq!(editor.active_outline().document(), &before); assert_eq!(editor.selection(), selection); + assert_eq!(layouts(&editor), layouts_before); assert!(editor.redo(engine).unwrap()); assert_eq!(editor.active_outline().document(), &after); assert_eq!(editor.selection(), after_selection); - if step[5] & 128 != 0 && editor.caret_outline().is_none() { + assert_eq!(layouts(&editor), layouts_after); + if step[5] & 128 != 0 + && editor.caret_outline().is_none() + && !editor.active_outline().title + { let outlines = editor .outlines() .iter() @@ -144,11 +379,245 @@ fuzz_target!(|input: &[u8]| { } assert!(editor.undo(engine).unwrap()); } - if step[6] & 1 != 0 { - width = [1.0, 10.0, 72.0, 240.0][usize::from(step[7]) % 4]; + if step[6] & 1 != 0 && !editor.active_outline().title { + let width = [1.0, 10.0, 72.0, 240.0][usize::from(step[7]) % 4]; + let before = editor.active_outline().layout().clone(); + let document = editor.active_outline().document().clone(); + let selection = editor.selection(); + let preview = editor.preview_resize(engine, width).unwrap(); + assert_eq!(editor.active_outline().layout(), &before); + assert_eq!(editor.active_outline().document(), &document); editor.resize(engine, width).unwrap(); + assert_eq!(editor.active_outline().layout(), preview.layout()); + if before != *preview.layout() && editor.caret_outline().is_none() { + editor.undo(engine).unwrap(); + assert_eq!(editor.active_outline().layout(), &before); + assert_eq!(editor.active_outline().document(), &document); + assert_eq!(editor.selection(), selection); + editor.redo(engine).unwrap(); + assert_eq!(editor.active_outline().layout(), preview.layout()); + } } - let rebuilt = CanvasEditor::new(engine, after, width).unwrap(); + if step[6] & 2 != 0 { + let before = editor + .outlines() + .iter() + .map(|outline| (outline.id, outline.document().clone())) + .collect::>(); + let movement = [ + Movement::Left, + Movement::Right, + Movement::Up, + Movement::Down, + Movement::WordLeft, + Movement::WordRight, + Movement::LineStart, + Movement::LineEnd, + Movement::ParagraphStart, + Movement::ParagraphEnd, + Movement::DocumentStart, + Movement::DocumentEnd, + ][usize::from(step[7] >> 2) % 12]; + editor + .move_selection(engine, movement, step[6] & 4 != 0) + .unwrap(); + assert_eq!( + editor + .outlines() + .iter() + .map(|outline| (outline.id, outline.document().clone())) + .collect::>(), + before + ); + let caret = editor.caret(1.0).unwrap(); + for unit in [ + SelectionUnit::Grapheme, + SelectionUnit::Word, + SelectionUnit::Paragraph, + ] { + let selection = editor + .selection_at(caret.x0 as f32, ((caret.y0 + caret.y1) * 0.5) as f32, unit) + .unwrap(); + editor.select(selection).unwrap(); + } + } + if step[5] & 32 != 0 { + let id = editor.active_outline().id; + let point = editor + .outlines() + .iter() + .find(|outline| outline.id == id) + .map(|outline| { + [ + outline.bounds().width() as f32 * 0.5, + outline.bounds().height() as f32 + + [1.0, 20.0, 26.0, 28.0][usize::from(step[0]) % 4], + ] + }) + .unwrap_or([0.0; 2]); + let stored = editor + .outlines() + .iter() + .map(|outline| { + ( + outline.id, + outline.document().clone(), + outline.layout().clone(), + ) + }) + .collect::>(); + let before = editor.active_outline().document().clone(); + let selection = editor.selection(); + let extended = editor.select_below(engine, id, point).unwrap(); + assert_eq!( + editor + .outlines() + .iter() + .map(|outline| ( + outline.id, + outline.document().clone(), + outline.layout().clone() + )) + .collect::>(), + stored + ); + if !extended { + assert_eq!(editor.active_outline().document(), &before); + assert_eq!(editor.selection(), selection); + } + } + if editor + .outlines() + .iter() + .find(|outline| outline.id == editor.active_outline().id) + .is_some_and(|outline| outline.document() != editor.active_outline().document()) + { + let pending = editor.active_outline().document().clone(); + let stored = editor + .outlines() + .iter() + .map(|outline| (outline.id, outline.document().clone())) + .collect::>(); + editor + .move_selection(engine, Movement::DocumentEnd, false) + .unwrap(); + let selection = editor.selection(); + let mut expected = pending + .paragraphs() + .map(|paragraph| paragraph.text()) + .collect::>() + .join("\n"); + expected.push('x'); + editor.insert(engine, "x").unwrap(); + assert_eq!( + editor + .active_outline() + .document() + .paragraphs() + .map(|paragraph| paragraph.text()) + .collect::>() + .join("\n"), + expected + ); + assert!(editor.undo(engine).unwrap()); + assert_eq!(editor.active_outline().document(), &pending); + assert_eq!(editor.selection(), selection); + assert_eq!( + editor + .outlines() + .iter() + .map(|outline| (outline.id, outline.document().clone())) + .collect::>(), + stored + ); + editor.place_caret(engine, [0.0; 2], width).unwrap(); + } + if step[6] & 8 != 0 && editor.caret_outline().is_none() { + let before = editor.active_outline().document().clone(); + let selection = editor.selection(); + match editor.indent(engine, step[6] & 16 != 0) { + Ok(true) => { + let indented = editor.active_outline().document().clone(); + assert!(editor.undo(engine).unwrap()); + assert_eq!(editor.active_outline().document(), &before); + assert_eq!(editor.selection(), selection); + assert!(editor.redo(engine).unwrap()); + assert_eq!(editor.active_outline().document(), &indented); + assert!(editor.undo(engine).unwrap()); + } + Ok(false) | Err(_) => { + assert_eq!(editor.active_outline().document(), &before); + assert_eq!(editor.selection(), selection); + } + } + } + if step[6] & 32 != 0 { + let provisional = editor.caret_outline().is_some(); + let stored = editor + .outlines() + .iter() + .map(|outline| (outline.id, outline.document().clone())) + .collect::>(); + let before = editor.active_outline().document().clone(); + let selection = editor.selection(); + let movement = [ + Movement::WordLeft, + Movement::WordRight, + Movement::LineStart, + Movement::LineEnd, + ][usize::from(step[7]) % 4]; + if editor.delete_to(engine, movement).unwrap() { + if provisional && editor.active_outline().is_empty() { + assert_eq!( + editor + .outlines() + .iter() + .map(|outline| (outline.id, outline.document().clone())) + .collect::>(), + stored + ); + } else { + assert!(editor.undo(engine).unwrap()); + assert_eq!(editor.active_outline().document(), &before); + assert_eq!(editor.selection(), selection); + } + } else { + assert_eq!(editor.active_outline().document(), &before); + assert_eq!(editor.selection(), selection); + } + } + if step[5] & 64 != 0 + && let Some(date) = editor.date() + { + let before = date.source().paragraphs.clone(); + let timestamp = date.timestamp(); + let selection = editor.selection(); + let updated = timestamp.wrapping_add(1 + u64::from(step[0])); + assert!( + editor + .change_date(engine, updated, [text.into(), "7:15 AM".into()]) + .unwrap() + ); + let after = editor.date().unwrap().source().paragraphs.clone(); + assert_eq!(editor.selection(), selection); + assert!(editor.undo(engine).unwrap()); + assert_eq!(editor.date().unwrap().source().paragraphs, before); + assert_eq!(editor.date().unwrap().timestamp(), timestamp); + assert!(editor.redo(engine).unwrap()); + assert_eq!(editor.date().unwrap().source().paragraphs, after); + assert_eq!(editor.date().unwrap().timestamp(), updated); + let date = editor.date().unwrap(); + let rebuilt = + PageDate::new(updated, date.source().clone(), engine, &BTreeMap::new()) + .unwrap(); + assert_eq!(date.layout().size, rebuilt.layout().size); + } + let rebuilt = TextOutline::from_outline( + engine, + &editor.active_outline().snapshot(), + &BTreeMap::new(), + ) + .unwrap(); let geometry = |outline: &TextOutline| { outline .layouts() @@ -173,14 +642,8 @@ fuzz_target!(|input: &[u8]| { }) .collect::>() }; - assert_eq!( - geometry(editor.active_outline()), - geometry(rebuilt.active_outline()) - ); - assert_eq!( - editor.active_outline().bounds(), - rebuilt.active_outline().bounds() - ); + assert_eq!(geometry(editor.active_outline()), geometry(&rebuilt)); + assert_eq!(editor.active_outline().bounds(), rebuilt.bounds()); let caret = editor.caret(1.0).unwrap(); assert!( [caret.x0, caret.y0, caret.x1, caret.y1] diff --git a/tools/canvas/README.md b/tools/canvas/README.md index f20a4220d1a55289a6204f924ee885dbaa1ef6d0..b048f6cf6c69fe11629998cd30d1365ebbb80fa8 100644 --- a/tools/canvas/README.md +++ b/tools/canvas/README.md @@ -8,32 +8,34 @@ The host opens nonempty UTF-8 text as paragraphs in one temporary text outline. ```sh python3 tools/canvas/build_macos.py -"target/One Canvas.app/Contents/MacOS/OneCanvas" -cargo test -p one-canvas -cargo test -p one-canvas-gpu -- --ignored +"target/Snowbound.app/Contents/MacOS/Snowbound" +cargo test -p canvas +cargo test -p canvas --features gpu gpu:: -- --ignored ``` The 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: ```sh -python3 tools/canvas/build_macos.py --release --output '/PATH/One Canvas Review.app' --bundle-id dev.onecanvas.review +python3 tools/canvas/build_macos.py --release --output '/PATH/Snowbound Review.app' --bundle-id dev.snowbound.review ``` -The 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. +The 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. -Omit 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. +Control-Command-Space opens the macOS character picker for the active text field. + +Omit 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. ```text onestore::Format ↓ -one-canvas text edits · inverse history · composition · paragraph layout · structural edits +canvas text edits · inverse history · composition · paragraph layout · structural edits ↓ -one-canvas-gpu Swash glyph rasterization · custom wgpu quads · bounded glyph/image caches +canvas::gpu Swash glyph rasterization · custom wgpu quads · bounded glyph/image caches ↓ -one-canvas-app Winit window/input · AppKit quit guard · clipboard · AccessKit +snowbound Winit window/input · AppKit quit guard · clipboard · AccessKit ``` -The 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. +The 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. Drawing 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. @@ -52,11 +54,11 @@ Checkboxes display completion; noncheckable tags ignore the completion bit requi The 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. ```sh -"target/One Canvas.app/Contents/MacOS/OneCanvas" TEXT_FILE 240 --substitute-font ARIMO_FILE -cargo run -p one-canvas --bin layout-probe -- resources/canvas/text-cases.json --substitute-font ARIMO_FILE --substitute-font CARLITO_FILE +"target/Snowbound.app/Contents/MacOS/Snowbound" TEXT_FILE 240 --substitute-font ARIMO_FILE +cargo run -p canvas --bin layout-probe -- resources/canvas/text-cases.json --substitute-font ARIMO_FILE --substitute-font CARLITO_FILE ``` -Arimo'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. +Arimo'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. Arimo 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. @@ -64,9 +66,9 @@ Arimo 1.341 and Carlito 1.104 match all 14 recoverable native ASCII wrap control `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`. -`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. +`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. -The 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. +The 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. Core 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. @@ -75,13 +77,13 @@ Core tests compare 220 Unicode range/replacement combinations with plain-text re The 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: ```sh -CANVAS_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 --nocapture +CANVAS_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 ``` The 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: ```sh -CANVAS_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 --nocapture +CANVAS_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 ``` ## Native text accessibility @@ -89,8 +91,8 @@ CANVAS_TEST_SECTION=PRIVATE_SECTION_COPY CANVAS_TEST_PAGE=EXACT_PAGE_TITLE CANVA AccessKit 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. ```sh -cargo test -p one-canvas-app -ONE_CANVAS_TRACE_INPUT=1 "target/One Canvas.app/Contents/MacOS/OneCanvas" SYNTHETIC_TEXT_FILE +cargo test -p snowbound +SNOWBOUND_TRACE_INPUT=1 "target/Snowbound.app/Contents/MacOS/Snowbound" SYNTHETIC_TEXT_FILE ``` Input 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 ## Reference pages in the macOS host ```sh -"target/One Canvas.app/Contents/MacOS/OneCanvas" --reference PRIVATE_SECTION_COPY EXACT_PAGE_TITLE --substitute-font CARLITO_REGULAR_FILE +"target/Snowbound.app/Contents/MacOS/Snowbound" --reference PRIVATE_SECTION_COPY EXACT_PAGE_TITLE --substitute-font CARLITO_REGULAR_FILE ``` This 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. -The 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. +The 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. The 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. ## Editing an imported page ```sh -"target/One Canvas.app/Contents/MacOS/OneCanvas" --page PRIVATE_SECTION_COPY EXACT_PAGE_TITLE --substitute-font CARLITO_REGULAR_FILE +"target/Snowbound.app/Contents/MacOS/Snowbound" --page PRIVATE_SECTION_COPY EXACT_PAGE_TITLE --substitute-font CARLITO_REGULAR_FILE ``` -`--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. +`--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. -`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. +`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. The app's private integration test exercises the actual outline painter and accessibility consumer for each imported outline, then verifies text restoration through undo: ```sh -CANVAS_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 --nocapture +CANVAS_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 ``` ## Unsupported page content -Unsupported 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. +Unsupported 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. -Placeholders 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. +Placeholders 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. -`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. +Document and layout values are read-only outside the editor; edits update their retained geometry together. `CanvasEditor::from_outlines` remains available for text-only construction. ## Owned page import `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. ```sh -cargo run -p one-canvas --bin page-probe -- PRIVATE_SECTION_COPY EXACT_PAGE_TITLE > PRIVATE_PAGE_JSON +cargo run -p canvas --bin page-probe -- PRIVATE_SECTION_COPY EXACT_PAGE_TITLE > PRIVATE_PAGE_JSON python3 tools/canvas/compare_page.py PRIVATE_PAGE_JSON NATIVE_PAGE_XML > PRIVATE_COMPARISON_JSON ``` -The 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. +The 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. The PDF companion verifies the matching native XML first, then recovers paragraph wraps from its adjacent PDF export: @@ -155,7 +157,7 @@ Complete-outline text identifies repeated paragraphs. Otherwise, a paragraph nee All 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. ```sh -cargo run -p one-canvas-gpu --example render_page -- PRIVATE_SECTION_COPY EXACT_PAGE_TITLE NEW_PNG_PATH +cargo run -p canvas --features gpu --example render_page -- PRIVATE_SECTION_COPY EXACT_PAGE_TITLE NEW_PNG_PATH ``` This 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 `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. ```sh -cargo run -p one-canvas --bin layout-probe -- resources/canvas/text-cases.json > PARLEY_JSON +cargo run -p canvas --bin layout-probe -- resources/canvas/text-cases.json > PARLEY_JSON swift tools/canvas/core_text_probe.swift resources/canvas/text-cases.json > CORE_TEXT_JSON python3 tools/canvas/native_fixture.py resources/canvas/text-cases.json NEW_NOTEBOOK_DIRECTORY python3 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 The 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. ```sh -cargo test --release -p one-canvas-app canvas_pipeline_cost -- --ignored --nocapture --test-threads=1 -CANVAS_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=1 +cargo test --release -p snowbound canvas_pipeline_cost -- --ignored --nocapture --test-threads=1 +CANVAS_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 ``` Private-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 The 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. ```sh -cargo test --release -p one-canvas-gpu renderer_cost -- --ignored --nocapture -CANVAS_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 --nocapture +cargo test --release -p canvas --features gpu renderer_cost -- --ignored --nocapture +CANVAS_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 ``` Each `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. diff --git a/tools/canvas/build_macos.py b/tools/canvas/build_macos.py index 066e651aea9260c88de1bae85eddefef9e3bda5b..9ba61a8f81a32095272653996f67b519c5ce62a6 100644 --- a/tools/canvas/build_macos.py +++ b/tools/canvas/build_macos.py @@ -18,16 +18,16 @@ if bool(args.output) != bool(args.bundle_id): if args.output and (args.output.suffix != '.app' or args.output.exists()): parser.error('Choose a new output path ending in .app.') root = Path(__file__).resolve().parents[2] -subprocess.run(['cargo', 'build', '-p', 'one-canvas-app'] + (['--release'] if args.release else []), cwd=root, check=True) +subprocess.run(['cargo', 'build', '-p', 'snowbound'] + (['--release'] if args.release else []), cwd=root, check=True) metadata = json.loads(subprocess.check_output(['cargo', 'metadata', '--format-version=1', '--no-deps'], cwd=root)) target = Path(metadata['target_directory']) -bundle = args.output.resolve() if args.output else target / 'One Canvas.app' +bundle = args.output.resolve() if args.output else target / 'Snowbound.app' if args.output: bundle.mkdir(parents=True, exist_ok=False) -binary = bundle / 'Contents/MacOS/OneCanvas' +binary = bundle / 'Contents/MacOS/Snowbound' binary.parent.mkdir(parents=True, exist_ok=True) pending = binary.with_suffix('.next') -shutil.copy2(target / ('release' if args.release else 'debug') / 'one-canvas-app', pending) +shutil.copy2(target / ('release' if args.release else 'debug') / 'snowbound', pending) pending.replace(binary) icon = 'Snowbound-Tahoe' if int(platform.mac_ver()[0].split('.')[0] or 0) >= 26 else 'Snowbound-Sequoia' resources = bundle / 'Contents/Resources' @@ -35,7 +35,7 @@ resources.mkdir(exist_ok=True) shutil.copy2(root / 'crates/snowbound/assets/icon' / f'{icon}.icns', resources / 'Snowbound.icns') (bundle / 'Contents/Info.plist').write_bytes(plistlib.dumps({ 'CFBundleExecutable': binary.name, - 'CFBundleIdentifier': args.bundle_id or 'dev.onecanvas.app', + 'CFBundleIdentifier': args.bundle_id or 'dev.snowbound.app', 'CFBundleName': bundle.stem, 'CFBundleDisplayName': bundle.stem, 'CFBundlePackageType': 'APPL',