From 1b0d6e5c862e65471df75c1239a1a111b9c11302 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Thu, 24 Sep 2026 21:51:25 -0700 Subject: [PATCH] refactor: move page interaction from the snowbound host into canvas Hit layers, drags, the placement grid, picture handles, outline and picture chrome, key routing, scrolling, zoom, caret blink and the AccessKit tree lived in the winit host, mixed with window plumbing. They now live in canvas::interaction as PageView: hosts translate their events into its calls, carry out the requests it returns (date picker, clipboard, character picker) and follow its changes. Snowbound keeps the window, surface, AppKit pickers, clipboard and saving, and shrinks from 3.8k to under 1k lines. Behaviour is unchanged; the host tests moved with the logic, plus one driving PageView end to end. Assisted-by: claude-opus-5.5 --- Cargo.lock | 3 +- crates/canvas/Cargo.toml | 4 + .../src/interaction}/accessibility.rs | 34 +- crates/canvas/src/interaction/mod.rs | 1785 +++++++++ .../src => canvas/src/interaction}/profile.rs | 27 +- .../src => canvas/src/interaction}/scroll.rs | 2 +- crates/canvas/src/interaction/tests.rs | 1610 ++++++++ crates/canvas/src/lib.rs | 2 + crates/snowbound/Cargo.toml | 4 +- crates/snowbound/src/macos.rs | 2 +- crates/snowbound/src/main.rs | 3317 ++--------------- tools/canvas/README.md | 15 +- 12 files changed, 3690 insertions(+), 3115 deletions(-) rename crates/{snowbound/src => canvas/src/interaction}/accessibility.rs (98%) create mode 100644 crates/canvas/src/interaction/mod.rs rename crates/{snowbound/src => canvas/src/interaction}/profile.rs (90%) rename crates/{snowbound/src => canvas/src/interaction}/scroll.rs (99%) create mode 100644 crates/canvas/src/interaction/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 8bb1847e9dfc59f9b63727ec255fedaded0d1bdd..262651448567dc238141cdb83026bfa19baa2761 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -393,6 +393,8 @@ dependencies = [ name = "canvas" version = "0.1.0" dependencies = [ + "accesskit", + "accesskit_consumer", "bytemuck", "image", "onestore", @@ -2627,7 +2629,6 @@ name = "snowbound" version = "0.1.0" dependencies = [ "accesskit", - "accesskit_consumer", "accesskit_winit", "arboard", "canvas", diff --git a/crates/canvas/Cargo.toml b/crates/canvas/Cargo.toml index 66ff45704b09d662434b21a8866b626e3fdf3de9..ba0af9be35a4de56cd96c287438b1e3f7ef6e861 100644 --- a/crates/canvas/Cargo.toml +++ b/crates/canvas/Cargo.toml @@ -6,6 +6,8 @@ publish = false [features] gpu = ["dep:bytemuck", "dep:swash", "dep:wgpu", "dep:image", "dep:roxmltree"] +# How a page responds to input and presents itself to assistive technology. +interaction = ["gpu", "dep:accesskit"] [dependencies] onestore = { path = "../onestore" } @@ -19,6 +21,7 @@ swash = { version = "0.2.10", optional = true } wgpu = { workspace = true, optional = true } image = { version = "0.25", default-features = false, features = ["gif", "jpeg", "png"], optional = true } roxmltree = { version = "0.21", optional = true } +accesskit = { version = "0.25.0", optional = true } [[bin]] name = "layout-probe" @@ -29,6 +32,7 @@ name = "page-probe" path = "src/bin/page_probe.rs" [dev-dependencies] +accesskit_consumer = "0.39.0" pollster = "0.4" png = "0.18" diff --git a/crates/snowbound/src/accessibility.rs b/crates/canvas/src/interaction/accessibility.rs similarity index 98% rename from crates/snowbound/src/accessibility.rs rename to crates/canvas/src/interaction/accessibility.rs index cb8c3bfc6440f460004a22550547de2653454e37..b52c9a314d7e42df320265326460bbe21c8638b2 100644 --- a/crates/snowbound/src/accessibility.rs +++ b/crates/canvas/src/interaction/accessibility.rs @@ -1,13 +1,13 @@ -use accesskit::{ - Action, Affine, Node, NodeId, Rect, Role, TextDirection, TextSelection, TreeId, TreeInfo, - TreeUpdate, -}; -use canvas::gpu::Viewport; -use canvas::{ +use crate::gpu::Viewport; +use crate::{ date::DateField, document::TextPosition, editor::{CanvasEditor, Selection, TextOutline}, }; +use accesskit::{ + Action, Affine, Node, NodeId, Rect, Role, TextDirection, TextSelection, TreeId, TreeInfo, + TreeUpdate, +}; use onestore::page::text::EditError; use parley::Affinity; use std::{collections::HashMap, ops::Range}; @@ -64,7 +64,7 @@ impl Accessibility { pub fn append_page_fields( &mut self, update: &mut TreeUpdate, - scene: Option<&(canvas::gpu::page::PageScene, [f32; 2])>, + scene: Option<&(crate::gpu::page::PageScene, [f32; 2])>, editor: &CanvasEditor, viewport: Viewport, focus: Option, @@ -110,7 +110,7 @@ impl Accessibility { 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_label(super::DATE_LABELS[field as usize]); node.set_value( editor.date().unwrap().source().paragraphs[index] .text() @@ -361,7 +361,7 @@ impl Accessibility { .tags .iter() .map(|tag| { - use canvas::outline::TagIcon; + use crate::outline::TagIcon; let fallback = match tag.icon { TagIcon::CheckBox { .. } => "To do", TagIcon::Question => "Question", @@ -588,7 +588,7 @@ impl Accessibility { #[cfg(test)] mod tests { use super::*; - use canvas::{document::TextDocument, layout::TextEngine}; + use crate::{document::TextDocument, layout::TextEngine}; use onestore::document::Format; use onestore::page::text::Paragraph; @@ -641,7 +641,7 @@ mod tests { ], }; let (scene, mut editor) = - canvas::gpu::page::PageScene::from_page(page, &mut engine).unwrap(); + crate::gpu::page::PageScene::from_page(page, &mut engine).unwrap(); let scene = (scene, [30.0, 40.0]); let viewport = Viewport { size: [800, 600], @@ -688,13 +688,13 @@ mod tests { 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)) + super::super::page_hit_test(&editor, Some(&scene), [155.0, y + 45.0], 1.0), + Some(super::super::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)) + super::super::page_hit_test(&editor, Some(&scene), [155.0, 125.0], 1.0), + Some(super::super::Hit::ReadOnly(0)) ); } } @@ -702,7 +702,7 @@ mod tests { #[test] fn read_only_objects_keep_accessibility_identity_through_edits_and_view_changes() { - use canvas::gpu::page::PageScene; + use crate::gpu::page::PageScene; use onestore::page::{Page, PageObject, Unsupported}; let page = Page { identity: None, @@ -1217,7 +1217,7 @@ mod tests { } #[test] fn keyboard_and_accessibility_preserve_visual_caret_at_wraps() { - use canvas::editor::Movement; + use crate::editor::Movement; let mut engine = TextEngine::default(); let document = TextDocument::new(vec![ Paragraph::new( diff --git a/crates/canvas/src/interaction/mod.rs b/crates/canvas/src/interaction/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..46eee5639591591655e2ecfd9f12c332f5c02373 --- /dev/null +++ b/crates/canvas/src/interaction/mod.rs @@ -0,0 +1,1785 @@ +//! How a page responds to pointer, keyboard and text input as OneNote does: hit layers, drags, +//! the placement grid, picture handles, outline and picture chrome, and key routing. Hosts +//! translate their platform's events into these calls and carry out the returned requests. + +pub mod accessibility; +#[cfg(test)] +mod profile; +mod scroll; +#[cfg(test)] +mod tests; + +use crate::gpu::{Primitive, Stroke, Viewport, page::PageScene}; +use crate::{ + date::DateField, + editor::{ + CanvasEditor, DEFAULT_OUTLINE_WIDTH, Movement, Selection, SelectionUnit, TextOutline, + }, + layout::TextEngine, +}; +use std::{ + error::Error, + time::{Duration, Instant}, +}; + +const HANDLE_HEIGHT: f32 = 6.75; +/// Accessible names of the page date's fields. +pub const DATE_LABELS: [&str; 2] = ["Page date", "Page time"]; + +pub type Result = std::result::Result>; + +#[derive(Clone, Debug, PartialEq)] +pub enum Key { + Named(NamedKey), + Character(String), +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum NamedKey { + Escape, + Tab, + Space, + Enter, + Backspace, + Delete, + ArrowLeft, + ArrowRight, + ArrowUp, + ArrowDown, + Home, + End, + /// Shift, Control, Option or Command pressed alone. + Modifier, + Other, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Modifiers { + pub shift: bool, + pub control: bool, + pub option: bool, + pub command: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Cursor { + Default, + Text, + Pointer, + Move, + EwResize, + NsResize, + NwseResize, + NeswResize, +} + +/// Work only the host can do, asked for by an event. +#[derive(Debug, PartialEq)] +pub enum Request { + /// Show the date or time picker, then report the choice through `PageView::change_date`. + EditDate(DateField), + /// Put this text on the clipboard. + Copy(String), + /// Read the clipboard and hand its text to `PageView::commit_text`. + Paste, + /// Show the platform's character picker. + CharacterPalette, +} + +/// What an event did: `changed` means the page, selection or view changed (the host saves, +/// updates accessibility and redraws); `redraw` alone repaints hover feedback. +#[must_use] +#[derive(Debug, Default, PartialEq)] +pub struct Response { + pub changed: bool, + pub redraw: bool, + pub request: Option, +} + +impl Response { + fn changed() -> Self { + Self { + changed: true, + redraw: true, + request: None, + } + } + + fn redraw() -> Self { + Self { + redraw: true, + ..Self::default() + } + } + + fn request(request: Request) -> Self { + Self { + request: Some(request), + ..Self::changed() + } + } +} + +/// The platform's caret and text selection colours, linear RGBA. +#[derive(Clone, Copy, Debug)] +pub struct TextColors { + pub caret: [f32; 4], + pub selection: [f32; 4], +} + +/// How to paint an outline: whether the caret is in its blink-on phase, the view scale, +/// document points per device pixel, and the text colours. +#[derive(Clone, Copy)] +struct Paint { + show_caret: bool, + scale: f32, + pixel: f32, + colors: TextColors, +} + +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]>, + }, + Image { + id: onestore::ExGuid, + handle: [i8; 2], + /// Document point of the press. + press: [f32; 2], + pending_press: Option<[f32; 2]>, + }, +} + +#[derive(Clone, Copy)] +enum PointerFeedback<'a> { + Hover(onestore::ExGuid), + Move(onestore::ExGuid, [f32; 2]), + Resize(&'a TextOutline), + /// A picture being moved or resized, drawn at this origin and size. + Image(onestore::ExGuid, [f32; 2], [f32; 2]), +} + +/// A non-text object holding focus, which hides the text caret and suspends typing. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ObjectFocus { + ReadOnly(usize), + Image(onestore::ExGuid), +} + +impl ObjectFocus { + pub fn read_only(self) -> Option { + match self { + Self::ReadOnly(index) => Some(index), + Self::Image(_) => None, + } + } +} + +fn read_only_shortcut(key: &Key, modifiers: Modifiers) -> bool { + key == &Key::Named(NamedKey::Escape) + || (modifiers.control && key == &Key::Named(NamedKey::Tab)) + || (modifiers.command + && matches!(key, Key::Character(value) if matches!(value.as_str(), "+" | "=" | "-" | "0") || (modifiers.shift && value.eq_ignore_ascii_case("n")))) +} + +/// A page on screen: the editor, the drawn scene, the view onto them and the pointer and +/// keyboard state between events. +pub struct PageView { + pub editor: CanvasEditor, + pub engine: TextEngine, + /// The page's noneditable content and where it sits in the view. + pub scene: Option<(PageScene, [f32; 2])>, + pub viewport: Viewport, + /// Device pixels per logical pixel. + display_scale: f32, + double_click: Duration, + pointer: [f32; 2], + pointer_inside: bool, + last_click: Option<(Instant, [f32; 2], u8)>, + drag: Option, + object_focus: Option, + modifiers: Modifiers, + focused: bool, + caret: bool, + blink_at: Instant, +} + +impl PageView { + /// `size` is in device pixels; `double_click` is the platform's double-click interval. + pub fn new( + editor: CanvasEditor, + engine: TextEngine, + scene: Option<(PageScene, [f32; 2])>, + size: [u32; 2], + display_scale: f32, + double_click: Duration, + ) -> Self { + Self { + editor, + engine, + scene, + viewport: Viewport { + size, + scale: display_scale * 96.0 / 72.0, + origin: [48.0 * display_scale; 2], + }, + display_scale, + double_click, + pointer: [0.0; 2], + pointer_inside: false, + last_click: None, + drag: None, + object_focus: None, + modifiers: Modifiers::default(), + focused: true, + caret: true, + blink_at: Instant::now() + Duration::from_millis(500), + } + } + + /// Shows a page reloaded from storage in place of the edited one. + pub fn replace(&mut self, editor: CanvasEditor, scene: Option<(PageScene, [f32; 2])>) { + self.editor = editor; + self.scene = scene; + self.drag = None; + self.object_focus = None; + } + + pub fn modifiers(&self) -> Modifiers { + self.modifiers + } + + pub fn object_focus(&self) -> Option { + self.object_focus + } + + /// Text input goes to the editor unless an object holds focus. + pub fn accepts_text(&self) -> bool { + self.object_focus.is_none() + } + + /// The outline being dragged and where it would land. + pub fn outline_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.option { + position + } else { + snap_to_grid(position, self.editor.margin_origin()) + }, + )) + } + + fn image_preview(&self) -> Option<(onestore::ExGuid, [f32; 2], [f32; 2])> { + let Drag::Image { + id, + handle, + press, + pending_press: None, + } = self.drag.as_ref()? + else { + return None; + }; + let (origin, size) = self.editor.image_placement(*id)?; + let point = self.viewport.document_point(self.pointer); + let delta = [point[0] - press[0], point[1] - press[1]]; + if *handle != [0, 0] { + let (origin, size) = resize_image(origin, size, *handle, delta); + return Some((*id, origin, size)); + } + // A picture in an outline's flow keeps its place. + if self.editor.image_in_outline(*id) { + return Some((*id, origin, size)); + } + let origin = [origin[0] + delta[0], origin[1] + delta[1]]; + let origin = if self.modifiers.option { + origin + } else { + snap_to_grid(origin, self.editor.margin_origin()) + }; + Some((*id, origin, size)) + } + + fn pixel(&self) -> f32 { + self.display_scale / self.viewport.scale + } + + /// The selected picture's handles lie above the page; its body keeps its paint order. + fn hit_test(&self, point: [f32; 2]) -> Option { + if let Some(ObjectFocus::Image(id)) = self.object_focus + && let Some((origin, size)) = self.editor.image_placement(id) + && let Some(handle) = image_handle_at(image_rect(origin, size), self.pixel(), point) + { + return Some(Hit::Image { id, handle }); + } + page_hit_test(&self.editor, self.scene.as_ref(), point, self.pixel()) + } + + fn set_object_focus(&mut self, focus: Option) { + if self.object_focus != focus { + self.editor.finish_composition(); + self.drag = None; + self.object_focus = focus; + } + } + + /// Page coordinates of a focused object. + fn object_rect(&self, focus: ObjectFocus) -> [f32; 4] { + let (scene, offset) = self.scene.as_ref().unwrap(); + let [x0, y0, x1, y1] = match focus { + ObjectFocus::ReadOnly(index) => scene + .read_only(Some(&self.editor)) + .nth(index) + .unwrap() + .rect(), + ObjectFocus::Image(id) => { + let (origin, size) = self.editor.image_placement(id).unwrap(); + image_rect(origin, size) + } + }; + [ + x0 + offset[0], + y0 + offset[1], + x1 + offset[0], + y1 + offset[1], + ] + } + + /// Keeps the view in bounds and restarts the caret blink after a change. + fn changed(&mut self) -> Result { + self.scroll().clamp(&mut self.viewport); + self.caret = true; + self.blink_at = Instant::now() + Duration::from_millis(500); + Ok(Response::changed()) + } + + /// The caret's rectangle in device pixels, for placing the platform's input method. + pub fn caret_area(&self) -> Result<[f32; 4]> { + let mut rect = self.editor.caret(1.0)?; + let origin = self + .outline_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; + let x = rect.x0 as f32 * v.scale + v.origin[0]; + let y = rect.y0 as f32 * v.scale + v.origin[1]; + Ok([ + x, + y, + x + (rect.width() as f32 * v.scale).max(1.0), + y + (rect.height() as f32 * v.scale).max(1.0), + ]) + } + + fn reveal_focus(&mut self) -> Result<()> { + if self.viewport.size.contains(&0) { + return Ok(()); + } + let rect = if let Some(focus) = self.object_focus { + let [x0, y0, x1, y1] = self.object_rect(focus).map(f64::from); + parley::BoundingBox { x0, y0, x1, y1 } + } 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.object_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(()) + } + + /// Reveals the caret or focused object after an edit, then reports the change. + fn edited(&mut self) -> Result { + self.reveal_focus()?; + self.changed() + } + + fn zoom(&mut self, factor: f32) { + let point = self.viewport.document_point(self.pointer); + let dpr = self.display_scale; + 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)) + } + + /// Everything to draw this frame, scrollbars included. + pub fn primitives(&self, colors: TextColors) -> Result>> { + let preview = match &self.drag { + Some(Drag::Resize { + outline: Some(outline), + .. + }) => Some(PointerFeedback::Resize(outline)), + _ => self + .outline_preview() + .map(|(id, origin)| PointerFeedback::Move(id, origin)) + .or_else(|| { + self.image_preview() + .map(|(id, origin, size)| PointerFeedback::Image(id, origin, size)) + }) + .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.pixel(), + ) { + Some( + Hit::Text { id, .. } | Hit::Handle { id, .. } | Hit::Resize { id, .. }, + ) => Some(PointerFeedback::Hover(id)), + _ => None, + } + }), + }; + let mut primitives = page_primitives( + &self.editor, + self.scene.as_ref(), + preview, + self.object_focus, + Paint { + show_caret: self.caret + && self.focused + && !matches!( + self.drag, + Some(Drag::Outline { .. } | Drag::Resize { .. } | Drag::Image { .. }) + ), + scale: self.viewport.scale, + pixel: self.pixel(), + colors, + }, + )?; + self.scroll() + .append(self.viewport, self.display_scale, &mut primitives); + Ok(primitives) + } + + /// The pointer shape at the pointer's position. + pub fn cursor(&self) -> Cursor { + let hit = self.hit_test(self.viewport.document_point(self.pointer)); + let scrollbar = self + .scroll() + .hit_test(self.viewport, self.display_scale, self.pointer) + .is_some(); + match (&self.drag, hit) { + (Some(Drag::Scrollbar { .. }), _) => Cursor::Default, + (None, _) if scrollbar => Cursor::Default, + (Some(Drag::Image { handle, .. }), _) => handle_cursor(*handle), + (None, Some(Hit::Image { handle, .. })) => handle_cursor(handle), + (Some(Drag::Resize { .. }), _) | (None, Some(Hit::Resize { .. })) => Cursor::EwResize, + (Some(Drag::Outline { .. }), _) | (None, Some(Hit::Handle { .. })) => Cursor::Move, + (None, Some(Hit::Date(_))) => Cursor::Pointer, + (None, Some(Hit::ReadOnly(_))) => Cursor::Default, + _ => Cursor::Text, + } + } + + /// Advances the caret blink; returns whether to repaint and when to call again. + pub fn blink(&mut self, now: Instant) -> (bool, Option) { + let [anchor, focus] = self.editor.selection().positions; + if !(self.focused && self.object_focus.is_none() && anchor == focus) { + return (false, None); + } + let repaint = now >= self.blink_at; + if repaint { + self.caret = !self.caret; + self.blink_at = now + Duration::from_millis(500); + } + (repaint, Some(self.blink_at)) + } + + /// `size` in device pixels. + pub fn resized(&mut self, size: [u32; 2]) -> Result { + self.viewport.size = size; + if size.contains(&0) { + return Ok(Response::default()); + } + self.changed() + } + + pub fn scale_factor_changed(&mut self, scale: f32) -> Result { + let ratio = scale / self.display_scale; + self.viewport.scale *= ratio; + self.viewport.origin[0] *= ratio; + self.viewport.origin[1] *= ratio; + self.display_scale = scale; + self.changed() + } + + pub fn focus_changed(&mut self, focused: bool) -> Result { + self.focused = focused; + if !focused { + self.drag = None; + } + self.changed() + } + + pub fn modifiers_changed(&mut self, modifiers: Modifiers) -> Result { + self.modifiers = modifiers; + if matches!(self.drag, Some(Drag::Outline { .. })) { + return self.changed(); + } + Ok(Response::default()) + } + + pub fn pointer_left(&mut self) -> Response { + self.pointer_inside = false; + Response::redraw() + } + + /// `position` in device pixels from the view's top-left. + pub fn pointer_moved(&mut self, position: [f32; 2]) -> Result { + self.pointer_inside = true; + self.pointer = position; + match &mut self.drag { + Some(Drag::Scrollbar { axis, grab }) => { + let (axis, grab) = (*axis, *grab); + self.scroll().drag( + &mut self.viewport, + self.display_scale, + axis, + self.pointer[axis], + grab, + ); + self.changed() + } + Some(Drag::Text { anchor, unit }) => { + let (anchor, unit) = (*anchor, *unit); + let point = self.viewport.document_point(self.pointer); + let origin = self.editor.active_outline().origin(); + let target = + self.editor + .selection_at(point[0] - origin[0], point[1] - origin[1], unit)?; + self.editor.select(drag_selection(anchor, target, unit))?; + self.changed() + } + Some(Drag::Outline { pending_press, .. } | Drag::Image { pending_press, .. }) => { + if pending_press.is_some_and(|press| { + (0..2) + .any(|axis| (position[axis] - press[axis]).abs() > 2.0 * self.display_scale) + }) { + *pending_press = None; + } + self.changed() + } + Some(Drag::Resize { outline, grab }) => { + let point = self.viewport.document_point(self.pointer); + let width = (point[0] - self.editor.active_outline().origin()[0] - *grab).max(36.0); + if outline.is_some() + || (width - self.editor.active_outline().bounds().width() as f32).abs() + * self.viewport.scale + > 2.0 * self.display_scale + { + *outline = Some(Box::new( + self.editor.preview_resize(&mut self.engine, width)?, + )); + return self.changed(); + } + Ok(Response::default()) + } + None => Ok(Response::redraw()), + } + } + + /// A primary-button press at the pointer's position, at `now` for click counting. + pub fn pointer_pressed(&mut self, now: Instant) -> Result { + let point = self.viewport.document_point(self.pointer); + let count = self + .last_click + .filter(|(time, point, _)| { + now.duration_since(*time) <= self.double_click + && (0..2).all(|axis| { + (point[axis] - self.pointer[axis]).abs() <= 4.0 * self.display_scale + }) + }) + .map_or(1, |(_, _, count)| (count % 3) + 1); + self.last_click = Some((now, self.pointer, count)); + if let Some((axis, grab)) = + self.scroll() + .hit_test(self.viewport, self.display_scale, self.pointer) + { + self.drag = Some(Drag::Scrollbar { axis, grab }); + return self.changed(); + } + match self.hit_test(point) { + Some(Hit::Date(field)) => { + self.editor.finish_composition(); + self.drag = None; + return Ok(Response::request(Request::EditDate(field))); + } + Some(Hit::ReadOnly(index)) => self.set_object_focus(Some(ObjectFocus::ReadOnly(index))), + Some(Hit::Image { id, handle }) => { + self.set_object_focus(Some(ObjectFocus::Image(id))); + self.drag = Some(Drag::Image { + id, + handle, + press: point, + pending_press: Some(self.pointer), + }); + } + Some(Hit::Handle { id, grab }) => { + self.set_object_focus(None); + self.editor.focus_outline(id)?; + self.drag = Some(Drag::Outline { + id, + grab, + pending_press: Some(self.pointer), + }); + } + Some(Hit::Resize { id, grab }) => { + self.set_object_focus(None); + self.editor.focus_outline(id)?; + self.drag = Some(Drag::Resize { + outline: None, + grab, + }); + } + Some(Hit::Text { id, point }) => { + let extend = self.modifiers.shift + && self.object_focus.is_none() + && id == self.editor.active_outline().id; + self.set_object_focus(None); + self.editor.focus_outline(id)?; + let previous = self.editor.selection(); + self.editor.select_below(&mut self.engine, id, point)?; + let unit = match count { + 2 => SelectionUnit::Word, + 3 => SelectionUnit::Paragraph, + _ => SelectionUnit::Grapheme, + }; + let selection = self.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 + }; + self.editor.select(selection)?; + self.drag = Some(Drag::Text { + anchor: selection, + unit, + }); + } + None => { + // A fresh click places text 7 px above the pointer, on the grid. + let position = [point[0], point[1] - 7.0 * self.pixel()]; + let position = if self.modifiers.option { + position + } else { + snap_to_grid(position, self.editor.margin_origin()) + }; + self.editor + .place_caret(&mut self.engine, position, DEFAULT_OUTLINE_WIDTH)?; + self.set_object_focus(None); + self.drag = Some(Drag::Text { + anchor: self.editor.selection(), + unit: SelectionUnit::Grapheme, + }); + } + } + self.changed() + } + + pub fn pointer_released(&mut self) -> Result { + let preview = self.outline_preview(); + if let Some((id, origin, size)) = self.image_preview() { + self.editor + .place_image(&mut self.engine, id, origin, size)?; + } + if let Some(Drag::Resize { + outline: Some(outline), + .. + }) = self.drag.take() + { + self.editor + .resize(&mut self.engine, outline.bounds().width() as f32)?; + } + if let Some((id, origin)) = preview { + self.editor.move_outline(id, origin)?; + } + self.changed() + } + + /// `delta` in device pixels; Command zooms about the pointer instead of scrolling. + pub fn wheel(&mut self, delta: [f32; 2]) -> Result { + if self.modifiers.command { + self.zoom((delta[1] * 0.005).exp()); + } else { + self.viewport.origin[0] += delta[0]; + self.viewport.origin[1] += delta[1]; + } + self.changed() + } + + /// Text the platform inserts outside key events, such as the character picker's; + /// control characters are not text here. + pub fn insert_text(&mut self, text: String) -> Result { + if text.is_empty() || text.chars().any(char::is_control) { + return Ok(Response::default()); + } + self.commit_text(text) + } + + /// Text committed by an input method or pasted, line breaks included. + pub fn commit_text(&mut self, text: String) -> Result { + if !self.accepts_text() { + return Ok(Response::default()); + } + self.editor.commit_text(&mut self.engine, text)?; + self.edited() + } + + /// An input method's marked text; `cursor` is its UTF-8 selection within `text`. + pub fn compose(&mut self, text: String, cursor: Option<(usize, usize)>) -> Result { + if !self.accepts_text() { + return Ok(Response::default()); + } + if text.is_empty() { + self.editor.cancel_composition(&mut self.engine)?; + } else { + let (start, end) = cursor.unwrap_or((text.len(), text.len())); + let utf16 = |end: usize| -> Result { + Ok(text + .get(..end) + .ok_or("IME range splits UTF-8")? + .encode_utf16() + .count() + .try_into()?) + }; + let range = utf16(start)?..utf16(end)?; + self.editor.compose(&mut self.engine, text, range)?; + } + self.edited() + } + + /// The input method was switched off mid-composition. + pub fn cancel_composition(&mut self) -> Result { + if !self.accepts_text() { + return Ok(Response::default()); + } + self.editor.cancel_composition(&mut self.engine)?; + self.edited() + } + + /// The date or time chosen after `Request::EditDate`. + pub fn change_date(&mut self, timestamp: u64, text: [String; 2]) -> Result { + self.editor.change_date(&mut self.engine, timestamp, text)?; + self.changed() + } + + /// Gives keyboard focus to a read-only object, as an assistive technology asks. + pub fn focus_read_only(&mut self, index: usize) -> Result { + self.set_object_focus(Some(ObjectFocus::ReadOnly(index))); + self.edited() + } + + /// Returns focus to text after the host acted on the editor directly, as an assistive + /// technology's selection or value change does. + pub fn focus_text(&mut self) -> Result { + self.set_object_focus(None); + self.edited() + } + + pub fn key(&mut self, key: &Key, text: Option<&str>) -> Result { + let Modifiers { + shift, + control, + option, + command, + } = self.modifiers; + if let Some(ObjectFocus::Image(id)) = self.object_focus + && matches!(key, Key::Named(NamedKey::Backspace | NamedKey::Delete)) + { + self.editor.remove_image(&mut self.engine, id)?; + self.set_object_focus(None); + return self.changed(); + } + if let Some(ObjectFocus::Image(id)) = self.object_focus + && !(shift || command || option || control) + && let Key::Named(NamedKey::ArrowLeft | NamedKey::ArrowRight) = key + && self.editor.step_from_image( + &mut self.engine, + id, + key == &Key::Named(NamedKey::ArrowRight), + )? + { + self.set_object_focus(None); + return self.edited(); + } + if let Some(focus) = self.object_focus { + let undo = matches!(focus, ObjectFocus::Image(_)) + && command + && matches!(key, Key::Character(value) if value.eq_ignore_ascii_case("z")); + if !undo && !read_only_shortcut(key, self.modifiers) { + return Ok(Response::default()); + } + if key == &Key::Named(NamedKey::Escape) { + self.set_object_focus(None); + return self.edited(); + } + } + if command && control && key == &Key::Named(NamedKey::Space) { + return Ok(Response { + request: Some(Request::CharacterPalette), + ..Response::default() + }); + } + if matches!( + self.drag, + Some(Drag::Outline { .. } | Drag::Resize { .. } | Drag::Image { .. }) + ) { + if key == &Key::Named(NamedKey::Modifier) { + return Ok(Response::default()); + } + 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], + )?; + return self.edited(); + } + } + if control && 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 + .object_focus + .and_then(ObjectFocus::read_only) + .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_object_focus(None); + } else { + self.set_object_focus(Some(ObjectFocus::ReadOnly(next - outlines.len()))); + } + return self.edited(); + } + let mut request = None; + if command && let Key::Character(key) = key { + match key.to_lowercase().as_str() { + "n" if shift => { + let position = if let Some(focus) = self.object_focus { + let rect = self.object_rect(focus); + [rect[2] + 24.0, rect[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, self.editor.margin_origin()), + DEFAULT_OUTLINE_WIDTH, + )?; + self.set_object_focus(None); + } + "a" => self.editor.select_all()?, + "z" => { + if shift { + self.editor.redo(&mut self.engine)?; + } else { + self.editor.undo(&mut self.engine)?; + } + if let Some(ObjectFocus::Image(id)) = self.object_focus + && self.editor.image_placement(id).is_none() + { + self.set_object_focus(None); + } + } + "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() { + if key.eq_ignore_ascii_case("x") { + self.editor.insert(&mut self.engine, "")?; + } + request = Some(Request::Copy(text)); + } + } + "v" => request = Some(Request::Paste), + "+" | "=" => { + 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(Response::default()), + } + } 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 control && !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(end @ (NamedKey::Home | NamedKey::End)) => { + let limits = self.scroll(); + self.viewport.origin[1] = -if *end == NamedKey::Home { + limits.min[1] + } else { + limits.max[1] + }; + return self.changed(); + } + Key::Character(key) if control && !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 && !control => { + 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)?; + } + } + Ok(Response { + request, + ..self.edited()? + }) + } +} + +/// What a document point lands on. +#[derive(Debug, PartialEq)] +pub 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), + /// `handle` is [0, 0] on the picture and a direction on the selected picture's handles. + Image { + id: onestore::ExGuid, + handle: [i8; 2], + }, +} + +/// What a document point lands on; `pixel` is document points per device pixel. +pub fn page_hit_test( + editor: &CanvasEditor, + scene: Option<&(PageScene, [f32; 2])>, + point: [f32; 2], + pixel: f32, +) -> Option { + /// Grips and text resolve front to back before any outline's padding, as a native + /// width handle stays reachable under the next outline's left padding; the typing room + /// below an outline comes last. + #[derive(Clone, Copy, PartialEq)] + enum Layer { + Grips, + Body, + Below, + } + let hit = |outline: &TextOutline, offset: [f32; 2], layer: Layer| { + if layer == Layer::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 layer == Layer::Grips { + // Width handles: 15 px inside to 6 px outside the header's right end, and 6 px + // either side of the right border below it. + let inside = if y < body_top { 15.0 } else { 6.0 }; + if !outline.title + && (bounds[2] - inside * pixel..=bounds[2] + 6.0 * pixel).contains(&x) + && (bounds[1]..=bounds[3]).contains(&y) + { + return Some(Hit::Resize { + id: outline.id, + grab: point[0] - outline.bounds().x1 as f32, + }); + } + 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], + ], + }); + } + let text = outline.bounds(); + if !(text.x0..=text.x1).contains(&f64::from(x)) + || !(text.y0..=text.y1).contains(&f64::from(y)) + { + return None; + } + } + let inner = [x - outline.origin()[0], y - outline.origin()[1]]; + if let Some(picture) = outline.shaped().objects.iter().find(|object| { + matches!(object.kind, crate::outline::ObjectKind::Picture) + && (object.rect[0]..=object.rect[2]).contains(&inner[0]) + && (object.rect[1]..=object.rect[3]).contains(&inner[1]) + }) { + return Some(Hit::Image { + id: picture.id, + handle: [0, 0], + }); + } + 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] + outline.shaped().tag_column_offset() + tag.origin[0]; + let y = origin[1] + paragraph.origin[1] + tag.origin[1]; + let size = crate::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 = |layer| { + 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], layer)) + { + 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, layer)) + }, + )? { + crate::gpu::page::SceneHit::Outline(hit) => Some(hit), + crate::gpu::page::SceneHit::Date(field) => Some(Hit::Date(field)), + crate::gpu::page::SceneHit::ReadOnly(index) => Some(Hit::ReadOnly(index)), + crate::gpu::page::SceneHit::Image(id) => Some(Hit::Image { id, handle: [0, 0] }), + } + }; + [Layer::Grips, Layer::Body, Layer::Below] + .into_iter() + .find_map(pass) +} + +fn page_primitives<'a>( + editor: &'a CanvasEditor, + scene: Option<&'a (PageScene, [f32; 2])>, + preview: Option>, + object_focus: Option, + paint: Paint, +) -> Result>> { + let mut primitives = Vec::new(); + let draw_outline = |id, offset: [f32; 2], primitives: &mut Vec<_>| { + let outline = editor + .visible_outlines() + .find(|outline| outline.id == id) + .ok_or(crate::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 (object_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]], + paint.pixel, + primitives, + ); + } + append_outline( + (object_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]], + paint, + primitives, + )?; + if let Some((scene, _)) = scene { + let moving = match preview { + Some(PointerFeedback::Image(id, origin, size)) => { + let [x0, y0, x1, y1] = image_rect(origin, size); + Some(( + id, + [ + x0 + offset[0], + y0 + offset[1], + x1 + offset[0], + y1 + offset[1], + ], + )) + } + _ => None, + }; + scene.append_outline_objects( + outline.shaped(), + [origin[0] + offset[0], origin[1] + offset[1]], + moving, + primitives, + ); + } + Ok::<_, Box>(()) + }; + if let Some((scene, origin)) = scene { + let moving = match preview { + Some(PointerFeedback::Image(id, origin, size)) => Some((id, image_rect(origin, size))), + _ => None, + }; + scene.append_primitives_with( + &mut primitives, + *origin, + Some(editor), + moving, + &draw_outline, + )?; + } + for outline in editor + .visible_outlines() + .filter(|outline| scene.is_none() || !editor.has_page_outline(outline.id)) + { + draw_outline(outline.id, [0.0; 2], &mut primitives)?; + } + if object_focus.is_none() + && let Some(outline) = editor.caret_outline() + { + append_outline( + Some(editor), + outline, + outline.origin(), + paint, + &mut primitives, + )?; + } + if let Some(ObjectFocus::Image(id)) = object_focus { + let (origin, size) = match preview { + Some(PointerFeedback::Image(moving, origin, size)) if moving == id => (origin, size), + _ => editor + .image_placement(id) + .ok_or("The selected picture is missing.")?, + }; + append_image_chrome(image_rect(origin, size), paint.pixel, &mut primitives); + } + if let Some(ObjectFocus::ReadOnly(index)) = object_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 / paint.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) +} + +/// Native picture handles sit on a selection border drawn 5 px outside the picture, named +/// by their direction from its center. +fn image_handles(rect: [f32; 4], pixel: f32) -> impl Iterator { + let border = [ + rect[0] - 5.0 * pixel, + rect[1] - 5.0 * pixel, + rect[2] + 5.0 * pixel, + rect[3] + 5.0 * pixel, + ]; + [-1, 0, 1] + .into_iter() + .flat_map(|y| [-1, 0, 1].map(|x| [x, y])) + .filter(|handle| *handle != [0, 0]) + .map(move |handle| { + ( + handle, + std::array::from_fn(|axis| { + let [start, end] = [border[axis], border[axis + 2]]; + start + (end - start) * f32::from(handle[axis] + 1) * 0.5 + }), + ) + }) +} + +fn image_handle_at(rect: [f32; 4], pixel: f32, point: [f32; 2]) -> Option<[i8; 2]> { + image_handles(rect, pixel) + .find(|(_, center)| (0..2).all(|axis| (point[axis] - center[axis]).abs() <= 5.0 * pixel)) + .map(|(handle, _)| handle) +} + +fn handle_cursor(handle: [i8; 2]) -> Cursor { + match handle { + [0, 0] => Cursor::Move, + [_, 0] => Cursor::EwResize, + [0, _] => Cursor::NsResize, + [x, y] if x == y => Cursor::NwseResize, + _ => Cursor::NeswResize, + } +} + +fn image_rect(origin: [f32; 2], size: [f32; 2]) -> [f32; 4] { + [ + origin[0], + origin[1], + origin[0] + size[0], + origin[1] + size[1], + ] +} + +/// Drags a picture's `handle` by `delta`: edges stretch one axis, corners keep the aspect +/// ratio at the larger of the two scales, and the opposite side stays fixed. +fn resize_image( + origin: [f32; 2], + size: [f32; 2], + handle: [i8; 2], + delta: [f32; 2], +) -> ([f32; 2], [f32; 2]) { + let mut scale: [f32; 2] = std::array::from_fn(|axis| { + (size[axis] + f32::from(handle[axis]) * delta[axis]) / size[axis] + }); + if !handle.contains(&0) { + scale = [scale[0].max(scale[1]); 2]; + } + let resized: [f32; 2] = std::array::from_fn(|axis| (size[axis] * scale[axis]).max(1.0)); + let origin = std::array::from_fn(|axis| { + if handle[axis] < 0 { + origin[axis] + size[axis] - resized[axis] + } else { + origin[axis] + } + }); + (origin, resized) +} + +fn append_image_chrome(rect: [f32; 4], pixel: f32, primitives: &mut Vec>) { + let mut tint = crate::gpu::colorref(0x00e0d2e6); + // Native pictures take this tint at 25% in sRGB; 10% in linear light matches it. + tint[3] = 0.1; + primitives.push(Primitive::Rect { rect, color: tint }); + primitives.push(Primitive::RoundedRect { + rect: [ + rect[0] - 5.0 * pixel, + rect[1] - 5.0 * pixel, + rect[2] + 5.0 * pixel, + rect[3] + 5.0 * pixel, + ], + radius: [0.0; 2], + stroke: Some(Stroke::Dashed(pixel)), + color: crate::gpu::colorref(0x00ff9a31), + }); + for (handle, [x, y]) in image_handles(rect, pixel) { + let (half, radius) = if handle.contains(&0) { + (3.5, 0.0) + } else { + (4.0, 4.0) + }; + let rect = [ + x - half * pixel, + y - half * pixel, + x + half * pixel, + y + half * pixel, + ]; + primitives.push(Primitive::RoundedRect { + rect, + radius: [radius * pixel; 2], + stroke: None, + color: crate::gpu::colorref(0x00ffefe7), + }); + primitives.push(Primitive::RoundedRect { + rect, + radius: [radius * pixel; 2], + stroke: Some(Stroke::Solid(pixel)), + color: crate::gpu::colorref(0x00dea67b), + }); + } +} + +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], margin: [f32; 2]) -> [f32; 2] { + std::array::from_fn(|axis| { + let offset = margin[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: crate::gpu::colorref(0x007f7f7f), + }); + return; + } + primitives.push(Primitive::RoundedRect { + rect: [left, top, right, body_top], + radius: [3.0 * pixel; 2], + stroke: None, + color: crate::gpu::colorref(0x00e8ebed), + }); + primitives.push(Primitive::RoundedRect { + rect: [right - 9.0 * pixel, top, right, body_top], + radius: [3.0 * pixel; 2], + stroke: None, + color: crate::gpu::colorref(0x00e5dee7), + }); + primitives.push(Primitive::RoundedRect { + rect: [left, top, right, bottom], + radius: [3.0 * pixel; 2], + stroke: Some(Stroke::Solid(pixel)), + color: crate::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: crate::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: crate::gpu::colorref(0x00b4a5b4), + }); + } + } +} + +fn append_outline<'a>( + editor: Option<&'a CanvasEditor>, + outline: &'a TextOutline, + origin: [f32; 2], + paint: Paint, + primitives: &mut Vec>, +) -> Result<()> { + let [x, y] = origin; + let Paint { + show_caret, + scale, + pixel, + colors, + } = paint; + outline.shaped().append_table_primitives(primitives, origin); + outline + .shaped() + .append_background_primitives(primitives, origin); + 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: colors.selection, + }); + } + } + for (index, (_, paragraph)) in outline.layouts().enumerate() { + outline + .shaped() + .append_paragraph_primitives(index, paragraph, origin, primitives); + } + 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: colors.caret, + }); + } + } + Ok(()) +} diff --git a/crates/snowbound/src/profile.rs b/crates/canvas/src/interaction/profile.rs similarity index 90% rename from crates/snowbound/src/profile.rs rename to crates/canvas/src/interaction/profile.rs index ea29586944886d46bbee7421277376f7391c5ee8..ff7c084c0d56ce34d49a99160f44c7142244d56d 100644 --- a/crates/snowbound/src/profile.rs +++ b/crates/canvas/src/interaction/profile.rs @@ -1,5 +1,10 @@ use super::*; -use canvas::document::TextPosition; +use crate::document::TextDocument; +use crate::document::TextPosition; +use onestore::document::Format; +use onestore::page::Page; +use onestore::page::text::Paragraph; +use std::sync::Arc; use std::{hint::black_box, process::Command}; fn timed(case: &str, phase: &str, sample: usize, operation: impl FnOnce() -> T) -> T { @@ -95,8 +100,14 @@ fn editing_cost( 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(); + let primitives = page_primitives( + editor, + scene, + None, + None, + super::tests::paint(true, viewport.scale, 1.0), + ) + .unwrap(); black_box(&primitives); primitives.len() }); @@ -154,8 +165,14 @@ fn canvas_pipeline_cost() { 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(), + page_primitives( + &editor, + Some(&scene), + None, + None, + super::tests::paint(false, 96.0 / 72.0, 1.0), + ) + .unwrap(), ); } resident("native", "open", cycle); diff --git a/crates/snowbound/src/scroll.rs b/crates/canvas/src/interaction/scroll.rs similarity index 99% rename from crates/snowbound/src/scroll.rs rename to crates/canvas/src/interaction/scroll.rs index 18cbcebfe159945944c2edece9ed17f1228e848a..b9bd93b74bb5b089f66d4a7934b6f4b5764b8cc4 100644 --- a/crates/snowbound/src/scroll.rs +++ b/crates/canvas/src/interaction/scroll.rs @@ -1,4 +1,4 @@ -use canvas::gpu::{Primitive, Viewport}; +use crate::gpu::{Primitive, Viewport}; pub struct Scroll { pub min: [f32; 2], diff --git a/crates/canvas/src/interaction/tests.rs b/crates/canvas/src/interaction/tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..602286da71f71068afdef6845e5b15af7c363f1a --- /dev/null +++ b/crates/canvas/src/interaction/tests.rs @@ -0,0 +1,1610 @@ +use super::*; +use crate::document::TextDocument; +use onestore::document::Format; +use onestore::page::Page; +use onestore::page::text::Paragraph; +use std::sync::Arc; + +const COLORS: TextColors = TextColors { + caret: [0.1, 0.3, 0.9, 1.0], + selection: [0.7, 0.8, 1.0, 1.0], +}; + +/// Paint for a view at `scale` device pixels per point on a display of `display_scale`. +pub(super) fn paint(show_caret: bool, scale: f32, display_scale: f32) -> Paint { + Paint { + show_caret, + scale, + pixel: display_scale / scale, + colors: COLORS, + } +} + +#[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 { + identity: None, + title: "Header".into(), + created: Some(1), + margin_origin: [36.0, 14.4], + definitions: Default::default(), + objects: vec![onestore::page::PageObject::Title(onestore::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 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, paint(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, paint(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 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, paint(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 == COLORS.caret) + ); + 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] +#[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] + crate::outline::ParagraphTag::SIZE / 2.0, + outline.origin()[1] + + paragraph.origin[1] + + tag.origin[1] + + crate::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, + paint(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( + [crate::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, + paint(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() + ); +} + +#[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 = onestore::page::Page { + identity: None, + title: String::new(), + created: None, + margin_origin: [0.0; 2], + definitions: Default::default(), + objects: vec![onestore::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 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, paint(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 a_covered_width_handle_stays_reachable_beside_the_outline_above_it() { + let mut engine = TextEngine::default(); + let pixel = 0.75; + let mut first = TextOutline::new( + &mut engine, + TextDocument::new(vec![Paragraph::new("text".into(), Format::default())]).unwrap(), + 100.0, + [36.0, 68.4], + ) + .unwrap(); + let editor = + CanvasEditor::from_text_outlines(vec![first.clone()], Default::default(), None).unwrap(); + first = editor.preview_resize(&mut engine, 226.5).unwrap(); + let (frame, body_top) = outline_chrome(&first, pixel); + // The casual night page: the next outline starts 7.5 pt right of the first one. + let second = TextOutline::new( + &mut engine, + TextDocument::new(vec![Paragraph::new("text".into(), Format::default())]).unwrap(), + 100.0, + [first.bounds().x1 as f32 + 7.5, 68.4], + ) + .unwrap(); + let (first_id, second_id) = (first.id, second.id); + let editor = + CanvasEditor::from_text_outlines(vec![first, second], Default::default(), None).unwrap(); + let header = (frame[1] + body_top) / 2.0; + let hit = |x| page_hit_test(&editor, None, [x, header], pixel); + assert!(matches!(hit(frame[2] - 12.0 * pixel), Some(Hit::Resize { id, .. }) if id == first_id)); + assert!(matches!(hit(frame[2] - 2.0 * pixel), Some(Hit::Handle { id, .. }) if id == second_id)); + let body = |x| page_hit_test(&editor, None, [x, body_top + 20.0], pixel); + assert!(matches!(body(frame[2] - 4.0 * pixel), Some(Hit::Resize { id, .. }) if id == first_id)); + assert!(matches!(body(frame[2] - 8.0 * pixel), Some(Hit::Text { id, .. }) if id == second_id)); + assert!(matches!(body(frame[2] - 12.0 * pixel), Some(Hit::Text { id, .. }) if id == first_id)); +} + +#[test] +fn a_picture_in_an_outline_takes_the_click_over_its_text() { + use onestore::page::{Image, Page, PageObject, ParagraphContent}; + let mut engine = TextEngine::default(); + let mut source = TextOutline::new( + &mut engine, + TextDocument::new(vec![ + Paragraph::new("Before".into(), Default::default()), + Paragraph::new("After".into(), Default::default()), + ]) + .unwrap(), + 240.0, + [36.0, 36.0], + ) + .unwrap() + .snapshot(); + let mut picture = source.paragraphs[0].clone(); + picture.id = onestore::page::text::new_id().unwrap(); + let id = onestore::page::text::new_id().unwrap(); + picture.content = ParagraphContent::Image(Image { + size: Some([40.0, 30.0]), + id, + layout: Default::default(), + bytes: None, + alt: None, + background: false, + }); + source.paragraphs.insert(1, picture); + let editor = CanvasEditor::from_page( + Page { + title: String::new(), + identity: None, + created: None, + margin_origin: [36.0, 14.4], + definitions: Default::default(), + objects: vec![PageObject::Outline(source)], + }, + &mut engine, + ) + .unwrap(); + let (origin, size) = editor.image_placement(id).unwrap(); + let center = [origin[0] + size[0] / 2.0, origin[1] + size[1] / 2.0]; + assert_eq!( + page_hit_test(&editor, None, center, 1.0), + Some(Hit::Image { id, handle: [0, 0] }) + ); + assert!(matches!( + page_hit_test(&editor, None, [origin[0] + 2.0, origin[1] - 4.0], 1.0), + Some(Hit::Text { .. }) + )); +} + +#[test] +fn picture_handles_resize_as_onenote_does() { + // Native drags of the 333 x 200.1 pt mockup on "av: casual night in the trees". + let (origin, size) = ([468.0, 86.4], [333.0, 200.1]); + let (corner_origin, corner) = resize_image(origin, size, [1, -1], [-75.0, 30.75]); + assert!((corner[0] - 281.827).abs() < 0.01 && (corner[1] - 169.351).abs() < 0.01); + assert!((corner_origin[1] - 117.15).abs() < 0.01 && corner_origin[0] == 468.0); + assert_eq!( + resize_image(origin, size, [1, 0], [46.5, 10.0]), + (origin, [379.5, 200.1]) + ); + let (left_origin, left) = resize_image(origin, size, [-1, 0], [400.0, 0.0]); + assert_eq!(left, [1.0, 200.1]); + assert_eq!(left_origin[0], 468.0 + 333.0 - 1.0); + let pixel = 0.75; + let rect = image_rect(origin, size); + assert_eq!( + image_handle_at(rect, pixel, [468.0 - 3.75, 86.4 - 3.75]), + Some([-1, -1]) + ); + assert_eq!( + image_handle_at(rect, pixel, [468.0 + 166.5, 286.5 + 3.75]), + Some([0, 1]) + ); + assert_eq!(image_handle_at(rect, pixel, [600.0, 150.0]), None); +} + +#[test] +fn grouped_drag_keeps_the_initial_word_when_reversing_direction() { + let selection = |start, end| { + Selection::from([start, end].map(|offset| crate::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 + ); +} + +const DEFAULT_MARGIN: [f32; 2] = [36.0, 14.4]; + +#[test] +fn native_grid_follows_the_page_margin_origin() { + // "av: casual night in the trees": margin 36.75; a 22.5 x 15 pt drag lands one cell on. + let margin = [36.75, 14.4]; + let result = snap_to_grid([468.75 + 22.5, 86.4 + 15.0], margin); + assert!((result[0] - 486.75).abs() < 0.0001); + assert!((result[1] - 104.4).abs() < 0.0001); +} + +#[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, DEFAULT_MARGIN); + 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], + DEFAULT_MARGIN, + ); + 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, DEFAULT_MARGIN); + 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, DEFAULT_MARGIN), 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], + DEFAULT_MARGIN, + ); + for axis in 0..2 { + assert!((result[axis] - expected[axis]).abs() < 0.00004); + } + } + } +} + +#[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], + Paint { + show_caret: false, + scale: 1.0, + pixel: 1.0, + colors: COLORS, + }, + &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 == crate::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 == COLORS.selection => 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], + Paint { + show_caret: false, + scale: 1.0, + pixel: 1.0, + colors: COLORS, + }, + &mut primitives, + ) + .unwrap(); + let color = crate::gpu::colorref(0x00a3a3a3); + assert!(matches!(primitives[0], Primitive::RoundedRect { + stroke: Some(crate::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 == COLORS.selection)); + 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, + paint(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, paint(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 onestore::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 { + identity: None, + 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, paint(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, paint(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 [ + Modifiers::default(), + Modifiers { + shift: true, + ..Modifiers::default() + }, + Modifiers { + option: true, + ..Modifiers::default() + }, + Modifiers { + command: true, + ..Modifiers::default() + }, + Modifiers { + command: true, + shift: true, + ..Modifiers::default() + }, + Modifiers { + command: true, + option: true, + ..Modifiers::default() + }, + ] { + 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), Modifiers::default()), + ( + Key::Named(NamedKey::Tab), + Modifiers { + control: true, + ..Modifiers::default() + }, + ), + ( + Key::Named(NamedKey::Tab), + Modifiers { + control: true, + shift: true, + ..Modifiers::default() + }, + ), + ( + Key::Character("N".into()), + Modifiers { + command: true, + shift: true, + ..Modifiers::default() + }, + ), + ( + Key::Character("+".into()), + Modifiers { + command: true, + ..Modifiers::default() + }, + ), + ( + Key::Character("0".into()), + Modifiers { + command: true, + ..Modifiers::default() + }, + ), + ( + Key::Character("-".into()), + Modifiers { + command: true, + ..Modifiers::default() + }, + ), + ] { + assert!(read_only_shortcut(&key, modifiers)); + } + assert!(!read_only_shortcut( + &Key::Character("n".into()), + Modifiers { + command: true, + ..Modifiers::default() + } + )); + assert!(!read_only_shortcut( + &Key::Named(NamedKey::Tab), + Modifiers::default() + )); +} + +#[test] +fn read_only_focus_retires_text_overlays_and_draws_a_scaled_focus_border() { + use onestore::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 { + identity: None, + 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, paint(true, scale, 1.0)).unwrap(), + ); + assert!(text.iter().any(|(_, color)| *color == COLORS.caret)); + let selected = rectangles( + page_primitives( + &editor, + Some(&scene), + None, + Some(ObjectFocus::ReadOnly(0)), + paint(true, scale, 1.0), + ) + .unwrap(), + ); + assert!(!selected.iter().any(|(_, color)| *color == COLORS.caret)); + assert_eq!( + selected, + rectangles( + page_primitives( + &editor, + Some(&scene), + None, + Some(ObjectFocus::ReadOnly(0)), + paint(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, paint(false, 1.0, 1.0)).unwrap(), + ); + assert!(text.iter().any(|(_, color)| *color == COLORS.selection)); + let selected = rectangles( + page_primitives( + &editor, + Some(&scene), + None, + Some(ObjectFocus::ReadOnly(0)), + paint(true, 1.0, 1.0), + ) + .unwrap(), + ); + assert!(!selected.iter().any(|(_, color)| *color == COLORS.selection)); + assert_eq!(editor.active_outline().document(), &original); +} + +/// A view at 100% on a 1x display over one outline holding "Before", a 40 x 30 pt picture and +/// "After" at (36, 36), with the picture's identity. +fn picture_view() -> (PageView, onestore::ExGuid) { + use onestore::page::{Image, PageObject, ParagraphContent}; + let mut engine = TextEngine::default(); + let mut source = TextOutline::new( + &mut engine, + TextDocument::new(vec![ + Paragraph::new("Before".into(), Default::default()), + Paragraph::new("After".into(), Default::default()), + ]) + .unwrap(), + 240.0, + [36.0, 36.0], + ) + .unwrap() + .snapshot(); + let mut picture = source.paragraphs[0].clone(); + picture.id = onestore::page::text::new_id().unwrap(); + let id = onestore::page::text::new_id().unwrap(); + picture.content = ParagraphContent::Image(Image { + size: Some([40.0, 30.0]), + id, + layout: Default::default(), + bytes: None, + alt: None, + background: false, + }); + source.paragraphs.insert(1, picture); + let editor = CanvasEditor::from_page( + Page { + title: String::new(), + identity: None, + created: None, + margin_origin: [36.0, 14.4], + definitions: Default::default(), + objects: vec![PageObject::Outline(source)], + }, + &mut engine, + ) + .unwrap(); + let mut view = PageView::new( + editor, + engine, + None, + [800, 600], + 1.0, + Duration::from_millis(500), + ); + view.viewport.scale = 1.0; + view.viewport.origin = [0.0; 2]; + (view, id) +} + +fn click(view: &mut PageView, point: [f32; 2], now: Instant) { + let _ = view.pointer_moved(point).unwrap(); + assert!(view.pointer_pressed(now).unwrap().changed); + assert!(view.pointer_released().unwrap().changed); +} + +#[test] +fn events_route_through_the_view_as_the_host_delivers_them() { + let (mut view, picture) = picture_view(); + let now = Instant::now(); + let outline = view.editor.active_outline().id; + let bounds = view.editor.active_outline().bounds(); + + // Typing at a clicked caret edits the outline and asks for nothing else. + click( + &mut view, + [bounds.x0 as f32 + 2.0, bounds.y0 as f32 + 6.0], + now, + ); + assert_eq!(view.cursor(), Cursor::Text); + let typed = view.key(&Key::Character("X".into()), Some("X")).unwrap(); + assert!(typed.changed && typed.request.is_none()); + assert!( + view.editor + .active_outline() + .document() + .paragraphs() + .next() + .unwrap() + .text() + .starts_with('X') + ); + + // Command-C asks the host to copy; Command-V asks it to paste, then commits its text. + let _ = view + .modifiers_changed(Modifiers { + shift: true, + ..Modifiers::default() + }) + .unwrap(); + let _ = view.key(&Key::Named(NamedKey::ArrowRight), None).unwrap(); + let _ = view + .modifiers_changed(Modifiers { + command: true, + ..Modifiers::default() + }) + .unwrap(); + assert_eq!( + view.key(&Key::Character("c".into()), None).unwrap().request, + Some(Request::Copy("B".into())) + ); + assert_eq!( + view.key(&Key::Character("v".into()), None).unwrap().request, + Some(Request::Paste) + ); + assert!(view.commit_text("line\nbreak".into()).unwrap().changed); + assert_eq!( + view.editor.active_outline().document().paragraphs().count(), + 3 + ); + let _ = view.modifiers_changed(Modifiers::default()).unwrap(); + + // A click on the picture selects it, stops text input, and Delete removes it. + let (origin, size) = view.editor.image_placement(picture).unwrap(); + let center = [origin[0] + size[0] / 2.0, origin[1] + size[1] / 2.0]; + click(&mut view, center, now + Duration::from_secs(1)); + assert_eq!(view.object_focus(), Some(ObjectFocus::Image(picture))); + assert!(!view.accepts_text()); + assert_eq!(view.cursor(), Cursor::Move); + assert_eq!( + view.commit_text("ignored".into()).unwrap(), + Response::default() + ); + assert!( + view.key(&Key::Named(NamedKey::Delete), None) + .unwrap() + .changed + ); + assert!(view.editor.image_placement(picture).is_none()); + assert!(view.accepts_text()); + + // The header drags the outline onto the grid anchored at the page margin. + let bounds = view.editor.active_outline().bounds(); + let header = [(bounds.x0 + bounds.x1) as f32 / 2.0, bounds.y0 as f32 - 8.0]; + let _ = view.pointer_moved(header).unwrap(); + let _ = view.pointer_pressed(now + Duration::from_secs(2)).unwrap(); + assert_eq!(view.cursor(), Cursor::Move); + let _ = view + .pointer_moved([header[0] + 30.0, header[1] + 20.0]) + .unwrap(); + assert_eq!(view.outline_preview().map(|(id, _)| id), Some(outline)); + let _ = view.pointer_released().unwrap(); + let origin = view.editor.active_outline().origin(); + assert_eq!(origin, [72.0, 50.4]); + + // An empty spot places a new caret there, 7 px above the pointer on the grid. + click(&mut view, [400.0, 300.0], now + Duration::from_secs(3)); + let caret = view.editor.caret_outline().unwrap().origin(); + assert_eq!(caret, snap_to_grid([400.0, 293.0], [36.0, 14.4])); +} diff --git a/crates/canvas/src/lib.rs b/crates/canvas/src/lib.rs index 8d4e6c92e97c59fcc74b1ddbe9cfc182d70cc6b6..d11c82dda1636d9fce147852e91bab53b38ecf2c 100644 --- a/crates/canvas/src/lib.rs +++ b/crates/canvas/src/lib.rs @@ -7,6 +7,8 @@ pub mod outline; pub mod date; #[cfg(feature = "gpu")] pub mod gpu; +#[cfg(feature = "interaction")] +pub mod interaction; /// Parley's caret affinity mapped onto the page model's hidden-field affinity. pub fn affinity(affinity: parley::Affinity) -> onestore::page::text::Affinity { diff --git a/crates/snowbound/Cargo.toml b/crates/snowbound/Cargo.toml index dc6a9c3cfb15d7d40f2803726ce389ee3eadeb86..b30bef5cd01d12ae45347b46707d96ce3ed4ecd6 100644 --- a/crates/snowbound/Cargo.toml +++ b/crates/snowbound/Cargo.toml @@ -5,7 +5,7 @@ edition = "2024" publish = false [dependencies] -canvas = { path = "../canvas", features = ["gpu"] } +canvas = { path = "../canvas", features = ["interaction"] } onestore = { path = "../onestore" } notebook = { path = "../notebook" } pollster = "0.4" @@ -19,5 +19,3 @@ 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/macos.rs b/crates/snowbound/src/macos.rs index 3e642ff0895ee3e5442b9d732edfd510d22fcee0..ad838f6a372e1eceb6d475574b360c74c102b7f6 100644 --- a/crates/snowbound/src/macos.rs +++ b/crates/snowbound/src/macos.rs @@ -116,7 +116,7 @@ pub fn edit_date( picker.setMinDate(Some(&NSDate::dateWithTimeIntervalSince1970( -11_644_473_600.0, ))); - let label = NSString::from_str(crate::DATE_LABELS[field as usize]); + let label = NSString::from_str(canvas::interaction::DATE_LABELS[field as usize]); let _: () = msg_send![&picker, setAccessibilityLabel: &*label]; picker.sizeToFit(); let alert = NSAlert::new(mtm); diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index 9bdcefce74dfe849c65ae0d01bb5f947172bbedc..88f51531175861558ba6979f72b0672b9139d04f 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -1,39 +1,29 @@ -mod accessibility; mod macos; -#[cfg(test)] -mod profile; -mod scroll; -use canvas::gpu::{Primitive, Renderer, Stroke, Viewport, page::PageScene}; +use canvas::gpu::{Renderer, page::PageScene}; +use canvas::interaction::{ + Cursor, Key as PageKey, Modifiers, NamedKey as PageNamedKey, PageView, Request, Response, + TextColors, accessibility, +}; use canvas::{ date::DateField, document::TextDocument, - editor::{ - CanvasEditor, DEFAULT_OUTLINE_WIDTH, Movement, Selection, SelectionUnit, TextOutline, - }, + editor::{CanvasEditor, DEFAULT_OUTLINE_WIDTH, TextOutline}, layout::TextEngine, }; use onestore::document::Format; use onestore::page::Page; use onestore::page::text::Paragraph; -use std::{ - error::Error, - path::PathBuf, - sync::Arc, - time::{Duration, Instant}, -}; +use std::{error::Error, path::PathBuf, sync::Arc, time::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}, + window::{CursorIcon, Window, WindowId}, }; -const HANDLE_HEIGHT: f32 = 6.75; -const DATE_LABELS: [&str; 2] = ["Page date", "Page time"]; - #[derive(Debug)] enum UserEvent { Quit, @@ -55,11 +45,54 @@ fn trace_input(event: &impl std::fmt::Debug) { } } -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")))) +fn page_key(key: &Key) -> PageKey { + match key { + Key::Character(text) => PageKey::Character(text.to_string()), + Key::Named(named) => PageKey::Named(match named { + NamedKey::Escape => PageNamedKey::Escape, + NamedKey::Tab => PageNamedKey::Tab, + NamedKey::Space => PageNamedKey::Space, + NamedKey::Enter => PageNamedKey::Enter, + NamedKey::Backspace => PageNamedKey::Backspace, + NamedKey::Delete => PageNamedKey::Delete, + NamedKey::ArrowLeft => PageNamedKey::ArrowLeft, + NamedKey::ArrowRight => PageNamedKey::ArrowRight, + NamedKey::ArrowUp => PageNamedKey::ArrowUp, + NamedKey::ArrowDown => PageNamedKey::ArrowDown, + NamedKey::Home => PageNamedKey::Home, + NamedKey::End => PageNamedKey::End, + NamedKey::Alt + | NamedKey::AltGraph + | NamedKey::Control + | NamedKey::Shift + | NamedKey::Super + | NamedKey::Meta => PageNamedKey::Modifier, + _ => PageNamedKey::Other, + }), + _ => PageKey::Named(PageNamedKey::Other), + } +} + +fn page_modifiers(modifiers: ModifiersState) -> Modifiers { + Modifiers { + shift: modifiers.shift_key(), + control: modifiers.control_key(), + option: modifiers.alt_key(), + command: modifiers.super_key(), + } +} + +fn cursor_icon(cursor: Cursor) -> CursorIcon { + match cursor { + Cursor::Default => CursorIcon::Default, + Cursor::Text => CursorIcon::Text, + Cursor::Pointer => CursorIcon::Pointer, + Cursor::Move => CursorIcon::Move, + Cursor::EwResize => CursorIcon::EwResize, + Cursor::NsResize => CursorIcon::NsResize, + Cursor::NwseResize => CursorIcon::NwseResize, + Cursor::NeswResize => CursorIcon::NeswResize, + } } struct App { @@ -105,103 +138,25 @@ impl Session { } } -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]>, - }, - Image { - id: onestore::ExGuid, - handle: [i8; 2], - /// Document point of the press. - press: [f32; 2], - pending_press: Option<[f32; 2]>, - }, -} - -#[derive(Clone, Copy)] -enum PointerFeedback<'a> { - Hover(onestore::ExGuid), - Move(onestore::ExGuid, [f32; 2]), - Resize(&'a TextOutline), - /// A picture being moved or resized, drawn at this origin and size. - Image(onestore::ExGuid, [f32; 2], [f32; 2]), -} - -/// A non-text object holding focus, which hides the text caret and suspends typing. -#[derive(Clone, Copy, Debug, PartialEq)] -enum ObjectFocus { - ReadOnly(usize), - Image(onestore::ExGuid), -} - -impl ObjectFocus { - fn read_only(self) -> Option { - match self { - Self::ReadOnly(index) => Some(index), - Self::Image(_) => None, - } - } -} - struct State { window: Arc, instance: wgpu::Instance, surface: wgpu::Surface<'static>, config: wgpu::SurfaceConfiguration, renderer: Renderer, - engine: TextEngine, - editor: CanvasEditor, + view: PageView, session: Option, 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, - object_focus: Option, - modifiers: ModifiersState, - focused: bool, occluded: bool, - caret: bool, - blink_at: Instant, + ime_allowed: bool, 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, @@ -309,7 +264,7 @@ impl State { .collect(); let dpr = window.scale_factor() as f32; window.set_ime_allowed(true); - window.set_cursor(winit::window::CursorIcon::Text); + window.set_cursor(CursorIcon::Text); window.request_redraw(); eprintln!("Canvas GPU: {:?}; scale factor {dpr}", adapter.get_info()); Ok(Self { @@ -318,187 +273,99 @@ impl State { surface, config, renderer, - engine, - editor, + view: PageView::new( + editor, + engine, + scene, + [size.width, size.height], + dpr, + macos::double_click_interval(), + ), session, 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, - object_focus: None, - modifiers: ModifiersState::empty(), - focused: true, occluded: false, - caret: true, - blink_at: Instant::now() + Duration::from_millis(500), + ime_allowed: true, 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)); + /// Carries out what a page event asked of the platform and follows its changes: the + /// input method's position, accessibility, saving, the window title and a repaint. + fn respond(&mut self, response: Response) -> Result<(), Box> { + match response.request { + Some(Request::EditDate(field)) => self.edit_date(field)?, + Some(Request::Copy(text)) => self.clipboard.set_text(text)?, + Some(Request::Paste) => { + let text = self.clipboard.get_text()?; + let response = self.view.commit_text(text)?; + self.respond(response)?; + } + Some(Request::CharacterPalette) => macos::show_character_palette(), + None => {} } - 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, self.editor.margin_origin()) - }, - )) + if self.ime_allowed != self.view.accepts_text() { + self.ime_allowed = self.view.accepts_text(); + self.window.set_ime_allowed(self.ime_allowed); + } + self.window.set_cursor(cursor_icon(self.view.cursor())); + if response.changed { + let [x0, y0, x1, y1] = self.view.caret_area()?; + self.window.set_ime_cursor_area( + PhysicalPosition::new(x0, y0), + PhysicalSize::new(x1 - x0, y1 - y0), + ); + self.update_accessibility()?; + self.persist()?; + self.title_from_page(); + } + if response.redraw { + self.window.request_redraw(); + } + Ok(()) } - fn image_preview(&self) -> Option<(onestore::ExGuid, [f32; 2], [f32; 2])> { - let Drag::Image { - id, - handle, - press, - pending_press: None, - } = self.drag.as_ref()? - else { - return None; - }; - let (origin, size) = self.editor.image_placement(*id)?; - let point = self.viewport.document_point(self.pointer); - let delta = [point[0] - press[0], point[1] - press[1]]; - if *handle != [0, 0] { - let (origin, size) = resize_image(origin, size, *handle, delta); - return Some((*id, origin, size)); + /// A temporary page's window takes its title's first line. + fn title_from_page(&self) { + let editor = &self.view.editor; + if self.session.is_some() || !editor.active_outline().title { + return; } - // A picture in an outline's flow keeps its place. - if self.editor.image_in_outline(*id) { - return Some((*id, origin, size)); - } - let origin = [origin[0] + delta[0], origin[1] + delta[1]]; - let origin = if self.modifiers.alt_key() { - origin + let title = editor + .active_outline() + .document() + .paragraphs() + .next() + .unwrap() + .text() + .split(['\u{000b}', '\n', '\r']) + .next() + .unwrap(); + let title = if title.trim().is_empty() { + "Untitled" } else { - snap_to_grid(origin, self.editor.margin_origin()) + title }; - Some((*id, origin, size)) - } - - /// The selected picture's handles lie above the page; its body keeps its paint order. - fn hit_test(&self, point: [f32; 2]) -> Option { - let pixel = self.display_scale / self.viewport.scale; - if let Some(ObjectFocus::Image(id)) = self.object_focus - && let Some((origin, size)) = self.editor.image_placement(id) - && let Some(handle) = image_handle_at(image_rect(origin, size), pixel, point) - { - return Some(Hit::Image { id, handle }); + let title = format!("{title} · Temporary page"); + if self.window.title() != title { + self.window.set_title(&title); } - page_hit_test(&self.editor, self.scene.as_ref(), point, pixel) } - fn set_object_focus(&mut self, focus: Option) { - if self.object_focus != focus { - self.editor.finish_composition(); - self.drag = None; - self.object_focus = focus; - self.window.set_ime_allowed(focus.is_none()); - } - } - - /// Page coordinates of a focused object. - fn object_rect(&self, focus: ObjectFocus) -> [f32; 4] { - let (scene, offset) = self.scene.as_ref().unwrap(); - let [x0, y0, x1, y1] = match focus { - ObjectFocus::ReadOnly(index) => scene - .read_only(Some(&self.editor)) - .nth(index) - .unwrap() - .rect(), - ObjectFocus::Image(id) => { - let (origin, size) = self.editor.image_placement(id).unwrap(); - image_rect(origin, size) - } + fn edit_date(&mut self, field: DateField) -> Result<(), Box> { + let Some(date) = self.view.editor.date() else { + return Ok(()); }; - [ - x0 + offset[0], - y0 + offset[1], - x1 + offset[0], - y1 + offset[1], - ] - } - - 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()?; - self.persist()?; - if self.session.is_none() && 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); - } + let timestamp = date.timestamp(); + self.view.editor.finish_composition(); + macos::clear_marked_text(&self.window); + if let Some((timestamp, text)) = macos::edit_date(timestamp, field)? { + let response = self.view.change_date(timestamp, text)?; + self.respond(response)?; } - self.window.request_redraw(); Ok(()) } @@ -508,7 +375,7 @@ impl State { let Some(session) = &mut self.session else { return Ok(()); }; - let after = self.editor.page()?; + let after = self.view.editor.page()?; match session .section .save(session.space, &session.before, &after, "snowbound")? @@ -530,12 +397,9 @@ impl State { return Ok(()); }; let page = session.section.page(session.space)?; - let (scene, editor) = PageScene::from_page(page.clone(), &mut self.engine)?; + let (scene, editor) = PageScene::from_page(page.clone(), &mut self.view.engine)?; session.before = page; - self.editor = editor; - self.scene = Some((scene, [0.0; 2])); - self.drag = None; - self.object_focus = None; + self.view.replace(editor, Some((scene, [0.0; 2]))); self.window.set_title(&session.window_title()); self.update_accessibility()?; self.window.request_redraw(); @@ -557,7 +421,7 @@ impl State { return Ok(()); }; let reviewed = if keep_mine { - self.editor.page()? + self.view.editor.page()? } else { session.section.remote_page(session.space)? }; @@ -610,21 +474,21 @@ impl State { fn update_accessibility(&mut self) -> Result<(), Box> { let mut error = None; - let preview = self.preview(); + let view = &self.view; self.access_adapter.update_if_active(|| { match self.accessibility.update( - &self.editor, - self.viewport, + &view.editor, + view.viewport, &self.window.title(), - preview, + view.outline_preview(), ) { Ok(mut update) => { self.accessibility.append_page_fields( &mut update, - self.scene.as_ref(), - &self.editor, - self.viewport, - self.object_focus.and_then(ObjectFocus::read_only), + view.scene.as_ref(), + &view.editor, + view.viewport, + view.object_focus().and_then(|focus| focus.read_only()), ); update } @@ -662,138 +526,46 @@ impl State { } if let Some(index) = self.accessibility.read_only_for_node(request.target_node) { if request.action == Action::Focus { - self.set_object_focus(Some(ObjectFocus::ReadOnly(index))); self.window.focus_window(); - self.reveal_focus()?; - self.changed()?; + let response = self.view.focus_read_only(index)?; + self.respond(response)?; } return Ok(()); } let Some(outline) = self.accessibility.outline_for_node(request.target_node) else { return Ok(()); }; + let view = &mut self.view; match (request.action, request.data) { (Action::Focus, _) => { - self.editor.focus_outline(outline)?; + view.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()); + view.editor.focus_outline(outline)?; + view.editor.select(selection)?; + trace_input(&view.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())?; + view.editor.focus_outline(outline)?; + view.editor.select_all()?; + view.editor.commit_text(&mut view.engine, text.into())?; } (Action::ReplaceSelectedText, Some(ActionData::Value(text))) => { - self.editor.focus_outline(outline)?; - self.editor.commit_text(&mut self.engine, text.into())?; + view.editor.focus_outline(outline)?; + view.editor.commit_text(&mut view.engine, text.into())?; } _ => return Ok(()), } - self.set_object_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(focus) = self.object_focus { - let [x0, y0, x1, y1] = self.object_rect(focus).map(f64::from); - parley::BoundingBox { x0, y0, x1, y1 } - } 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.object_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)) + let response = self.view.focus_text()?; + self.respond(response) } fn draw(&mut self) -> Result<(), Box> { - trace_input(&("Draw", self.viewport.origin, self.viewport.scale)); - if self.occluded || self.viewport.size.contains(&0) { + let viewport = self.view.viewport; + trace_input(&("Draw", viewport.origin, viewport.scale)); + if self.occluded || viewport.size.contains(&0) { return Ok(()); } let mut reconfigure = false; @@ -827,56 +599,12 @@ impl State { 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(|| { - self.image_preview() - .map(|(id, origin, size)| PointerFeedback::Image(id, origin, size)) - }) - .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.object_focus, - self.caret - && self.focused - && !matches!( - self.drag, - Some(Drag::Outline { .. } | Drag::Resize { .. } | Drag::Image { .. }) - ), - self.viewport.scale, - self.display_scale, - )?; - self.scroll() - .append(self.viewport, self.display_scale, &mut primitives); + let [caret, selection] = macos::text_colors(); + let primitives = self.view.primitives(TextColors { caret, selection })?; self.renderer .draw( &frame.texture.create_view(&Default::default()), - self.viewport, + viewport, &primitives, ) .map_err(|error| format!("Canvas drawing failed: {error:?}"))?; @@ -890,353 +618,42 @@ impl State { } 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 let Some(ObjectFocus::Image(id)) = self.object_focus - && matches!(key, Key::Named(NamedKey::Backspace | NamedKey::Delete)) + let modifiers = self.view.modifiers(); + if modifiers.command + && modifiers.shift + && self.session.is_some() + && self.view.accepts_text() + && let Key::Character(character) = key { - self.editor.remove_image(&mut self.engine, id)?; - self.set_object_focus(None); - return self.changed(); - } - if let Some(ObjectFocus::Image(id)) = self.object_focus - && !(shift || command || option || self.modifiers.control_key()) - && let Key::Named(NamedKey::ArrowLeft | NamedKey::ArrowRight) = key - && self.editor.step_from_image( - &mut self.engine, - id, - key == &Key::Named(NamedKey::ArrowRight), - )? - { - self.set_object_focus(None); - self.reveal_focus()?; - return self.changed(); - } - if let Some(focus) = self.object_focus { - let undo = matches!(focus, ObjectFocus::Image(_)) - && command - && matches!(key, Key::Character(value) if value.eq_ignore_ascii_case("z")); - if !undo && !read_only_shortcut(key, self.modifiers) { - return Ok(()); - } - if key == &Key::Named(NamedKey::Escape) { - self.set_object_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 command && shift && self.session.is_some() { - match key { - Key::Character(character) if character.eq_ignore_ascii_case("k") => { - return self.resolve_conflict(true); - } - Key::Character(character) if character.eq_ignore_ascii_case("t") => { - return self.resolve_conflict(false); - } - _ => {} - } - } - if matches!( - self.drag, - Some(Drag::Outline { .. } | Drag::Resize { .. } | Drag::Image { .. }) - ) { - 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 - .object_focus - .and_then(ObjectFocus::read_only) - .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_object_focus(None); - } else { - self.set_object_focus(Some(ObjectFocus::ReadOnly(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(focus) = self.object_focus { - let rect = self.object_rect(focus); - [rect[2] + 24.0, rect[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, self.editor.margin_origin()), - DEFAULT_OUTLINE_WIDTH, - )?; - self.set_object_focus(None); - } - "a" => self.editor.select_all()?, - "z" => { - if shift { - self.editor.redo(&mut self.engine)?; - } else { - self.editor.undo(&mut self.engine)?; - } - if let Some(ObjectFocus::Image(id)) = self.object_focus - && self.editor.image_placement(id).is_none() - { - self.set_object_focus(None); - } - } - "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(()), + if character.eq_ignore_ascii_case("k") { + return self.resolve_conflict(true); } - } 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)?; + if character.eq_ignore_ascii_case("t") { + return self.resolve_conflict(false); } } - self.reveal_focus()?; - self.changed() + let response = self.view.key(&page_key(key), text)?; + self.respond(response) } } impl App { fn close(&self, event_loop: &ActiveEventLoop) { if self.state.as_ref().is_none_or(|state| { + let editor = &state.view.editor; state.session.is_some() - || state - .editor - .caret_outline() - .is_none_or(TextOutline::is_empty) - && state.initial_date == state.editor.date().map(|date| date.timestamp()) + || editor.caret_outline().is_none_or(TextOutline::is_empty) + && state.initial_date == editor.date().map(|date| date.timestamp()) && state .initial_layouts .iter() .map(|(id, layout)| (*id, layout)) - .eq(state.editor.object_layouts()) + .eq(editor.object_layouts()) && state .initial .iter() .map(|(id, document)| (id, document)) - .eq(state - .editor + .eq(editor .outlines() .iter() .map(|outline| (&outline.id, outline.document()))) @@ -1251,16 +668,11 @@ 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.object_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 Some(state) = &mut self.state { + let result = state + .view + .insert_text(text) + .and_then(|response| state.respond(response)); if let Err(error) = result { eprintln!("{error}"); } @@ -1287,7 +699,7 @@ impl ApplicationHandler for App { if event.window_id != state.window.id() { return; } - let was_marked = state.editor.marked_range().is_some(); + let was_marked = state.view.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), @@ -1296,7 +708,7 @@ impl ApplicationHandler for App { Ok(()) } }; - if was_marked && state.editor.marked_range().is_none() { + if was_marked && state.view.editor.marked_range().is_none() { macos::clear_marked_text(&state.window); } if let Err(error) = result { @@ -1345,333 +757,82 @@ impl ApplicationHandler for App { return; }; let was_marked = - !matches!(event, WindowEvent::Ime(_)) && state.editor.marked_range().is_some(); + !matches!(event, WindowEvent::Ime(_)) && state.view.editor.marked_range().is_some(); let result = (|| -> Result<(), Box> { - match event { - WindowEvent::RedrawRequested => state.draw()?, + let response = match event { + WindowEvent::RedrawRequested => return 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()?; - // Present inside AppKit's resize transaction; a redraw on the next turn - // lets the window show the previous frame at the new size. + } + let response = state.view.resized([size.width, size.height])?; + let changed = response.changed; + state.respond(response)?; + // Present inside AppKit's resize transaction; a redraw on the next turn + // lets the window show the previous frame at the new size. + if changed { state.draw()?; } + return Ok(()); } 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()?; + state.view.scale_factor_changed(scale_factor as f32)? } + WindowEvent::Focused(focused) => state.view.focus_changed(focused)?, WindowEvent::Occluded(occluded) => { trace_input(&("Window occluded", occluded)); state.occluded = occluded; - if !occluded { - state.changed()?; + if occluded { + return Ok(()); } - } - 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 = state.hit_test(state.viewport.document_point(state.pointer)); - 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::Image { handle, .. }), _) => handle_cursor(*handle), - (None, Some(Hit::Image { handle, .. })) => handle_cursor(handle), - (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, .. } | Drag::Image { 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(), + Response { + changed: true, + redraw: true, + request: None, } } + WindowEvent::ModifiersChanged(modifiers) => state + .view + .modifiers_changed(page_modifiers(modifiers.state()))?, + WindowEvent::CursorLeft { .. } => state.view.pointer_left(), + WindowEvent::CursorMoved { position, .. } => state + .view + .pointer_moved([position.x as f32, position.y as f32])?, WindowEvent::MouseInput { - state: button_state, + state: ElementState::Pressed, 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 state.hit_test(point) { - Some(Hit::Date(field)) => state.edit_date(field)?, - Some(Hit::ReadOnly(index)) => { - state.set_object_focus(Some(ObjectFocus::ReadOnly(index))) - } - Some(Hit::Image { id, handle }) => { - state.set_object_focus(Some(ObjectFocus::Image(id))); - state.drag = Some(Drag::Image { - id, - handle, - press: point, - pending_press: Some(state.pointer), - }); - } - Some(Hit::Handle { id, grab }) => { - state.set_object_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_object_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.object_focus.is_none() - && id == state.editor.active_outline().id; - state.set_object_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.margin_origin()) - }; - state.editor.place_caret( - &mut state.engine, - position, - DEFAULT_OUTLINE_WIDTH, - )?; - state.set_object_focus(None); - state.drag = Some(Drag::Text { - anchor: state.editor.selection(), - unit: SelectionUnit::Grapheme, - }); - } - } - } - } else { - let preview = state.preview(); - if let Some((id, origin, size)) = state.image_preview() { - state - .editor - .place_image(&mut state.engine, id, origin, size)?; - } - 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()?; - } + } => state.view.pointer_pressed(Instant::now())?, + WindowEvent::MouseInput { + state: ElementState::Released, + button: MouseButton::Left, + .. + } => state.view.pointer_released()?, WindowEvent::MouseWheel { delta, .. } => { let dpr = state.window.scale_factor() as f32; - let delta = match delta { + state.view.wheel(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.object_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(()) + return state.key(&event.logical_key, event.text.as_deref()); + } + WindowEvent::Ime(Ime::Preedit(text, cursor)) => state.view.compose(text, cursor)?, + WindowEvent::Ime(Ime::Commit(text)) => state.view.commit_text(text)?, + WindowEvent::Ime(Ime::Disabled) => state.view.cancel_composition()?, + _ => return Ok(()), + }; + state.respond(response) })(); - if was_marked && state.editor.marked_range().is_none() { + if was_marked && state.view.editor.marked_range().is_none() { macos::clear_marked_text(&state.window); } if let Err(error) = result { @@ -1683,615 +844,16 @@ impl ApplicationHandler for App { let Some(state) = &mut self.state else { return; }; - let [anchor, focus] = state.editor.selection().positions; - if state.focused && !state.occluded && state.object_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)); + let (repaint, next) = if state.occluded { + (false, None) } 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), - /// `handle` is [0, 0] on the picture and a direction on the selected picture's handles. - Image { - id: onestore::ExGuid, - handle: [i8; 2], - }, -} - -fn page_hit_test( - editor: &CanvasEditor, - scene: Option<&(PageScene, [f32; 2])>, - point: [f32; 2], - pixel: f32, -) -> Option { - /// Grips and text resolve front to back before any outline's padding, as a native - /// width handle stays reachable under the next outline's left padding; the typing room - /// below an outline comes last. - #[derive(Clone, Copy, PartialEq)] - enum Layer { - Grips, - Body, - Below, - } - let hit = |outline: &TextOutline, offset: [f32; 2], layer: Layer| { - if layer == Layer::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 layer == Layer::Grips { - // Width handles: 15 px inside to 6 px outside the header's right end, and 6 px - // either side of the right border below it. - let inside = if y < body_top { 15.0 } else { 6.0 }; - if !outline.title - && (bounds[2] - inside * pixel..=bounds[2] + 6.0 * pixel).contains(&x) - && (bounds[1]..=bounds[3]).contains(&y) - { - return Some(Hit::Resize { - id: outline.id, - grab: point[0] - outline.bounds().x1 as f32, - }); - } - 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], - ], - }); - } - let text = outline.bounds(); - if !(text.x0..=text.x1).contains(&f64::from(x)) - || !(text.y0..=text.y1).contains(&f64::from(y)) - { - return None; - } - } - let inner = [x - outline.origin()[0], y - outline.origin()[1]]; - if let Some(picture) = outline.shaped().objects.iter().find(|object| { - matches!(object.kind, canvas::outline::ObjectKind::Picture) - && (object.rect[0]..=object.rect[2]).contains(&inner[0]) - && (object.rect[1]..=object.rect[3]).contains(&inner[1]) - }) { - return Some(Hit::Image { - id: picture.id, - handle: [0, 0], - }); - } - 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] + outline.shaped().tag_column_offset() + 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 = |layer| { - 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], layer)) - { - 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, layer)) - }, - )? { - 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)), - canvas::gpu::page::SceneHit::Image(id) => Some(Hit::Image { id, handle: [0, 0] }), - } - }; - [Layer::Grips, Layer::Body, Layer::Below] - .into_iter() - .find_map(pass) -} - -fn page_primitives<'a>( - editor: &'a CanvasEditor, - scene: Option<&'a (PageScene, [f32; 2])>, - preview: Option>, - object_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, + state.view.blink(Instant::now()) }; - let origin = match preview { - Some(PointerFeedback::Move(id, origin)) if id == outline.id => origin, - _ => outline.origin(), - }; - if (object_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( - (object_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, _)) = scene { - let moving = match preview { - Some(PointerFeedback::Image(id, origin, size)) => { - let [x0, y0, x1, y1] = image_rect(origin, size); - Some(( - id, - [ - x0 + offset[0], - y0 + offset[1], - x1 + offset[0], - y1 + offset[1], - ], - )) - } - _ => None, - }; - scene.append_outline_objects( - outline.shaped(), - [origin[0] + offset[0], origin[1] + offset[1]], - moving, - primitives, - ); - } - Ok::<_, Box>(()) - }; - if let Some((scene, origin)) = scene { - let moving = match preview { - Some(PointerFeedback::Image(id, origin, size)) => Some((id, image_rect(origin, size))), - _ => None, - }; - scene.append_primitives_with(&mut primitives, *origin, Some(editor), moving, &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 object_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(ObjectFocus::Image(id)) = object_focus { - let (origin, size) = match preview { - Some(PointerFeedback::Image(moving, origin, size)) if moving == id => (origin, size), - _ => editor - .image_placement(id) - .ok_or("The selected picture is missing.")?, - }; - append_image_chrome( - image_rect(origin, size), - display_scale / scale, - &mut primitives, - ); - } - if let Some(ObjectFocus::ReadOnly(index)) = object_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) -} - -/// Native picture handles sit on a selection border drawn 5 px outside the picture, named -/// by their direction from its center. -fn image_handles(rect: [f32; 4], pixel: f32) -> impl Iterator { - let border = [ - rect[0] - 5.0 * pixel, - rect[1] - 5.0 * pixel, - rect[2] + 5.0 * pixel, - rect[3] + 5.0 * pixel, - ]; - [-1, 0, 1] - .into_iter() - .flat_map(|y| [-1, 0, 1].map(|x| [x, y])) - .filter(|handle| *handle != [0, 0]) - .map(move |handle| { - ( - handle, - std::array::from_fn(|axis| { - let [start, end] = [border[axis], border[axis + 2]]; - start + (end - start) * f32::from(handle[axis] + 1) * 0.5 - }), - ) - }) -} - -fn image_handle_at(rect: [f32; 4], pixel: f32, point: [f32; 2]) -> Option<[i8; 2]> { - image_handles(rect, pixel) - .find(|(_, center)| (0..2).all(|axis| (point[axis] - center[axis]).abs() <= 5.0 * pixel)) - .map(|(handle, _)| handle) -} - -fn handle_cursor(handle: [i8; 2]) -> winit::window::CursorIcon { - use winit::window::CursorIcon; - match handle { - [0, 0] => CursorIcon::Move, - [_, 0] => CursorIcon::EwResize, - [0, _] => CursorIcon::NsResize, - [x, y] if x == y => CursorIcon::NwseResize, - _ => CursorIcon::NeswResize, - } -} - -fn image_rect(origin: [f32; 2], size: [f32; 2]) -> [f32; 4] { - [ - origin[0], - origin[1], - origin[0] + size[0], - origin[1] + size[1], - ] -} - -/// Drags a picture's `handle` by `delta`: edges stretch one axis, corners keep the aspect -/// ratio at the larger of the two scales, and the opposite side stays fixed. -fn resize_image( - origin: [f32; 2], - size: [f32; 2], - handle: [i8; 2], - delta: [f32; 2], -) -> ([f32; 2], [f32; 2]) { - let mut scale: [f32; 2] = std::array::from_fn(|axis| { - (size[axis] + f32::from(handle[axis]) * delta[axis]) / size[axis] - }); - if !handle.contains(&0) { - scale = [scale[0].max(scale[1]); 2]; - } - let resized: [f32; 2] = std::array::from_fn(|axis| (size[axis] * scale[axis]).max(1.0)); - let origin = std::array::from_fn(|axis| { - if handle[axis] < 0 { - origin[axis] + size[axis] - resized[axis] - } else { - origin[axis] - } - }); - (origin, resized) -} - -fn append_image_chrome(rect: [f32; 4], pixel: f32, primitives: &mut Vec>) { - let mut tint = canvas::gpu::colorref(0x00e0d2e6); - // Native pictures take this tint at 25% in sRGB; 10% in linear light matches it. - tint[3] = 0.1; - primitives.push(Primitive::Rect { rect, color: tint }); - primitives.push(Primitive::RoundedRect { - rect: [ - rect[0] - 5.0 * pixel, - rect[1] - 5.0 * pixel, - rect[2] + 5.0 * pixel, - rect[3] + 5.0 * pixel, - ], - radius: [0.0; 2], - stroke: Some(Stroke::Dashed(pixel)), - color: canvas::gpu::colorref(0x00ff9a31), - }); - for (handle, [x, y]) in image_handles(rect, pixel) { - let (half, radius) = if handle.contains(&0) { - (3.5, 0.0) - } else { - (4.0, 4.0) - }; - let rect = [ - x - half * pixel, - y - half * pixel, - x + half * pixel, - y + half * pixel, - ]; - primitives.push(Primitive::RoundedRect { - rect, - radius: [radius * pixel; 2], - stroke: None, - color: canvas::gpu::colorref(0x00ffefe7), - }); - primitives.push(Primitive::RoundedRect { - rect, - radius: [radius * pixel; 2], - stroke: Some(Stroke::Solid(pixel)), - color: canvas::gpu::colorref(0x00dea67b), - }); - } -} - -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], margin: [f32; 2]) -> [f32; 2] { - std::array::from_fn(|axis| { - let offset = margin[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() { - outline - .shaped() - .append_paragraph_primitives(index, paragraph, origin, primitives); - } - 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], - }); + if repaint { + state.window.request_redraw(); } + event_loop.set_control_flow(next.map_or(ControlFlow::Wait, ControlFlow::WaitUntil)); } - Ok(()) } fn main() -> Result<(), Box> { @@ -2407,1410 +969,3 @@ fn main() -> Result<(), Box> { 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 { - identity: None, - title: "Header".into(), - created: Some(1), - margin_origin: [36.0, 14.4], - definitions: Default::default(), - objects: vec![onestore::page::PageObject::Title(onestore::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 = onestore::page::Page { - identity: None, - title: String::new(), - created: None, - margin_origin: [0.0; 2], - definitions: Default::default(), - objects: vec![onestore::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 a_covered_width_handle_stays_reachable_beside_the_outline_above_it() { - let mut engine = TextEngine::default(); - let pixel = 0.75; - let mut first = TextOutline::new( - &mut engine, - TextDocument::new(vec![Paragraph::new("text".into(), Format::default())]).unwrap(), - 100.0, - [36.0, 68.4], - ) - .unwrap(); - let editor = - CanvasEditor::from_text_outlines(vec![first.clone()], Default::default(), None) - .unwrap(); - first = editor.preview_resize(&mut engine, 226.5).unwrap(); - let (frame, body_top) = outline_chrome(&first, pixel); - // The casual night page: the next outline starts 7.5 pt right of the first one. - let second = TextOutline::new( - &mut engine, - TextDocument::new(vec![Paragraph::new("text".into(), Format::default())]).unwrap(), - 100.0, - [first.bounds().x1 as f32 + 7.5, 68.4], - ) - .unwrap(); - let (first_id, second_id) = (first.id, second.id); - let editor = - CanvasEditor::from_text_outlines(vec![first, second], Default::default(), None) - .unwrap(); - let header = (frame[1] + body_top) / 2.0; - let hit = |x| page_hit_test(&editor, None, [x, header], pixel); - assert!( - matches!(hit(frame[2] - 12.0 * pixel), Some(Hit::Resize { id, .. }) if id == first_id) - ); - assert!( - matches!(hit(frame[2] - 2.0 * pixel), Some(Hit::Handle { id, .. }) if id == second_id) - ); - let body = |x| page_hit_test(&editor, None, [x, body_top + 20.0], pixel); - assert!( - matches!(body(frame[2] - 4.0 * pixel), Some(Hit::Resize { id, .. }) if id == first_id) - ); - assert!( - matches!(body(frame[2] - 8.0 * pixel), Some(Hit::Text { id, .. }) if id == second_id) - ); - assert!( - matches!(body(frame[2] - 12.0 * pixel), Some(Hit::Text { id, .. }) if id == first_id) - ); - } - - #[test] - fn a_picture_in_an_outline_takes_the_click_over_its_text() { - use onestore::page::{Image, Page, PageObject, ParagraphContent}; - let mut engine = TextEngine::default(); - let mut source = TextOutline::new( - &mut engine, - TextDocument::new(vec![ - Paragraph::new("Before".into(), Default::default()), - Paragraph::new("After".into(), Default::default()), - ]) - .unwrap(), - 240.0, - [36.0, 36.0], - ) - .unwrap() - .snapshot(); - let mut picture = source.paragraphs[0].clone(); - picture.id = onestore::page::text::new_id().unwrap(); - let id = onestore::page::text::new_id().unwrap(); - picture.content = ParagraphContent::Image(Image { - size: Some([40.0, 30.0]), - id, - layout: Default::default(), - bytes: None, - alt: None, - background: false, - }); - source.paragraphs.insert(1, picture); - let editor = CanvasEditor::from_page( - Page { - title: String::new(), - identity: None, - created: None, - margin_origin: [36.0, 14.4], - definitions: Default::default(), - objects: vec![PageObject::Outline(source)], - }, - &mut engine, - ) - .unwrap(); - let (origin, size) = editor.image_placement(id).unwrap(); - let center = [origin[0] + size[0] / 2.0, origin[1] + size[1] / 2.0]; - assert_eq!( - page_hit_test(&editor, None, center, 1.0), - Some(Hit::Image { id, handle: [0, 0] }) - ); - assert!(matches!( - page_hit_test(&editor, None, [origin[0] + 2.0, origin[1] - 4.0], 1.0), - Some(Hit::Text { .. }) - )); - } - - #[test] - fn picture_handles_resize_as_onenote_does() { - // Native drags of the 333 x 200.1 pt mockup on "av: casual night in the trees". - let (origin, size) = ([468.0, 86.4], [333.0, 200.1]); - let (corner_origin, corner) = resize_image(origin, size, [1, -1], [-75.0, 30.75]); - assert!((corner[0] - 281.827).abs() < 0.01 && (corner[1] - 169.351).abs() < 0.01); - assert!((corner_origin[1] - 117.15).abs() < 0.01 && corner_origin[0] == 468.0); - assert_eq!( - resize_image(origin, size, [1, 0], [46.5, 10.0]), - (origin, [379.5, 200.1]) - ); - let (left_origin, left) = resize_image(origin, size, [-1, 0], [400.0, 0.0]); - assert_eq!(left, [1.0, 200.1]); - assert_eq!(left_origin[0], 468.0 + 333.0 - 1.0); - let pixel = 0.75; - let rect = image_rect(origin, size); - assert_eq!( - image_handle_at(rect, pixel, [468.0 - 3.75, 86.4 - 3.75]), - Some([-1, -1]) - ); - assert_eq!( - image_handle_at(rect, pixel, [468.0 + 166.5, 286.5 + 3.75]), - Some([0, 1]) - ); - assert_eq!(image_handle_at(rect, pixel, [600.0, 150.0]), None); - } - - #[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 - ); - } - - const DEFAULT_MARGIN: [f32; 2] = [36.0, 14.4]; - - #[test] - fn native_grid_follows_the_page_margin_origin() { - // "av: casual night in the trees": margin 36.75; a 22.5 x 15 pt drag lands one cell on. - let margin = [36.75, 14.4]; - let result = snap_to_grid([468.75 + 22.5, 86.4 + 15.0], margin); - assert!((result[0] - 486.75).abs() < 0.0001); - assert!((result[1] - 104.4).abs() < 0.0001); - } - - #[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, DEFAULT_MARGIN); - 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], - DEFAULT_MARGIN, - ); - 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, DEFAULT_MARGIN); - 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, DEFAULT_MARGIN), 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], - DEFAULT_MARGIN, - ); - 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 onestore::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 { - identity: None, - 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 onestore::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 { - identity: None, - 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(ObjectFocus::ReadOnly(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(ObjectFocus::ReadOnly(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(ObjectFocus::ReadOnly(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/tools/canvas/README.md b/tools/canvas/README.md index 2de1bb0b9ed015febd39ae49d881102a6dee014b..1942640d97e8a3c03b777e2241b0e45471386070 100644 --- a/tools/canvas/README.md +++ b/tools/canvas/README.md @@ -9,7 +9,7 @@ The host opens nonempty UTF-8 text as paragraphs in one temporary text outline. ```sh python3 tools/canvas/build_macos.py "target/Snowbound.app/Contents/MacOS/Snowbound" -cargo test -p canvas +cargo test -p canvas --features interaction cargo test -p canvas --features gpu gpu:: -- --ignored ``` @@ -32,7 +32,10 @@ canvas text edits · inverse history · composition · paragraph layout · ↓ canvas::gpu Swash glyph rasterization · custom wgpu quads · bounded glyph/image caches ↓ -snowbound Winit window/input · AppKit quit guard · clipboard · AccessKit +canvas::interaction hit layers · drags · placement grid · picture handles · chrome · key routing + · scrolling and zoom · caret blink · AccessKit tree + ↓ +snowbound Winit window and events · AppKit pickers and quit guard · clipboard · saving ``` 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. @@ -99,7 +102,7 @@ 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 snowbound +cargo test -p canvas --features interaction accessibility SNOWBOUND_TRACE_INPUT=1 "target/Snowbound.app/Contents/MacOS/Snowbound" SYNTHETIC_TEXT_FILE ``` @@ -130,7 +133,7 @@ The host sleeps its caret timer while macOS reports the window occluded and requ 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 snowbound 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 canvas --features interaction imported_page_widgets_and_accessibility_follow_edits -- --ignored --nocapture ``` ## Unsupported page content @@ -222,8 +225,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 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 +cargo test --release -p canvas --features interaction 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 canvas --features interaction 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. -- 2.54.0