| author | |
| committer | |
| log | 1b0d6e5c862e65471df75c1239a1a111b9c11302 |
| tree | bf5201a6d1ad36b0748cf5b351f0d1b9261d2f73 |
| parent | dc52eff3f6dfc98dc61d48517051cac6ad04d9de |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
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.515 files changed, 5470 insertions(+), 4895 deletions(-)
Cargo.lock+2-1| ... | @@ -393,6 +393,8 @@ dependencies = [ | ... | @@ -393,6 +393,8 @@ dependencies = [ |
| 393 | name = "canvas" | 393 | name = "canvas" |
| 394 | version = "0.1.0" | 394 | version = "0.1.0" |
| 395 | dependencies = [ | 395 | dependencies = [ |
| 396 | "accesskit", | ||
| 397 | "accesskit_consumer", | ||
| 396 | "bytemuck", | 398 | "bytemuck", |
| 397 | "image", | 399 | "image", |
| 398 | "onestore", | 400 | "onestore", |
| ... | @@ -2627,7 +2629,6 @@ name = "snowbound" | ... | @@ -2627,7 +2629,6 @@ name = "snowbound" |
| 2627 | version = "0.1.0" | 2629 | version = "0.1.0" |
| 2628 | dependencies = [ | 2630 | dependencies = [ |
| 2629 | "accesskit", | 2631 | "accesskit", |
| 2630 | "accesskit_consumer", | ||
| 2631 | "accesskit_winit", | 2632 | "accesskit_winit", |
| 2632 | "arboard", | 2633 | "arboard", |
| 2633 | "canvas", | 2634 | "canvas", |
crates/canvas/Cargo.toml+4| ... | @@ -6,6 +6,8 @@ publish = false | ... | @@ -6,6 +6,8 @@ publish = false |
| 6 | 6 | ||
| 7 | [features] | 7 | [features] |
| 8 | gpu = ["dep:bytemuck", "dep:swash", "dep:wgpu", "dep:image", "dep:roxmltree"] | 8 | gpu = ["dep:bytemuck", "dep:swash", "dep:wgpu", "dep:image", "dep:roxmltree"] |
| 9 | # How a page responds to input and presents itself to assistive technology. | ||
| 10 | interaction = ["gpu", "dep:accesskit"] | ||
| 9 | 11 | ||
| 10 | [dependencies] | 12 | [dependencies] |
| 11 | onestore = { path = "../onestore" } | 13 | onestore = { path = "../onestore" } |
| ... | @@ -19,6 +21,7 @@ swash = { version = "0.2.10", optional = true } | ... | @@ -19,6 +21,7 @@ swash = { version = "0.2.10", optional = true } |
| 19 | wgpu = { workspace = true, optional = true } | 21 | wgpu = { workspace = true, optional = true } |
| 20 | image = { version = "0.25", default-features = false, features = ["gif", "jpeg", "png"], optional = true } | 22 | image = { version = "0.25", default-features = false, features = ["gif", "jpeg", "png"], optional = true } |
| 21 | roxmltree = { version = "0.21", optional = true } | 23 | roxmltree = { version = "0.21", optional = true } |
| 24 | accesskit = { version = "0.25.0", optional = true } | ||
| 22 | 25 | ||
| 23 | [[bin]] | 26 | [[bin]] |
| 24 | name = "layout-probe" | 27 | name = "layout-probe" |
| ... | @@ -29,6 +32,7 @@ name = "page-probe" | ... | @@ -29,6 +32,7 @@ name = "page-probe" |
| 29 | path = "src/bin/page_probe.rs" | 32 | path = "src/bin/page_probe.rs" |
| 30 | 33 | ||
| 31 | [dev-dependencies] | 34 | [dev-dependencies] |
| 35 | accesskit_consumer = "0.39.0" | ||
| 32 | pollster = "0.4" | 36 | pollster = "0.4" |
| 33 | png = "0.18" | 37 | png = "0.18" |
| 34 | 38 |
crates/canvas/src/interaction/accessibility.rs created+1478| ... | @@ -0,0 +1,1478 @@ | ||
| 1 | use crate::gpu::Viewport; | ||
| 2 | use crate::{ | ||
| 3 | date::DateField, | ||
| 4 | document::TextPosition, | ||
| 5 | editor::{CanvasEditor, Selection, TextOutline}, | ||
| 6 | }; | ||
| 7 | use accesskit::{ | ||
| 8 | Action, Affine, Node, NodeId, Rect, Role, TextDirection, TextSelection, TreeId, TreeInfo, | ||
| 9 | TreeUpdate, | ||
| 10 | }; | ||
| 11 | use onestore::page::text::EditError; | ||
| 12 | use parley::Affinity; | ||
| 13 | use std::{collections::HashMap, ops::Range}; | ||
| 14 | |||
| 15 | pub const ROOT: NodeId = NodeId(0); | ||
| 16 | |||
| 17 | struct Run { | ||
| 18 | outline: onestore::ExGuid, | ||
| 19 | id: NodeId, | ||
| 20 | node: Node, | ||
| 21 | positions: Vec<TextPosition>, | ||
| 22 | } | ||
| 23 | |||
| 24 | #[derive(Hash, PartialEq, Eq)] | ||
| 25 | struct ParagraphKey { | ||
| 26 | layout: u64, | ||
| 27 | source: usize, | ||
| 28 | next: Option<usize>, | ||
| 29 | origin: [u32; 2], | ||
| 30 | } | ||
| 31 | |||
| 32 | pub struct Accessibility { | ||
| 33 | outlines: Vec<(onestore::ExGuid, NodeId)>, | ||
| 34 | runs: Vec<Run>, | ||
| 35 | paragraphs: HashMap<ParagraphKey, Range<usize>>, | ||
| 36 | read_only: Vec<NodeId>, | ||
| 37 | dates: Vec<(DateField, NodeId)>, | ||
| 38 | next_id: u64, | ||
| 39 | } | ||
| 40 | |||
| 41 | impl Default for Accessibility { | ||
| 42 | fn default() -> Self { | ||
| 43 | Self { | ||
| 44 | outlines: Vec::new(), | ||
| 45 | runs: Vec::new(), | ||
| 46 | paragraphs: HashMap::new(), | ||
| 47 | read_only: Vec::new(), | ||
| 48 | dates: Vec::new(), | ||
| 49 | next_id: 1, | ||
| 50 | } | ||
| 51 | } | ||
| 52 | } | ||
| 53 | |||
| 54 | impl Accessibility { | ||
| 55 | /// Retire published runs without recycling their IDs. | ||
| 56 | pub fn deactivate(&mut self) { | ||
| 57 | self.runs.clear(); | ||
| 58 | self.paragraphs.clear(); | ||
| 59 | self.outlines.clear(); | ||
| 60 | self.read_only.clear(); | ||
| 61 | self.dates.clear(); | ||
| 62 | } | ||
| 63 | |||
| 64 | pub fn append_page_fields( | ||
| 65 | &mut self, | ||
| 66 | update: &mut TreeUpdate, | ||
| 67 | scene: Option<&(crate::gpu::page::PageScene, [f32; 2])>, | ||
| 68 | editor: &CanvasEditor, | ||
| 69 | viewport: Viewport, | ||
| 70 | focus: Option<usize>, | ||
| 71 | ) { | ||
| 72 | let mut count = 0; | ||
| 73 | if let Some((scene, offset)) = scene { | ||
| 74 | for (index, object) in scene.read_only(Some(editor)).enumerate() { | ||
| 75 | if index == self.read_only.len() { | ||
| 76 | let id = self.allocate(); | ||
| 77 | self.read_only.push(id); | ||
| 78 | } | ||
| 79 | let id = self.read_only[index]; | ||
| 80 | let mut node = Node::new(Role::Label); | ||
| 81 | node.set_value(object.message); | ||
| 82 | node.set_read_only(); | ||
| 83 | node.add_action(Action::Focus); | ||
| 84 | if focus == Some(index) { | ||
| 85 | update.focus = id; | ||
| 86 | } | ||
| 87 | let [x0, y0, x1, y1] = object.rect(); | ||
| 88 | let x = |v| f64::from((v + offset[0]) * viewport.scale + viewport.origin[0]); | ||
| 89 | let y = |v| f64::from((v + offset[1]) * viewport.scale + viewport.origin[1]); | ||
| 90 | node.set_bounds(Rect::new(x(x0), y(y0), x(x1), y(y1))); | ||
| 91 | update | ||
| 92 | .nodes | ||
| 93 | .iter_mut() | ||
| 94 | .find(|(id, _)| *id == ROOT) | ||
| 95 | .unwrap() | ||
| 96 | .1 | ||
| 97 | .push_child(id); | ||
| 98 | update.nodes.push((id, node)); | ||
| 99 | count += 1; | ||
| 100 | } | ||
| 101 | } | ||
| 102 | self.read_only.truncate(count); | ||
| 103 | let mut date_count = 0; | ||
| 104 | if let Some((scene, offset)) = scene { | ||
| 105 | for (index, (field, rect)) in scene.date_fields(editor).enumerate() { | ||
| 106 | if index == self.dates.len() { | ||
| 107 | let id = self.allocate(); | ||
| 108 | self.dates.push((field, id)); | ||
| 109 | } | ||
| 110 | self.dates[index].0 = field; | ||
| 111 | let id = self.dates[index].1; | ||
| 112 | let mut node = Node::new(Role::Button); | ||
| 113 | node.set_label(super::DATE_LABELS[field as usize]); | ||
| 114 | node.set_value( | ||
| 115 | editor.date().unwrap().source().paragraphs[index] | ||
| 116 | .text() | ||
| 117 | .unwrap() | ||
| 118 | .text | ||
| 119 | .text(), | ||
| 120 | ); | ||
| 121 | node.add_action(Action::Click); | ||
| 122 | let x = |v| f64::from((v + offset[0]) * viewport.scale + viewport.origin[0]); | ||
| 123 | let y = |v| f64::from((v + offset[1]) * viewport.scale + viewport.origin[1]); | ||
| 124 | node.set_bounds(Rect::new(x(rect[0]), y(rect[1]), x(rect[2]), y(rect[3]))); | ||
| 125 | update | ||
| 126 | .nodes | ||
| 127 | .iter_mut() | ||
| 128 | .find(|(id, _)| *id == ROOT) | ||
| 129 | .unwrap() | ||
| 130 | .1 | ||
| 131 | .push_child(id); | ||
| 132 | update.nodes.push((id, node)); | ||
| 133 | date_count += 1; | ||
| 134 | } | ||
| 135 | } | ||
| 136 | self.dates.truncate(date_count); | ||
| 137 | } | ||
| 138 | |||
| 139 | fn allocate(&mut self) -> NodeId { | ||
| 140 | let id = NodeId(self.next_id); | ||
| 141 | self.next_id = self | ||
| 142 | .next_id | ||
| 143 | .checked_add(1) | ||
| 144 | .expect("Accessibility node IDs exhausted"); | ||
| 145 | id | ||
| 146 | } | ||
| 147 | |||
| 148 | pub fn read_only_for_node(&self, node: NodeId) -> Option<usize> { | ||
| 149 | self.read_only.iter().position(|id| *id == node) | ||
| 150 | } | ||
| 151 | |||
| 152 | pub fn date_for_node(&self, node: NodeId) -> Option<DateField> { | ||
| 153 | self.dates | ||
| 154 | .iter() | ||
| 155 | .find(|(_, id)| *id == node) | ||
| 156 | .map(|(field, _)| *field) | ||
| 157 | } | ||
| 158 | |||
| 159 | pub fn outline_for_node(&self, node: NodeId) -> Option<onestore::ExGuid> { | ||
| 160 | self.outlines | ||
| 161 | .iter() | ||
| 162 | .find(|(_, id)| *id == node) | ||
| 163 | .map(|(outline, _)| *outline) | ||
| 164 | } | ||
| 165 | |||
| 166 | pub fn update( | ||
| 167 | &mut self, | ||
| 168 | editor: &CanvasEditor, | ||
| 169 | viewport: Viewport, | ||
| 170 | title: &str, | ||
| 171 | preview: Option<(onestore::ExGuid, [f32; 2])>, | ||
| 172 | ) -> Result<TreeUpdate, EditError> { | ||
| 173 | let mut runs = Vec::new(); | ||
| 174 | let mut outlines = Vec::new(); | ||
| 175 | let mut paragraph_ranges = HashMap::new(); | ||
| 176 | for outline in editor.visible_outlines().chain(editor.caret_outline()) { | ||
| 177 | let field = self | ||
| 178 | .outlines | ||
| 179 | .iter() | ||
| 180 | .find(|(id, _)| *id == outline.id) | ||
| 181 | .map(|(_, node)| *node) | ||
| 182 | .unwrap_or_else(|| self.allocate()); | ||
| 183 | outlines.push((outline.id, field)); | ||
| 184 | let mut paragraphs = outline.layouts().peekable(); | ||
| 185 | while let Some((paragraph, shaped)) = paragraphs.next() { | ||
| 186 | let layout = &shaped.text; | ||
| 187 | let origin = shaped.origin; | ||
| 188 | let key = ParagraphKey { | ||
| 189 | layout: layout.id(), | ||
| 190 | source: paragraph, | ||
| 191 | next: paragraphs.peek().map(|(next, _)| *next), | ||
| 192 | origin: origin.map(f32::to_bits), | ||
| 193 | }; | ||
| 194 | let start = runs.len(); | ||
| 195 | if let Some(range) = self.paragraphs.get(&key) { | ||
| 196 | runs.extend( | ||
| 197 | self.runs[range.clone()] | ||
| 198 | .iter() | ||
| 199 | .map(|run| (run.outline, run.node.clone(), run.positions.clone())), | ||
| 200 | ); | ||
| 201 | paragraph_ranges.insert(key, start..runs.len()); | ||
| 202 | continue; | ||
| 203 | } | ||
| 204 | let projection = &shaped.projection; | ||
| 205 | let text = projection.text().text(); | ||
| 206 | let boundaries: Vec<_> = projection.source_boundaries().collect(); | ||
| 207 | let source_position = |byte| -> Result<TextPosition, EditError> { | ||
| 208 | let index = boundaries | ||
| 209 | .binary_search_by_key(&byte, |(byte, _)| *byte) | ||
| 210 | .map_err(|_| EditError::InvalidRange)?; | ||
| 211 | Ok(TextPosition { | ||
| 212 | paragraph, | ||
| 213 | offset: boundaries[index].1, | ||
| 214 | }) | ||
| 215 | }; | ||
| 216 | let line_count = layout.lines().count(); | ||
| 217 | for (line_index, (line, bounds)) in layout.lines().enumerate() { | ||
| 218 | let line_start = runs.len(); | ||
| 219 | let mut x = line.metrics().offset; | ||
| 220 | let mut logical_runs = Vec::new(); | ||
| 221 | for run in line.runs() { | ||
| 222 | let advance = run.advance(); | ||
| 223 | logical_runs.push((run, x)); | ||
| 224 | x += advance; | ||
| 225 | } | ||
| 226 | logical_runs.sort_by_key(|(run, _)| run.text_range().start); | ||
| 227 | for (run, x) in logical_runs { | ||
| 228 | // AccessKit stores each character's byte length in a u8. | ||
| 229 | let mut characters = Vec::new(); | ||
| 230 | for cluster in run.clusters() { | ||
| 231 | let range = cluster.text_range(); | ||
| 232 | if range.is_empty() || range.start == text.len() { | ||
| 233 | continue; | ||
| 234 | } | ||
| 235 | let cluster_text = | ||
| 236 | text.get(range.clone()).ok_or(EditError::InvalidRange)?; | ||
| 237 | if range.len() <= u8::MAX as usize { | ||
| 238 | characters.push(( | ||
| 239 | range, | ||
| 240 | cluster.advance(), | ||
| 241 | cluster.is_word_boundary(), | ||
| 242 | )); | ||
| 243 | } else { | ||
| 244 | for (index, (offset, ch)) in cluster_text.char_indices().enumerate() | ||
| 245 | { | ||
| 246 | characters.push(( | ||
| 247 | range.start + offset..range.start + offset + ch.len_utf8(), | ||
| 248 | if index == 0 { cluster.advance() } else { 0.0 }, | ||
| 249 | index == 0 && cluster.is_word_boundary(), | ||
| 250 | )); | ||
| 251 | } | ||
| 252 | } | ||
| 253 | } | ||
| 254 | let mut advance = 0.0; | ||
| 255 | for chunk in characters.chunks(256) { | ||
| 256 | let width: f32 = chunk.iter().map(|(_, width, _)| width).sum(); | ||
| 257 | let left = x + if run.is_rtl() { | ||
| 258 | run.advance() - advance - width | ||
| 259 | } else { | ||
| 260 | advance | ||
| 261 | }; | ||
| 262 | let mut node = Node::new(Role::TextRun); | ||
| 263 | node.set_bounds(Rect::new( | ||
| 264 | f64::from(left + origin[0]), | ||
| 265 | f64::from(bounds.top + origin[1]), | ||
| 266 | f64::from(left + width + origin[0]), | ||
| 267 | f64::from(bounds.top + bounds.height + origin[1]), | ||
| 268 | )); | ||
| 269 | node.set_text_direction(if run.is_rtl() { | ||
| 270 | TextDirection::RightToLeft | ||
| 271 | } else { | ||
| 272 | TextDirection::LeftToRight | ||
| 273 | }); | ||
| 274 | node.set_font_size(run.font_size()); | ||
| 275 | let start = chunk[0].0.start; | ||
| 276 | let end = chunk.last().unwrap().0.end; | ||
| 277 | node.set_value(text.get(start..end).ok_or(EditError::InvalidRange)?); | ||
| 278 | node.set_character_lengths( | ||
| 279 | chunk | ||
| 280 | .iter() | ||
| 281 | .map(|(range, _, _)| u8::try_from(range.len()).unwrap()) | ||
| 282 | .collect::<Vec<_>>(), | ||
| 283 | ); | ||
| 284 | let mut offset = 0.0; | ||
| 285 | node.set_character_positions( | ||
| 286 | chunk | ||
| 287 | .iter() | ||
| 288 | .map(|(_, width, _)| { | ||
| 289 | let position = offset; | ||
| 290 | offset += width; | ||
| 291 | position | ||
| 292 | }) | ||
| 293 | .collect::<Vec<_>>(), | ||
| 294 | ); | ||
| 295 | node.set_character_widths( | ||
| 296 | chunk.iter().map(|(_, width, _)| *width).collect::<Vec<_>>(), | ||
| 297 | ); | ||
| 298 | node.set_word_starts( | ||
| 299 | chunk | ||
| 300 | .iter() | ||
| 301 | .enumerate() | ||
| 302 | .filter_map(|(index, (_, _, word))| word.then_some(index as u8)) | ||
| 303 | .collect::<Vec<_>>(), | ||
| 304 | ); | ||
| 305 | let positions = chunk | ||
| 306 | .iter() | ||
| 307 | .map(|(range, _, _)| source_position(range.start)) | ||
| 308 | .chain(std::iter::once(source_position(end))) | ||
| 309 | .collect::<Result<Vec<_>, _>>()?; | ||
| 310 | runs.push((outline.id, node, positions)); | ||
| 311 | advance += width; | ||
| 312 | } | ||
| 313 | } | ||
| 314 | // Empty lines still need a text position for selection and insertion. | ||
| 315 | if runs.len() == line_start { | ||
| 316 | let mut node = Node::new(Role::TextRun); | ||
| 317 | node.set_value(""); | ||
| 318 | node.set_character_lengths(Vec::<u8>::new()); | ||
| 319 | node.set_character_positions(Vec::<f32>::new()); | ||
| 320 | node.set_character_widths(Vec::<f32>::new()); | ||
| 321 | node.set_text_direction(TextDirection::LeftToRight); | ||
| 322 | node.set_bounds(Rect::new( | ||
| 323 | f64::from(origin[0]), | ||
| 324 | f64::from(bounds.top + origin[1]), | ||
| 325 | f64::from(origin[0]), | ||
| 326 | f64::from(bounds.top + bounds.height + origin[1]), | ||
| 327 | )); | ||
| 328 | runs.push(( | ||
| 329 | outline.id, | ||
| 330 | node, | ||
| 331 | vec![source_position(bounds.source.start)?], | ||
| 332 | )); | ||
| 333 | } | ||
| 334 | if line_index + 1 == line_count | ||
| 335 | && let Some((next, _)) = paragraphs.peek() | ||
| 336 | { | ||
| 337 | let (_, node, source_positions) = runs.last_mut().unwrap(); | ||
| 338 | let mut value = node.value().unwrap().to_owned(); | ||
| 339 | value.push('\n'); | ||
| 340 | node.set_value(value); | ||
| 341 | let mut lengths = node.character_lengths().to_vec(); | ||
| 342 | lengths.push(1); | ||
| 343 | node.set_character_lengths(lengths); | ||
| 344 | if let Some(rect) = node.bounds() { | ||
| 345 | let mut positions = | ||
| 346 | node.character_positions().unwrap_or_default().to_vec(); | ||
| 347 | positions.push(rect.width() as f32); | ||
| 348 | node.set_character_positions(positions); | ||
| 349 | let mut widths = node.character_widths().unwrap_or_default().to_vec(); | ||
| 350 | widths.push(4.0); | ||
| 351 | node.set_character_widths(widths); | ||
| 352 | } | ||
| 353 | source_positions.push(TextPosition { | ||
| 354 | paragraph: *next, | ||
| 355 | offset: 0, | ||
| 356 | }); | ||
| 357 | } | ||
| 358 | } | ||
| 359 | if !shaped.tags.is_empty() { | ||
| 360 | let descriptions = shaped | ||
| 361 | .tags | ||
| 362 | .iter() | ||
| 363 | .map(|tag| { | ||
| 364 | use crate::outline::TagIcon; | ||
| 365 | let fallback = match tag.icon { | ||
| 366 | TagIcon::CheckBox { .. } => "To do", | ||
| 367 | TagIcon::Question => "Question", | ||
| 368 | TagIcon::Music => "Music", | ||
| 369 | TagIcon::Exclamation => "Critical", | ||
| 370 | TagIcon::RedSquare => "Project A", | ||
| 371 | TagIcon::YellowSquare => "Project B", | ||
| 372 | TagIcon::BlueSquare => "Project C", | ||
| 373 | }; | ||
| 374 | let label = if tag.label.is_empty() { | ||
| 375 | fallback | ||
| 376 | } else { | ||
| 377 | &tag.label | ||
| 378 | }; | ||
| 379 | let state = match tag.icon { | ||
| 380 | TagIcon::CheckBox { checked: true } => ", completed", | ||
| 381 | TagIcon::CheckBox { checked: false } => ", incomplete", | ||
| 382 | _ => "", | ||
| 383 | }; | ||
| 384 | format!( | ||
| 385 | "{label}{state}{}", | ||
| 386 | if tag.disabled { ", disabled" } else { "" } | ||
| 387 | ) | ||
| 388 | }) | ||
| 389 | .collect::<Vec<_>>() | ||
| 390 | .join("; "); | ||
| 391 | runs[start].1.set_description(descriptions); | ||
| 392 | } | ||
| 393 | paragraph_ranges.insert(key, start..runs.len()); | ||
| 394 | } | ||
| 395 | } | ||
| 396 | let mut runs: Vec<_> = runs | ||
| 397 | .into_iter() | ||
| 398 | .enumerate() | ||
| 399 | .map(|(index, (outline, mut node, positions))| { | ||
| 400 | node.clear_next_on_line(); | ||
| 401 | node.clear_previous_on_line(); | ||
| 402 | let id = match self.runs.get(index) { | ||
| 403 | Some(old) | ||
| 404 | if old.outline == outline | ||
| 405 | && old.positions == positions | ||
| 406 | && old.node.value() == node.value() => | ||
| 407 | { | ||
| 408 | old.id | ||
| 409 | } | ||
| 410 | _ => self.allocate(), | ||
| 411 | }; | ||
| 412 | Run { | ||
| 413 | outline, | ||
| 414 | id, | ||
| 415 | node, | ||
| 416 | positions, | ||
| 417 | } | ||
| 418 | }) | ||
| 419 | .collect(); | ||
| 420 | for index in 1..runs.len() { | ||
| 421 | if runs[index - 1].outline == runs[index].outline | ||
| 422 | && runs[index - 1].node.bounds().map(|r| r.y0) | ||
| 423 | == runs[index].node.bounds().map(|r| r.y0) | ||
| 424 | { | ||
| 425 | let (before, after) = runs.split_at_mut(index); | ||
| 426 | before[index - 1].node.set_next_on_line(after[0].id); | ||
| 427 | after[0].node.set_previous_on_line(before[index - 1].id); | ||
| 428 | } | ||
| 429 | } | ||
| 430 | self.runs = runs; | ||
| 431 | self.paragraphs = paragraph_ranges; | ||
| 432 | self.outlines = outlines; | ||
| 433 | let mut root = Node::new(Role::Window); | ||
| 434 | root.set_label(title); | ||
| 435 | root.set_children(self.outlines.iter().map(|(_, id)| *id).collect::<Vec<_>>()); | ||
| 436 | root.set_bounds(Rect::new( | ||
| 437 | 0.0, | ||
| 438 | 0.0, | ||
| 439 | viewport.size[0] as f64, | ||
| 440 | viewport.size[1] as f64, | ||
| 441 | )); | ||
| 442 | let mut nodes = vec![(ROOT, root)]; | ||
| 443 | let mut focus = ROOT; | ||
| 444 | for (index, (outline, (_, id))) in editor | ||
| 445 | .visible_outlines() | ||
| 446 | .chain(editor.caret_outline()) | ||
| 447 | .zip(&self.outlines) | ||
| 448 | .enumerate() | ||
| 449 | { | ||
| 450 | let mut field = Node::new(Role::MultilineTextInput); | ||
| 451 | field.set_label(if outline.title { | ||
| 452 | "Page title".into() | ||
| 453 | } else if editor | ||
| 454 | .caret_outline() | ||
| 455 | .is_some_and(|caret| caret.id == outline.id) | ||
| 456 | { | ||
| 457 | "Text input".into() | ||
| 458 | } else { | ||
| 459 | format!("Text outline {}", index + 1) | ||
| 460 | }); | ||
| 461 | field.add_action(Action::Focus); | ||
| 462 | field.add_action(Action::SetTextSelection); | ||
| 463 | field.add_action(Action::ReplaceSelectedText); | ||
| 464 | field.add_action(Action::SetValue); | ||
| 465 | let runs = self.runs.iter().filter(|run| run.outline == outline.id); | ||
| 466 | field.set_value( | ||
| 467 | runs.clone() | ||
| 468 | .filter_map(|run| run.node.value()) | ||
| 469 | .collect::<String>(), | ||
| 470 | ); | ||
| 471 | field.set_children(runs.map(|run| run.id).collect::<Vec<_>>()); | ||
| 472 | let origin = preview | ||
| 473 | .filter(|(id, _)| *id == outline.id) | ||
| 474 | .map(|(_, origin)| origin) | ||
| 475 | .unwrap_or_else(|| outline.origin()); | ||
| 476 | field.set_transform(Affine::new([ | ||
| 477 | f64::from(viewport.scale), | ||
| 478 | 0.0, | ||
| 479 | 0.0, | ||
| 480 | f64::from(viewport.scale), | ||
| 481 | f64::from(viewport.origin[0]) + f64::from(origin[0]) * f64::from(viewport.scale), | ||
| 482 | f64::from(viewport.origin[1]) + f64::from(origin[1]) * f64::from(viewport.scale), | ||
| 483 | ])); | ||
| 484 | let bounds = outline.bounds(); | ||
| 485 | field.set_bounds(Rect::new(0.0, 0.0, bounds.width(), bounds.height())); | ||
| 486 | if outline.id == editor.active_outline().id { | ||
| 487 | let Selection { | ||
| 488 | positions: [anchor, caret], | ||
| 489 | affinities, | ||
| 490 | } = editor.selection(); | ||
| 491 | field.set_text_selection(TextSelection { | ||
| 492 | anchor: self.position(outline, anchor, affinities[0])?, | ||
| 493 | focus: self.position(outline, caret, affinities[1])?, | ||
| 494 | }); | ||
| 495 | focus = *id; | ||
| 496 | } | ||
| 497 | nodes.push((*id, field)); | ||
| 498 | } | ||
| 499 | nodes.extend(self.runs.iter().map(|run| (run.id, run.node.clone()))); | ||
| 500 | Ok(TreeUpdate { | ||
| 501 | nodes, | ||
| 502 | tree: Some(TreeInfo::new(ROOT)), | ||
| 503 | tree_id: TreeId::ROOT, | ||
| 504 | focus, | ||
| 505 | }) | ||
| 506 | } | ||
| 507 | |||
| 508 | fn position( | ||
| 509 | &self, | ||
| 510 | outline: &TextOutline, | ||
| 511 | mut position: TextPosition, | ||
| 512 | affinity: Affinity, | ||
| 513 | ) -> Result<accesskit::TextPosition, EditError> { | ||
| 514 | let shaped = outline.paragraph_layout(position.paragraph)?; | ||
| 515 | let projection = &shaped.projection; | ||
| 516 | let layout = &shaped.text; | ||
| 517 | let origin = shaped.origin; | ||
| 518 | let cursor = layout.cursor( | ||
| 519 | projection | ||
| 520 | .text() | ||
| 521 | .byte_offset(projection.visible_offset(position.offset)?)?, | ||
| 522 | affinity, | ||
| 523 | ); | ||
| 524 | position.offset = projection.source_offset( | ||
| 525 | projection.text().utf16_offset(cursor.index())?, | ||
| 526 | onestore::page::text::Affinity::Downstream, | ||
| 527 | )?; | ||
| 528 | let caret = layout.caret(cursor, 0.0); | ||
| 529 | let mut positions = self | ||
| 530 | .runs | ||
| 531 | .iter() | ||
| 532 | .filter(|run| { | ||
| 533 | run.outline == outline.id | ||
| 534 | && run | ||
| 535 | .node | ||
| 536 | .bounds() | ||
| 537 | .is_some_and(|rect| rect.y0 == f64::from(caret.y0 as f32 + origin[1])) | ||
| 538 | }) | ||
| 539 | .filter_map(|run| { | ||
| 540 | run.positions | ||
| 541 | .iter() | ||
| 542 | .position(|p| *p == position) | ||
| 543 | .map(|character_index| accesskit::TextPosition { | ||
| 544 | node: run.id, | ||
| 545 | character_index, | ||
| 546 | }) | ||
| 547 | }); | ||
| 548 | match affinity { | ||
| 549 | Affinity::Upstream => positions.next(), | ||
| 550 | Affinity::Downstream => positions.next_back(), | ||
| 551 | } | ||
| 552 | .ok_or(EditError::InvalidRange) | ||
| 553 | } | ||
| 554 | |||
| 555 | pub fn selection( | ||
| 556 | &self, | ||
| 557 | outline: onestore::ExGuid, | ||
| 558 | selection: TextSelection, | ||
| 559 | ) -> Result<Selection, EditError> { | ||
| 560 | let position = |position: accesskit::TextPosition| { | ||
| 561 | let run = self | ||
| 562 | .runs | ||
| 563 | .iter() | ||
| 564 | .find(|run| run.outline == outline && run.id == position.node) | ||
| 565 | .ok_or(EditError::InvalidRange)?; | ||
| 566 | let source = *run | ||
| 567 | .positions | ||
| 568 | .get(position.character_index) | ||
| 569 | .ok_or(EditError::InvalidRange)?; | ||
| 570 | let affinity = if position.character_index > 0 | ||
| 571 | && position.character_index + 1 == run.positions.len() | ||
| 572 | { | ||
| 573 | Affinity::Upstream | ||
| 574 | } else { | ||
| 575 | Affinity::Downstream | ||
| 576 | }; | ||
| 577 | Ok::<_, EditError>((source, affinity)) | ||
| 578 | }; | ||
| 579 | let (anchor, anchor_affinity) = position(selection.anchor)?; | ||
| 580 | let (focus, focus_affinity) = position(selection.focus)?; | ||
| 581 | Ok(Selection { | ||
| 582 | positions: [anchor, focus], | ||
| 583 | affinities: [anchor_affinity, focus_affinity], | ||
| 584 | }) | ||
| 585 | } | ||
| 586 | } | ||
| 587 | |||
| 588 | #[cfg(test)] | ||
| 589 | mod tests { | ||
| 590 | use super::*; | ||
| 591 | use crate::{document::TextDocument, layout::TextEngine}; | ||
| 592 | use onestore::document::Format; | ||
| 593 | use onestore::page::text::Paragraph; | ||
| 594 | |||
| 595 | #[test] | ||
| 596 | fn title_flow_updates_read_only_hit_and_accessibility_bounds() { | ||
| 597 | use onestore::page::{Page, PageObject, Title, Unsupported}; | ||
| 598 | let mut engine = TextEngine::default(); | ||
| 599 | let mut title = TextOutline::new( | ||
| 600 | &mut engine, | ||
| 601 | TextDocument::new(vec![Paragraph::new( | ||
| 602 | "Header".into(), | ||
| 603 | Format { | ||
| 604 | font: Some("Arial".into()), | ||
| 605 | font_size: Some(8.0), | ||
| 606 | line_spacing: Some(20.0), | ||
| 607 | ..Default::default() | ||
| 608 | }, | ||
| 609 | )]) | ||
| 610 | .unwrap(), | ||
| 611 | 468.0, | ||
| 612 | [0.0; 2], | ||
| 613 | ) | ||
| 614 | .unwrap() | ||
| 615 | .snapshot(); | ||
| 616 | title.title = true; | ||
| 617 | let page = Page { | ||
| 618 | identity: None, | ||
| 619 | title: "Header".into(), | ||
| 620 | created: None, | ||
| 621 | margin_origin: [36.0, 14.4], | ||
| 622 | definitions: Default::default(), | ||
| 623 | objects: vec![ | ||
| 624 | PageObject::Title(Title { | ||
| 625 | id: Default::default(), | ||
| 626 | date: None, | ||
| 627 | layout: Default::default(), | ||
| 628 | outlines: vec![title], | ||
| 629 | }), | ||
| 630 | PageObject::Unsupported(Unsupported { | ||
| 631 | id: Default::default(), | ||
| 632 | jcid: 0xdead, | ||
| 633 | layout: onestore::document::Layout { | ||
| 634 | x: Some(120.0), | ||
| 635 | y: Some(80.0), | ||
| 636 | max_width: Some(200.0), | ||
| 637 | max_height: Some(60.0), | ||
| 638 | ..Default::default() | ||
| 639 | }, | ||
| 640 | }), | ||
| 641 | ], | ||
| 642 | }; | ||
| 643 | let (scene, mut editor) = | ||
| 644 | crate::gpu::page::PageScene::from_page(page, &mut engine).unwrap(); | ||
| 645 | let scene = (scene, [30.0, 40.0]); | ||
| 646 | let viewport = Viewport { | ||
| 647 | size: [800, 600], | ||
| 648 | origin: [10.0, -20.0], | ||
| 649 | scale: 2.0, | ||
| 650 | }; | ||
| 651 | let mut access = Accessibility::default(); | ||
| 652 | let mut identity = None; | ||
| 653 | for phase in 0..4 { | ||
| 654 | match phase { | ||
| 655 | 1 => { | ||
| 656 | editor.select_all().unwrap(); | ||
| 657 | editor.insert(&mut engine, "Header\u{000b}Second\u{000b}Third\u{000b}Fourth\u{000b}Fifth\u{000b}Sixth").unwrap(); | ||
| 658 | } | ||
| 659 | 2 => { | ||
| 660 | editor.undo(&mut engine).unwrap(); | ||
| 661 | } | ||
| 662 | 3 => { | ||
| 663 | editor.redo(&mut engine).unwrap(); | ||
| 664 | } | ||
| 665 | _ => {} | ||
| 666 | } | ||
| 667 | let mut update = access.update(&editor, viewport, "Test", None).unwrap(); | ||
| 668 | access.append_page_fields(&mut update, Some(&scene), &editor, viewport, Some(0)); | ||
| 669 | let (id, node) = update | ||
| 670 | .nodes | ||
| 671 | .iter() | ||
| 672 | .find(|(_, node)| node.role() == Role::Label) | ||
| 673 | .unwrap(); | ||
| 674 | if let Some(identity) = identity { | ||
| 675 | assert_eq!(*id, identity); | ||
| 676 | } | ||
| 677 | identity = Some(*id); | ||
| 678 | let y = if phase % 2 == 0 { 80.0 } else { 145.92 }; | ||
| 679 | let expected = Rect::new( | ||
| 680 | 310.0, | ||
| 681 | f64::from((y + 40.0) * 2.0 - 20.0), | ||
| 682 | 710.0, | ||
| 683 | f64::from((y + 100.0) * 2.0 - 20.0), | ||
| 684 | ); | ||
| 685 | let bounds = node.bounds().unwrap(); | ||
| 686 | assert_eq!(bounds.x0, expected.x0); | ||
| 687 | assert_eq!(bounds.x1, expected.x1); | ||
| 688 | assert!((bounds.y0 - expected.y0).abs() < 0.001); | ||
| 689 | assert!((bounds.y1 - expected.y1).abs() < 0.001); | ||
| 690 | assert_eq!( | ||
| 691 | super::super::page_hit_test(&editor, Some(&scene), [155.0, y + 45.0], 1.0), | ||
| 692 | Some(super::super::Hit::ReadOnly(0)) | ||
| 693 | ); | ||
| 694 | if phase % 2 == 1 { | ||
| 695 | assert_ne!( | ||
| 696 | super::super::page_hit_test(&editor, Some(&scene), [155.0, 125.0], 1.0), | ||
| 697 | Some(super::super::Hit::ReadOnly(0)) | ||
| 698 | ); | ||
| 699 | } | ||
| 700 | } | ||
| 701 | } | ||
| 702 | |||
| 703 | #[test] | ||
| 704 | fn read_only_objects_keep_accessibility_identity_through_edits_and_view_changes() { | ||
| 705 | use crate::gpu::page::PageScene; | ||
| 706 | use onestore::page::{Page, PageObject, Unsupported}; | ||
| 707 | let page = Page { | ||
| 708 | identity: None, | ||
| 709 | created: None, | ||
| 710 | title: String::new(), | ||
| 711 | margin_origin: [0.0; 2], | ||
| 712 | definitions: Default::default(), | ||
| 713 | objects: vec![PageObject::Unsupported(Unsupported { | ||
| 714 | id: Default::default(), | ||
| 715 | jcid: 0xdead, | ||
| 716 | layout: onestore::document::Layout { | ||
| 717 | x: Some(10.0), | ||
| 718 | y: Some(20.0), | ||
| 719 | max_width: Some(200.0), | ||
| 720 | max_height: Some(60.0), | ||
| 721 | ..Default::default() | ||
| 722 | }, | ||
| 723 | })], | ||
| 724 | }; | ||
| 725 | let mut engine = TextEngine::default(); | ||
| 726 | let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap(); | ||
| 727 | let scene = (scene, [30.0, 40.0]); | ||
| 728 | let mut access = Accessibility::default(); | ||
| 729 | let mut identity = None; | ||
| 730 | for scale in [1.0, 2.0, 0.5] { | ||
| 731 | editor.insert(&mut engine, "annotation ").unwrap(); | ||
| 732 | let viewport = Viewport { | ||
| 733 | size: [800, 600], | ||
| 734 | origin: [-200.0, 10.0], | ||
| 735 | scale, | ||
| 736 | }; | ||
| 737 | let mut update = access.update(&editor, viewport, "Test", None).unwrap(); | ||
| 738 | access.append_page_fields(&mut update, Some(&scene), &editor, viewport, None); | ||
| 739 | let (id, node) = update | ||
| 740 | .nodes | ||
| 741 | .iter() | ||
| 742 | .find(|(_, node)| node.role() == Role::Label) | ||
| 743 | .unwrap(); | ||
| 744 | if let Some(identity) = identity { | ||
| 745 | assert_eq!(*id, identity); | ||
| 746 | } | ||
| 747 | identity = Some(*id); | ||
| 748 | assert_eq!(node.value(), Some("Unsupported content\nRead-only")); | ||
| 749 | assert!(node.is_read_only()); | ||
| 750 | assert!(node.supports_action(Action::Focus)); | ||
| 751 | for action in [ | ||
| 752 | Action::SetValue, | ||
| 753 | Action::ReplaceSelectedText, | ||
| 754 | Action::SetTextSelection, | ||
| 755 | ] { | ||
| 756 | assert!(!node.supports_action(action)); | ||
| 757 | } | ||
| 758 | assert_eq!( | ||
| 759 | node.bounds(), | ||
| 760 | Some(Rect::new( | ||
| 761 | f64::from(40.0 * scale - 200.0), | ||
| 762 | f64::from(60.0 * scale + 10.0), | ||
| 763 | f64::from(240.0 * scale - 200.0), | ||
| 764 | f64::from(120.0 * scale + 10.0) | ||
| 765 | )) | ||
| 766 | ); | ||
| 767 | assert_eq!(access.outline_for_node(*id), None); | ||
| 768 | let tree = accesskit_consumer::Tree::new(update, true); | ||
| 769 | assert_eq!( | ||
| 770 | tree.state().focus().unwrap().document_range().text(), | ||
| 771 | "annotation " | ||
| 772 | ); | ||
| 773 | let mut focused = access.update(&editor, viewport, "Test", None).unwrap(); | ||
| 774 | access.append_page_fields(&mut focused, Some(&scene), &editor, viewport, Some(0)); | ||
| 775 | assert_eq!(focused.focus, identity.unwrap()); | ||
| 776 | assert_eq!(access.read_only_for_node(focused.focus), Some(0)); | ||
| 777 | let tree = accesskit_consumer::Tree::new(focused, true); | ||
| 778 | assert_eq!( | ||
| 779 | tree.state().focus().unwrap().value().as_deref(), | ||
| 780 | Some("Unsupported content\nRead-only") | ||
| 781 | ); | ||
| 782 | editor.undo(&mut engine).unwrap(); | ||
| 783 | } | ||
| 784 | access.deactivate(); | ||
| 785 | assert!(access.read_only.is_empty()); | ||
| 786 | } | ||
| 787 | |||
| 788 | #[test] | ||
| 789 | fn tag_descriptions_preserve_plain_text_and_survive_edit_undo_and_cache_reuse() { | ||
| 790 | use onestore::page::{Definition, Outline}; | ||
| 791 | use onestore::{ | ||
| 792 | ExGuid, | ||
| 793 | document::{Kind, Layout, Tag}, | ||
| 794 | }; | ||
| 795 | use std::collections::BTreeMap; | ||
| 796 | let text = TextDocument::new( | ||
| 797 | ["task one", "task two", "question", "music"] | ||
| 798 | .into_iter() | ||
| 799 | .map(|text| Paragraph::new(text.into(), Format::default())) | ||
| 800 | .collect(), | ||
| 801 | ) | ||
| 802 | .unwrap(); | ||
| 803 | let mut nodes = text.nodes().to_vec(); | ||
| 804 | let mut definitions = BTreeMap::new(); | ||
| 805 | for (index, (shape, label, status)) in [ | ||
| 806 | (3, Some("Rehearsal"), 0), | ||
| 807 | (3, Some("Rehearsal"), 1), | ||
| 808 | (15, None, 1), | ||
| 809 | (121, None, 3), | ||
| 810 | ] | ||
| 811 | .into_iter() | ||
| 812 | .enumerate() | ||
| 813 | { | ||
| 814 | let id = ExGuid { | ||
| 815 | n: index as u32 + 500, | ||
| 816 | ..ExGuid::default() | ||
| 817 | }; | ||
| 818 | definitions.insert( | ||
| 819 | id, | ||
| 820 | Definition { | ||
| 821 | kind: Kind::TagDefinition { | ||
| 822 | label: label.map(str::to_owned), | ||
| 823 | action_type: None, | ||
| 824 | shape: Some(shape), | ||
| 825 | color: None, | ||
| 826 | highlight: None, | ||
| 827 | }, | ||
| 828 | format: Format::default(), | ||
| 829 | }, | ||
| 830 | ); | ||
| 831 | nodes[index].text_mut().unwrap().tags.push(Tag { | ||
| 832 | definition: Some(id), | ||
| 833 | action_type: None, | ||
| 834 | status, | ||
| 835 | created: None, | ||
| 836 | completed: None, | ||
| 837 | start: None, | ||
| 838 | due: None, | ||
| 839 | task_id: None, | ||
| 840 | extra_set: 0, | ||
| 841 | }); | ||
| 842 | } | ||
| 843 | let mut engine = TextEngine::default(); | ||
| 844 | let mut editor = CanvasEditor::from_outlines( | ||
| 845 | &mut engine, | ||
| 846 | vec![Outline { | ||
| 847 | title: false, | ||
| 848 | min_width: None, | ||
| 849 | id: ExGuid { | ||
| 850 | n: 900, | ||
| 851 | ..ExGuid::default() | ||
| 852 | }, | ||
| 853 | layout: Layout { | ||
| 854 | max_width: Some(120.0), | ||
| 855 | ..Layout::default() | ||
| 856 | }, | ||
| 857 | indents: vec![18.0, 0.0], | ||
| 858 | paragraphs: nodes, | ||
| 859 | unsupported: Vec::new(), | ||
| 860 | }], | ||
| 861 | definitions, | ||
| 862 | ) | ||
| 863 | .unwrap(); | ||
| 864 | let mut access = Accessibility::default(); | ||
| 865 | let viewport = Viewport { | ||
| 866 | size: [800, 600], | ||
| 867 | origin: [48.0; 2], | ||
| 868 | scale: 2.0, | ||
| 869 | }; | ||
| 870 | for step in 0..4 { | ||
| 871 | if step == 2 { | ||
| 872 | editor.insert(&mut engine, "new ").unwrap(); | ||
| 873 | } | ||
| 874 | if step == 3 { | ||
| 875 | editor.undo(&mut engine).unwrap(); | ||
| 876 | } | ||
| 877 | let update = access.update(&editor, viewport, "Test", None).unwrap(); | ||
| 878 | assert_eq!( | ||
| 879 | update | ||
| 880 | .nodes | ||
| 881 | .iter() | ||
| 882 | .filter_map(|(_, n)| n.description()) | ||
| 883 | .collect::<Vec<_>>(), | ||
| 884 | [ | ||
| 885 | "Rehearsal, incomplete", | ||
| 886 | "Rehearsal, completed", | ||
| 887 | "Question", | ||
| 888 | "Music, disabled" | ||
| 889 | ] | ||
| 890 | ); | ||
| 891 | let tree = accesskit_consumer::Tree::new(update, true); | ||
| 892 | let text = tree.state().focus().unwrap().document_range().text(); | ||
| 893 | assert_eq!( | ||
| 894 | text, | ||
| 895 | if step == 2 { | ||
| 896 | "new task one\ntask two\nquestion\nmusic" | ||
| 897 | } else { | ||
| 898 | "task one\ntask two\nquestion\nmusic" | ||
| 899 | } | ||
| 900 | ); | ||
| 901 | } | ||
| 902 | } | ||
| 903 | |||
| 904 | #[test] | ||
| 905 | fn collapsed_paragraphs_keep_visible_accessibility_ranges_in_source_coordinates() { | ||
| 906 | let document = TextDocument::new( | ||
| 907 | [ | ||
| 908 | "root", | ||
| 909 | "folded", | ||
| 910 | "hidden", | ||
| 911 | "last", | ||
| 912 | "folded tail", | ||
| 913 | "hidden tail", | ||
| 914 | ] | ||
| 915 | .into_iter() | ||
| 916 | .map(|text| Paragraph::new(text.into(), Format::default())) | ||
| 917 | .collect(), | ||
| 918 | ) | ||
| 919 | .unwrap(); | ||
| 920 | let mut nodes = document.nodes().to_vec(); | ||
| 921 | for (child, parent, level) in [(1, 0, 2), (2, 1, 3), (4, 3, 2), (5, 4, 3)] { | ||
| 922 | nodes[child].parent = Some(nodes[parent].id); | ||
| 923 | nodes[child].level = level; | ||
| 924 | } | ||
| 925 | nodes[1].collapsed = true; | ||
| 926 | nodes[4].collapsed = true; | ||
| 927 | let mut engine = TextEngine::default(); | ||
| 928 | let mut editor = | ||
| 929 | CanvasEditor::new(&mut engine, TextDocument::from_nodes(nodes).unwrap(), 180.0) | ||
| 930 | .unwrap(); | ||
| 931 | let mut access = Accessibility::default(); | ||
| 932 | let viewport = Viewport { | ||
| 933 | size: [900, 700], | ||
| 934 | scale: 2.0, | ||
| 935 | origin: [13.0, 19.0], | ||
| 936 | }; | ||
| 937 | let update = access.update(&editor, viewport, "Test", None).unwrap(); | ||
| 938 | let tree = accesskit_consumer::Tree::new(update, true); | ||
| 939 | let field = tree.state().focus().unwrap(); | ||
| 940 | assert_eq!( | ||
| 941 | field.document_range().text(), | ||
| 942 | "root\nfolded\nlast\nfolded tail" | ||
| 943 | ); | ||
| 944 | assert!( | ||
| 945 | access | ||
| 946 | .runs | ||
| 947 | .iter() | ||
| 948 | .flat_map(|run| &run.positions) | ||
| 949 | .all(|position| ![2, 5].contains(&position.paragraph)) | ||
| 950 | ); | ||
| 951 | let source = access | ||
| 952 | .selection( | ||
| 953 | editor.active_outline().id, | ||
| 954 | field.document_range().to_text_selection(), | ||
| 955 | ) | ||
| 956 | .unwrap(); | ||
| 957 | assert_eq!( | ||
| 958 | source.positions[0], | ||
| 959 | TextPosition { | ||
| 960 | paragraph: 0, | ||
| 961 | offset: 0 | ||
| 962 | } | ||
| 963 | ); | ||
| 964 | assert_eq!( | ||
| 965 | source.positions[1], | ||
| 966 | TextPosition { | ||
| 967 | paragraph: 4, | ||
| 968 | offset: 11 | ||
| 969 | } | ||
| 970 | ); | ||
| 971 | editor.select(source).unwrap(); | ||
| 972 | let update = access.update(&editor, viewport, "Test", None).unwrap(); | ||
| 973 | let tree = accesskit_consumer::Tree::new(update, true); | ||
| 974 | assert_eq!( | ||
| 975 | tree.state() | ||
| 976 | .focus() | ||
| 977 | .unwrap() | ||
| 978 | .text_selection() | ||
| 979 | .unwrap() | ||
| 980 | .text(), | ||
| 981 | "root\nfolded\nlast\nfolded tail" | ||
| 982 | ); | ||
| 983 | editor | ||
| 984 | .select( | ||
| 985 | [TextPosition { | ||
| 986 | paragraph: 4, | ||
| 987 | offset: 0, | ||
| 988 | }; 2] | ||
| 989 | .into(), | ||
| 990 | ) | ||
| 991 | .unwrap(); | ||
| 992 | access.update(&editor, viewport, "Test", None).unwrap(); | ||
| 993 | let caret = editor.caret(1.0).unwrap(); | ||
| 994 | assert_eq!(caret.x0, 27.0); | ||
| 995 | let position = access | ||
| 996 | .position( | ||
| 997 | editor.active_outline(), | ||
| 998 | editor.selection().positions[1], | ||
| 999 | Affinity::Downstream, | ||
| 1000 | ) | ||
| 1001 | .unwrap(); | ||
| 1002 | let run = access | ||
| 1003 | .runs | ||
| 1004 | .iter() | ||
| 1005 | .find(|run| run.id == position.node) | ||
| 1006 | .unwrap(); | ||
| 1007 | assert_eq!(run.node.bounds().unwrap().x0, 27.0); | ||
| 1008 | assert_eq!(run.node.bounds().unwrap().y0, caret.y0); | ||
| 1009 | } | ||
| 1010 | |||
| 1011 | #[test] | ||
| 1012 | fn consumer_reads_unicode_blank_paragraphs_and_hidden_fields() { | ||
| 1013 | let cases = [ | ||
| 1014 | "", | ||
| 1015 | "a\n\nשלום\nend", | ||
| 1016 | "café e\u{301} 🌳 👩👩👧👦 words wrap here", | ||
| 1017 | "\n\n", | ||
| 1018 | "a\nb\n", | ||
| 1019 | ]; | ||
| 1020 | let mut engine = TextEngine::default(); | ||
| 1021 | for text in cases | ||
| 1022 | .into_iter() | ||
| 1023 | .map(str::to_owned) | ||
| 1024 | .chain([format!("a{}", "\u{301}".repeat(300)), "x".repeat(600)]) | ||
| 1025 | { | ||
| 1026 | for width in [24.0, 480.0, 10000.0] { | ||
| 1027 | let document = TextDocument::new( | ||
| 1028 | text.split('\n') | ||
| 1029 | .map(|line| { | ||
| 1030 | Paragraph::from_runs([ | ||
| 1031 | ( | ||
| 1032 | String::from("secret"), | ||
| 1033 | Format { | ||
| 1034 | hidden: Some(true), | ||
| 1035 | ..Format::default() | ||
| 1036 | }, | ||
| 1037 | ), | ||
| 1038 | (line.to_owned(), Format::default()), | ||
| 1039 | ]) | ||
| 1040 | }) | ||
| 1041 | .collect(), | ||
| 1042 | ) | ||
| 1043 | .unwrap(); | ||
| 1044 | let mut editor = CanvasEditor::new(&mut engine, document, width).unwrap(); | ||
| 1045 | let mut access = Accessibility::default(); | ||
| 1046 | let viewport = Viewport { | ||
| 1047 | size: [800, 600], | ||
| 1048 | origin: [48.0; 2], | ||
| 1049 | scale: 2.0, | ||
| 1050 | }; | ||
| 1051 | let update = access.update(&editor, viewport, "Test", None).unwrap(); | ||
| 1052 | for (_, node) in &update.nodes { | ||
| 1053 | if node.role() == Role::TextRun { | ||
| 1054 | assert_eq!( | ||
| 1055 | node.character_lengths() | ||
| 1056 | .iter() | ||
| 1057 | .map(|n| *n as usize) | ||
| 1058 | .sum::<usize>(), | ||
| 1059 | node.value().unwrap().len() | ||
| 1060 | ); | ||
| 1061 | if let Some(positions) = node.character_positions() { | ||
| 1062 | assert_eq!(positions.len(), node.character_lengths().len()); | ||
| 1063 | } | ||
| 1064 | if let Some(widths) = node.character_widths() { | ||
| 1065 | assert_eq!(widths.len(), node.character_lengths().len()); | ||
| 1066 | } | ||
| 1067 | } | ||
| 1068 | } | ||
| 1069 | let tree = accesskit_consumer::Tree::new(update, true); | ||
| 1070 | let field = tree.state().focus().unwrap(); | ||
| 1071 | assert_eq!(field.document_range().text(), text, "width {width}"); | ||
| 1072 | let selection = field.document_range().to_text_selection(); | ||
| 1073 | let source_selection = access | ||
| 1074 | .selection(editor.active_outline().id, selection) | ||
| 1075 | .unwrap(); | ||
| 1076 | editor.select(source_selection).unwrap(); | ||
| 1077 | let selected = access.update(&editor, viewport, "Test", None).unwrap(); | ||
| 1078 | let tree = accesskit_consumer::Tree::new(selected, true); | ||
| 1079 | assert_eq!( | ||
| 1080 | tree.state() | ||
| 1081 | .focus() | ||
| 1082 | .unwrap() | ||
| 1083 | .text_selection() | ||
| 1084 | .unwrap() | ||
| 1085 | .text(), | ||
| 1086 | text | ||
| 1087 | ); | ||
| 1088 | let stable_ids: Vec<_> = access.runs.iter().map(|run| run.id).collect(); | ||
| 1089 | access | ||
| 1090 | .update( | ||
| 1091 | &editor, | ||
| 1092 | Viewport { | ||
| 1093 | scale: 3.0, | ||
| 1094 | ..viewport | ||
| 1095 | }, | ||
| 1096 | "Test", | ||
| 1097 | None, | ||
| 1098 | ) | ||
| 1099 | .unwrap(); | ||
| 1100 | assert_eq!( | ||
| 1101 | stable_ids, | ||
| 1102 | access.runs.iter().map(|run| run.id).collect::<Vec<_>>() | ||
| 1103 | ); | ||
| 1104 | access.deactivate(); | ||
| 1105 | access.update(&editor, viewport, "Test", None).unwrap(); | ||
| 1106 | assert!( | ||
| 1107 | access | ||
| 1108 | .selection(editor.active_outline().id, selection) | ||
| 1109 | .is_err() | ||
| 1110 | ); | ||
| 1111 | let selection = access | ||
| 1112 | .update(&editor, viewport, "Test", None) | ||
| 1113 | .unwrap() | ||
| 1114 | .nodes[1] | ||
| 1115 | .1 | ||
| 1116 | .text_selection() | ||
| 1117 | .copied() | ||
| 1118 | .unwrap(); | ||
| 1119 | editor.insert(&mut engine, "replacement").unwrap(); | ||
| 1120 | access.update(&editor, viewport, "Test", None).unwrap(); | ||
| 1121 | assert!( | ||
| 1122 | access | ||
| 1123 | .selection(editor.active_outline().id, selection) | ||
| 1124 | .is_err() | ||
| 1125 | ); | ||
| 1126 | editor.undo(&mut engine).unwrap(); | ||
| 1127 | let update = access.update(&editor, viewport, "Test", None).unwrap(); | ||
| 1128 | let tree = accesskit_consumer::Tree::new(update, true); | ||
| 1129 | assert_eq!(tree.state().focus().unwrap().document_range().text(), text); | ||
| 1130 | } | ||
| 1131 | } | ||
| 1132 | } | ||
| 1133 | |||
| 1134 | #[test] | ||
| 1135 | fn caret_geometry_uses_visible_offsets_and_canvas_line_metrics() { | ||
| 1136 | let mut engine = TextEngine::default(); | ||
| 1137 | let document = TextDocument::new(vec![ | ||
| 1138 | Paragraph::from_runs([ | ||
| 1139 | ("abc ".into(), Format::default()), | ||
| 1140 | ( | ||
| 1141 | "hidden field".into(), | ||
| 1142 | Format { | ||
| 1143 | hidden: Some(true), | ||
| 1144 | ..Format::default() | ||
| 1145 | }, | ||
| 1146 | ), | ||
| 1147 | ("e\u{301} 🌳 words wrap here".into(), Format::default()), | ||
| 1148 | ]), | ||
| 1149 | Paragraph::new("".into(), Format::default()), | ||
| 1150 | Paragraph::new("abc שלום def عالم end".into(), Format::default()), | ||
| 1151 | Paragraph::new("last\nline\n".into(), Format::default()), | ||
| 1152 | ]) | ||
| 1153 | .unwrap(); | ||
| 1154 | let positions: Vec<_> = document | ||
| 1155 | .paragraphs() | ||
| 1156 | .enumerate() | ||
| 1157 | .flat_map(|(paragraph, text)| { | ||
| 1158 | text.text() | ||
| 1159 | .char_indices() | ||
| 1160 | .map(|(byte, _)| text.utf16_offset(byte).unwrap()) | ||
| 1161 | .chain(std::iter::once( | ||
| 1162 | text.utf16_offset(text.text().len()).unwrap(), | ||
| 1163 | )) | ||
| 1164 | .map(move |offset| TextPosition { paragraph, offset }) | ||
| 1165 | }) | ||
| 1166 | .collect(); | ||
| 1167 | let mut editor = CanvasEditor::new(&mut engine, document, 48.0).unwrap(); | ||
| 1168 | let mut access = Accessibility::default(); | ||
| 1169 | let viewport = Viewport { | ||
| 1170 | size: [800, 600], | ||
| 1171 | origin: [31.0, -57.0], | ||
| 1172 | scale: 2.5, | ||
| 1173 | }; | ||
| 1174 | for (position, affinity) in positions.into_iter().flat_map(|position| { | ||
| 1175 | [Affinity::Upstream, Affinity::Downstream].map(|affinity| (position, affinity)) | ||
| 1176 | }) { | ||
| 1177 | editor | ||
| 1178 | .select(Selection { | ||
| 1179 | positions: [position; 2], | ||
| 1180 | affinities: [affinity; 2], | ||
| 1181 | }) | ||
| 1182 | .unwrap(); | ||
| 1183 | let caret = editor.caret(0.0).unwrap(); | ||
| 1184 | let update = access.update(&editor, viewport, "Test", None).unwrap(); | ||
| 1185 | let tree = accesskit_consumer::Tree::new(update, true); | ||
| 1186 | let field = tree.state().focus().unwrap(); | ||
| 1187 | assert_eq!( | ||
| 1188 | field.document_range().text(), | ||
| 1189 | "abc e\u{301} 🌳 words wrap here\n\nabc שלום def عالم end\nlast\nline\n" | ||
| 1190 | ); | ||
| 1191 | let rects = field.text_selection().unwrap().bounding_boxes(); | ||
| 1192 | assert_eq!(rects.len(), 1, "{position:?}"); | ||
| 1193 | let rect = rects[0]; | ||
| 1194 | for (actual, expected) in [ | ||
| 1195 | (rect.x0, caret.x0 * 2.5 + 31.0), | ||
| 1196 | (rect.y0, caret.y0 * 2.5 - 57.0), | ||
| 1197 | (rect.y1, caret.y1 * 2.5 - 57.0), | ||
| 1198 | ] { | ||
| 1199 | assert!( | ||
| 1200 | (actual - expected).abs() < 0.0001, | ||
| 1201 | "{position:?} {affinity:?}: {rect:?} vs {caret:?}" | ||
| 1202 | ); | ||
| 1203 | } | ||
| 1204 | let selection = access | ||
| 1205 | .selection( | ||
| 1206 | editor.active_outline().id, | ||
| 1207 | field.text_selection().unwrap().to_text_selection(), | ||
| 1208 | ) | ||
| 1209 | .unwrap(); | ||
| 1210 | editor.select(selection).unwrap(); | ||
| 1211 | assert_eq!( | ||
| 1212 | editor.caret(0.0).unwrap(), | ||
| 1213 | caret, | ||
| 1214 | "{position:?} {affinity:?}: AX selection moved the caret" | ||
| 1215 | ); | ||
| 1216 | } | ||
| 1217 | } | ||
| 1218 | #[test] | ||
| 1219 | fn keyboard_and_accessibility_preserve_visual_caret_at_wraps() { | ||
| 1220 | use crate::editor::Movement; | ||
| 1221 | let mut engine = TextEngine::default(); | ||
| 1222 | let document = TextDocument::new(vec![ | ||
| 1223 | Paragraph::new( | ||
| 1224 | "first words wrap here and continue".into(), | ||
| 1225 | Format::default(), | ||
| 1226 | ), | ||
| 1227 | Paragraph::new("abc שלום def عالم end".into(), Format::default()), | ||
| 1228 | Paragraph::new("last\nline".into(), Format::default()), | ||
| 1229 | ]) | ||
| 1230 | .unwrap(); | ||
| 1231 | let mut editor = CanvasEditor::new(&mut engine, document, 72.0).unwrap(); | ||
| 1232 | let mut access = Accessibility::default(); | ||
| 1233 | let viewport = Viewport { | ||
| 1234 | size: [800, 600], | ||
| 1235 | origin: [0.0; 2], | ||
| 1236 | scale: 1.0, | ||
| 1237 | }; | ||
| 1238 | for movement in [ | ||
| 1239 | Movement::LineEnd, | ||
| 1240 | Movement::Right, | ||
| 1241 | Movement::Left, | ||
| 1242 | Movement::Down, | ||
| 1243 | Movement::LineStart, | ||
| 1244 | Movement::LineEnd, | ||
| 1245 | Movement::Down, | ||
| 1246 | Movement::Down, | ||
| 1247 | Movement::Down, | ||
| 1248 | Movement::LineEnd, | ||
| 1249 | Movement::Left, | ||
| 1250 | Movement::Left, | ||
| 1251 | Movement::Right, | ||
| 1252 | Movement::LineStart, | ||
| 1253 | Movement::Up, | ||
| 1254 | Movement::Right, | ||
| 1255 | Movement::LineEnd, | ||
| 1256 | ] { | ||
| 1257 | editor.move_selection(&mut engine, movement, false).unwrap(); | ||
| 1258 | let caret = editor.caret(0.0).unwrap(); | ||
| 1259 | let tree = accesskit_consumer::Tree::new( | ||
| 1260 | access.update(&editor, viewport, "Test", None).unwrap(), | ||
| 1261 | true, | ||
| 1262 | ); | ||
| 1263 | let field = tree.state().focus().unwrap(); | ||
| 1264 | let selection = field.text_selection().unwrap(); | ||
| 1265 | let rects = selection.bounding_boxes(); | ||
| 1266 | assert_eq!(rects.len(), 1); | ||
| 1267 | let rect = rects[0]; | ||
| 1268 | for (actual, expected) in [ | ||
| 1269 | (rect.x0, caret.x0), | ||
| 1270 | (rect.y0, caret.y0), | ||
| 1271 | (rect.y1, caret.y1), | ||
| 1272 | ] { | ||
| 1273 | assert!( | ||
| 1274 | (actual - expected).abs() < 0.0001, | ||
| 1275 | "{movement:?}: {rect:?} vs {caret:?}" | ||
| 1276 | ); | ||
| 1277 | } | ||
| 1278 | let source_selection = access | ||
| 1279 | .selection(editor.active_outline().id, selection.to_text_selection()) | ||
| 1280 | .unwrap(); | ||
| 1281 | editor.select(source_selection).unwrap(); | ||
| 1282 | let restored = editor.caret(0.0).unwrap(); | ||
| 1283 | assert_eq!( | ||
| 1284 | restored, caret, | ||
| 1285 | "{movement:?}: AX selection moved the caret" | ||
| 1286 | ); | ||
| 1287 | } | ||
| 1288 | } | ||
| 1289 | #[test] | ||
| 1290 | fn cached_runs_match_reconstruction_through_editing_and_view_changes() { | ||
| 1291 | let mut engine = TextEngine::default(); | ||
| 1292 | let document = TextDocument::new( | ||
| 1293 | [ | ||
| 1294 | "one two three", | ||
| 1295 | "", | ||
| 1296 | "אבג 日本 👩🏽💻 e\u{301}", | ||
| 1297 | "last paragraph", | ||
| 1298 | ] | ||
| 1299 | .into_iter() | ||
| 1300 | .map(|text| Paragraph::new(text.into(), Format::default())) | ||
| 1301 | .collect(), | ||
| 1302 | ) | ||
| 1303 | .unwrap(); | ||
| 1304 | let mut editor = CanvasEditor::new(&mut engine, document, 72.0).unwrap(); | ||
| 1305 | let mut cached = Accessibility::default(); | ||
| 1306 | let mut rebuilt = Accessibility::default(); | ||
| 1307 | let mut compare = |editor: &CanvasEditor, step: usize| { | ||
| 1308 | let viewport = Viewport { | ||
| 1309 | size: [800, 600], | ||
| 1310 | origin: [step as f32 * 0.125, -17.25], | ||
| 1311 | scale: 0.75 + (step % 5) as f32 * 0.5, | ||
| 1312 | }; | ||
| 1313 | let preview = step | ||
| 1314 | .is_multiple_of(2) | ||
| 1315 | .then_some((editor.active_outline().id, [13.5, -7.25])); | ||
| 1316 | for repeat in 0..2 { | ||
| 1317 | rebuilt.paragraphs.clear(); | ||
| 1318 | let actual = cached.update(editor, viewport, "Test", preview).unwrap(); | ||
| 1319 | let expected = rebuilt.update(editor, viewport, "Test", preview).unwrap(); | ||
| 1320 | assert_eq!(actual.nodes, expected.nodes, "step {step}, repeat {repeat}"); | ||
| 1321 | assert_eq!(actual.focus, expected.focus); | ||
| 1322 | let tree = accesskit_consumer::Tree::new(actual, true); | ||
| 1323 | let field = tree.state().focus().unwrap(); | ||
| 1324 | let selection = field.document_range().to_text_selection(); | ||
| 1325 | assert_eq!( | ||
| 1326 | cached | ||
| 1327 | .selection(editor.active_outline().id, selection) | ||
| 1328 | .unwrap(), | ||
| 1329 | rebuilt | ||
| 1330 | .selection(editor.active_outline().id, selection) | ||
| 1331 | .unwrap() | ||
| 1332 | ); | ||
| 1333 | let count = editor | ||
| 1334 | .outlines() | ||
| 1335 | .iter() | ||
| 1336 | .map(|o| o.layouts().count()) | ||
| 1337 | .sum::<usize>(); | ||
| 1338 | assert_eq!(cached.paragraphs.len(), count); | ||
| 1339 | } | ||
| 1340 | }; | ||
| 1341 | compare(&editor, 0); | ||
| 1342 | let mut seed = 0x923e_u64; | ||
| 1343 | let mut next = || { | ||
| 1344 | seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); | ||
| 1345 | (seed >> 32) as usize | ||
| 1346 | }; | ||
| 1347 | for step in 1..=160 { | ||
| 1348 | let id = editor.outlines()[next() % editor.outlines().len()].id; | ||
| 1349 | editor.focus_outline(id).unwrap(); | ||
| 1350 | let positions: Vec<_> = editor | ||
| 1351 | .active_outline() | ||
| 1352 | .document() | ||
| 1353 | .paragraphs() | ||
| 1354 | .enumerate() | ||
| 1355 | .flat_map(|(paragraph, text)| { | ||
| 1356 | (0..=text.text().len()).filter_map(move |byte| { | ||
| 1357 | text.utf16_offset(byte) | ||
| 1358 | .ok() | ||
| 1359 | .map(|offset| TextPosition { paragraph, offset }) | ||
| 1360 | }) | ||
| 1361 | }) | ||
| 1362 | .collect(); | ||
| 1363 | let anchor = positions[next() % positions.len()]; | ||
| 1364 | let focus = if step % 3 == 0 { | ||
| 1365 | positions[next() % positions.len()] | ||
| 1366 | } else { | ||
| 1367 | anchor | ||
| 1368 | }; | ||
| 1369 | editor.select([anchor, focus].into()).unwrap(); | ||
| 1370 | match step % 10 { | ||
| 1371 | 0 => editor.insert(&mut engine, "\n").unwrap(), | ||
| 1372 | 1 => editor.insert(&mut engine, "abc 👩🏽💻 שלום e\u{301}").unwrap(), | ||
| 1373 | 2 => { | ||
| 1374 | editor.delete(&mut engine, true).unwrap(); | ||
| 1375 | } | ||
| 1376 | 3 => editor | ||
| 1377 | .resize(&mut engine, 48.0 + (step % 7) as f32 * 31.25) | ||
| 1378 | .unwrap(), | ||
| 1379 | 4 => { | ||
| 1380 | editor | ||
| 1381 | .create_outline(&mut engine, [step as f32, -5.0], 97.0) | ||
| 1382 | .unwrap(); | ||
| 1383 | editor.insert(&mut engine, "annotation").unwrap(); | ||
| 1384 | } | ||
| 1385 | 5 => { | ||
| 1386 | editor.undo(&mut engine).unwrap(); | ||
| 1387 | } | ||
| 1388 | 6 => { | ||
| 1389 | editor.redo(&mut engine).unwrap(); | ||
| 1390 | } | ||
| 1391 | 7 => editor.move_outline(id, [17.5, step as f32]).unwrap(), | ||
| 1392 | 8 => { | ||
| 1393 | editor.compose(&mut engine, "仮\n名".into(), 1..1).unwrap(); | ||
| 1394 | compare(&editor, step); | ||
| 1395 | editor.cancel_composition(&mut engine).unwrap(); | ||
| 1396 | } | ||
| 1397 | _ => {} | ||
| 1398 | } | ||
| 1399 | compare(&editor, step); | ||
| 1400 | } | ||
| 1401 | while editor.undo(&mut engine).unwrap() { | ||
| 1402 | compare(&editor, 161); | ||
| 1403 | } | ||
| 1404 | while editor.redo(&mut engine).unwrap() { | ||
| 1405 | compare(&editor, 162); | ||
| 1406 | } | ||
| 1407 | } | ||
| 1408 | #[test] | ||
| 1409 | fn outlines_have_independent_accessibility_identity_and_selection() { | ||
| 1410 | let mut engine = TextEngine::default(); | ||
| 1411 | let document = | ||
| 1412 | TextDocument::new(vec![Paragraph::new("lyrics".into(), Format::default())]).unwrap(); | ||
| 1413 | let mut editor = CanvasEditor::new(&mut engine, document, 240.0).unwrap(); | ||
| 1414 | let first = editor.active_outline().id; | ||
| 1415 | let second = editor | ||
| 1416 | .create_outline(&mut engine, [300.0, 40.0], 120.0) | ||
| 1417 | .unwrap(); | ||
| 1418 | editor.insert(&mut engine, "annotation 🌳").unwrap(); | ||
| 1419 | let mut access = Accessibility::default(); | ||
| 1420 | let viewport = Viewport { | ||
| 1421 | size: [1000, 720], | ||
| 1422 | scale: 2.0, | ||
| 1423 | origin: [48.0; 2], | ||
| 1424 | }; | ||
| 1425 | let update = access.update(&editor, viewport, "Test", None).unwrap(); | ||
| 1426 | let fields: Vec<_> = update | ||
| 1427 | .nodes | ||
| 1428 | .iter() | ||
| 1429 | .filter(|(_, node)| node.role() == Role::MultilineTextInput) | ||
| 1430 | .map(|(id, node)| (*id, node.value().unwrap().to_owned())) | ||
| 1431 | .collect(); | ||
| 1432 | assert_eq!( | ||
| 1433 | fields | ||
| 1434 | .iter() | ||
| 1435 | .map(|(_, text)| text.as_str()) | ||
| 1436 | .collect::<Vec<_>>(), | ||
| 1437 | ["lyrics", "annotation 🌳"] | ||
| 1438 | ); | ||
| 1439 | assert_ne!(fields[0].0, fields[1].0); | ||
| 1440 | assert_eq!(access.outline_for_node(fields[0].0), Some(first)); | ||
| 1441 | assert_eq!(access.outline_for_node(fields[1].0), Some(second)); | ||
| 1442 | let tree = accesskit_consumer::Tree::new(update, true); | ||
| 1443 | let field = tree.state().focus().unwrap(); | ||
| 1444 | assert_eq!(field.document_range().text(), "annotation 🌳"); | ||
| 1445 | let selection = field.document_range().to_text_selection(); | ||
| 1446 | assert!(access.selection(first, selection).is_err()); | ||
| 1447 | let source_selection = access.selection(second, selection).unwrap(); | ||
| 1448 | editor.select(source_selection).unwrap(); | ||
| 1449 | let preview = access | ||
| 1450 | .update(&editor, viewport, "Test", Some((second, [400.0, 50.0]))) | ||
| 1451 | .unwrap(); | ||
| 1452 | let preview_tree = accesskit_consumer::Tree::new(preview, true); | ||
| 1453 | let preview_field = preview_tree.state().focus().unwrap(); | ||
| 1454 | let rect = preview_field.document_range().bounding_boxes()[0]; | ||
| 1455 | assert_eq!(rect.x0, 848.0); | ||
| 1456 | assert_eq!(rect.y0, 148.0); | ||
| 1457 | assert_eq!(editor.active_outline().origin(), [300.0, 40.0]); | ||
| 1458 | editor.move_outline(second, [400.0, 50.0]).unwrap(); | ||
| 1459 | access.update(&editor, viewport, "Test", None).unwrap(); | ||
| 1460 | assert_eq!(access.outline_for_node(fields[1].0), Some(second)); | ||
| 1461 | assert!(access.selection(second, selection).is_ok()); | ||
| 1462 | editor.undo(&mut engine).unwrap(); | ||
| 1463 | editor.undo(&mut engine).unwrap(); | ||
| 1464 | editor.undo(&mut engine).unwrap(); | ||
| 1465 | let update = access.update(&editor, viewport, "Test", None).unwrap(); | ||
| 1466 | assert_eq!( | ||
| 1467 | update | ||
| 1468 | .nodes | ||
| 1469 | .iter() | ||
| 1470 | .filter(|(_, node)| node.role() == Role::MultilineTextInput) | ||
| 1471 | .count(), | ||
| 1472 | 1 | ||
| 1473 | ); | ||
| 1474 | assert_eq!(access.outline_for_node(fields[1].0), None); | ||
| 1475 | assert!(access.selection(second, selection).is_err()); | ||
| 1476 | assert_eq!(access.outline_for_node(fields[0].0), Some(first)); | ||
| 1477 | } | ||
| 1478 | } | ||
crates/canvas/src/interaction/mod.rs created+1785| ... | @@ -0,0 +1,1785 @@ | ||
| 1 | //! How a page responds to pointer, keyboard and text input as OneNote does: hit layers, drags, | ||
| 2 | //! the placement grid, picture handles, outline and picture chrome, and key routing. Hosts | ||
| 3 | //! translate their platform's events into these calls and carry out the returned requests. | ||
| 4 | |||
| 5 | pub mod accessibility; | ||
| 6 | #[cfg(test)] | ||
| 7 | mod profile; | ||
| 8 | mod scroll; | ||
| 9 | #[cfg(test)] | ||
| 10 | mod tests; | ||
| 11 | |||
| 12 | use crate::gpu::{Primitive, Stroke, Viewport, page::PageScene}; | ||
| 13 | use crate::{ | ||
| 14 | date::DateField, | ||
| 15 | editor::{ | ||
| 16 | CanvasEditor, DEFAULT_OUTLINE_WIDTH, Movement, Selection, SelectionUnit, TextOutline, | ||
| 17 | }, | ||
| 18 | layout::TextEngine, | ||
| 19 | }; | ||
| 20 | use std::{ | ||
| 21 | error::Error, | ||
| 22 | time::{Duration, Instant}, | ||
| 23 | }; | ||
| 24 | |||
| 25 | const HANDLE_HEIGHT: f32 = 6.75; | ||
| 26 | /// Accessible names of the page date's fields. | ||
| 27 | pub const DATE_LABELS: [&str; 2] = ["Page date", "Page time"]; | ||
| 28 | |||
| 29 | pub type Result<T> = std::result::Result<T, Box<dyn Error>>; | ||
| 30 | |||
| 31 | #[derive(Clone, Debug, PartialEq)] | ||
| 32 | pub enum Key { | ||
| 33 | Named(NamedKey), | ||
| 34 | Character(String), | ||
| 35 | } | ||
| 36 | |||
| 37 | #[derive(Clone, Copy, Debug, PartialEq)] | ||
| 38 | pub enum NamedKey { | ||
| 39 | Escape, | ||
| 40 | Tab, | ||
| 41 | Space, | ||
| 42 | Enter, | ||
| 43 | Backspace, | ||
| 44 | Delete, | ||
| 45 | ArrowLeft, | ||
| 46 | ArrowRight, | ||
| 47 | ArrowUp, | ||
| 48 | ArrowDown, | ||
| 49 | Home, | ||
| 50 | End, | ||
| 51 | /// Shift, Control, Option or Command pressed alone. | ||
| 52 | Modifier, | ||
| 53 | Other, | ||
| 54 | } | ||
| 55 | |||
| 56 | #[derive(Clone, Copy, Debug, Default, PartialEq)] | ||
| 57 | pub struct Modifiers { | ||
| 58 | pub shift: bool, | ||
| 59 | pub control: bool, | ||
| 60 | pub option: bool, | ||
| 61 | pub command: bool, | ||
| 62 | } | ||
| 63 | |||
| 64 | #[derive(Clone, Copy, Debug, PartialEq)] | ||
| 65 | pub enum Cursor { | ||
| 66 | Default, | ||
| 67 | Text, | ||
| 68 | Pointer, | ||
| 69 | Move, | ||
| 70 | EwResize, | ||
| 71 | NsResize, | ||
| 72 | NwseResize, | ||
| 73 | NeswResize, | ||
| 74 | } | ||
| 75 | |||
| 76 | /// Work only the host can do, asked for by an event. | ||
| 77 | #[derive(Debug, PartialEq)] | ||
| 78 | pub enum Request { | ||
| 79 | /// Show the date or time picker, then report the choice through `PageView::change_date`. | ||
| 80 | EditDate(DateField), | ||
| 81 | /// Put this text on the clipboard. | ||
| 82 | Copy(String), | ||
| 83 | /// Read the clipboard and hand its text to `PageView::commit_text`. | ||
| 84 | Paste, | ||
| 85 | /// Show the platform's character picker. | ||
| 86 | CharacterPalette, | ||
| 87 | } | ||
| 88 | |||
| 89 | /// What an event did: `changed` means the page, selection or view changed (the host saves, | ||
| 90 | /// updates accessibility and redraws); `redraw` alone repaints hover feedback. | ||
| 91 | #[must_use] | ||
| 92 | #[derive(Debug, Default, PartialEq)] | ||
| 93 | pub struct Response { | ||
| 94 | pub changed: bool, | ||
| 95 | pub redraw: bool, | ||
| 96 | pub request: Option<Request>, | ||
| 97 | } | ||
| 98 | |||
| 99 | impl Response { | ||
| 100 | fn changed() -> Self { | ||
| 101 | Self { | ||
| 102 | changed: true, | ||
| 103 | redraw: true, | ||
| 104 | request: None, | ||
| 105 | } | ||
| 106 | } | ||
| 107 | |||
| 108 | fn redraw() -> Self { | ||
| 109 | Self { | ||
| 110 | redraw: true, | ||
| 111 | ..Self::default() | ||
| 112 | } | ||
| 113 | } | ||
| 114 | |||
| 115 | fn request(request: Request) -> Self { | ||
| 116 | Self { | ||
| 117 | request: Some(request), | ||
| 118 | ..Self::changed() | ||
| 119 | } | ||
| 120 | } | ||
| 121 | } | ||
| 122 | |||
| 123 | /// The platform's caret and text selection colours, linear RGBA. | ||
| 124 | #[derive(Clone, Copy, Debug)] | ||
| 125 | pub struct TextColors { | ||
| 126 | pub caret: [f32; 4], | ||
| 127 | pub selection: [f32; 4], | ||
| 128 | } | ||
| 129 | |||
| 130 | /// How to paint an outline: whether the caret is in its blink-on phase, the view scale, | ||
| 131 | /// document points per device pixel, and the text colours. | ||
| 132 | #[derive(Clone, Copy)] | ||
| 133 | struct Paint { | ||
| 134 | show_caret: bool, | ||
| 135 | scale: f32, | ||
| 136 | pixel: f32, | ||
| 137 | colors: TextColors, | ||
| 138 | } | ||
| 139 | |||
| 140 | enum Drag { | ||
| 141 | Text { | ||
| 142 | anchor: Selection, | ||
| 143 | unit: SelectionUnit, | ||
| 144 | }, | ||
| 145 | Scrollbar { | ||
| 146 | axis: usize, | ||
| 147 | grab: f32, | ||
| 148 | }, | ||
| 149 | Resize { | ||
| 150 | outline: Option<Box<TextOutline>>, | ||
| 151 | grab: f32, | ||
| 152 | }, | ||
| 153 | Outline { | ||
| 154 | id: onestore::ExGuid, | ||
| 155 | grab: [f32; 2], | ||
| 156 | pending_press: Option<[f32; 2]>, | ||
| 157 | }, | ||
| 158 | Image { | ||
| 159 | id: onestore::ExGuid, | ||
| 160 | handle: [i8; 2], | ||
| 161 | /// Document point of the press. | ||
| 162 | press: [f32; 2], | ||
| 163 | pending_press: Option<[f32; 2]>, | ||
| 164 | }, | ||
| 165 | } | ||
| 166 | |||
| 167 | #[derive(Clone, Copy)] | ||
| 168 | enum PointerFeedback<'a> { | ||
| 169 | Hover(onestore::ExGuid), | ||
| 170 | Move(onestore::ExGuid, [f32; 2]), | ||
| 171 | Resize(&'a TextOutline), | ||
| 172 | /// A picture being moved or resized, drawn at this origin and size. | ||
| 173 | Image(onestore::ExGuid, [f32; 2], [f32; 2]), | ||
| 174 | } | ||
| 175 | |||
| 176 | /// A non-text object holding focus, which hides the text caret and suspends typing. | ||
| 177 | #[derive(Clone, Copy, Debug, PartialEq)] | ||
| 178 | pub enum ObjectFocus { | ||
| 179 | ReadOnly(usize), | ||
| 180 | Image(onestore::ExGuid), | ||
| 181 | } | ||
| 182 | |||
| 183 | impl ObjectFocus { | ||
| 184 | pub fn read_only(self) -> Option<usize> { | ||
| 185 | match self { | ||
| 186 | Self::ReadOnly(index) => Some(index), | ||
| 187 | Self::Image(_) => None, | ||
| 188 | } | ||
| 189 | } | ||
| 190 | } | ||
| 191 | |||
| 192 | fn read_only_shortcut(key: &Key, modifiers: Modifiers) -> bool { | ||
| 193 | key == &Key::Named(NamedKey::Escape) | ||
| 194 | || (modifiers.control && key == &Key::Named(NamedKey::Tab)) | ||
| 195 | || (modifiers.command | ||
| 196 | && matches!(key, Key::Character(value) if matches!(value.as_str(), "+" | "=" | "-" | "0") || (modifiers.shift && value.eq_ignore_ascii_case("n")))) | ||
| 197 | } | ||
| 198 | |||
| 199 | /// A page on screen: the editor, the drawn scene, the view onto them and the pointer and | ||
| 200 | /// keyboard state between events. | ||
| 201 | pub struct PageView { | ||
| 202 | pub editor: CanvasEditor, | ||
| 203 | pub engine: TextEngine, | ||
| 204 | /// The page's noneditable content and where it sits in the view. | ||
| 205 | pub scene: Option<(PageScene, [f32; 2])>, | ||
| 206 | pub viewport: Viewport, | ||
| 207 | /// Device pixels per logical pixel. | ||
| 208 | display_scale: f32, | ||
| 209 | double_click: Duration, | ||
| 210 | pointer: [f32; 2], | ||
| 211 | pointer_inside: bool, | ||
| 212 | last_click: Option<(Instant, [f32; 2], u8)>, | ||
| 213 | drag: Option<Drag>, | ||
| 214 | object_focus: Option<ObjectFocus>, | ||
| 215 | modifiers: Modifiers, | ||
| 216 | focused: bool, | ||
| 217 | caret: bool, | ||
| 218 | blink_at: Instant, | ||
| 219 | } | ||
| 220 | |||
| 221 | impl PageView { | ||
| 222 | /// `size` is in device pixels; `double_click` is the platform's double-click interval. | ||
| 223 | pub fn new( | ||
| 224 | editor: CanvasEditor, | ||
| 225 | engine: TextEngine, | ||
| 226 | scene: Option<(PageScene, [f32; 2])>, | ||
| 227 | size: [u32; 2], | ||
| 228 | display_scale: f32, | ||
| 229 | double_click: Duration, | ||
| 230 | ) -> Self { | ||
| 231 | Self { | ||
| 232 | editor, | ||
| 233 | engine, | ||
| 234 | scene, | ||
| 235 | viewport: Viewport { | ||
| 236 | size, | ||
| 237 | scale: display_scale * 96.0 / 72.0, | ||
| 238 | origin: [48.0 * display_scale; 2], | ||
| 239 | }, | ||
| 240 | display_scale, | ||
| 241 | double_click, | ||
| 242 | pointer: [0.0; 2], | ||
| 243 | pointer_inside: false, | ||
| 244 | last_click: None, | ||
| 245 | drag: None, | ||
| 246 | object_focus: None, | ||
| 247 | modifiers: Modifiers::default(), | ||
| 248 | focused: true, | ||
| 249 | caret: true, | ||
| 250 | blink_at: Instant::now() + Duration::from_millis(500), | ||
| 251 | } | ||
| 252 | } | ||
| 253 | |||
| 254 | /// Shows a page reloaded from storage in place of the edited one. | ||
| 255 | pub fn replace(&mut self, editor: CanvasEditor, scene: Option<(PageScene, [f32; 2])>) { | ||
| 256 | self.editor = editor; | ||
| 257 | self.scene = scene; | ||
| 258 | self.drag = None; | ||
| 259 | self.object_focus = None; | ||
| 260 | } | ||
| 261 | |||
| 262 | pub fn modifiers(&self) -> Modifiers { | ||
| 263 | self.modifiers | ||
| 264 | } | ||
| 265 | |||
| 266 | pub fn object_focus(&self) -> Option<ObjectFocus> { | ||
| 267 | self.object_focus | ||
| 268 | } | ||
| 269 | |||
| 270 | /// Text input goes to the editor unless an object holds focus. | ||
| 271 | pub fn accepts_text(&self) -> bool { | ||
| 272 | self.object_focus.is_none() | ||
| 273 | } | ||
| 274 | |||
| 275 | /// The outline being dragged and where it would land. | ||
| 276 | pub fn outline_preview(&self) -> Option<(onestore::ExGuid, [f32; 2])> { | ||
| 277 | let Drag::Outline { | ||
| 278 | id, | ||
| 279 | grab, | ||
| 280 | pending_press, | ||
| 281 | } = self.drag.as_ref()? | ||
| 282 | else { | ||
| 283 | return None; | ||
| 284 | }; | ||
| 285 | let origin = self | ||
| 286 | .editor | ||
| 287 | .outlines() | ||
| 288 | .iter() | ||
| 289 | .find(|outline| outline.id == *id)? | ||
| 290 | .origin(); | ||
| 291 | if pending_press.is_some() { | ||
| 292 | return Some((*id, origin)); | ||
| 293 | } | ||
| 294 | let point = self.viewport.document_point(self.pointer); | ||
| 295 | let position = [point[0] - grab[0], point[1] - grab[1]]; | ||
| 296 | Some(( | ||
| 297 | *id, | ||
| 298 | if self.modifiers.option { | ||
| 299 | position | ||
| 300 | } else { | ||
| 301 | snap_to_grid(position, self.editor.margin_origin()) | ||
| 302 | }, | ||
| 303 | )) | ||
| 304 | } | ||
| 305 | |||
| 306 | fn image_preview(&self) -> Option<(onestore::ExGuid, [f32; 2], [f32; 2])> { | ||
| 307 | let Drag::Image { | ||
| 308 | id, | ||
| 309 | handle, | ||
| 310 | press, | ||
| 311 | pending_press: None, | ||
| 312 | } = self.drag.as_ref()? | ||
| 313 | else { | ||
| 314 | return None; | ||
| 315 | }; | ||
| 316 | let (origin, size) = self.editor.image_placement(*id)?; | ||
| 317 | let point = self.viewport.document_point(self.pointer); | ||
| 318 | let delta = [point[0] - press[0], point[1] - press[1]]; | ||
| 319 | if *handle != [0, 0] { | ||
| 320 | let (origin, size) = resize_image(origin, size, *handle, delta); | ||
| 321 | return Some((*id, origin, size)); | ||
| 322 | } | ||
| 323 | // A picture in an outline's flow keeps its place. | ||
| 324 | if self.editor.image_in_outline(*id) { | ||
| 325 | return Some((*id, origin, size)); | ||
| 326 | } | ||
| 327 | let origin = [origin[0] + delta[0], origin[1] + delta[1]]; | ||
| 328 | let origin = if self.modifiers.option { | ||
| 329 | origin | ||
| 330 | } else { | ||
| 331 | snap_to_grid(origin, self.editor.margin_origin()) | ||
| 332 | }; | ||
| 333 | Some((*id, origin, size)) | ||
| 334 | } | ||
| 335 | |||
| 336 | fn pixel(&self) -> f32 { | ||
| 337 | self.display_scale / self.viewport.scale | ||
| 338 | } | ||
| 339 | |||
| 340 | /// The selected picture's handles lie above the page; its body keeps its paint order. | ||
| 341 | fn hit_test(&self, point: [f32; 2]) -> Option<Hit> { | ||
| 342 | if let Some(ObjectFocus::Image(id)) = self.object_focus | ||
| 343 | && let Some((origin, size)) = self.editor.image_placement(id) | ||
| 344 | && let Some(handle) = image_handle_at(image_rect(origin, size), self.pixel(), point) | ||
| 345 | { | ||
| 346 | return Some(Hit::Image { id, handle }); | ||
| 347 | } | ||
| 348 | page_hit_test(&self.editor, self.scene.as_ref(), point, self.pixel()) | ||
| 349 | } | ||
| 350 | |||
| 351 | fn set_object_focus(&mut self, focus: Option<ObjectFocus>) { | ||
| 352 | if self.object_focus != focus { | ||
| 353 | self.editor.finish_composition(); | ||
| 354 | self.drag = None; | ||
| 355 | self.object_focus = focus; | ||
| 356 | } | ||
| 357 | } | ||
| 358 | |||
| 359 | /// Page coordinates of a focused object. | ||
| 360 | fn object_rect(&self, focus: ObjectFocus) -> [f32; 4] { | ||
| 361 | let (scene, offset) = self.scene.as_ref().unwrap(); | ||
| 362 | let [x0, y0, x1, y1] = match focus { | ||
| 363 | ObjectFocus::ReadOnly(index) => scene | ||
| 364 | .read_only(Some(&self.editor)) | ||
| 365 | .nth(index) | ||
| 366 | .unwrap() | ||
| 367 | .rect(), | ||
| 368 | ObjectFocus::Image(id) => { | ||
| 369 | let (origin, size) = self.editor.image_placement(id).unwrap(); | ||
| 370 | image_rect(origin, size) | ||
| 371 | } | ||
| 372 | }; | ||
| 373 | [ | ||
| 374 | x0 + offset[0], | ||
| 375 | y0 + offset[1], | ||
| 376 | x1 + offset[0], | ||
| 377 | y1 + offset[1], | ||
| 378 | ] | ||
| 379 | } | ||
| 380 | |||
| 381 | /// Keeps the view in bounds and restarts the caret blink after a change. | ||
| 382 | fn changed(&mut self) -> Result<Response> { | ||
| 383 | self.scroll().clamp(&mut self.viewport); | ||
| 384 | self.caret = true; | ||
| 385 | self.blink_at = Instant::now() + Duration::from_millis(500); | ||
| 386 | Ok(Response::changed()) | ||
| 387 | } | ||
| 388 | |||
| 389 | /// The caret's rectangle in device pixels, for placing the platform's input method. | ||
| 390 | pub fn caret_area(&self) -> Result<[f32; 4]> { | ||
| 391 | let mut rect = self.editor.caret(1.0)?; | ||
| 392 | let origin = self | ||
| 393 | .outline_preview() | ||
| 394 | .map(|(_, origin)| origin) | ||
| 395 | .unwrap_or_else(|| self.editor.active_outline().origin()); | ||
| 396 | rect.x0 += f64::from(origin[0]); | ||
| 397 | rect.y0 += f64::from(origin[1]); | ||
| 398 | rect.x1 += f64::from(origin[0]); | ||
| 399 | rect.y1 += f64::from(origin[1]); | ||
| 400 | let v = self.viewport; | ||
| 401 | let x = rect.x0 as f32 * v.scale + v.origin[0]; | ||
| 402 | let y = rect.y0 as f32 * v.scale + v.origin[1]; | ||
| 403 | Ok([ | ||
| 404 | x, | ||
| 405 | y, | ||
| 406 | x + (rect.width() as f32 * v.scale).max(1.0), | ||
| 407 | y + (rect.height() as f32 * v.scale).max(1.0), | ||
| 408 | ]) | ||
| 409 | } | ||
| 410 | |||
| 411 | fn reveal_focus(&mut self) -> Result<()> { | ||
| 412 | if self.viewport.size.contains(&0) { | ||
| 413 | return Ok(()); | ||
| 414 | } | ||
| 415 | let rect = if let Some(focus) = self.object_focus { | ||
| 416 | let [x0, y0, x1, y1] = self.object_rect(focus).map(f64::from); | ||
| 417 | parley::BoundingBox { x0, y0, x1, y1 } | ||
| 418 | } else { | ||
| 419 | let mut rect = self.editor.caret(1.0)?; | ||
| 420 | let origin = self.editor.active_outline().origin(); | ||
| 421 | rect.x0 += f64::from(origin[0]); | ||
| 422 | rect.x1 += f64::from(origin[0]); | ||
| 423 | rect.y0 += f64::from(origin[1]); | ||
| 424 | rect.y1 += f64::from(origin[1]); | ||
| 425 | rect | ||
| 426 | }; | ||
| 427 | let outline = self.editor.active_outline().bounds(); | ||
| 428 | for (axis, (mut start, mut end)) in [(rect.x0, rect.x1), (rect.y0, rect.y1)] | ||
| 429 | .into_iter() | ||
| 430 | .enumerate() | ||
| 431 | { | ||
| 432 | let size = f64::from(self.viewport.size[axis]); | ||
| 433 | let margin = f64::from(16.0 * self.display_scale).min(size * 0.25); | ||
| 434 | let (outline_start, outline_end) = | ||
| 435 | [(outline.x0, outline.x1), (outline.y0, outline.y1)][axis]; | ||
| 436 | let outline_start = outline_start.min(start); | ||
| 437 | let outline_end = outline_end.max(end); | ||
| 438 | if self.object_focus.is_none() | ||
| 439 | && (outline_end - outline_start) * f64::from(self.viewport.scale) | ||
| 440 | <= size - margin * 2.0 | ||
| 441 | { | ||
| 442 | start = outline_start; | ||
| 443 | end = outline_end; | ||
| 444 | } | ||
| 445 | let start = | ||
| 446 | start * f64::from(self.viewport.scale) + f64::from(self.viewport.origin[axis]); | ||
| 447 | let end = end * f64::from(self.viewport.scale) + f64::from(self.viewport.origin[axis]); | ||
| 448 | let shift = if start < margin { | ||
| 449 | margin - start | ||
| 450 | } else if end > size - margin { | ||
| 451 | size - margin - end | ||
| 452 | } else { | ||
| 453 | 0.0 | ||
| 454 | }; | ||
| 455 | self.viewport.origin[axis] += shift as f32; | ||
| 456 | } | ||
| 457 | Ok(()) | ||
| 458 | } | ||
| 459 | |||
| 460 | /// Reveals the caret or focused object after an edit, then reports the change. | ||
| 461 | fn edited(&mut self) -> Result<Response> { | ||
| 462 | self.reveal_focus()?; | ||
| 463 | self.changed() | ||
| 464 | } | ||
| 465 | |||
| 466 | fn zoom(&mut self, factor: f32) { | ||
| 467 | let point = self.viewport.document_point(self.pointer); | ||
| 468 | let dpr = self.display_scale; | ||
| 469 | self.viewport.scale = (self.viewport.scale * factor).clamp(dpr / 3.0, dpr * 16.0 / 3.0); | ||
| 470 | self.viewport.origin = [ | ||
| 471 | self.pointer[0] - point[0] * self.viewport.scale, | ||
| 472 | self.pointer[1] - point[1] * self.viewport.scale, | ||
| 473 | ]; | ||
| 474 | } | ||
| 475 | |||
| 476 | fn scroll(&self) -> scroll::Scroll { | ||
| 477 | let editable = self | ||
| 478 | .editor | ||
| 479 | .visible_outlines() | ||
| 480 | .chain(self.editor.caret_outline()) | ||
| 481 | .map(|outline| { | ||
| 482 | let rect = outline.bounds(); | ||
| 483 | let offset = self | ||
| 484 | .scene | ||
| 485 | .as_ref() | ||
| 486 | .filter(|_| self.editor.has_page_outline(outline.id)) | ||
| 487 | .map(|(_, offset)| *offset) | ||
| 488 | .unwrap_or([0.0; 2]); | ||
| 489 | [ | ||
| 490 | rect.x0 as f32 + offset[0], | ||
| 491 | rect.y0 as f32 + offset[1], | ||
| 492 | rect.x1 as f32 + offset[0], | ||
| 493 | rect.y1 as f32 + offset[1], | ||
| 494 | ] | ||
| 495 | }); | ||
| 496 | let fixed = self.scene.iter().flat_map(|(scene, offset)| { | ||
| 497 | scene.content_bounds(&self.editor).map(|rect| { | ||
| 498 | [ | ||
| 499 | rect[0] + offset[0], | ||
| 500 | rect[1] + offset[1], | ||
| 501 | rect[2] + offset[0], | ||
| 502 | rect[3] + offset[1], | ||
| 503 | ] | ||
| 504 | }) | ||
| 505 | }); | ||
| 506 | scroll::Scroll::new(self.viewport, editable.chain(fixed)) | ||
| 507 | } | ||
| 508 | |||
| 509 | /// Everything to draw this frame, scrollbars included. | ||
| 510 | pub fn primitives(&self, colors: TextColors) -> Result<Vec<Primitive<'_>>> { | ||
| 511 | let preview = match &self.drag { | ||
| 512 | Some(Drag::Resize { | ||
| 513 | outline: Some(outline), | ||
| 514 | .. | ||
| 515 | }) => Some(PointerFeedback::Resize(outline)), | ||
| 516 | _ => self | ||
| 517 | .outline_preview() | ||
| 518 | .map(|(id, origin)| PointerFeedback::Move(id, origin)) | ||
| 519 | .or_else(|| { | ||
| 520 | self.image_preview() | ||
| 521 | .map(|(id, origin, size)| PointerFeedback::Image(id, origin, size)) | ||
| 522 | }) | ||
| 523 | .or_else(|| { | ||
| 524 | if !self.pointer_inside { | ||
| 525 | return None; | ||
| 526 | } | ||
| 527 | match page_hit_test( | ||
| 528 | &self.editor, | ||
| 529 | self.scene.as_ref(), | ||
| 530 | self.viewport.document_point(self.pointer), | ||
| 531 | self.pixel(), | ||
| 532 | ) { | ||
| 533 | Some( | ||
| 534 | Hit::Text { id, .. } | Hit::Handle { id, .. } | Hit::Resize { id, .. }, | ||
| 535 | ) => Some(PointerFeedback::Hover(id)), | ||
| 536 | _ => None, | ||
| 537 | } | ||
| 538 | }), | ||
| 539 | }; | ||
| 540 | let mut primitives = page_primitives( | ||
| 541 | &self.editor, | ||
| 542 | self.scene.as_ref(), | ||
| 543 | preview, | ||
| 544 | self.object_focus, | ||
| 545 | Paint { | ||
| 546 | show_caret: self.caret | ||
| 547 | && self.focused | ||
| 548 | && !matches!( | ||
| 549 | self.drag, | ||
| 550 | Some(Drag::Outline { .. } | Drag::Resize { .. } | Drag::Image { .. }) | ||
| 551 | ), | ||
| 552 | scale: self.viewport.scale, | ||
| 553 | pixel: self.pixel(), | ||
| 554 | colors, | ||
| 555 | }, | ||
| 556 | )?; | ||
| 557 | self.scroll() | ||
| 558 | .append(self.viewport, self.display_scale, &mut primitives); | ||
| 559 | Ok(primitives) | ||
| 560 | } | ||
| 561 | |||
| 562 | /// The pointer shape at the pointer's position. | ||
| 563 | pub fn cursor(&self) -> Cursor { | ||
| 564 | let hit = self.hit_test(self.viewport.document_point(self.pointer)); | ||
| 565 | let scrollbar = self | ||
| 566 | .scroll() | ||
| 567 | .hit_test(self.viewport, self.display_scale, self.pointer) | ||
| 568 | .is_some(); | ||
| 569 | match (&self.drag, hit) { | ||
| 570 | (Some(Drag::Scrollbar { .. }), _) => Cursor::Default, | ||
| 571 | (None, _) if scrollbar => Cursor::Default, | ||
| 572 | (Some(Drag::Image { handle, .. }), _) => handle_cursor(*handle), | ||
| 573 | (None, Some(Hit::Image { handle, .. })) => handle_cursor(handle), | ||
| 574 | (Some(Drag::Resize { .. }), _) | (None, Some(Hit::Resize { .. })) => Cursor::EwResize, | ||
| 575 | (Some(Drag::Outline { .. }), _) | (None, Some(Hit::Handle { .. })) => Cursor::Move, | ||
| 576 | (None, Some(Hit::Date(_))) => Cursor::Pointer, | ||
| 577 | (None, Some(Hit::ReadOnly(_))) => Cursor::Default, | ||
| 578 | _ => Cursor::Text, | ||
| 579 | } | ||
| 580 | } | ||
| 581 | |||
| 582 | /// Advances the caret blink; returns whether to repaint and when to call again. | ||
| 583 | pub fn blink(&mut self, now: Instant) -> (bool, Option<Instant>) { | ||
| 584 | let [anchor, focus] = self.editor.selection().positions; | ||
| 585 | if !(self.focused && self.object_focus.is_none() && anchor == focus) { | ||
| 586 | return (false, None); | ||
| 587 | } | ||
| 588 | let repaint = now >= self.blink_at; | ||
| 589 | if repaint { | ||
| 590 | self.caret = !self.caret; | ||
| 591 | self.blink_at = now + Duration::from_millis(500); | ||
| 592 | } | ||
| 593 | (repaint, Some(self.blink_at)) | ||
| 594 | } | ||
| 595 | |||
| 596 | /// `size` in device pixels. | ||
| 597 | pub fn resized(&mut self, size: [u32; 2]) -> Result<Response> { | ||
| 598 | self.viewport.size = size; | ||
| 599 | if size.contains(&0) { | ||
| 600 | return Ok(Response::default()); | ||
| 601 | } | ||
| 602 | self.changed() | ||
| 603 | } | ||
| 604 | |||
| 605 | pub fn scale_factor_changed(&mut self, scale: f32) -> Result<Response> { | ||
| 606 | let ratio = scale / self.display_scale; | ||
| 607 | self.viewport.scale *= ratio; | ||
| 608 | self.viewport.origin[0] *= ratio; | ||
| 609 | self.viewport.origin[1] *= ratio; | ||
| 610 | self.display_scale = scale; | ||
| 611 | self.changed() | ||
| 612 | } | ||
| 613 | |||
| 614 | pub fn focus_changed(&mut self, focused: bool) -> Result<Response> { | ||
| 615 | self.focused = focused; | ||
| 616 | if !focused { | ||
| 617 | self.drag = None; | ||
| 618 | } | ||
| 619 | self.changed() | ||
| 620 | } | ||
| 621 | |||
| 622 | pub fn modifiers_changed(&mut self, modifiers: Modifiers) -> Result<Response> { | ||
| 623 | self.modifiers = modifiers; | ||
| 624 | if matches!(self.drag, Some(Drag::Outline { .. })) { | ||
| 625 | return self.changed(); | ||
| 626 | } | ||
| 627 | Ok(Response::default()) | ||
| 628 | } | ||
| 629 | |||
| 630 | pub fn pointer_left(&mut self) -> Response { | ||
| 631 | self.pointer_inside = false; | ||
| 632 | Response::redraw() | ||
| 633 | } | ||
| 634 | |||
| 635 | /// `position` in device pixels from the view's top-left. | ||
| 636 | pub fn pointer_moved(&mut self, position: [f32; 2]) -> Result<Response> { | ||
| 637 | self.pointer_inside = true; | ||
| 638 | self.pointer = position; | ||
| 639 | match &mut self.drag { | ||
| 640 | Some(Drag::Scrollbar { axis, grab }) => { | ||
| 641 | let (axis, grab) = (*axis, *grab); | ||
| 642 | self.scroll().drag( | ||
| 643 | &mut self.viewport, | ||
| 644 | self.display_scale, | ||
| 645 | axis, | ||
| 646 | self.pointer[axis], | ||
| 647 | grab, | ||
| 648 | ); | ||
| 649 | self.changed() | ||
| 650 | } | ||
| 651 | Some(Drag::Text { anchor, unit }) => { | ||
| 652 | let (anchor, unit) = (*anchor, *unit); | ||
| 653 | let point = self.viewport.document_point(self.pointer); | ||
| 654 | let origin = self.editor.active_outline().origin(); | ||
| 655 | let target = | ||
| 656 | self.editor | ||
| 657 | .selection_at(point[0] - origin[0], point[1] - origin[1], unit)?; | ||
| 658 | self.editor.select(drag_selection(anchor, target, unit))?; | ||
| 659 | self.changed() | ||
| 660 | } | ||
| 661 | Some(Drag::Outline { pending_press, .. } | Drag::Image { pending_press, .. }) => { | ||
| 662 | if pending_press.is_some_and(|press| { | ||
| 663 | (0..2) | ||
| 664 | .any(|axis| (position[axis] - press[axis]).abs() > 2.0 * self.display_scale) | ||
| 665 | }) { | ||
| 666 | *pending_press = None; | ||
| 667 | } | ||
| 668 | self.changed() | ||
| 669 | } | ||
| 670 | Some(Drag::Resize { outline, grab }) => { | ||
| 671 | let point = self.viewport.document_point(self.pointer); | ||
| 672 | let width = (point[0] - self.editor.active_outline().origin()[0] - *grab).max(36.0); | ||
| 673 | if outline.is_some() | ||
| 674 | || (width - self.editor.active_outline().bounds().width() as f32).abs() | ||
| 675 | * self.viewport.scale | ||
| 676 | > 2.0 * self.display_scale | ||
| 677 | { | ||
| 678 | *outline = Some(Box::new( | ||
| 679 | self.editor.preview_resize(&mut self.engine, width)?, | ||
| 680 | )); | ||
| 681 | return self.changed(); | ||
| 682 | } | ||
| 683 | Ok(Response::default()) | ||
| 684 | } | ||
| 685 | None => Ok(Response::redraw()), | ||
| 686 | } | ||
| 687 | } | ||
| 688 | |||
| 689 | /// A primary-button press at the pointer's position, at `now` for click counting. | ||
| 690 | pub fn pointer_pressed(&mut self, now: Instant) -> Result<Response> { | ||
| 691 | let point = self.viewport.document_point(self.pointer); | ||
| 692 | let count = self | ||
| 693 | .last_click | ||
| 694 | .filter(|(time, point, _)| { | ||
| 695 | now.duration_since(*time) <= self.double_click | ||
| 696 | && (0..2).all(|axis| { | ||
| 697 | (point[axis] - self.pointer[axis]).abs() <= 4.0 * self.display_scale | ||
| 698 | }) | ||
| 699 | }) | ||
| 700 | .map_or(1, |(_, _, count)| (count % 3) + 1); | ||
| 701 | self.last_click = Some((now, self.pointer, count)); | ||
| 702 | if let Some((axis, grab)) = | ||
| 703 | self.scroll() | ||
| 704 | .hit_test(self.viewport, self.display_scale, self.pointer) | ||
| 705 | { | ||
| 706 | self.drag = Some(Drag::Scrollbar { axis, grab }); | ||
| 707 | return self.changed(); | ||
| 708 | } | ||
| 709 | match self.hit_test(point) { | ||
| 710 | Some(Hit::Date(field)) => { | ||
| 711 | self.editor.finish_composition(); | ||
| 712 | self.drag = None; | ||
| 713 | return Ok(Response::request(Request::EditDate(field))); | ||
| 714 | } | ||
| 715 | Some(Hit::ReadOnly(index)) => self.set_object_focus(Some(ObjectFocus::ReadOnly(index))), | ||
| 716 | Some(Hit::Image { id, handle }) => { | ||
| 717 | self.set_object_focus(Some(ObjectFocus::Image(id))); | ||
| 718 | self.drag = Some(Drag::Image { | ||
| 719 | id, | ||
| 720 | handle, | ||
| 721 | press: point, | ||
| 722 | pending_press: Some(self.pointer), | ||
| 723 | }); | ||
| 724 | } | ||
| 725 | Some(Hit::Handle { id, grab }) => { | ||
| 726 | self.set_object_focus(None); | ||
| 727 | self.editor.focus_outline(id)?; | ||
| 728 | self.drag = Some(Drag::Outline { | ||
| 729 | id, | ||
| 730 | grab, | ||
| 731 | pending_press: Some(self.pointer), | ||
| 732 | }); | ||
| 733 | } | ||
| 734 | Some(Hit::Resize { id, grab }) => { | ||
| 735 | self.set_object_focus(None); | ||
| 736 | self.editor.focus_outline(id)?; | ||
| 737 | self.drag = Some(Drag::Resize { | ||
| 738 | outline: None, | ||
| 739 | grab, | ||
| 740 | }); | ||
| 741 | } | ||
| 742 | Some(Hit::Text { id, point }) => { | ||
| 743 | let extend = self.modifiers.shift | ||
| 744 | && self.object_focus.is_none() | ||
| 745 | && id == self.editor.active_outline().id; | ||
| 746 | self.set_object_focus(None); | ||
| 747 | self.editor.focus_outline(id)?; | ||
| 748 | let previous = self.editor.selection(); | ||
| 749 | self.editor.select_below(&mut self.engine, id, point)?; | ||
| 750 | let unit = match count { | ||
| 751 | 2 => SelectionUnit::Word, | ||
| 752 | 3 => SelectionUnit::Paragraph, | ||
| 753 | _ => SelectionUnit::Grapheme, | ||
| 754 | }; | ||
| 755 | let selection = self.editor.selection_at(point[0], point[1], unit)?; | ||
| 756 | let selection = if extend { | ||
| 757 | Selection { | ||
| 758 | positions: [previous.positions[0], selection.positions[1]], | ||
| 759 | affinities: [previous.affinities[0], selection.affinities[1]], | ||
| 760 | } | ||
| 761 | } else { | ||
| 762 | selection | ||
| 763 | }; | ||
| 764 | self.editor.select(selection)?; | ||
| 765 | self.drag = Some(Drag::Text { | ||
| 766 | anchor: selection, | ||
| 767 | unit, | ||
| 768 | }); | ||
| 769 | } | ||
| 770 | None => { | ||
| 771 | // A fresh click places text 7 px above the pointer, on the grid. | ||
| 772 | let position = [point[0], point[1] - 7.0 * self.pixel()]; | ||
| 773 | let position = if self.modifiers.option { | ||
| 774 | position | ||
| 775 | } else { | ||
| 776 | snap_to_grid(position, self.editor.margin_origin()) | ||
| 777 | }; | ||
| 778 | self.editor | ||
| 779 | .place_caret(&mut self.engine, position, DEFAULT_OUTLINE_WIDTH)?; | ||
| 780 | self.set_object_focus(None); | ||
| 781 | self.drag = Some(Drag::Text { | ||
| 782 | anchor: self.editor.selection(), | ||
| 783 | unit: SelectionUnit::Grapheme, | ||
| 784 | }); | ||
| 785 | } | ||
| 786 | } | ||
| 787 | self.changed() | ||
| 788 | } | ||
| 789 | |||
| 790 | pub fn pointer_released(&mut self) -> Result<Response> { | ||
| 791 | let preview = self.outline_preview(); | ||
| 792 | if let Some((id, origin, size)) = self.image_preview() { | ||
| 793 | self.editor | ||
| 794 | .place_image(&mut self.engine, id, origin, size)?; | ||
| 795 | } | ||
| 796 | if let Some(Drag::Resize { | ||
| 797 | outline: Some(outline), | ||
| 798 | .. | ||
| 799 | }) = self.drag.take() | ||
| 800 | { | ||
| 801 | self.editor | ||
| 802 | .resize(&mut self.engine, outline.bounds().width() as f32)?; | ||
| 803 | } | ||
| 804 | if let Some((id, origin)) = preview { | ||
| 805 | self.editor.move_outline(id, origin)?; | ||
| 806 | } | ||
| 807 | self.changed() | ||
| 808 | } | ||
| 809 | |||
| 810 | /// `delta` in device pixels; Command zooms about the pointer instead of scrolling. | ||
| 811 | pub fn wheel(&mut self, delta: [f32; 2]) -> Result<Response> { | ||
| 812 | if self.modifiers.command { | ||
| 813 | self.zoom((delta[1] * 0.005).exp()); | ||
| 814 | } else { | ||
| 815 | self.viewport.origin[0] += delta[0]; | ||
| 816 | self.viewport.origin[1] += delta[1]; | ||
| 817 | } | ||
| 818 | self.changed() | ||
| 819 | } | ||
| 820 | |||
| 821 | /// Text the platform inserts outside key events, such as the character picker's; | ||
| 822 | /// control characters are not text here. | ||
| 823 | pub fn insert_text(&mut self, text: String) -> Result<Response> { | ||
| 824 | if text.is_empty() || text.chars().any(char::is_control) { | ||
| 825 | return Ok(Response::default()); | ||
| 826 | } | ||
| 827 | self.commit_text(text) | ||
| 828 | } | ||
| 829 | |||
| 830 | /// Text committed by an input method or pasted, line breaks included. | ||
| 831 | pub fn commit_text(&mut self, text: String) -> Result<Response> { | ||
| 832 | if !self.accepts_text() { | ||
| 833 | return Ok(Response::default()); | ||
| 834 | } | ||
| 835 | self.editor.commit_text(&mut self.engine, text)?; | ||
| 836 | self.edited() | ||
| 837 | } | ||
| 838 | |||
| 839 | /// An input method's marked text; `cursor` is its UTF-8 selection within `text`. | ||
| 840 | pub fn compose(&mut self, text: String, cursor: Option<(usize, usize)>) -> Result<Response> { | ||
| 841 | if !self.accepts_text() { | ||
| 842 | return Ok(Response::default()); | ||
| 843 | } | ||
| 844 | if text.is_empty() { | ||
| 845 | self.editor.cancel_composition(&mut self.engine)?; | ||
| 846 | } else { | ||
| 847 | let (start, end) = cursor.unwrap_or((text.len(), text.len())); | ||
| 848 | let utf16 = |end: usize| -> Result<u32> { | ||
| 849 | Ok(text | ||
| 850 | .get(..end) | ||
| 851 | .ok_or("IME range splits UTF-8")? | ||
| 852 | .encode_utf16() | ||
| 853 | .count() | ||
| 854 | .try_into()?) | ||
| 855 | }; | ||
| 856 | let range = utf16(start)?..utf16(end)?; | ||
| 857 | self.editor.compose(&mut self.engine, text, range)?; | ||
| 858 | } | ||
| 859 | self.edited() | ||
| 860 | } | ||
| 861 | |||
| 862 | /// The input method was switched off mid-composition. | ||
| 863 | pub fn cancel_composition(&mut self) -> Result<Response> { | ||
| 864 | if !self.accepts_text() { | ||
| 865 | return Ok(Response::default()); | ||
| 866 | } | ||
| 867 | self.editor.cancel_composition(&mut self.engine)?; | ||
| 868 | self.edited() | ||
| 869 | } | ||
| 870 | |||
| 871 | /// The date or time chosen after `Request::EditDate`. | ||
| 872 | pub fn change_date(&mut self, timestamp: u64, text: [String; 2]) -> Result<Response> { | ||
| 873 | self.editor.change_date(&mut self.engine, timestamp, text)?; | ||
| 874 | self.changed() | ||
| 875 | } | ||
| 876 | |||
| 877 | /// Gives keyboard focus to a read-only object, as an assistive technology asks. | ||
| 878 | pub fn focus_read_only(&mut self, index: usize) -> Result<Response> { | ||
| 879 | self.set_object_focus(Some(ObjectFocus::ReadOnly(index))); | ||
| 880 | self.edited() | ||
| 881 | } | ||
| 882 | |||
| 883 | /// Returns focus to text after the host acted on the editor directly, as an assistive | ||
| 884 | /// technology's selection or value change does. | ||
| 885 | pub fn focus_text(&mut self) -> Result<Response> { | ||
| 886 | self.set_object_focus(None); | ||
| 887 | self.edited() | ||
| 888 | } | ||
| 889 | |||
| 890 | pub fn key(&mut self, key: &Key, text: Option<&str>) -> Result<Response> { | ||
| 891 | let Modifiers { | ||
| 892 | shift, | ||
| 893 | control, | ||
| 894 | option, | ||
| 895 | command, | ||
| 896 | } = self.modifiers; | ||
| 897 | if let Some(ObjectFocus::Image(id)) = self.object_focus | ||
| 898 | && matches!(key, Key::Named(NamedKey::Backspace | NamedKey::Delete)) | ||
| 899 | { | ||
| 900 | self.editor.remove_image(&mut self.engine, id)?; | ||
| 901 | self.set_object_focus(None); | ||
| 902 | return self.changed(); | ||
| 903 | } | ||
| 904 | if let Some(ObjectFocus::Image(id)) = self.object_focus | ||
| 905 | && !(shift || command || option || control) | ||
| 906 | && let Key::Named(NamedKey::ArrowLeft | NamedKey::ArrowRight) = key | ||
| 907 | && self.editor.step_from_image( | ||
| 908 | &mut self.engine, | ||
| 909 | id, | ||
| 910 | key == &Key::Named(NamedKey::ArrowRight), | ||
| 911 | )? | ||
| 912 | { | ||
| 913 | self.set_object_focus(None); | ||
| 914 | return self.edited(); | ||
| 915 | } | ||
| 916 | if let Some(focus) = self.object_focus { | ||
| 917 | let undo = matches!(focus, ObjectFocus::Image(_)) | ||
| 918 | && command | ||
| 919 | && matches!(key, Key::Character(value) if value.eq_ignore_ascii_case("z")); | ||
| 920 | if !undo && !read_only_shortcut(key, self.modifiers) { | ||
| 921 | return Ok(Response::default()); | ||
| 922 | } | ||
| 923 | if key == &Key::Named(NamedKey::Escape) { | ||
| 924 | self.set_object_focus(None); | ||
| 925 | return self.edited(); | ||
| 926 | } | ||
| 927 | } | ||
| 928 | if command && control && key == &Key::Named(NamedKey::Space) { | ||
| 929 | return Ok(Response { | ||
| 930 | request: Some(Request::CharacterPalette), | ||
| 931 | ..Response::default() | ||
| 932 | }); | ||
| 933 | } | ||
| 934 | if matches!( | ||
| 935 | self.drag, | ||
| 936 | Some(Drag::Outline { .. } | Drag::Resize { .. } | Drag::Image { .. }) | ||
| 937 | ) { | ||
| 938 | if key == &Key::Named(NamedKey::Modifier) { | ||
| 939 | return Ok(Response::default()); | ||
| 940 | } | ||
| 941 | self.drag = None; | ||
| 942 | if key == &Key::Named(NamedKey::Escape) { | ||
| 943 | return self.changed(); | ||
| 944 | } | ||
| 945 | } | ||
| 946 | if command && option { | ||
| 947 | let delta = match key { | ||
| 948 | Key::Named(NamedKey::ArrowLeft) => Some([-1.0, 0.0]), | ||
| 949 | Key::Named(NamedKey::ArrowRight) => Some([1.0, 0.0]), | ||
| 950 | Key::Named(NamedKey::ArrowUp) => Some([0.0, -1.0]), | ||
| 951 | Key::Named(NamedKey::ArrowDown) => Some([0.0, 1.0]), | ||
| 952 | _ => None, | ||
| 953 | }; | ||
| 954 | if let Some(delta) = delta { | ||
| 955 | let outline = self.editor.active_outline(); | ||
| 956 | let origin = outline.origin(); | ||
| 957 | let step = if shift { 10.0 } else { 1.0 }; | ||
| 958 | self.editor.move_outline( | ||
| 959 | outline.id, | ||
| 960 | [origin[0] + delta[0] * step, origin[1] + delta[1] * step], | ||
| 961 | )?; | ||
| 962 | return self.edited(); | ||
| 963 | } | ||
| 964 | } | ||
| 965 | if control && key == &Key::Named(NamedKey::Tab) { | ||
| 966 | let outlines = self.editor.outlines(); | ||
| 967 | let count = outlines.len() | ||
| 968 | + self | ||
| 969 | .scene | ||
| 970 | .as_ref() | ||
| 971 | .map_or(0, |(scene, _)| scene.read_only(Some(&self.editor)).count()); | ||
| 972 | if count == 0 { | ||
| 973 | return self.changed(); | ||
| 974 | } | ||
| 975 | let index = self | ||
| 976 | .object_focus | ||
| 977 | .and_then(ObjectFocus::read_only) | ||
| 978 | .map_or_else( | ||
| 979 | || { | ||
| 980 | outlines | ||
| 981 | .iter() | ||
| 982 | .position(|outline| outline.id == self.editor.active_outline().id) | ||
| 983 | }, | ||
| 984 | |index| Some(outlines.len() + index), | ||
| 985 | ); | ||
| 986 | let next = index.map_or(if shift { count - 1 } else { 0 }, |index| { | ||
| 987 | if shift { | ||
| 988 | (index + count - 1) % count | ||
| 989 | } else { | ||
| 990 | (index + 1) % count | ||
| 991 | } | ||
| 992 | }); | ||
| 993 | if next < outlines.len() { | ||
| 994 | self.editor.focus_outline(outlines[next].id)?; | ||
| 995 | self.set_object_focus(None); | ||
| 996 | } else { | ||
| 997 | self.set_object_focus(Some(ObjectFocus::ReadOnly(next - outlines.len()))); | ||
| 998 | } | ||
| 999 | return self.edited(); | ||
| 1000 | } | ||
| 1001 | let mut request = None; | ||
| 1002 | if command && let Key::Character(key) = key { | ||
| 1003 | match key.to_lowercase().as_str() { | ||
| 1004 | "n" if shift => { | ||
| 1005 | let position = if let Some(focus) = self.object_focus { | ||
| 1006 | let rect = self.object_rect(focus); | ||
| 1007 | [rect[2] + 24.0, rect[1]] | ||
| 1008 | } else { | ||
| 1009 | let bounds = self.editor.active_outline().bounds(); | ||
| 1010 | [bounds.x1 as f32 + 24.0, bounds.y0 as f32] | ||
| 1011 | }; | ||
| 1012 | self.editor.place_caret( | ||
| 1013 | &mut self.engine, | ||
| 1014 | snap_to_grid(position, self.editor.margin_origin()), | ||
| 1015 | DEFAULT_OUTLINE_WIDTH, | ||
| 1016 | )?; | ||
| 1017 | self.set_object_focus(None); | ||
| 1018 | } | ||
| 1019 | "a" => self.editor.select_all()?, | ||
| 1020 | "z" => { | ||
| 1021 | if shift { | ||
| 1022 | self.editor.redo(&mut self.engine)?; | ||
| 1023 | } else { | ||
| 1024 | self.editor.undo(&mut self.engine)?; | ||
| 1025 | } | ||
| 1026 | if let Some(ObjectFocus::Image(id)) = self.object_focus | ||
| 1027 | && self.editor.image_placement(id).is_none() | ||
| 1028 | { | ||
| 1029 | self.set_object_focus(None); | ||
| 1030 | } | ||
| 1031 | } | ||
| 1032 | "c" | "x" => { | ||
| 1033 | let [anchor, focus] = self.editor.selection().positions; | ||
| 1034 | let selected = self | ||
| 1035 | .editor | ||
| 1036 | .active_outline() | ||
| 1037 | .document() | ||
| 1038 | .slice(anchor.min(focus)..anchor.max(focus))?; | ||
| 1039 | let text = selected | ||
| 1040 | .iter() | ||
| 1041 | .map(|paragraph| { | ||
| 1042 | paragraph | ||
| 1043 | .project() | ||
| 1044 | .map(|projection| projection.text().text().to_owned()) | ||
| 1045 | }) | ||
| 1046 | .collect::<std::result::Result<Vec<_>, _>>()? | ||
| 1047 | .join("\n"); | ||
| 1048 | if !text.is_empty() { | ||
| 1049 | if key.eq_ignore_ascii_case("x") { | ||
| 1050 | self.editor.insert(&mut self.engine, "")?; | ||
| 1051 | } | ||
| 1052 | request = Some(Request::Copy(text)); | ||
| 1053 | } | ||
| 1054 | } | ||
| 1055 | "v" => request = Some(Request::Paste), | ||
| 1056 | "+" | "=" => { | ||
| 1057 | self.zoom(1.1); | ||
| 1058 | return self.changed(); | ||
| 1059 | } | ||
| 1060 | "-" => { | ||
| 1061 | self.zoom(1.0 / 1.1); | ||
| 1062 | return self.changed(); | ||
| 1063 | } | ||
| 1064 | "0" => { | ||
| 1065 | self.zoom((self.display_scale * 96.0 / 72.0) / self.viewport.scale); | ||
| 1066 | return self.changed(); | ||
| 1067 | } | ||
| 1068 | _ => return Ok(Response::default()), | ||
| 1069 | } | ||
| 1070 | } else { | ||
| 1071 | let movement = match key { | ||
| 1072 | Key::Named(NamedKey::ArrowLeft) => Some(if command { | ||
| 1073 | Movement::LineStart | ||
| 1074 | } else if option { | ||
| 1075 | Movement::WordLeft | ||
| 1076 | } else { | ||
| 1077 | Movement::Left | ||
| 1078 | }), | ||
| 1079 | Key::Named(NamedKey::ArrowRight) => Some(if command { | ||
| 1080 | Movement::LineEnd | ||
| 1081 | } else if option { | ||
| 1082 | Movement::WordRight | ||
| 1083 | } else { | ||
| 1084 | Movement::Right | ||
| 1085 | }), | ||
| 1086 | Key::Named(NamedKey::ArrowUp) => Some(if command { | ||
| 1087 | Movement::DocumentStart | ||
| 1088 | } else if option { | ||
| 1089 | Movement::ParagraphStart | ||
| 1090 | } else { | ||
| 1091 | Movement::Up | ||
| 1092 | }), | ||
| 1093 | Key::Named(NamedKey::ArrowDown) => Some(if command { | ||
| 1094 | Movement::DocumentEnd | ||
| 1095 | } else if option { | ||
| 1096 | Movement::ParagraphEnd | ||
| 1097 | } else { | ||
| 1098 | Movement::Down | ||
| 1099 | }), | ||
| 1100 | Key::Named(NamedKey::Home) if shift => Some(Movement::DocumentStart), | ||
| 1101 | Key::Named(NamedKey::End) if shift => Some(Movement::DocumentEnd), | ||
| 1102 | Key::Character(key) if control && !option => match key.as_str() { | ||
| 1103 | "a" => Some(Movement::LineStart), | ||
| 1104 | "e" => Some(Movement::LineEnd), | ||
| 1105 | "b" => Some(Movement::Left), | ||
| 1106 | "f" => Some(Movement::Right), | ||
| 1107 | "p" => Some(Movement::Up), | ||
| 1108 | "n" => Some(Movement::Down), | ||
| 1109 | _ => None, | ||
| 1110 | }, | ||
| 1111 | _ => None, | ||
| 1112 | }; | ||
| 1113 | if let Some(movement) = movement { | ||
| 1114 | self.editor | ||
| 1115 | .move_selection(&mut self.engine, movement, shift)?; | ||
| 1116 | } else if self.editor.marked_range().is_none() { | ||
| 1117 | match key { | ||
| 1118 | Key::Named(NamedKey::Backspace) if command || option => { | ||
| 1119 | self.editor.delete_to( | ||
| 1120 | &mut self.engine, | ||
| 1121 | if command { | ||
| 1122 | Movement::LineStart | ||
| 1123 | } else { | ||
| 1124 | Movement::WordLeft | ||
| 1125 | }, | ||
| 1126 | )?; | ||
| 1127 | } | ||
| 1128 | Key::Named(NamedKey::Delete) if command || option => { | ||
| 1129 | self.editor.delete_to( | ||
| 1130 | &mut self.engine, | ||
| 1131 | if command { | ||
| 1132 | Movement::LineEnd | ||
| 1133 | } else { | ||
| 1134 | Movement::WordRight | ||
| 1135 | }, | ||
| 1136 | )?; | ||
| 1137 | } | ||
| 1138 | Key::Named(NamedKey::Backspace) => { | ||
| 1139 | self.editor.delete(&mut self.engine, true)?; | ||
| 1140 | } | ||
| 1141 | Key::Named(NamedKey::Delete) => { | ||
| 1142 | self.editor.delete(&mut self.engine, false)?; | ||
| 1143 | } | ||
| 1144 | Key::Named(end @ (NamedKey::Home | NamedKey::End)) => { | ||
| 1145 | let limits = self.scroll(); | ||
| 1146 | self.viewport.origin[1] = -if *end == NamedKey::Home { | ||
| 1147 | limits.min[1] | ||
| 1148 | } else { | ||
| 1149 | limits.max[1] | ||
| 1150 | }; | ||
| 1151 | return self.changed(); | ||
| 1152 | } | ||
| 1153 | Key::Character(key) if control && !option => match key.as_str() { | ||
| 1154 | "h" => { | ||
| 1155 | self.editor.delete(&mut self.engine, true)?; | ||
| 1156 | } | ||
| 1157 | "d" => { | ||
| 1158 | self.editor.delete(&mut self.engine, false)?; | ||
| 1159 | } | ||
| 1160 | "k" if !self.editor.delete_to(&mut self.engine, Movement::LineEnd)? => { | ||
| 1161 | self.editor.delete(&mut self.engine, false)?; | ||
| 1162 | } | ||
| 1163 | _ => {} | ||
| 1164 | }, | ||
| 1165 | Key::Named(NamedKey::Enter) => { | ||
| 1166 | self.editor.enter(&mut self.engine, shift)?; | ||
| 1167 | } | ||
| 1168 | Key::Named(NamedKey::Tab) => { | ||
| 1169 | self.editor.tab(&mut self.engine, shift)?; | ||
| 1170 | } | ||
| 1171 | _ if !command && !control => { | ||
| 1172 | if let Some(text) = text | ||
| 1173 | .filter(|text| !text.is_empty() && !text.chars().any(char::is_control)) | ||
| 1174 | { | ||
| 1175 | self.editor.insert(&mut self.engine, text)?; | ||
| 1176 | } | ||
| 1177 | } | ||
| 1178 | _ => {} | ||
| 1179 | } | ||
| 1180 | } else if key == &Key::Named(NamedKey::Escape) { | ||
| 1181 | self.editor.cancel_composition(&mut self.engine)?; | ||
| 1182 | } | ||
| 1183 | } | ||
| 1184 | Ok(Response { | ||
| 1185 | request, | ||
| 1186 | ..self.edited()? | ||
| 1187 | }) | ||
| 1188 | } | ||
| 1189 | } | ||
| 1190 | |||
| 1191 | /// What a document point lands on. | ||
| 1192 | #[derive(Debug, PartialEq)] | ||
| 1193 | pub enum Hit { | ||
| 1194 | Date(DateField), | ||
| 1195 | Resize { | ||
| 1196 | id: onestore::ExGuid, | ||
| 1197 | grab: f32, | ||
| 1198 | }, | ||
| 1199 | Text { | ||
| 1200 | id: onestore::ExGuid, | ||
| 1201 | point: [f32; 2], | ||
| 1202 | }, | ||
| 1203 | Handle { | ||
| 1204 | id: onestore::ExGuid, | ||
| 1205 | grab: [f32; 2], | ||
| 1206 | }, | ||
| 1207 | ReadOnly(usize), | ||
| 1208 | /// `handle` is [0, 0] on the picture and a direction on the selected picture's handles. | ||
| 1209 | Image { | ||
| 1210 | id: onestore::ExGuid, | ||
| 1211 | handle: [i8; 2], | ||
| 1212 | }, | ||
| 1213 | } | ||
| 1214 | |||
| 1215 | /// What a document point lands on; `pixel` is document points per device pixel. | ||
| 1216 | pub fn page_hit_test( | ||
| 1217 | editor: &CanvasEditor, | ||
| 1218 | scene: Option<&(PageScene, [f32; 2])>, | ||
| 1219 | point: [f32; 2], | ||
| 1220 | pixel: f32, | ||
| 1221 | ) -> Option<Hit> { | ||
| 1222 | /// Grips and text resolve front to back before any outline's padding, as a native | ||
| 1223 | /// width handle stays reachable under the next outline's left padding; the typing room | ||
| 1224 | /// below an outline comes last. | ||
| 1225 | #[derive(Clone, Copy, PartialEq)] | ||
| 1226 | enum Layer { | ||
| 1227 | Grips, | ||
| 1228 | Body, | ||
| 1229 | Below, | ||
| 1230 | } | ||
| 1231 | let hit = |outline: &TextOutline, offset: [f32; 2], layer: Layer| { | ||
| 1232 | if layer == Layer::Below { | ||
| 1233 | let outline = editor | ||
| 1234 | .outlines() | ||
| 1235 | .iter() | ||
| 1236 | .find(|source| source.id == outline.id)?; | ||
| 1237 | let local = [ | ||
| 1238 | point[0] - offset[0] - outline.origin()[0], | ||
| 1239 | point[1] - offset[1] - outline.origin()[1], | ||
| 1240 | ]; | ||
| 1241 | return outline.contains_extension(local).then_some(Hit::Text { | ||
| 1242 | id: outline.id, | ||
| 1243 | point: local, | ||
| 1244 | }); | ||
| 1245 | } | ||
| 1246 | let (bounds, body_top) = outline_chrome(outline, pixel); | ||
| 1247 | let local = [point[0] - offset[0], point[1] - offset[1]]; | ||
| 1248 | let [x, y] = local; | ||
| 1249 | if layer == Layer::Grips { | ||
| 1250 | // Width handles: 15 px inside to 6 px outside the header's right end, and 6 px | ||
| 1251 | // either side of the right border below it. | ||
| 1252 | let inside = if y < body_top { 15.0 } else { 6.0 }; | ||
| 1253 | if !outline.title | ||
| 1254 | && (bounds[2] - inside * pixel..=bounds[2] + 6.0 * pixel).contains(&x) | ||
| 1255 | && (bounds[1]..=bounds[3]).contains(&y) | ||
| 1256 | { | ||
| 1257 | return Some(Hit::Resize { | ||
| 1258 | id: outline.id, | ||
| 1259 | grab: point[0] - outline.bounds().x1 as f32, | ||
| 1260 | }); | ||
| 1261 | } | ||
| 1262 | if x >= bounds[0] && x <= bounds[2] && y >= bounds[1] && y < body_top { | ||
| 1263 | return Some(Hit::Handle { | ||
| 1264 | id: outline.id, | ||
| 1265 | grab: [ | ||
| 1266 | point[0] - outline.origin()[0], | ||
| 1267 | point[1] - outline.origin()[1], | ||
| 1268 | ], | ||
| 1269 | }); | ||
| 1270 | } | ||
| 1271 | let text = outline.bounds(); | ||
| 1272 | if !(text.x0..=text.x1).contains(&f64::from(x)) | ||
| 1273 | || !(text.y0..=text.y1).contains(&f64::from(y)) | ||
| 1274 | { | ||
| 1275 | return None; | ||
| 1276 | } | ||
| 1277 | } | ||
| 1278 | let inner = [x - outline.origin()[0], y - outline.origin()[1]]; | ||
| 1279 | if let Some(picture) = outline.shaped().objects.iter().find(|object| { | ||
| 1280 | matches!(object.kind, crate::outline::ObjectKind::Picture) | ||
| 1281 | && (object.rect[0]..=object.rect[2]).contains(&inner[0]) | ||
| 1282 | && (object.rect[1]..=object.rect[3]).contains(&inner[1]) | ||
| 1283 | }) { | ||
| 1284 | return Some(Hit::Image { | ||
| 1285 | id: picture.id, | ||
| 1286 | handle: [0, 0], | ||
| 1287 | }); | ||
| 1288 | } | ||
| 1289 | if (x >= bounds[0] && x <= bounds[2] && y >= body_top && y <= bounds[3]) | ||
| 1290 | || outline.layouts().any(|(_, paragraph)| { | ||
| 1291 | paragraph.tags.iter().any(|tag| { | ||
| 1292 | let origin = outline.origin(); | ||
| 1293 | let x = origin[0] + outline.shaped().tag_column_offset() + tag.origin[0]; | ||
| 1294 | let y = origin[1] + paragraph.origin[1] + tag.origin[1]; | ||
| 1295 | let size = crate::outline::ParagraphTag::SIZE; | ||
| 1296 | (x..=x + size).contains(&local[0]) && (y..=y + size).contains(&local[1]) | ||
| 1297 | }) | ||
| 1298 | }) | ||
| 1299 | { | ||
| 1300 | return Some(Hit::Text { | ||
| 1301 | id: outline.id, | ||
| 1302 | point: [ | ||
| 1303 | local[0] - outline.origin()[0], | ||
| 1304 | local[1] - outline.origin()[1], | ||
| 1305 | ], | ||
| 1306 | }); | ||
| 1307 | } | ||
| 1308 | None | ||
| 1309 | }; | ||
| 1310 | let pass = |layer| { | ||
| 1311 | if let Some(hit) = editor | ||
| 1312 | .visible_outlines() | ||
| 1313 | .rev() | ||
| 1314 | .filter(|outline| scene.is_none() || !editor.has_page_outline(outline.id)) | ||
| 1315 | .find_map(|outline| hit(outline, [0.0; 2], layer)) | ||
| 1316 | { | ||
| 1317 | return Some(hit); | ||
| 1318 | } | ||
| 1319 | let (scene, offset) = scene?; | ||
| 1320 | match scene.hit_test( | ||
| 1321 | [point[0] - offset[0], point[1] - offset[1]], | ||
| 1322 | Some(editor), | ||
| 1323 | |id| { | ||
| 1324 | editor | ||
| 1325 | .visible_outlines() | ||
| 1326 | .find(|outline| outline.id == id) | ||
| 1327 | .and_then(|outline| hit(outline, *offset, layer)) | ||
| 1328 | }, | ||
| 1329 | )? { | ||
| 1330 | crate::gpu::page::SceneHit::Outline(hit) => Some(hit), | ||
| 1331 | crate::gpu::page::SceneHit::Date(field) => Some(Hit::Date(field)), | ||
| 1332 | crate::gpu::page::SceneHit::ReadOnly(index) => Some(Hit::ReadOnly(index)), | ||
| 1333 | crate::gpu::page::SceneHit::Image(id) => Some(Hit::Image { id, handle: [0, 0] }), | ||
| 1334 | } | ||
| 1335 | }; | ||
| 1336 | [Layer::Grips, Layer::Body, Layer::Below] | ||
| 1337 | .into_iter() | ||
| 1338 | .find_map(pass) | ||
| 1339 | } | ||
| 1340 | |||
| 1341 | fn page_primitives<'a>( | ||
| 1342 | editor: &'a CanvasEditor, | ||
| 1343 | scene: Option<&'a (PageScene, [f32; 2])>, | ||
| 1344 | preview: Option<PointerFeedback<'a>>, | ||
| 1345 | object_focus: Option<ObjectFocus>, | ||
| 1346 | paint: Paint, | ||
| 1347 | ) -> Result<Vec<Primitive<'a>>> { | ||
| 1348 | let mut primitives = Vec::new(); | ||
| 1349 | let draw_outline = |id, offset: [f32; 2], primitives: &mut Vec<_>| { | ||
| 1350 | let outline = editor | ||
| 1351 | .visible_outlines() | ||
| 1352 | .find(|outline| outline.id == id) | ||
| 1353 | .ok_or(crate::gpu::page::SceneError::MissingOutline)?; | ||
| 1354 | let outline = match preview { | ||
| 1355 | Some(PointerFeedback::Resize(resized)) if resized.id == outline.id => resized, | ||
| 1356 | _ => outline, | ||
| 1357 | }; | ||
| 1358 | let origin = match preview { | ||
| 1359 | Some(PointerFeedback::Move(id, origin)) if id == outline.id => origin, | ||
| 1360 | _ => outline.origin(), | ||
| 1361 | }; | ||
| 1362 | if (object_focus.is_none() && outline.id == editor.active_outline().id) | ||
| 1363 | || matches!(preview, Some(PointerFeedback::Hover(id) | PointerFeedback::Move(id, _)) if id == outline.id) | ||
| 1364 | || matches!(preview, Some(PointerFeedback::Resize(resized)) if resized.id == outline.id) | ||
| 1365 | { | ||
| 1366 | append_outline_chrome( | ||
| 1367 | outline, | ||
| 1368 | [origin[0] + offset[0], origin[1] + offset[1]], | ||
| 1369 | paint.pixel, | ||
| 1370 | primitives, | ||
| 1371 | ); | ||
| 1372 | } | ||
| 1373 | append_outline( | ||
| 1374 | (object_focus.is_none() | ||
| 1375 | && outline.id == editor.active_outline().id | ||
| 1376 | && !matches!(preview, Some(PointerFeedback::Resize(_)))) | ||
| 1377 | .then_some(editor), | ||
| 1378 | outline, | ||
| 1379 | [origin[0] + offset[0], origin[1] + offset[1]], | ||
| 1380 | paint, | ||
| 1381 | primitives, | ||
| 1382 | )?; | ||
| 1383 | if let Some((scene, _)) = scene { | ||
| 1384 | let moving = match preview { | ||
| 1385 | Some(PointerFeedback::Image(id, origin, size)) => { | ||
| 1386 | let [x0, y0, x1, y1] = image_rect(origin, size); | ||
| 1387 | Some(( | ||
| 1388 | id, | ||
| 1389 | [ | ||
| 1390 | x0 + offset[0], | ||
| 1391 | y0 + offset[1], | ||
| 1392 | x1 + offset[0], | ||
| 1393 | y1 + offset[1], | ||
| 1394 | ], | ||
| 1395 | )) | ||
| 1396 | } | ||
| 1397 | _ => None, | ||
| 1398 | }; | ||
| 1399 | scene.append_outline_objects( | ||
| 1400 | outline.shaped(), | ||
| 1401 | [origin[0] + offset[0], origin[1] + offset[1]], | ||
| 1402 | moving, | ||
| 1403 | primitives, | ||
| 1404 | ); | ||
| 1405 | } | ||
| 1406 | Ok::<_, Box<dyn Error>>(()) | ||
| 1407 | }; | ||
| 1408 | if let Some((scene, origin)) = scene { | ||
| 1409 | let moving = match preview { | ||
| 1410 | Some(PointerFeedback::Image(id, origin, size)) => Some((id, image_rect(origin, size))), | ||
| 1411 | _ => None, | ||
| 1412 | }; | ||
| 1413 | scene.append_primitives_with( | ||
| 1414 | &mut primitives, | ||
| 1415 | *origin, | ||
| 1416 | Some(editor), | ||
| 1417 | moving, | ||
| 1418 | &draw_outline, | ||
| 1419 | )?; | ||
| 1420 | } | ||
| 1421 | for outline in editor | ||
| 1422 | .visible_outlines() | ||
| 1423 | .filter(|outline| scene.is_none() || !editor.has_page_outline(outline.id)) | ||
| 1424 | { | ||
| 1425 | draw_outline(outline.id, [0.0; 2], &mut primitives)?; | ||
| 1426 | } | ||
| 1427 | if object_focus.is_none() | ||
| 1428 | && let Some(outline) = editor.caret_outline() | ||
| 1429 | { | ||
| 1430 | append_outline( | ||
| 1431 | Some(editor), | ||
| 1432 | outline, | ||
| 1433 | outline.origin(), | ||
| 1434 | paint, | ||
| 1435 | &mut primitives, | ||
| 1436 | )?; | ||
| 1437 | } | ||
| 1438 | if let Some(ObjectFocus::Image(id)) = object_focus { | ||
| 1439 | let (origin, size) = match preview { | ||
| 1440 | Some(PointerFeedback::Image(moving, origin, size)) if moving == id => (origin, size), | ||
| 1441 | _ => editor | ||
| 1442 | .image_placement(id) | ||
| 1443 | .ok_or("The selected picture is missing.")?, | ||
| 1444 | }; | ||
| 1445 | append_image_chrome(image_rect(origin, size), paint.pixel, &mut primitives); | ||
| 1446 | } | ||
| 1447 | if let Some(ObjectFocus::ReadOnly(index)) = object_focus { | ||
| 1448 | let (scene, offset) = scene.unwrap(); | ||
| 1449 | let [x0, y0, x1, y1] = scene.read_only(Some(editor)).nth(index).unwrap().rect(); | ||
| 1450 | let [x0, y0, x1, y1] = [ | ||
| 1451 | x0 + offset[0], | ||
| 1452 | y0 + offset[1], | ||
| 1453 | x1 + offset[0], | ||
| 1454 | y1 + offset[1], | ||
| 1455 | ]; | ||
| 1456 | let border = 2.0 / paint.scale; | ||
| 1457 | for rect in [ | ||
| 1458 | [x0, y0, x1, y0 + border], | ||
| 1459 | [x0, y1 - border, x1, y1], | ||
| 1460 | [x0, y0, x0 + border, y1], | ||
| 1461 | [x1 - border, y0, x1, y1], | ||
| 1462 | ] { | ||
| 1463 | primitives.push(Primitive::Rect { | ||
| 1464 | rect, | ||
| 1465 | color: [0.25, 0.45, 0.7, 1.0], | ||
| 1466 | }); | ||
| 1467 | } | ||
| 1468 | } | ||
| 1469 | Ok(primitives) | ||
| 1470 | } | ||
| 1471 | |||
| 1472 | /// Native picture handles sit on a selection border drawn 5 px outside the picture, named | ||
| 1473 | /// by their direction from its center. | ||
| 1474 | fn image_handles(rect: [f32; 4], pixel: f32) -> impl Iterator<Item = ([i8; 2], [f32; 2])> { | ||
| 1475 | let border = [ | ||
| 1476 | rect[0] - 5.0 * pixel, | ||
| 1477 | rect[1] - 5.0 * pixel, | ||
| 1478 | rect[2] + 5.0 * pixel, | ||
| 1479 | rect[3] + 5.0 * pixel, | ||
| 1480 | ]; | ||
| 1481 | [-1, 0, 1] | ||
| 1482 | .into_iter() | ||
| 1483 | .flat_map(|y| [-1, 0, 1].map(|x| [x, y])) | ||
| 1484 | .filter(|handle| *handle != [0, 0]) | ||
| 1485 | .map(move |handle| { | ||
| 1486 | ( | ||
| 1487 | handle, | ||
| 1488 | std::array::from_fn(|axis| { | ||
| 1489 | let [start, end] = [border[axis], border[axis + 2]]; | ||
| 1490 | start + (end - start) * f32::from(handle[axis] + 1) * 0.5 | ||
| 1491 | }), | ||
| 1492 | ) | ||
| 1493 | }) | ||
| 1494 | } | ||
| 1495 | |||
| 1496 | fn image_handle_at(rect: [f32; 4], pixel: f32, point: [f32; 2]) -> Option<[i8; 2]> { | ||
| 1497 | image_handles(rect, pixel) | ||
| 1498 | .find(|(_, center)| (0..2).all(|axis| (point[axis] - center[axis]).abs() <= 5.0 * pixel)) | ||
| 1499 | .map(|(handle, _)| handle) | ||
| 1500 | } | ||
| 1501 | |||
| 1502 | fn handle_cursor(handle: [i8; 2]) -> Cursor { | ||
| 1503 | match handle { | ||
| 1504 | [0, 0] => Cursor::Move, | ||
| 1505 | [_, 0] => Cursor::EwResize, | ||
| 1506 | [0, _] => Cursor::NsResize, | ||
| 1507 | [x, y] if x == y => Cursor::NwseResize, | ||
| 1508 | _ => Cursor::NeswResize, | ||
| 1509 | } | ||
| 1510 | } | ||
| 1511 | |||
| 1512 | fn image_rect(origin: [f32; 2], size: [f32; 2]) -> [f32; 4] { | ||
| 1513 | [ | ||
| 1514 | origin[0], | ||
| 1515 | origin[1], | ||
| 1516 | origin[0] + size[0], | ||
| 1517 | origin[1] + size[1], | ||
| 1518 | ] | ||
| 1519 | } | ||
| 1520 | |||
| 1521 | /// Drags a picture's `handle` by `delta`: edges stretch one axis, corners keep the aspect | ||
| 1522 | /// ratio at the larger of the two scales, and the opposite side stays fixed. | ||
| 1523 | fn resize_image( | ||
| 1524 | origin: [f32; 2], | ||
| 1525 | size: [f32; 2], | ||
| 1526 | handle: [i8; 2], | ||
| 1527 | delta: [f32; 2], | ||
| 1528 | ) -> ([f32; 2], [f32; 2]) { | ||
| 1529 | let mut scale: [f32; 2] = std::array::from_fn(|axis| { | ||
| 1530 | (size[axis] + f32::from(handle[axis]) * delta[axis]) / size[axis] | ||
| 1531 | }); | ||
| 1532 | if !handle.contains(&0) { | ||
| 1533 | scale = [scale[0].max(scale[1]); 2]; | ||
| 1534 | } | ||
| 1535 | let resized: [f32; 2] = std::array::from_fn(|axis| (size[axis] * scale[axis]).max(1.0)); | ||
| 1536 | let origin = std::array::from_fn(|axis| { | ||
| 1537 | if handle[axis] < 0 { | ||
| 1538 | origin[axis] + size[axis] - resized[axis] | ||
| 1539 | } else { | ||
| 1540 | origin[axis] | ||
| 1541 | } | ||
| 1542 | }); | ||
| 1543 | (origin, resized) | ||
| 1544 | } | ||
| 1545 | |||
| 1546 | fn append_image_chrome(rect: [f32; 4], pixel: f32, primitives: &mut Vec<Primitive<'_>>) { | ||
| 1547 | let mut tint = crate::gpu::colorref(0x00e0d2e6); | ||
| 1548 | // Native pictures take this tint at 25% in sRGB; 10% in linear light matches it. | ||
| 1549 | tint[3] = 0.1; | ||
| 1550 | primitives.push(Primitive::Rect { rect, color: tint }); | ||
| 1551 | primitives.push(Primitive::RoundedRect { | ||
| 1552 | rect: [ | ||
| 1553 | rect[0] - 5.0 * pixel, | ||
| 1554 | rect[1] - 5.0 * pixel, | ||
| 1555 | rect[2] + 5.0 * pixel, | ||
| 1556 | rect[3] + 5.0 * pixel, | ||
| 1557 | ], | ||
| 1558 | radius: [0.0; 2], | ||
| 1559 | stroke: Some(Stroke::Dashed(pixel)), | ||
| 1560 | color: crate::gpu::colorref(0x00ff9a31), | ||
| 1561 | }); | ||
| 1562 | for (handle, [x, y]) in image_handles(rect, pixel) { | ||
| 1563 | let (half, radius) = if handle.contains(&0) { | ||
| 1564 | (3.5, 0.0) | ||
| 1565 | } else { | ||
| 1566 | (4.0, 4.0) | ||
| 1567 | }; | ||
| 1568 | let rect = [ | ||
| 1569 | x - half * pixel, | ||
| 1570 | y - half * pixel, | ||
| 1571 | x + half * pixel, | ||
| 1572 | y + half * pixel, | ||
| 1573 | ]; | ||
| 1574 | primitives.push(Primitive::RoundedRect { | ||
| 1575 | rect, | ||
| 1576 | radius: [radius * pixel; 2], | ||
| 1577 | stroke: None, | ||
| 1578 | color: crate::gpu::colorref(0x00ffefe7), | ||
| 1579 | }); | ||
| 1580 | primitives.push(Primitive::RoundedRect { | ||
| 1581 | rect, | ||
| 1582 | radius: [radius * pixel; 2], | ||
| 1583 | stroke: Some(Stroke::Solid(pixel)), | ||
| 1584 | color: crate::gpu::colorref(0x00dea67b), | ||
| 1585 | }); | ||
| 1586 | } | ||
| 1587 | } | ||
| 1588 | |||
| 1589 | fn drag_selection(anchor: Selection, target: Selection, unit: SelectionUnit) -> Selection { | ||
| 1590 | if unit == SelectionUnit::Grapheme { | ||
| 1591 | return Selection { | ||
| 1592 | positions: [anchor.positions[0], target.positions[1]], | ||
| 1593 | affinities: [anchor.affinities[0], target.affinities[1]], | ||
| 1594 | }; | ||
| 1595 | } | ||
| 1596 | let backwards = target.positions[0] < anchor.positions[0].min(anchor.positions[1]); | ||
| 1597 | let start = usize::from((anchor.positions[0] > anchor.positions[1]) != backwards); | ||
| 1598 | let end = usize::from((target.positions[0] > target.positions[1]) == backwards); | ||
| 1599 | Selection { | ||
| 1600 | positions: [anchor.positions[start], target.positions[end]], | ||
| 1601 | affinities: [anchor.affinities[start], target.affinities[end]], | ||
| 1602 | } | ||
| 1603 | } | ||
| 1604 | |||
| 1605 | fn snap_to_grid(point: [f32; 2], margin: [f32; 2]) -> [f32; 2] { | ||
| 1606 | std::array::from_fn(|axis| { | ||
| 1607 | let offset = margin[axis]; | ||
| 1608 | let cell = (point[axis] - offset) / 18.0; | ||
| 1609 | let nearest = cell.round(); | ||
| 1610 | // Native midpoints remain free; account for the source coordinate's float precision. | ||
| 1611 | if ((cell - nearest).abs() - 0.5).abs() <= f32::EPSILON * cell.abs().max(1.0) { | ||
| 1612 | point[axis] | ||
| 1613 | } else { | ||
| 1614 | nearest * 18.0 + offset | ||
| 1615 | } | ||
| 1616 | }) | ||
| 1617 | } | ||
| 1618 | |||
| 1619 | fn outline_chrome(outline: &TextOutline, pixel: f32) -> ([f32; 4], f32) { | ||
| 1620 | let bounds = outline.bounds(); | ||
| 1621 | // Native chrome combines page-scaled gutters with a fixed screen inset. | ||
| 1622 | let inset = 5.0 * pixel; | ||
| 1623 | let body_top = bounds.y0 as f32 - inset; | ||
| 1624 | if outline.title { | ||
| 1625 | let (_, paragraph) = outline.layouts().last().unwrap(); | ||
| 1626 | let bottom = outline.origin()[1] + paragraph.origin[1] + paragraph.text.height(); | ||
| 1627 | return ( | ||
| 1628 | [ | ||
| 1629 | bounds.x0 as f32 - inset - 6.0 * pixel, | ||
| 1630 | body_top, | ||
| 1631 | bounds.x1 as f32 + inset - 2.0 * pixel, | ||
| 1632 | bottom + inset, | ||
| 1633 | ], | ||
| 1634 | body_top, | ||
| 1635 | ); | ||
| 1636 | } | ||
| 1637 | ( | ||
| 1638 | [ | ||
| 1639 | bounds.x0 as f32 - 7.5 - inset, | ||
| 1640 | body_top - HANDLE_HEIGHT, | ||
| 1641 | bounds.x1 as f32 + inset, | ||
| 1642 | bounds.y1 as f32 + HANDLE_HEIGHT + inset, | ||
| 1643 | ], | ||
| 1644 | body_top, | ||
| 1645 | ) | ||
| 1646 | } | ||
| 1647 | |||
| 1648 | fn append_outline_chrome( | ||
| 1649 | outline: &TextOutline, | ||
| 1650 | origin: [f32; 2], | ||
| 1651 | pixel: f32, | ||
| 1652 | primitives: &mut Vec<Primitive<'_>>, | ||
| 1653 | ) { | ||
| 1654 | let [x, y] = origin; | ||
| 1655 | let (bounds, body_top) = outline_chrome(outline, pixel); | ||
| 1656 | let [dx, dy] = [x - outline.origin()[0], y - outline.origin()[1]]; | ||
| 1657 | let [left, top, right, bottom] = [ | ||
| 1658 | bounds[0] + dx, | ||
| 1659 | bounds[1] + dy, | ||
| 1660 | bounds[2] + dx, | ||
| 1661 | bounds[3] + dy, | ||
| 1662 | ]; | ||
| 1663 | let body_top = body_top + dy; | ||
| 1664 | if outline.title { | ||
| 1665 | primitives.push(Primitive::RoundedRect { | ||
| 1666 | rect: [left, top, right, bottom], | ||
| 1667 | radius: [6.0 * pixel, (bottom - top) * 0.5], | ||
| 1668 | stroke: Some(Stroke::Dashed(pixel)), | ||
| 1669 | color: crate::gpu::colorref(0x007f7f7f), | ||
| 1670 | }); | ||
| 1671 | return; | ||
| 1672 | } | ||
| 1673 | primitives.push(Primitive::RoundedRect { | ||
| 1674 | rect: [left, top, right, body_top], | ||
| 1675 | radius: [3.0 * pixel; 2], | ||
| 1676 | stroke: None, | ||
| 1677 | color: crate::gpu::colorref(0x00e8ebed), | ||
| 1678 | }); | ||
| 1679 | primitives.push(Primitive::RoundedRect { | ||
| 1680 | rect: [right - 9.0 * pixel, top, right, body_top], | ||
| 1681 | radius: [3.0 * pixel; 2], | ||
| 1682 | stroke: None, | ||
| 1683 | color: crate::gpu::colorref(0x00e5dee7), | ||
| 1684 | }); | ||
| 1685 | primitives.push(Primitive::RoundedRect { | ||
| 1686 | rect: [left, top, right, bottom], | ||
| 1687 | radius: [3.0 * pixel; 2], | ||
| 1688 | stroke: Some(Stroke::Solid(pixel)), | ||
| 1689 | color: crate::gpu::colorref(0x00d9cfd8), | ||
| 1690 | }); | ||
| 1691 | let middle = (top + body_top) * 0.5; | ||
| 1692 | for offset in [-3.0, 0.0, 3.0] { | ||
| 1693 | let center = (left + right) * 0.5 + offset * pixel; | ||
| 1694 | primitives.push(Primitive::RoundedRect { | ||
| 1695 | rect: [ | ||
| 1696 | center - pixel * 0.5, | ||
| 1697 | middle - pixel * 0.5, | ||
| 1698 | center + pixel * 0.5, | ||
| 1699 | middle + pixel * 0.5, | ||
| 1700 | ], | ||
| 1701 | radius: [pixel * 0.5; 2], | ||
| 1702 | stroke: None, | ||
| 1703 | color: crate::gpu::colorref(0x00b4a5b4), | ||
| 1704 | }); | ||
| 1705 | } | ||
| 1706 | for column in [0.0, 1.0, 2.0] { | ||
| 1707 | let half = (column + 0.5) * pixel; | ||
| 1708 | for x in [ | ||
| 1709 | right - (8.0 - column) * pixel, | ||
| 1710 | right - (2.0 + column) * pixel, | ||
| 1711 | ] { | ||
| 1712 | primitives.push(Primitive::Rect { | ||
| 1713 | rect: [x, middle - half, x + pixel, middle + half], | ||
| 1714 | color: crate::gpu::colorref(0x00b4a5b4), | ||
| 1715 | }); | ||
| 1716 | } | ||
| 1717 | } | ||
| 1718 | } | ||
| 1719 | |||
| 1720 | fn append_outline<'a>( | ||
| 1721 | editor: Option<&'a CanvasEditor>, | ||
| 1722 | outline: &'a TextOutline, | ||
| 1723 | origin: [f32; 2], | ||
| 1724 | paint: Paint, | ||
| 1725 | primitives: &mut Vec<Primitive<'a>>, | ||
| 1726 | ) -> Result<()> { | ||
| 1727 | let [x, y] = origin; | ||
| 1728 | let Paint { | ||
| 1729 | show_caret, | ||
| 1730 | scale, | ||
| 1731 | pixel, | ||
| 1732 | colors, | ||
| 1733 | } = paint; | ||
| 1734 | outline.shaped().append_table_primitives(primitives, origin); | ||
| 1735 | outline | ||
| 1736 | .shaped() | ||
| 1737 | .append_background_primitives(primitives, origin); | ||
| 1738 | if let Some(editor) = editor { | ||
| 1739 | for rect in editor.selection_rects()? { | ||
| 1740 | primitives.push(Primitive::Rect { | ||
| 1741 | rect: [ | ||
| 1742 | rect.x0 as f32 + x, | ||
| 1743 | rect.y0 as f32 + y, | ||
| 1744 | rect.x1 as f32 + x, | ||
| 1745 | rect.y1 as f32 + y, | ||
| 1746 | ], | ||
| 1747 | color: colors.selection, | ||
| 1748 | }); | ||
| 1749 | } | ||
| 1750 | } | ||
| 1751 | for (index, (_, paragraph)) in outline.layouts().enumerate() { | ||
| 1752 | outline | ||
| 1753 | .shaped() | ||
| 1754 | .append_paragraph_primitives(index, paragraph, origin, primitives); | ||
| 1755 | } | ||
| 1756 | if let Some(editor) = editor { | ||
| 1757 | for rect in editor.marked_rects()? { | ||
| 1758 | primitives.push(Primitive::Rect { | ||
| 1759 | rect: [ | ||
| 1760 | rect.x0 as f32 + x, | ||
| 1761 | rect.y1 as f32 + y - 1.0 / scale, | ||
| 1762 | rect.x1 as f32 + x, | ||
| 1763 | rect.y1 as f32 + y, | ||
| 1764 | ], | ||
| 1765 | color: [0.0, 0.0, 0.0, 1.0], | ||
| 1766 | }); | ||
| 1767 | } | ||
| 1768 | let [anchor, focus] = editor.selection().positions; | ||
| 1769 | if show_caret && anchor == focus { | ||
| 1770 | let rect = editor.caret(2.0 * pixel)?; | ||
| 1771 | primitives.push(Primitive::RoundedRect { | ||
| 1772 | rect: [ | ||
| 1773 | rect.x0 as f32 + x, | ||
| 1774 | rect.y0 as f32 + y, | ||
| 1775 | rect.x1 as f32 + x, | ||
| 1776 | rect.y1 as f32 + y, | ||
| 1777 | ], | ||
| 1778 | radius: [pixel; 2], | ||
| 1779 | stroke: None, | ||
| 1780 | color: colors.caret, | ||
| 1781 | }); | ||
| 1782 | } | ||
| 1783 | } | ||
| 1784 | Ok(()) | ||
| 1785 | } | ||
crates/canvas/src/interaction/profile.rs created+209| ... | @@ -0,0 +1,209 @@ | ||
| 1 | use super::*; | ||
| 2 | use crate::document::TextDocument; | ||
| 3 | use crate::document::TextPosition; | ||
| 4 | use onestore::document::Format; | ||
| 5 | use onestore::page::Page; | ||
| 6 | use onestore::page::text::Paragraph; | ||
| 7 | use std::sync::Arc; | ||
| 8 | use std::{hint::black_box, process::Command}; | ||
| 9 | |||
| 10 | fn timed<T>(case: &str, phase: &str, sample: usize, operation: impl FnOnce() -> T) -> T { | ||
| 11 | let start = Instant::now(); | ||
| 12 | let result = operation(); | ||
| 13 | eprintln!( | ||
| 14 | "canvas_profile\t{case}\t{phase}\t{sample}\t{}", | ||
| 15 | start.elapsed().as_nanos() | ||
| 16 | ); | ||
| 17 | result | ||
| 18 | } | ||
| 19 | |||
| 20 | fn resident(case: &str, phase: &str, sample: usize) { | ||
| 21 | let output = Command::new("/bin/ps") | ||
| 22 | .args(["-o", "rss=", "-p", &std::process::id().to_string()]) | ||
| 23 | .output() | ||
| 24 | .unwrap(); | ||
| 25 | assert!(output.status.success()); | ||
| 26 | let kib: u64 = std::str::from_utf8(&output.stdout) | ||
| 27 | .unwrap() | ||
| 28 | .trim() | ||
| 29 | .parse() | ||
| 30 | .unwrap(); | ||
| 31 | eprintln!("canvas_rss_kib\t{case}\t{phase}\t{sample}\t{kib}"); | ||
| 32 | } | ||
| 33 | |||
| 34 | fn layout_ids(editor: &CanvasEditor) -> Vec<u64> { | ||
| 35 | editor | ||
| 36 | .outlines() | ||
| 37 | .iter() | ||
| 38 | .flat_map(|outline| outline.layouts().map(|(_, paragraph)| paragraph.text.id())) | ||
| 39 | .collect() | ||
| 40 | } | ||
| 41 | |||
| 42 | fn editing_cost( | ||
| 43 | case: &str, | ||
| 44 | engine: &mut TextEngine, | ||
| 45 | editor: &mut CanvasEditor, | ||
| 46 | scene: Option<&(PageScene, [f32; 2])>, | ||
| 47 | ) { | ||
| 48 | let viewport = Viewport { | ||
| 49 | size: [1000, 720], | ||
| 50 | scale: 96.0 / 72.0, | ||
| 51 | origin: [48.0; 2], | ||
| 52 | }; | ||
| 53 | let initial: Vec<_> = timed(case, "opening_snapshot", 0, || { | ||
| 54 | editor | ||
| 55 | .outlines() | ||
| 56 | .iter() | ||
| 57 | .map(|outline| outline.document().clone()) | ||
| 58 | .collect() | ||
| 59 | }); | ||
| 60 | let mut access = accessibility::Accessibility::default(); | ||
| 61 | let update = timed(case, "ax_first", 0, || { | ||
| 62 | access.update(editor, viewport, "Profile", None).unwrap() | ||
| 63 | }); | ||
| 64 | eprintln!("canvas_count\t{case}\tax_nodes\t0\t{}", update.nodes.len()); | ||
| 65 | drop(update); | ||
| 66 | let count: usize = editor | ||
| 67 | .outlines() | ||
| 68 | .iter() | ||
| 69 | .map(|outline| outline.document().nodes().len()) | ||
| 70 | .sum(); | ||
| 71 | eprintln!("canvas_count\t{case}\tparagraphs\t0\t{count}"); | ||
| 72 | for sample in 0..32 { | ||
| 73 | let index = sample % editor.outlines().len(); | ||
| 74 | let id = editor.outlines()[index].id; | ||
| 75 | editor.focus_outline(id).unwrap(); | ||
| 76 | let paragraph = if sample % 2 == 0 { | ||
| 77 | 0 | ||
| 78 | } else { | ||
| 79 | editor.active_outline().layouts().last().unwrap().0 | ||
| 80 | }; | ||
| 81 | editor | ||
| 82 | .select( | ||
| 83 | [TextPosition { | ||
| 84 | paragraph, | ||
| 85 | offset: 0, | ||
| 86 | }; 2] | ||
| 87 | .into(), | ||
| 88 | ) | ||
| 89 | .unwrap(); | ||
| 90 | let original_layouts = layout_ids(editor); | ||
| 91 | timed(case, "insert", sample, || { | ||
| 92 | editor.insert(engine, "x").unwrap() | ||
| 93 | }); | ||
| 94 | let edited_layouts = layout_ids(editor); | ||
| 95 | let changed = original_layouts | ||
| 96 | .iter() | ||
| 97 | .zip(&edited_layouts) | ||
| 98 | .filter(|(a, b)| a != b) | ||
| 99 | .count(); | ||
| 100 | assert_eq!(changed, 1); | ||
| 101 | eprintln!("canvas_count\t{case}\tchanged_layouts\t{sample}\t{changed}"); | ||
| 102 | let primitives = timed(case, "frame_primitives", sample, || { | ||
| 103 | let primitives = page_primitives( | ||
| 104 | editor, | ||
| 105 | scene, | ||
| 106 | None, | ||
| 107 | None, | ||
| 108 | super::tests::paint(true, viewport.scale, 1.0), | ||
| 109 | ) | ||
| 110 | .unwrap(); | ||
| 111 | black_box(&primitives); | ||
| 112 | primitives.len() | ||
| 113 | }); | ||
| 114 | eprintln!("canvas_count\t{case}\tprimitives\t{sample}\t{primitives}"); | ||
| 115 | timed(case, "ax_insert", sample, || { | ||
| 116 | black_box(access.update(editor, viewport, "Profile", None).unwrap()); | ||
| 117 | }); | ||
| 118 | assert_eq!(edited_layouts, layout_ids(editor)); | ||
| 119 | timed(case, "undo", sample, || { | ||
| 120 | assert!(editor.undo(engine).unwrap()) | ||
| 121 | }); | ||
| 122 | timed(case, "ax_undo", sample, || { | ||
| 123 | black_box(access.update(editor, viewport, "Profile", None).unwrap()); | ||
| 124 | }); | ||
| 125 | assert_eq!(editor.active_outline().document(), &initial[index]); | ||
| 126 | } | ||
| 127 | resident(case, "after_edits", 0); | ||
| 128 | } | ||
| 129 | |||
| 130 | #[test] | ||
| 131 | #[ignore = "manual release canvas pipeline timing probe"] | ||
| 132 | fn canvas_pipeline_cost() { | ||
| 133 | if let Some(path) = std::env::var_os("CANVAS_TEST_SECTION") { | ||
| 134 | let bytes = timed("native", "read", 0, || std::fs::read(path).unwrap()); | ||
| 135 | let title = std::env::var("CANVAS_TEST_PAGE").unwrap(); | ||
| 136 | let mut engine = timed("native", "engine", 0, TextEngine::default); | ||
| 137 | if let Some(font) = std::env::var_os("CANVAS_TEST_SUBSTITUTE") { | ||
| 138 | timed("native", "font", 0, || { | ||
| 139 | engine | ||
| 140 | .register_substitute(parley::fontique::Blob::new(Arc::new( | ||
| 141 | std::fs::read(font).unwrap(), | ||
| 142 | ))) | ||
| 143 | .unwrap() | ||
| 144 | }); | ||
| 145 | } | ||
| 146 | let cycles = std::env::var("CANVAS_PROFILE_CYCLES") | ||
| 147 | .map(|value| value.parse::<usize>().unwrap()) | ||
| 148 | .unwrap_or(16); | ||
| 149 | assert!(cycles > 0); | ||
| 150 | for cycle in 0..cycles { | ||
| 151 | let page = timed("native", "parse", cycle, || { | ||
| 152 | let store = onestore::Store::parse(&bytes).unwrap(); | ||
| 153 | let index = onestore::RevisionIndex::parse(&store).unwrap(); | ||
| 154 | Page::from_document( | ||
| 155 | &onestore::document::Document::parse(&index).unwrap(), | ||
| 156 | &title, | ||
| 157 | ) | ||
| 158 | .unwrap() | ||
| 159 | }); | ||
| 160 | let (scene, mut editor) = timed("native", "scene", cycle, || { | ||
| 161 | PageScene::from_page(page, &mut engine).unwrap() | ||
| 162 | }); | ||
| 163 | let scene = (scene, [0.0; 2]); | ||
| 164 | if cycle == 0 { | ||
| 165 | editing_cost("native", &mut engine, &mut editor, Some(&scene)); | ||
| 166 | } else { | ||
| 167 | black_box( | ||
| 168 | page_primitives( | ||
| 169 | &editor, | ||
| 170 | Some(&scene), | ||
| 171 | None, | ||
| 172 | None, | ||
| 173 | super::tests::paint(false, 96.0 / 72.0, 1.0), | ||
| 174 | ) | ||
| 175 | .unwrap(), | ||
| 176 | ); | ||
| 177 | } | ||
| 178 | resident("native", "open", cycle); | ||
| 179 | drop(editor); | ||
| 180 | drop(scene); | ||
| 181 | resident("native", "closed", cycle); | ||
| 182 | } | ||
| 183 | } else { | ||
| 184 | for count in [100, 1_000, 5_000] { | ||
| 185 | let case = format!("synthetic_{count}"); | ||
| 186 | let mut engine = timed(&case, "engine", 0, TextEngine::default); | ||
| 187 | let document = timed(&case, "document", 0, || { | ||
| 188 | TextDocument::new( | ||
| 189 | (0..count) | ||
| 190 | .map(|_| { | ||
| 191 | Paragraph::new( | ||
| 192 | "A paragraph of plain text with several words.".into(), | ||
| 193 | Format::default(), | ||
| 194 | ) | ||
| 195 | }) | ||
| 196 | .collect(), | ||
| 197 | ) | ||
| 198 | .unwrap() | ||
| 199 | }); | ||
| 200 | let mut editor = timed(&case, "scene", 0, || { | ||
| 201 | CanvasEditor::new(&mut engine, document, 480.0).unwrap() | ||
| 202 | }); | ||
| 203 | editing_cost(&case, &mut engine, &mut editor, None); | ||
| 204 | drop(editor); | ||
| 205 | drop(engine); | ||
| 206 | resident(&case, "closed", 0); | ||
| 207 | } | ||
| 208 | } | ||
| 209 | } | ||
crates/canvas/src/interaction/scroll.rs created+134| ... | @@ -0,0 +1,134 @@ | ||
| 1 | use crate::gpu::{Primitive, Viewport}; | ||
| 2 | |||
| 3 | pub struct Scroll { | ||
| 4 | pub min: [f32; 2], | ||
| 5 | pub max: [f32; 2], | ||
| 6 | } | ||
| 7 | |||
| 8 | impl Scroll { | ||
| 9 | pub fn new(viewport: Viewport, bounds: impl Iterator<Item = [f32; 4]>) -> Self { | ||
| 10 | let mut min = [0.0_f32; 2]; | ||
| 11 | let mut max = [0.0_f32; 2]; | ||
| 12 | for rect in bounds { | ||
| 13 | for axis in 0..2 { | ||
| 14 | min[axis] = min[axis].min(rect[axis]); | ||
| 15 | max[axis] = max[axis].max(rect[axis + 2]); | ||
| 16 | } | ||
| 17 | } | ||
| 18 | // OneNote stops at the page origin or the object furthest above or left of it. | ||
| 19 | for axis in 0..2 { | ||
| 20 | min[axis] *= viewport.scale; | ||
| 21 | max[axis] = | ||
| 22 | ((max[axis] + 36.0) * viewport.scale - viewport.size[axis] as f32).max(min[axis]); | ||
| 23 | } | ||
| 24 | Self { min, max } | ||
| 25 | } | ||
| 26 | |||
| 27 | pub fn clamp(&self, viewport: &mut Viewport) { | ||
| 28 | for axis in 0..2 { | ||
| 29 | viewport.origin[axis] = -(-viewport.origin[axis]).clamp(self.min[axis], self.max[axis]); | ||
| 30 | } | ||
| 31 | } | ||
| 32 | |||
| 33 | pub fn thumb(&self, viewport: Viewport, dpr: f32, axis: usize) -> Option<[f32; 4]> { | ||
| 34 | let range = self.max[axis] - self.min[axis]; | ||
| 35 | let length = viewport.size[axis] as f32; | ||
| 36 | let track = length - 20.0 * dpr; | ||
| 37 | if range <= 0.0 || track <= 0.0 { | ||
| 38 | return None; | ||
| 39 | } | ||
| 40 | let size = (track * length / (length + range)) | ||
| 41 | .max(28.0 * dpr) | ||
| 42 | .min(track); | ||
| 43 | let position = (-viewport.origin[axis] - self.min[axis]) / range; | ||
| 44 | let start = 4.0 * dpr + position.clamp(0.0, 1.0) * (track - size); | ||
| 45 | let cross = viewport.size[1 - axis] as f32 - 4.0 * dpr; | ||
| 46 | Some(if axis == 0 { | ||
| 47 | [start, cross - 6.0 * dpr, start + size, cross] | ||
| 48 | } else { | ||
| 49 | [cross - 6.0 * dpr, start, cross, start + size] | ||
| 50 | }) | ||
| 51 | } | ||
| 52 | |||
| 53 | pub fn drag(&self, viewport: &mut Viewport, dpr: f32, axis: usize, pointer: f32, grab: f32) { | ||
| 54 | let Some(rect) = self.thumb(*viewport, dpr, axis) else { | ||
| 55 | return; | ||
| 56 | }; | ||
| 57 | let travel = viewport.size[axis] as f32 - 20.0 * dpr - (rect[axis + 2] - rect[axis]); | ||
| 58 | if travel > 0.0 { | ||
| 59 | let fraction = ((pointer - grab - 4.0 * dpr) / travel).clamp(0.0, 1.0); | ||
| 60 | viewport.origin[axis] = | ||
| 61 | -(self.min[axis] + fraction * (self.max[axis] - self.min[axis])); | ||
| 62 | } | ||
| 63 | } | ||
| 64 | |||
| 65 | pub fn hit_test(&self, viewport: Viewport, dpr: f32, point: [f32; 2]) -> Option<(usize, f32)> { | ||
| 66 | (0..2).find_map(|axis| { | ||
| 67 | let rect = self.thumb(viewport, dpr, axis)?; | ||
| 68 | let inset = 3.0 * dpr; | ||
| 69 | ((rect[0] - inset..=rect[2] + inset).contains(&point[0]) | ||
| 70 | && (rect[1] - inset..=rect[3] + inset).contains(&point[1])) | ||
| 71 | .then_some((axis, point[axis] - rect[axis])) | ||
| 72 | }) | ||
| 73 | } | ||
| 74 | |||
| 75 | pub fn append(&self, viewport: Viewport, dpr: f32, primitives: &mut Vec<Primitive<'_>>) { | ||
| 76 | for axis in 0..2 { | ||
| 77 | if let Some(rect) = self.thumb(viewport, dpr, axis) { | ||
| 78 | let start = viewport.document_point([rect[0], rect[1]]); | ||
| 79 | let end = viewport.document_point([rect[2], rect[3]]); | ||
| 80 | primitives.push(Primitive::RoundedRect { | ||
| 81 | rect: [start[0], start[1], end[0], end[1]], | ||
| 82 | radius: [3.0 * dpr / viewport.scale; 2], | ||
| 83 | stroke: None, | ||
| 84 | color: [0.18, 0.18, 0.18, 0.45], | ||
| 85 | }); | ||
| 86 | } | ||
| 87 | } | ||
| 88 | } | ||
| 89 | } | ||
| 90 | |||
| 91 | #[cfg(test)] | ||
| 92 | mod tests { | ||
| 93 | use super::*; | ||
| 94 | |||
| 95 | #[test] | ||
| 96 | fn bounds_include_negative_objects_and_clamp_both_ends() { | ||
| 97 | let mut viewport = Viewport { | ||
| 98 | size: [800, 600], | ||
| 99 | scale: 2.0, | ||
| 100 | origin: [9999.0; 2], | ||
| 101 | }; | ||
| 102 | let scroll = Scroll::new(viewport, [[-80.0, -20.0, 1000.0, 1200.0]].into_iter()); | ||
| 103 | scroll.clamp(&mut viewport); | ||
| 104 | assert_eq!(viewport.origin, [160.0, 40.0]); | ||
| 105 | viewport.origin = [-9999.0; 2]; | ||
| 106 | scroll.clamp(&mut viewport); | ||
| 107 | assert_eq!(viewport.origin, [-1272.0, -1872.0]); | ||
| 108 | } | ||
| 109 | |||
| 110 | #[test] | ||
| 111 | fn small_page_has_no_scrollbars_and_thumb_drag_reaches_endpoints() { | ||
| 112 | for dpr in [1.0, 2.0] { | ||
| 113 | let mut viewport = Viewport { | ||
| 114 | size: [(800.0 * dpr) as u32, (600.0 * dpr) as u32], | ||
| 115 | scale: dpr * 4.0 / 3.0, | ||
| 116 | origin: [48.0 * dpr; 2], | ||
| 117 | }; | ||
| 118 | let scroll = Scroll::new(viewport, [[0.0, 0.0, 72.0, 14.0]].into_iter()); | ||
| 119 | assert!(scroll.thumb(viewport, dpr, 0).is_none()); | ||
| 120 | assert!(scroll.thumb(viewport, dpr, 1).is_none()); | ||
| 121 | let scroll = Scroll::new(viewport, [[0.0, 0.0, 2000.0, 2000.0]].into_iter()); | ||
| 122 | for axis in 0..2 { | ||
| 123 | scroll.drag(&mut viewport, dpr, axis, -1000.0, 3.0 * dpr); | ||
| 124 | assert_eq!(-viewport.origin[axis], scroll.min[axis]); | ||
| 125 | let rect = scroll.thumb(viewport, dpr, axis).unwrap(); | ||
| 126 | assert!((rect[axis] - 4.0 * dpr).abs() < 0.001); | ||
| 127 | scroll.drag(&mut viewport, dpr, axis, 10000.0, 3.0 * dpr); | ||
| 128 | assert_eq!(-viewport.origin[axis], scroll.max[axis]); | ||
| 129 | let rect = scroll.thumb(viewport, dpr, axis).unwrap(); | ||
| 130 | assert!((rect[axis + 2] - (viewport.size[axis] as f32 - 16.0 * dpr)).abs() < 0.001); | ||
| 131 | } | ||
| 132 | } | ||
| 133 | } | ||
| 134 | } | ||
crates/canvas/src/interaction/tests.rs created+1610| ... | @@ -0,0 +1,1610 @@ | ||
| 1 | use super::*; | ||
| 2 | use crate::document::TextDocument; | ||
| 3 | use onestore::document::Format; | ||
| 4 | use onestore::page::Page; | ||
| 5 | use onestore::page::text::Paragraph; | ||
| 6 | use std::sync::Arc; | ||
| 7 | |||
| 8 | const COLORS: TextColors = TextColors { | ||
| 9 | caret: [0.1, 0.3, 0.9, 1.0], | ||
| 10 | selection: [0.7, 0.8, 1.0, 1.0], | ||
| 11 | }; | ||
| 12 | |||
| 13 | /// Paint for a view at `scale` device pixels per point on a display of `display_scale`. | ||
| 14 | pub(super) fn paint(show_caret: bool, scale: f32, display_scale: f32) -> Paint { | ||
| 15 | Paint { | ||
| 16 | show_caret, | ||
| 17 | scale, | ||
| 18 | pixel: display_scale / scale, | ||
| 19 | colors: COLORS, | ||
| 20 | } | ||
| 21 | } | ||
| 22 | |||
| 23 | #[test] | ||
| 24 | fn date_buttons_keep_accessibility_identity_and_match_mouse_hits_after_reflow() { | ||
| 25 | let mut engine = TextEngine::default(); | ||
| 26 | let mut fields = [vec!["Header"], vec!["Monday", "6:14 AM"]].map(|text| { | ||
| 27 | TextOutline::new( | ||
| 28 | &mut engine, | ||
| 29 | TextDocument::new( | ||
| 30 | text.into_iter() | ||
| 31 | .map(|text| Paragraph::new(text.into(), Default::default())) | ||
| 32 | .collect(), | ||
| 33 | ) | ||
| 34 | .unwrap(), | ||
| 35 | 468.0, | ||
| 36 | [0.0; 2], | ||
| 37 | ) | ||
| 38 | .unwrap() | ||
| 39 | .snapshot() | ||
| 40 | }); | ||
| 41 | fields[0].title = true; | ||
| 42 | let page = Page { | ||
| 43 | identity: None, | ||
| 44 | title: "Header".into(), | ||
| 45 | created: Some(1), | ||
| 46 | margin_origin: [36.0, 14.4], | ||
| 47 | definitions: Default::default(), | ||
| 48 | objects: vec![onestore::page::PageObject::Title(onestore::page::Title { | ||
| 49 | id: onestore::ExGuid::default(), | ||
| 50 | date: Some(fields[1].id), | ||
| 51 | layout: Default::default(), | ||
| 52 | outlines: fields.into(), | ||
| 53 | })], | ||
| 54 | }; | ||
| 55 | let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap(); | ||
| 56 | let scene = (scene, [20.0, 40.0]); | ||
| 57 | let viewport = Viewport { | ||
| 58 | size: [1000, 700], | ||
| 59 | scale: 2.0, | ||
| 60 | origin: [-30.0, -50.0], | ||
| 61 | }; | ||
| 62 | let mut access = accessibility::Accessibility::default(); | ||
| 63 | let mut identities = Vec::new(); | ||
| 64 | for phase in 0..3 { | ||
| 65 | if phase == 1 { | ||
| 66 | editor | ||
| 67 | .insert(&mut engine, &"A wrapped title ".repeat(25)) | ||
| 68 | .unwrap(); | ||
| 69 | } | ||
| 70 | if phase == 2 { | ||
| 71 | editor | ||
| 72 | .change_date(&mut engine, 2, ["Wednesday".into(), "8:40 AM".into()]) | ||
| 73 | .unwrap(); | ||
| 74 | } | ||
| 75 | let mut update = access.update(&editor, viewport, "Header", None).unwrap(); | ||
| 76 | access.append_page_fields(&mut update, Some(&scene), &editor, viewport, None); | ||
| 77 | let buttons: Vec<_> = update | ||
| 78 | .nodes | ||
| 79 | .iter() | ||
| 80 | .filter(|(_, node)| node.role() == accesskit::Role::Button) | ||
| 81 | .collect(); | ||
| 82 | assert_eq!(buttons.len(), 2); | ||
| 83 | for (index, (id, node)) in buttons.iter().enumerate() { | ||
| 84 | let field = [DateField::Date, DateField::Time][index]; | ||
| 85 | assert_eq!(access.date_for_node(*id), Some(field)); | ||
| 86 | assert!(node.supports_action(accesskit::Action::Click)); | ||
| 87 | let rect = node.bounds().unwrap(); | ||
| 88 | let point = viewport.document_point([ | ||
| 89 | ((rect.x0 + rect.x1) * 0.5) as f32, | ||
| 90 | ((rect.y0 + rect.y1) * 0.5) as f32, | ||
| 91 | ]); | ||
| 92 | assert_eq!( | ||
| 93 | page_hit_test(&editor, Some(&scene), point, 0.5), | ||
| 94 | Some(Hit::Date(field)) | ||
| 95 | ); | ||
| 96 | if phase == 0 { | ||
| 97 | identities.push(*id); | ||
| 98 | } else { | ||
| 99 | assert_eq!(*id, identities[index]); | ||
| 100 | } | ||
| 101 | } | ||
| 102 | if phase == 2 { | ||
| 103 | assert_eq!(buttons[1].1.value(), Some("8:40 AM")); | ||
| 104 | } | ||
| 105 | accesskit_consumer::Tree::new(update, true); | ||
| 106 | } | ||
| 107 | } | ||
| 108 | |||
| 109 | #[test] | ||
| 110 | fn title_chrome_selects_text_and_exposes_a_named_editable_field() { | ||
| 111 | let mut engine = TextEngine::default(); | ||
| 112 | let mut source = TextOutline::new( | ||
| 113 | &mut engine, | ||
| 114 | TextDocument::new(vec![Paragraph::new( | ||
| 115 | "Header".into(), | ||
| 116 | Format { | ||
| 117 | font_size: Some(8.0), | ||
| 118 | line_spacing: Some(20.751953), | ||
| 119 | ..Default::default() | ||
| 120 | }, | ||
| 121 | )]) | ||
| 122 | .unwrap(), | ||
| 123 | 468.0, | ||
| 124 | [36.0, 14.4], | ||
| 125 | ) | ||
| 126 | .unwrap() | ||
| 127 | .snapshot(); | ||
| 128 | source.title = true; | ||
| 129 | source.min_width = Some(162.0); | ||
| 130 | source.layout.max_width = None; | ||
| 131 | source.layout.width_set_by_user = None; | ||
| 132 | source.layout.max_height = Some(21.6); | ||
| 133 | let outline = TextOutline::from_outline(&mut engine, &source, &Default::default()).unwrap(); | ||
| 134 | let editor = CanvasEditor::from_text_outlines(vec![outline], Default::default(), None).unwrap(); | ||
| 135 | assert_eq!(editor.active_outline().bounds().width(), 162.0); | ||
| 136 | assert_eq!(editor.active_outline().snapshot().min_width, Some(162.0)); | ||
| 137 | let (native_rect, _) = outline_chrome(editor.active_outline(), 0.75); | ||
| 138 | for (axis, (actual, expected)) in native_rect | ||
| 139 | .iter() | ||
| 140 | .zip([85.0, 98.0, 315.0, 134.0]) | ||
| 141 | .enumerate() | ||
| 142 | { | ||
| 143 | let origin = if axis % 2 == 0 { 48.0 } else { 83.0 }; | ||
| 144 | assert!((actual / 0.75 + origin - expected).abs() < 1.5); | ||
| 145 | } | ||
| 146 | let id = editor.active_outline().id; | ||
| 147 | let (rect, _) = outline_chrome(editor.active_outline(), 1.0); | ||
| 148 | for point in [ | ||
| 149 | [rect[0] + 1.0, rect[1] + 1.0], | ||
| 150 | [rect[2] - 1.0, rect[1] + 1.0], | ||
| 151 | [40.0, 20.0], | ||
| 152 | ] { | ||
| 153 | assert!( | ||
| 154 | matches!(page_hit_test(&editor, None, point, 1.0), Some(Hit::Text { id: hit, .. }) if hit == id) | ||
| 155 | ); | ||
| 156 | } | ||
| 157 | let primitives = page_primitives(&editor, None, None, None, paint(false, 1.0, 1.0)).unwrap(); | ||
| 158 | let borders = primitives | ||
| 159 | .iter() | ||
| 160 | .filter_map(|p| match p { | ||
| 161 | Primitive::RoundedRect { stroke, .. } => Some(*stroke), | ||
| 162 | _ => None, | ||
| 163 | }) | ||
| 164 | .collect::<Vec<_>>(); | ||
| 165 | assert_eq!(borders, [Some(Stroke::Dashed(1.0))]); | ||
| 166 | assert!(primitives.iter().any(|primitive| matches!(primitive, Primitive::RoundedRect { rect, radius, stroke: Some(Stroke::Dashed(_)), .. } if radius[0] == 6.0 && radius[1] * 2.0 == rect[3] - rect[1]))); | ||
| 167 | let mut access = accessibility::Accessibility::default(); | ||
| 168 | let update = access | ||
| 169 | .update( | ||
| 170 | &editor, | ||
| 171 | Viewport { | ||
| 172 | size: [800, 600], | ||
| 173 | scale: 1.0, | ||
| 174 | origin: [0.0; 2], | ||
| 175 | }, | ||
| 176 | "Header", | ||
| 177 | None, | ||
| 178 | ) | ||
| 179 | .unwrap(); | ||
| 180 | assert!(update.nodes.iter().any(|(_, node)| node.role() | ||
| 181 | == accesskit::Role::MultilineTextInput | ||
| 182 | && node.label() == Some("Page title"))); | ||
| 183 | } | ||
| 184 | |||
| 185 | #[test] | ||
| 186 | fn provisional_lines_share_one_drawn_hit_tested_and_accessible_outline() { | ||
| 187 | let mut engine = TextEngine::default(); | ||
| 188 | let mut editor = CanvasEditor::new( | ||
| 189 | &mut engine, | ||
| 190 | TextDocument::new(vec![Paragraph::new("body".into(), Format::default())]).unwrap(), | ||
| 191 | 240.0, | ||
| 192 | ) | ||
| 193 | .unwrap(); | ||
| 194 | let id = editor.active_outline().id; | ||
| 195 | for _ in 0..2 { | ||
| 196 | editor | ||
| 197 | .move_selection(&mut engine, Movement::Down, false) | ||
| 198 | .unwrap(); | ||
| 199 | } | ||
| 200 | let primitives = page_primitives(&editor, None, None, None, paint(true, 1.0, 1.0)).unwrap(); | ||
| 201 | assert_eq!( | ||
| 202 | primitives | ||
| 203 | .iter() | ||
| 204 | .filter(|p| matches!(p, Primitive::Text { .. })) | ||
| 205 | .count(), | ||
| 206 | 3 | ||
| 207 | ); | ||
| 208 | let caret = editor.caret(1.0).unwrap(); | ||
| 209 | assert!( | ||
| 210 | matches!(page_hit_test(&editor, None, [caret.x0 as f32, ((caret.y0 + caret.y1) * 0.5) as f32], 1.0), Some(Hit::Text { id: hit, .. }) if hit == id) | ||
| 211 | ); | ||
| 212 | let mut access = accessibility::Accessibility::default(); | ||
| 213 | let update = access | ||
| 214 | .update( | ||
| 215 | &editor, | ||
| 216 | Viewport { | ||
| 217 | size: [800, 600], | ||
| 218 | scale: 1.0, | ||
| 219 | origin: [0.0; 2], | ||
| 220 | }, | ||
| 221 | "Test", | ||
| 222 | None, | ||
| 223 | ) | ||
| 224 | .unwrap(); | ||
| 225 | let fields = update | ||
| 226 | .nodes | ||
| 227 | .iter() | ||
| 228 | .filter(|(_, node)| node.role() == accesskit::Role::MultilineTextInput) | ||
| 229 | .collect::<Vec<_>>(); | ||
| 230 | assert_eq!(fields.len(), 1); | ||
| 231 | assert_eq!(fields[0].1.value(), Some("body\n\n")); | ||
| 232 | assert_eq!(editor.outlines()[0].document().nodes().len(), 1); | ||
| 233 | editor | ||
| 234 | .place_caret(&mut engine, [300.0, 200.0], 240.0) | ||
| 235 | .unwrap(); | ||
| 236 | assert_eq!( | ||
| 237 | editor | ||
| 238 | .visible_outlines() | ||
| 239 | .next() | ||
| 240 | .unwrap() | ||
| 241 | .document() | ||
| 242 | .nodes() | ||
| 243 | .len(), | ||
| 244 | 1 | ||
| 245 | ); | ||
| 246 | } | ||
| 247 | |||
| 248 | #[test] | ||
| 249 | fn blank_caret_and_composition_have_accessible_text_without_outline_chrome() { | ||
| 250 | let mut engine = TextEngine::default(); | ||
| 251 | let mut editor = CanvasEditor::new( | ||
| 252 | &mut engine, | ||
| 253 | TextDocument::new(vec![Paragraph::new(String::new(), Default::default())]).unwrap(), | ||
| 254 | 240.0, | ||
| 255 | ) | ||
| 256 | .unwrap(); | ||
| 257 | editor | ||
| 258 | .place_caret(&mut engine, [120.0, 100.0], 240.0) | ||
| 259 | .unwrap(); | ||
| 260 | let mut access = accessibility::Accessibility::default(); | ||
| 261 | let viewport = Viewport { | ||
| 262 | size: [1000, 720], | ||
| 263 | scale: 96.0 / 72.0, | ||
| 264 | origin: [48.0; 2], | ||
| 265 | }; | ||
| 266 | let initial = access.update(&editor, viewport, "Page", None).unwrap(); | ||
| 267 | let id = editor.active_outline().id; | ||
| 268 | assert_eq!(access.outline_for_node(initial.focus), Some(id)); | ||
| 269 | let rectangles = |editor: &CanvasEditor| { | ||
| 270 | page_primitives(editor, None, None, None, paint(true, viewport.scale, 1.0)) | ||
| 271 | .unwrap() | ||
| 272 | .into_iter() | ||
| 273 | .filter_map(|p| match p { | ||
| 274 | Primitive::Rect { rect, color } | Primitive::RoundedRect { rect, color, .. } => { | ||
| 275 | Some((rect, color)) | ||
| 276 | } | ||
| 277 | _ => None, | ||
| 278 | }) | ||
| 279 | .collect::<Vec<_>>() | ||
| 280 | }; | ||
| 281 | assert_eq!(rectangles(&editor).len(), 1); | ||
| 282 | assert_eq!( | ||
| 283 | page_hit_test(&editor, None, [120.0, 100.0], 1.0 / viewport.scale), | ||
| 284 | None | ||
| 285 | ); | ||
| 286 | editor.compose(&mut engine, "に".into(), 1..1).unwrap(); | ||
| 287 | assert!(editor.outlines().is_empty()); | ||
| 288 | assert!( | ||
| 289 | rectangles(&editor) | ||
| 290 | .iter() | ||
| 291 | .all(|(_, color)| *color == [0.0, 0.0, 0.0, 1.0] || *color == COLORS.caret) | ||
| 292 | ); | ||
| 293 | let preedit = access.update(&editor, viewport, "Page", None).unwrap(); | ||
| 294 | assert_eq!(preedit.focus, initial.focus); | ||
| 295 | editor.commit_text(&mut engine, "日本".into()).unwrap(); | ||
| 296 | let committed = access.update(&editor, viewport, "Page", None).unwrap(); | ||
| 297 | assert_eq!(committed.focus, initial.focus); | ||
| 298 | assert!(rectangles(&editor).len() > 1); | ||
| 299 | editor.undo(&mut engine).unwrap(); | ||
| 300 | assert!(editor.outlines().is_empty()); | ||
| 301 | assert_eq!(rectangles(&editor).len(), 1); | ||
| 302 | let empty = access.update(&editor, viewport, "Page", None).unwrap(); | ||
| 303 | assert_eq!(empty.focus, committed.focus); | ||
| 304 | assert_eq!(access.outline_for_node(committed.focus), Some(id)); | ||
| 305 | editor.redo(&mut engine).unwrap(); | ||
| 306 | editor.select_all().unwrap(); | ||
| 307 | editor.delete(&mut engine, true).unwrap(); | ||
| 308 | assert!(editor.outlines().is_empty()); | ||
| 309 | assert_eq!(rectangles(&editor).len(), 1); | ||
| 310 | let retired = access.update(&editor, viewport, "Page", None).unwrap(); | ||
| 311 | assert_eq!(retired.focus, committed.focus); | ||
| 312 | assert_eq!(access.outline_for_node(retired.focus), Some(id)); | ||
| 313 | editor.undo(&mut engine).unwrap(); | ||
| 314 | assert!(rectangles(&editor).len() > 1); | ||
| 315 | let restored = access.update(&editor, viewport, "Page", None).unwrap(); | ||
| 316 | assert_eq!(restored.focus, committed.focus); | ||
| 317 | editor.redo(&mut engine).unwrap(); | ||
| 318 | assert_eq!(rectangles(&editor).len(), 1); | ||
| 319 | assert!(editor.caret_outline().unwrap().is_empty()); | ||
| 320 | editor.insert(&mut engine, " ").unwrap(); | ||
| 321 | assert!(editor.caret_outline().unwrap().is_empty()); | ||
| 322 | editor.compose(&mut engine, "に".into(), 1..1).unwrap(); | ||
| 323 | assert!(!editor.caret_outline().unwrap().is_empty()); | ||
| 324 | } | ||
| 325 | |||
| 326 | #[test] | ||
| 327 | #[ignore = "requires CANVAS_TEST_SECTION and CANVAS_TEST_PAGE private fixture inputs"] | ||
| 328 | fn imported_page_widgets_and_accessibility_follow_edits() { | ||
| 329 | let bytes = std::fs::read(std::env::var_os("CANVAS_TEST_SECTION").unwrap()).unwrap(); | ||
| 330 | let page = { | ||
| 331 | let store = onestore::Store::parse(&bytes).unwrap(); | ||
| 332 | let index = onestore::RevisionIndex::parse(&store).unwrap(); | ||
| 333 | Page::from_document( | ||
| 334 | &onestore::document::Document::parse(&index).unwrap(), | ||
| 335 | &std::env::var("CANVAS_TEST_PAGE").unwrap(), | ||
| 336 | ) | ||
| 337 | .unwrap() | ||
| 338 | }; | ||
| 339 | drop(bytes); | ||
| 340 | let mut engine = TextEngine::default(); | ||
| 341 | if let Some(font) = std::env::var_os("CANVAS_TEST_SUBSTITUTE") { | ||
| 342 | engine | ||
| 343 | .register_substitute(parley::fontique::Blob::new(Arc::new( | ||
| 344 | std::fs::read(font).unwrap(), | ||
| 345 | ))) | ||
| 346 | .unwrap(); | ||
| 347 | } | ||
| 348 | let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap(); | ||
| 349 | let scene = (scene, [0.0; 2]); | ||
| 350 | let viewport = Viewport { | ||
| 351 | size: [1000, 720], | ||
| 352 | scale: 96.0 / 72.0, | ||
| 353 | origin: [48.0; 2], | ||
| 354 | }; | ||
| 355 | let mut tag_hits = 0; | ||
| 356 | for outline in editor.outlines() { | ||
| 357 | for (_, paragraph) in outline.layouts() { | ||
| 358 | for tag in &paragraph.tags { | ||
| 359 | let point = [ | ||
| 360 | outline.origin()[0] + tag.origin[0] + crate::outline::ParagraphTag::SIZE / 2.0, | ||
| 361 | outline.origin()[1] | ||
| 362 | + paragraph.origin[1] | ||
| 363 | + tag.origin[1] | ||
| 364 | + crate::outline::ParagraphTag::SIZE / 2.0, | ||
| 365 | ]; | ||
| 366 | assert!( | ||
| 367 | matches!(page_hit_test(&editor, Some(&scene), point, 1.0), Some(Hit::Text { id, .. }) if id == outline.id), | ||
| 368 | "Tag center {point:?} did not target outline {:?}", | ||
| 369 | outline.id | ||
| 370 | ); | ||
| 371 | tag_hits += 1; | ||
| 372 | } | ||
| 373 | } | ||
| 374 | } | ||
| 375 | eprintln!("Verified {tag_hits} imported tag-gutter targets"); | ||
| 376 | let mut access = accessibility::Accessibility::default(); | ||
| 377 | let tags = |editor: &CanvasEditor| { | ||
| 378 | page_primitives( | ||
| 379 | editor, | ||
| 380 | Some(&scene), | ||
| 381 | None, | ||
| 382 | None, | ||
| 383 | paint(true, viewport.scale, 1.0), | ||
| 384 | ) | ||
| 385 | .unwrap() | ||
| 386 | .into_iter() | ||
| 387 | .filter_map(|primitive| match primitive { | ||
| 388 | Primitive::Tag { tag, origin } => Some((tag.clone(), origin)), | ||
| 389 | _ => None, | ||
| 390 | }) | ||
| 391 | .collect::<Vec<_>>() | ||
| 392 | }; | ||
| 393 | let original_tags = tags(&editor); | ||
| 394 | let ids: Vec<_> = editor.outlines().iter().map(|outline| outline.id).collect(); | ||
| 395 | for id in ids { | ||
| 396 | editor.focus_outline(id).unwrap(); | ||
| 397 | let original = editor.active_outline().document().clone(); | ||
| 398 | editor | ||
| 399 | .select( | ||
| 400 | [crate::document::TextPosition { | ||
| 401 | paragraph: 0, | ||
| 402 | offset: 0, | ||
| 403 | }; 2] | ||
| 404 | .into(), | ||
| 405 | ) | ||
| 406 | .unwrap(); | ||
| 407 | let before = access.update(&editor, viewport, "Test", None).unwrap(); | ||
| 408 | let before = accesskit_consumer::Tree::new(before, true); | ||
| 409 | let before = before.state().focus().unwrap().document_range().text(); | ||
| 410 | editor.insert(&mut engine, "CANVAS CHECK ").unwrap(); | ||
| 411 | { | ||
| 412 | let update = access.update(&editor, viewport, "Test", None).unwrap(); | ||
| 413 | let tree = accesskit_consumer::Tree::new(update, true); | ||
| 414 | assert_eq!( | ||
| 415 | tree.state().focus().unwrap().document_range().text(), | ||
| 416 | format!("CANVAS CHECK {before}") | ||
| 417 | ); | ||
| 418 | let primitives = page_primitives( | ||
| 419 | &editor, | ||
| 420 | Some(&scene), | ||
| 421 | None, | ||
| 422 | None, | ||
| 423 | paint(true, viewport.scale, 1.0), | ||
| 424 | ) | ||
| 425 | .unwrap(); | ||
| 426 | assert!(!primitives.is_empty()); | ||
| 427 | let edited_tags = tags(&editor); | ||
| 428 | assert_eq!(edited_tags.len(), original_tags.len()); | ||
| 429 | for ((edited, _), (original, _)) in edited_tags.iter().zip(&original_tags) { | ||
| 430 | assert_eq!(edited.icon, original.icon); | ||
| 431 | assert_eq!(edited.label, original.label); | ||
| 432 | assert_eq!(edited.disabled, original.disabled); | ||
| 433 | } | ||
| 434 | } | ||
| 435 | editor.undo(&mut engine).unwrap(); | ||
| 436 | assert_eq!(editor.active_outline().document(), &original); | ||
| 437 | assert_eq!(tags(&editor), original_tags); | ||
| 438 | let update = access.update(&editor, viewport, "Test", None).unwrap(); | ||
| 439 | let tree = accesskit_consumer::Tree::new(update, true); | ||
| 440 | assert_eq!( | ||
| 441 | tree.state().focus().unwrap().document_range().text(), | ||
| 442 | before | ||
| 443 | ); | ||
| 444 | } | ||
| 445 | eprintln!( | ||
| 446 | "Rendered and restored {} imported outline widgets and accessibility fields", | ||
| 447 | editor.outlines().len() | ||
| 448 | ); | ||
| 449 | } | ||
| 450 | |||
| 451 | #[test] | ||
| 452 | fn extension_hits_yield_to_objects_and_keep_their_source_coordinate_frame() { | ||
| 453 | let mut engine = TextEngine::default(); | ||
| 454 | let upper = TextOutline::new( | ||
| 455 | &mut engine, | ||
| 456 | TextDocument::new(vec![Paragraph::new("Upper".into(), Default::default())]).unwrap(), | ||
| 457 | 240.0, | ||
| 458 | [36.0, 90.0], | ||
| 459 | ) | ||
| 460 | .unwrap(); | ||
| 461 | let bottom = upper.bounds().y1 as f32; | ||
| 462 | let id = upper.id; | ||
| 463 | let lower = TextOutline::new( | ||
| 464 | &mut engine, | ||
| 465 | TextDocument::new(vec![Paragraph::new("Lower".into(), Default::default())]).unwrap(), | ||
| 466 | 240.0, | ||
| 467 | [36.0, bottom + 20.0], | ||
| 468 | ) | ||
| 469 | .unwrap(); | ||
| 470 | let lower_id = lower.id; | ||
| 471 | let source = upper.snapshot(); | ||
| 472 | let mut editor = | ||
| 473 | CanvasEditor::from_text_outlines(vec![lower, upper], Default::default(), None).unwrap(); | ||
| 474 | assert!( | ||
| 475 | matches!(page_hit_test(&editor, None, [60.0, bottom + 22.0], 0.75), Some(Hit::Text { id, .. }) if id == lower_id) | ||
| 476 | ); | ||
| 477 | editor.move_outline(lower_id, [600.0, 500.0]).unwrap(); | ||
| 478 | for pixel in [0.375, 0.75, 1.5] { | ||
| 479 | let hit = page_hit_test(&editor, None, [60.0, bottom + 26.0], pixel).unwrap(); | ||
| 480 | assert!( | ||
| 481 | matches!(hit, Hit::Text { id: hit_id, point } if hit_id == id && (point[1] - (bottom + 26.0 - 90.0)).abs() < 0.001) | ||
| 482 | ); | ||
| 483 | assert_eq!( | ||
| 484 | page_hit_test(&editor, None, [60.0, bottom + 30.0], pixel), | ||
| 485 | None | ||
| 486 | ); | ||
| 487 | } | ||
| 488 | let page = onestore::page::Page { | ||
| 489 | identity: None, | ||
| 490 | title: String::new(), | ||
| 491 | created: None, | ||
| 492 | margin_origin: [0.0; 2], | ||
| 493 | definitions: Default::default(), | ||
| 494 | objects: vec![onestore::page::PageObject::Outline(source)], | ||
| 495 | }; | ||
| 496 | let (scene, editor) = PageScene::from_page(page, &mut engine).unwrap(); | ||
| 497 | let offset = [50.0, 300.0]; | ||
| 498 | assert!( | ||
| 499 | matches!(page_hit_test(&editor, Some(&(scene, offset)), [60.0 + offset[0], bottom + 26.0 + offset[1]], 0.75), Some(Hit::Text { id: hit_id, .. }) if hit_id == id) | ||
| 500 | ); | ||
| 501 | } | ||
| 502 | |||
| 503 | #[test] | ||
| 504 | fn chrome_follows_focus_hover_and_drag_without_changing_hit_geometry() { | ||
| 505 | let mut engine = TextEngine::default(); | ||
| 506 | let outlines = [[0.0, 0.0], [220.0, 0.0]] | ||
| 507 | .into_iter() | ||
| 508 | .map(|origin| { | ||
| 509 | TextOutline::new( | ||
| 510 | &mut engine, | ||
| 511 | TextDocument::new(vec![Paragraph::new("text".into(), Format::default())]).unwrap(), | ||
| 512 | 100.0, | ||
| 513 | origin, | ||
| 514 | ) | ||
| 515 | .unwrap() | ||
| 516 | }) | ||
| 517 | .collect(); | ||
| 518 | let editor = CanvasEditor::from_text_outlines(outlines, Default::default(), None).unwrap(); | ||
| 519 | let second = editor.outlines()[1].id; | ||
| 520 | let borders = |feedback| { | ||
| 521 | page_primitives(&editor, None, feedback, None, paint(false, 1.0, 1.0)) | ||
| 522 | .unwrap() | ||
| 523 | .into_iter() | ||
| 524 | .filter_map(|p| match p { | ||
| 525 | Primitive::RoundedRect { | ||
| 526 | rect, | ||
| 527 | stroke: Some(Stroke::Solid(1.0)), | ||
| 528 | .. | ||
| 529 | } => Some(rect), | ||
| 530 | _ => None, | ||
| 531 | }) | ||
| 532 | .collect::<Vec<_>>() | ||
| 533 | }; | ||
| 534 | assert_eq!(borders(None).len(), 1); | ||
| 535 | let hovered = borders(Some(PointerFeedback::Hover(second))); | ||
| 536 | assert_eq!(hovered.len(), 2); | ||
| 537 | let moved = borders(Some(PointerFeedback::Move(second, [240.0, 50.0]))); | ||
| 538 | assert_eq!(moved[1][0] - hovered[1][0], 20.0); | ||
| 539 | assert_eq!(moved[1][1] - hovered[1][1], 50.0); | ||
| 540 | assert!( | ||
| 541 | matches!(page_hit_test(&editor, None, [225.0, 5.0], 1.0), Some(Hit::Text { id, .. }) if id == second) | ||
| 542 | ); | ||
| 543 | } | ||
| 544 | |||
| 545 | #[test] | ||
| 546 | fn a_covered_width_handle_stays_reachable_beside_the_outline_above_it() { | ||
| 547 | let mut engine = TextEngine::default(); | ||
| 548 | let pixel = 0.75; | ||
| 549 | let mut first = TextOutline::new( | ||
| 550 | &mut engine, | ||
| 551 | TextDocument::new(vec![Paragraph::new("text".into(), Format::default())]).unwrap(), | ||
| 552 | 100.0, | ||
| 553 | [36.0, 68.4], | ||
| 554 | ) | ||
| 555 | .unwrap(); | ||
| 556 | let editor = | ||
| 557 | CanvasEditor::from_text_outlines(vec![first.clone()], Default::default(), None).unwrap(); | ||
| 558 | first = editor.preview_resize(&mut engine, 226.5).unwrap(); | ||
| 559 | let (frame, body_top) = outline_chrome(&first, pixel); | ||
| 560 | // The casual night page: the next outline starts 7.5 pt right of the first one. | ||
| 561 | let second = TextOutline::new( | ||
| 562 | &mut engine, | ||
| 563 | TextDocument::new(vec![Paragraph::new("text".into(), Format::default())]).unwrap(), | ||
| 564 | 100.0, | ||
| 565 | [first.bounds().x1 as f32 + 7.5, 68.4], | ||
| 566 | ) | ||
| 567 | .unwrap(); | ||
| 568 | let (first_id, second_id) = (first.id, second.id); | ||
| 569 | let editor = | ||
| 570 | CanvasEditor::from_text_outlines(vec![first, second], Default::default(), None).unwrap(); | ||
| 571 | let header = (frame[1] + body_top) / 2.0; | ||
| 572 | let hit = |x| page_hit_test(&editor, None, [x, header], pixel); | ||
| 573 | assert!(matches!(hit(frame[2] - 12.0 * pixel), Some(Hit::Resize { id, .. }) if id == first_id)); | ||
| 574 | assert!(matches!(hit(frame[2] - 2.0 * pixel), Some(Hit::Handle { id, .. }) if id == second_id)); | ||
| 575 | let body = |x| page_hit_test(&editor, None, [x, body_top + 20.0], pixel); | ||
| 576 | assert!(matches!(body(frame[2] - 4.0 * pixel), Some(Hit::Resize { id, .. }) if id == first_id)); | ||
| 577 | assert!(matches!(body(frame[2] - 8.0 * pixel), Some(Hit::Text { id, .. }) if id == second_id)); | ||
| 578 | assert!(matches!(body(frame[2] - 12.0 * pixel), Some(Hit::Text { id, .. }) if id == first_id)); | ||
| 579 | } | ||
| 580 | |||
| 581 | #[test] | ||
| 582 | fn a_picture_in_an_outline_takes_the_click_over_its_text() { | ||
| 583 | use onestore::page::{Image, Page, PageObject, ParagraphContent}; | ||
| 584 | let mut engine = TextEngine::default(); | ||
| 585 | let mut source = TextOutline::new( | ||
| 586 | &mut engine, | ||
| 587 | TextDocument::new(vec![ | ||
| 588 | Paragraph::new("Before".into(), Default::default()), | ||
| 589 | Paragraph::new("After".into(), Default::default()), | ||
| 590 | ]) | ||
| 591 | .unwrap(), | ||
| 592 | 240.0, | ||
| 593 | [36.0, 36.0], | ||
| 594 | ) | ||
| 595 | .unwrap() | ||
| 596 | .snapshot(); | ||
| 597 | let mut picture = source.paragraphs[0].clone(); | ||
| 598 | picture.id = onestore::page::text::new_id().unwrap(); | ||
| 599 | let id = onestore::page::text::new_id().unwrap(); | ||
| 600 | picture.content = ParagraphContent::Image(Image { | ||
| 601 | size: Some([40.0, 30.0]), | ||
| 602 | id, | ||
| 603 | layout: Default::default(), | ||
| 604 | bytes: None, | ||
| 605 | alt: None, | ||
| 606 | background: false, | ||
| 607 | }); | ||
| 608 | source.paragraphs.insert(1, picture); | ||
| 609 | let editor = CanvasEditor::from_page( | ||
| 610 | Page { | ||
| 611 | title: String::new(), | ||
| 612 | identity: None, | ||
| 613 | created: None, | ||
| 614 | margin_origin: [36.0, 14.4], | ||
| 615 | definitions: Default::default(), | ||
| 616 | objects: vec![PageObject::Outline(source)], | ||
| 617 | }, | ||
| 618 | &mut engine, | ||
| 619 | ) | ||
| 620 | .unwrap(); | ||
| 621 | let (origin, size) = editor.image_placement(id).unwrap(); | ||
| 622 | let center = [origin[0] + size[0] / 2.0, origin[1] + size[1] / 2.0]; | ||
| 623 | assert_eq!( | ||
| 624 | page_hit_test(&editor, None, center, 1.0), | ||
| 625 | Some(Hit::Image { id, handle: [0, 0] }) | ||
| 626 | ); | ||
| 627 | assert!(matches!( | ||
| 628 | page_hit_test(&editor, None, [origin[0] + 2.0, origin[1] - 4.0], 1.0), | ||
| 629 | Some(Hit::Text { .. }) | ||
| 630 | )); | ||
| 631 | } | ||
| 632 | |||
| 633 | #[test] | ||
| 634 | fn picture_handles_resize_as_onenote_does() { | ||
| 635 | // Native drags of the 333 x 200.1 pt mockup on "av: casual night in the trees". | ||
| 636 | let (origin, size) = ([468.0, 86.4], [333.0, 200.1]); | ||
| 637 | let (corner_origin, corner) = resize_image(origin, size, [1, -1], [-75.0, 30.75]); | ||
| 638 | assert!((corner[0] - 281.827).abs() < 0.01 && (corner[1] - 169.351).abs() < 0.01); | ||
| 639 | assert!((corner_origin[1] - 117.15).abs() < 0.01 && corner_origin[0] == 468.0); | ||
| 640 | assert_eq!( | ||
| 641 | resize_image(origin, size, [1, 0], [46.5, 10.0]), | ||
| 642 | (origin, [379.5, 200.1]) | ||
| 643 | ); | ||
| 644 | let (left_origin, left) = resize_image(origin, size, [-1, 0], [400.0, 0.0]); | ||
| 645 | assert_eq!(left, [1.0, 200.1]); | ||
| 646 | assert_eq!(left_origin[0], 468.0 + 333.0 - 1.0); | ||
| 647 | let pixel = 0.75; | ||
| 648 | let rect = image_rect(origin, size); | ||
| 649 | assert_eq!( | ||
| 650 | image_handle_at(rect, pixel, [468.0 - 3.75, 86.4 - 3.75]), | ||
| 651 | Some([-1, -1]) | ||
| 652 | ); | ||
| 653 | assert_eq!( | ||
| 654 | image_handle_at(rect, pixel, [468.0 + 166.5, 286.5 + 3.75]), | ||
| 655 | Some([0, 1]) | ||
| 656 | ); | ||
| 657 | assert_eq!(image_handle_at(rect, pixel, [600.0, 150.0]), None); | ||
| 658 | } | ||
| 659 | |||
| 660 | #[test] | ||
| 661 | fn grouped_drag_keeps_the_initial_word_when_reversing_direction() { | ||
| 662 | let selection = |start, end| { | ||
| 663 | Selection::from([start, end].map(|offset| crate::document::TextPosition { | ||
| 664 | paragraph: 0, | ||
| 665 | offset, | ||
| 666 | })) | ||
| 667 | }; | ||
| 668 | let anchor = selection(6, 10); | ||
| 669 | assert_eq!( | ||
| 670 | drag_selection(anchor, selection(11, 16), SelectionUnit::Word).positions, | ||
| 671 | selection(6, 16).positions | ||
| 672 | ); | ||
| 673 | assert_eq!( | ||
| 674 | drag_selection(anchor, selection(0, 5), SelectionUnit::Word).positions, | ||
| 675 | selection(10, 0).positions | ||
| 676 | ); | ||
| 677 | assert_eq!( | ||
| 678 | drag_selection(anchor, anchor, SelectionUnit::Word).positions, | ||
| 679 | anchor.positions | ||
| 680 | ); | ||
| 681 | let anchor = selection(10, 3); | ||
| 682 | assert_eq!( | ||
| 683 | drag_selection(anchor, selection(7, 7), SelectionUnit::Grapheme).positions, | ||
| 684 | selection(10, 7).positions | ||
| 685 | ); | ||
| 686 | } | ||
| 687 | |||
| 688 | const DEFAULT_MARGIN: [f32; 2] = [36.0, 14.4]; | ||
| 689 | |||
| 690 | #[test] | ||
| 691 | fn native_grid_follows_the_page_margin_origin() { | ||
| 692 | // "av: casual night in the trees": margin 36.75; a 22.5 x 15 pt drag lands one cell on. | ||
| 693 | let margin = [36.75, 14.4]; | ||
| 694 | let result = snap_to_grid([468.75 + 22.5, 86.4 + 15.0], margin); | ||
| 695 | assert!((result[0] - 486.75).abs() < 0.0001); | ||
| 696 | assert!((result[1] - 104.4).abs() < 0.0001); | ||
| 697 | } | ||
| 698 | |||
| 699 | #[test] | ||
| 700 | fn native_grid_matches_drag_offsets_midpoints_and_zoom() { | ||
| 701 | for (pixels, points) in [ | ||
| 702 | (5.0, 0.0), | ||
| 703 | (6.0, 0.0), | ||
| 704 | (7.0, 0.0), | ||
| 705 | (8.0, 0.0), | ||
| 706 | (11.0, 0.0), | ||
| 707 | (12.0, 9.0), | ||
| 708 | (13.0, 18.0), | ||
| 709 | (17.0, 18.0), | ||
| 710 | (18.0, 18.0), | ||
| 711 | (19.0, 18.0), | ||
| 712 | (25.0, 18.0), | ||
| 713 | (37.0, 36.0), | ||
| 714 | (-6.0, 0.0), | ||
| 715 | (-7.0, 0.0), | ||
| 716 | (-12.0, -9.0), | ||
| 717 | (-13.0, -18.0), | ||
| 718 | (-19.0, -18.0), | ||
| 719 | ] { | ||
| 720 | let origin = [486.0, 230.40001]; | ||
| 721 | let proposed = origin.map(|v| v + pixels * 72.0 / 96.0); | ||
| 722 | let result = snap_to_grid(proposed, DEFAULT_MARGIN); | ||
| 723 | for axis in 0..2 { | ||
| 724 | assert!((result[axis] - origin[axis] - points).abs() < 0.00004); | ||
| 725 | } | ||
| 726 | } | ||
| 727 | for pixels in [6.0, 7.0, 12.0, 13.0, 19.0] { | ||
| 728 | let result = snap_to_grid( | ||
| 729 | [491.25 + pixels * 0.75, 235.65 + pixels * 0.75], | ||
| 730 | DEFAULT_MARGIN, | ||
| 731 | ); | ||
| 732 | assert!((result[0] - 504.0).abs() < 0.00004); | ||
| 733 | assert!((result[1] - 248.4).abs() < 0.00004); | ||
| 734 | } | ||
| 735 | for (scale, delta, expected) in [ | ||
| 736 | (96.0 / 72.0, [27.0, 31.0], [54.0, 104.4]), | ||
| 737 | (96.0 / 72.0, [13.0, 17.0], [54.0, 104.4]), | ||
| 738 | (192.0 / 72.0, [13.0, 17.0], [36.0, 104.4]), | ||
| 739 | ] { | ||
| 740 | for dpr in [1.0, 2.0] { | ||
| 741 | let origin = [36.0, 90.0]; | ||
| 742 | let proposed = | ||
| 743 | std::array::from_fn(|axis| origin[axis] + delta[axis] * dpr / (scale * dpr)); | ||
| 744 | let result = snap_to_grid(proposed, DEFAULT_MARGIN); | ||
| 745 | for axis in 0..2 { | ||
| 746 | assert!((result[axis] - expected[axis]).abs() < 0.00004); | ||
| 747 | } | ||
| 748 | } | ||
| 749 | } | ||
| 750 | for point in [[9.0, 5.4], [-9.0, -12.6], [423.0, 239.4]] { | ||
| 751 | assert_eq!(snap_to_grid(point, DEFAULT_MARGIN), point); | ||
| 752 | } | ||
| 753 | } | ||
| 754 | |||
| 755 | #[test] | ||
| 756 | fn native_fresh_click_grid_uses_screen_offset_at_both_zoom_levels() { | ||
| 757 | for (x, y, scale, expected) in [ | ||
| 758 | (900.0, 500.0, 96.0 / 72.0, [639.0, 302.4]), | ||
| 759 | (901.0, 500.0, 96.0 / 72.0, [648.0, 302.4]), | ||
| 760 | (906.0, 500.0, 96.0 / 72.0, [648.0, 302.4]), | ||
| 761 | (913.0, 500.0, 96.0 / 72.0, [648.0, 302.4]), | ||
| 762 | (900.0, 501.0, 96.0 / 72.0, [639.0, 302.4]), | ||
| 763 | (900.0, 506.0, 96.0 / 72.0, [639.0, 320.4]), | ||
| 764 | (900.0, 520.0, 96.0 / 72.0, [639.0, 320.4]), | ||
| 765 | (900.0, 500.0, 192.0 / 72.0, [324.0, 158.4]), | ||
| 766 | ] { | ||
| 767 | for dpr in [1.0, 2.0] { | ||
| 768 | let viewport = Viewport { | ||
| 769 | size: [(1600.0 * dpr) as u32, (900.0 * dpr) as u32], | ||
| 770 | origin: [48.0 * dpr, 83.0 * dpr], | ||
| 771 | scale: scale * dpr, | ||
| 772 | }; | ||
| 773 | let point = viewport.document_point([x * dpr, y * dpr]); | ||
| 774 | let result = snap_to_grid( | ||
| 775 | [point[0], point[1] - 7.0 * dpr / viewport.scale], | ||
| 776 | DEFAULT_MARGIN, | ||
| 777 | ); | ||
| 778 | for axis in 0..2 { | ||
| 779 | assert!((result[axis] - expected[axis]).abs() < 0.00004); | ||
| 780 | } | ||
| 781 | } | ||
| 782 | } | ||
| 783 | } | ||
| 784 | |||
| 785 | #[test] | ||
| 786 | fn table_glyphs_highlights_and_selection_share_cell_paint_bounds() { | ||
| 787 | let mut engine = TextEngine::default(); | ||
| 788 | let mut editor = CanvasEditor::new( | ||
| 789 | &mut engine, | ||
| 790 | TextDocument::new(vec![Paragraph::new( | ||
| 791 | "M".into(), | ||
| 792 | Format { | ||
| 793 | font_size: Some(130.0), | ||
| 794 | highlight: Some(0xffff), | ||
| 795 | ..Default::default() | ||
| 796 | }, | ||
| 797 | )]) | ||
| 798 | .unwrap(), | ||
| 799 | 180.0, | ||
| 800 | ) | ||
| 801 | .unwrap(); | ||
| 802 | editor | ||
| 803 | .move_selection(&mut engine, Movement::LineEnd, false) | ||
| 804 | .unwrap(); | ||
| 805 | editor.tab(&mut engine, false).unwrap(); | ||
| 806 | editor.insert(&mut engine, "R").unwrap(); | ||
| 807 | editor.tab(&mut engine, true).unwrap(); | ||
| 808 | let mut primitives = Vec::new(); | ||
| 809 | append_outline( | ||
| 810 | Some(&editor), | ||
| 811 | editor.active_outline(), | ||
| 812 | [24.0, 48.0], | ||
| 813 | Paint { | ||
| 814 | show_caret: false, | ||
| 815 | scale: 1.0, | ||
| 816 | pixel: 1.0, | ||
| 817 | colors: COLORS, | ||
| 818 | }, | ||
| 819 | &mut primitives, | ||
| 820 | ) | ||
| 821 | .unwrap(); | ||
| 822 | let bounds = editor.active_outline().shaped().tables[0] | ||
| 823 | .cells | ||
| 824 | .iter() | ||
| 825 | .map(|cell| { | ||
| 826 | let [left, top, right, bottom] = cell.text_bounds(); | ||
| 827 | [left + 24.0, top + 48.0, right + 24.0, bottom + 48.0] | ||
| 828 | }) | ||
| 829 | .collect::<Vec<_>>(); | ||
| 830 | let text = primitives | ||
| 831 | .iter() | ||
| 832 | .filter_map(|primitive| match primitive { | ||
| 833 | Primitive::Text { clip, .. } => Some(clip.unwrap()), | ||
| 834 | _ => None, | ||
| 835 | }) | ||
| 836 | .collect::<Vec<_>>(); | ||
| 837 | assert_eq!(text, bounds); | ||
| 838 | let highlights = primitives | ||
| 839 | .iter() | ||
| 840 | .filter_map(|primitive| match primitive { | ||
| 841 | Primitive::Rect { rect, color } if *color == crate::gpu::colorref(0xffff) => { | ||
| 842 | Some(*rect) | ||
| 843 | } | ||
| 844 | _ => None, | ||
| 845 | }) | ||
| 846 | .collect::<Vec<_>>(); | ||
| 847 | assert_eq!(highlights.len(), 2); | ||
| 848 | for (rect, bounds) in highlights.iter().zip(&bounds) { | ||
| 849 | assert_eq!(rect[2], bounds[2]); | ||
| 850 | assert!(rect[0] >= bounds[0] && rect[1] >= bounds[1] && rect[3] <= bounds[3]); | ||
| 851 | } | ||
| 852 | let selection = primitives | ||
| 853 | .iter() | ||
| 854 | .find_map(|primitive| match primitive { | ||
| 855 | Primitive::Rect { rect, color } if *color == COLORS.selection => Some(*rect), | ||
| 856 | _ => None, | ||
| 857 | }) | ||
| 858 | .unwrap(); | ||
| 859 | assert_eq!(selection[2], bounds[0][2]); | ||
| 860 | assert!(editor.caret(1.0).unwrap().x0 + 24.0 > f64::from(bounds[0][2])); | ||
| 861 | } | ||
| 862 | |||
| 863 | #[test] | ||
| 864 | fn editable_tables_paint_borders_before_selection_and_cell_text() { | ||
| 865 | let mut engine = TextEngine::default(); | ||
| 866 | let mut editor = CanvasEditor::new( | ||
| 867 | &mut engine, | ||
| 868 | TextDocument::new(vec![Paragraph::new("Left".into(), Format::default())]).unwrap(), | ||
| 869 | 180.0, | ||
| 870 | ) | ||
| 871 | .unwrap(); | ||
| 872 | editor | ||
| 873 | .move_selection(&mut engine, Movement::LineEnd, false) | ||
| 874 | .unwrap(); | ||
| 875 | editor.tab(&mut engine, false).unwrap(); | ||
| 876 | editor.insert(&mut engine, "Right").unwrap(); | ||
| 877 | editor.tab(&mut engine, true).unwrap(); | ||
| 878 | let mut primitives = Vec::new(); | ||
| 879 | append_outline( | ||
| 880 | Some(&editor), | ||
| 881 | editor.active_outline(), | ||
| 882 | [24.0, 48.0], | ||
| 883 | Paint { | ||
| 884 | show_caret: false, | ||
| 885 | scale: 1.0, | ||
| 886 | pixel: 1.0, | ||
| 887 | colors: COLORS, | ||
| 888 | }, | ||
| 889 | &mut primitives, | ||
| 890 | ) | ||
| 891 | .unwrap(); | ||
| 892 | let color = crate::gpu::colorref(0x00a3a3a3); | ||
| 893 | assert!(matches!(primitives[0], Primitive::RoundedRect { | ||
| 894 | stroke: Some(crate::gpu::Stroke::Solid(0.75)), color: actual, .. | ||
| 895 | } if actual == color)); | ||
| 896 | assert!(matches!(primitives[1], Primitive::Rect { color: actual, .. } if actual == color)); | ||
| 897 | assert!(matches!(primitives[2], Primitive::Rect { color, .. } if color == COLORS.selection)); | ||
| 898 | let painted = primitives | ||
| 899 | .iter() | ||
| 900 | .filter_map(|primitive| match primitive { | ||
| 901 | Primitive::Text { layout, origin, .. } => Some((layout.id(), *origin)), | ||
| 902 | _ => None, | ||
| 903 | }) | ||
| 904 | .collect::<Vec<_>>(); | ||
| 905 | let expected = editor | ||
| 906 | .active_outline() | ||
| 907 | .layouts() | ||
| 908 | .map(|(_, paragraph)| { | ||
| 909 | ( | ||
| 910 | paragraph.text.id(), | ||
| 911 | [paragraph.origin[0] + 24.0, paragraph.origin[1] + 48.0], | ||
| 912 | ) | ||
| 913 | }) | ||
| 914 | .collect::<Vec<_>>(); | ||
| 915 | assert_eq!(painted, expected); | ||
| 916 | assert_eq!(painted.len(), 2); | ||
| 917 | } | ||
| 918 | |||
| 919 | #[test] | ||
| 920 | fn resize_preview_paints_reflowed_text_without_changing_the_editor() { | ||
| 921 | let mut engine = TextEngine::default(); | ||
| 922 | let editor = CanvasEditor::new( | ||
| 923 | &mut engine, | ||
| 924 | TextDocument::new(vec![Paragraph::new( | ||
| 925 | "A paragraph with enough words to wrap when the resize handle moves inward.".into(), | ||
| 926 | Format::default(), | ||
| 927 | )]) | ||
| 928 | .unwrap(), | ||
| 929 | 240.0, | ||
| 930 | ) | ||
| 931 | .unwrap(); | ||
| 932 | let original = editor.active_outline().snapshot(); | ||
| 933 | let original_layout = editor | ||
| 934 | .active_outline() | ||
| 935 | .paragraph_layout(0) | ||
| 936 | .unwrap() | ||
| 937 | .text | ||
| 938 | .id(); | ||
| 939 | let resized = editor.preview_resize(&mut engine, 72.0).unwrap(); | ||
| 940 | let primitives = page_primitives( | ||
| 941 | &editor, | ||
| 942 | None, | ||
| 943 | Some(PointerFeedback::Resize(&resized)), | ||
| 944 | None, | ||
| 945 | paint(false, 96.0 / 72.0, 1.0), | ||
| 946 | ) | ||
| 947 | .unwrap(); | ||
| 948 | let painted = primitives | ||
| 949 | .iter() | ||
| 950 | .find_map(|primitive| match primitive { | ||
| 951 | Primitive::Text { layout, .. } => Some(*layout), | ||
| 952 | _ => None, | ||
| 953 | }) | ||
| 954 | .unwrap(); | ||
| 955 | assert_eq!(painted.id(), resized.paragraph_layout(0).unwrap().text.id()); | ||
| 956 | assert!( | ||
| 957 | painted.lines().count() | ||
| 958 | > editor | ||
| 959 | .active_outline() | ||
| 960 | .paragraph_layout(0) | ||
| 961 | .unwrap() | ||
| 962 | .text | ||
| 963 | .lines() | ||
| 964 | .count() | ||
| 965 | ); | ||
| 966 | assert_eq!(editor.active_outline().layout(), &original.layout); | ||
| 967 | assert_eq!( | ||
| 968 | editor | ||
| 969 | .active_outline() | ||
| 970 | .paragraph_layout(0) | ||
| 971 | .unwrap() | ||
| 972 | .text | ||
| 973 | .id(), | ||
| 974 | original_layout | ||
| 975 | ); | ||
| 976 | assert!(primitives.iter().any( | ||
| 977 | |primitive| matches!(primitive, Primitive::RoundedRect { stroke, .. } if stroke.is_some()) | ||
| 978 | )); | ||
| 979 | } | ||
| 980 | |||
| 981 | #[test] | ||
| 982 | fn native_chrome_geometry_preserves_text_and_matches_hit_regions_across_zoom_and_dpi() { | ||
| 983 | let mut engine = TextEngine::default(); | ||
| 984 | let format = Format { | ||
| 985 | font: Some("Arial".into()), | ||
| 986 | font_size: Some(11.0), | ||
| 987 | ..Default::default() | ||
| 988 | }; | ||
| 989 | let mut editor = CanvasEditor::new(&mut engine, TextDocument::new(vec![ | ||
| 990 | Paragraph::new("HAMBURGEFONTS abcdefghijklmnopqrstuvwxyz 0123456789 HAMBURGEFONTS abcdefghijklmnopqrstuvwxyz 0123456789 HAMBURGEFONTS abcdefghijklmnopqrstuvwxyz 0123456789".into(), format.clone()), | ||
| 991 | Paragraph::new("Second paragraph abcdefghijklmnopqrstuvwxyz.".into(), format), | ||
| 992 | ]).unwrap(), 220.0).unwrap(); | ||
| 993 | let id = editor.active_outline().id; | ||
| 994 | editor.move_outline(id, [36.0, 90.0]).unwrap(); | ||
| 995 | let document = editor.active_outline().document().clone(); | ||
| 996 | let layout_ids: Vec<_> = editor | ||
| 997 | .active_outline() | ||
| 998 | .layouts() | ||
| 999 | .map(|(_, p)| p.text.id()) | ||
| 1000 | .collect(); | ||
| 1001 | for (zoom, expected) in [ | ||
| 1002 | (0.5, [62.0, 134.0, 223.0, 219.0]), | ||
| 1003 | (1.0, [81.0, 189.0, 394.0, 348.0]), | ||
| 1004 | (1.5, [101.0, 245.0, 565.0, 479.0]), | ||
| 1005 | (2.0, [120.0, 300.0, 736.0, 608.0]), | ||
| 1006 | ] { | ||
| 1007 | for dpr in [1.0, 2.0] { | ||
| 1008 | let scale = zoom * (96.0 / 72.0) * dpr; | ||
| 1009 | let pixel = dpr / scale; | ||
| 1010 | let (frame, body_top) = outline_chrome(editor.active_outline(), pixel); | ||
| 1011 | for (index, coordinate) in frame.iter().enumerate() { | ||
| 1012 | let origin = if index % 2 == 0 { 48.0 } else { 83.0 }; | ||
| 1013 | let screen = coordinate * scale / dpr + origin; | ||
| 1014 | assert!( | ||
| 1015 | (screen - expected[index]).abs() <= 1.5, | ||
| 1016 | "zoom={zoom} dpr={dpr} edge={index}: {screen} vs {}", | ||
| 1017 | expected[index] | ||
| 1018 | ); | ||
| 1019 | } | ||
| 1020 | let header = [36.0, (frame[1] + body_top) / 2.0]; | ||
| 1021 | assert!( | ||
| 1022 | matches!(page_hit_test(&editor, None, header, pixel), Some(Hit::Handle {id: hit, ..}) if hit == id) | ||
| 1023 | ); | ||
| 1024 | assert!(matches!( | ||
| 1025 | page_hit_test(&editor, None, [frame[2] - 4.0 * pixel, header[1]], pixel), | ||
| 1026 | Some(Hit::Resize { id: hit, .. }) if hit == id | ||
| 1027 | )); | ||
| 1028 | let padding = [frame[0] + pixel, 90.0]; | ||
| 1029 | assert!( | ||
| 1030 | matches!(page_hit_test(&editor, None, padding, pixel), Some(Hit::Text {id: hit, ..}) if hit == id) | ||
| 1031 | ); | ||
| 1032 | assert_eq!( | ||
| 1033 | page_hit_test(&editor, None, [frame[0] - pixel, 90.0], pixel), | ||
| 1034 | None | ||
| 1035 | ); | ||
| 1036 | let primitives = | ||
| 1037 | page_primitives(&editor, None, None, None, paint(false, scale, dpr)).unwrap(); | ||
| 1038 | assert!( | ||
| 1039 | matches!(&primitives[0], Primitive::RoundedRect {rect, ..} if *rect == [frame[0], frame[1], frame[2], body_top]) | ||
| 1040 | ); | ||
| 1041 | assert!( | ||
| 1042 | primitives | ||
| 1043 | .iter() | ||
| 1044 | .any(|p| matches!(p, Primitive::Text {origin, ..} if *origin == [36.0, 90.0])) | ||
| 1045 | ); | ||
| 1046 | } | ||
| 1047 | } | ||
| 1048 | assert_eq!(editor.active_outline().document(), &document); | ||
| 1049 | assert_eq!( | ||
| 1050 | editor | ||
| 1051 | .active_outline() | ||
| 1052 | .layouts() | ||
| 1053 | .map(|(_, p)| p.text.id()) | ||
| 1054 | .collect::<Vec<_>>(), | ||
| 1055 | layout_ids | ||
| 1056 | ); | ||
| 1057 | } | ||
| 1058 | |||
| 1059 | #[test] | ||
| 1060 | fn a_later_text_body_takes_priority_over_an_earlier_drag_handle() { | ||
| 1061 | let mut engine = TextEngine::default(); | ||
| 1062 | let mut editor = CanvasEditor::new( | ||
| 1063 | &mut engine, | ||
| 1064 | TextDocument::new(vec![Paragraph::new("first".into(), Default::default())]).unwrap(), | ||
| 1065 | 240.0, | ||
| 1066 | ) | ||
| 1067 | .unwrap(); | ||
| 1068 | let first = editor.active_outline().id; | ||
| 1069 | editor.move_outline(first, [0.0, 20.0]).unwrap(); | ||
| 1070 | assert_eq!( | ||
| 1071 | page_hit_test(&editor, None, [5.0, 12.0], 1.0), | ||
| 1072 | Some(Hit::Handle { | ||
| 1073 | id: first, | ||
| 1074 | grab: [5.0, -8.0] | ||
| 1075 | }) | ||
| 1076 | ); | ||
| 1077 | let second = editor | ||
| 1078 | .create_outline(&mut engine, [0.0, 10.0], 240.0) | ||
| 1079 | .unwrap(); | ||
| 1080 | assert_eq!( | ||
| 1081 | page_hit_test(&editor, None, [5.0, 12.0], 1.0), | ||
| 1082 | Some(Hit::Text { | ||
| 1083 | id: second, | ||
| 1084 | point: [5.0, 2.0] | ||
| 1085 | }) | ||
| 1086 | ); | ||
| 1087 | editor.undo(&mut engine).unwrap(); | ||
| 1088 | assert_eq!( | ||
| 1089 | page_hit_test(&editor, None, [5.0, 12.0], 1.0), | ||
| 1090 | Some(Hit::Handle { | ||
| 1091 | id: first, | ||
| 1092 | grab: [5.0, -8.0] | ||
| 1093 | }) | ||
| 1094 | ); | ||
| 1095 | } | ||
| 1096 | |||
| 1097 | #[test] | ||
| 1098 | fn overlapping_objects_follow_paint_order_through_creation_movement_and_undo() { | ||
| 1099 | use onestore::page::{Outline, PageObject, Unsupported}; | ||
| 1100 | for readonly_on_top in [false, true] { | ||
| 1101 | let mut engine = TextEngine::default(); | ||
| 1102 | let document = TextDocument::new(vec![Paragraph::new( | ||
| 1103 | "Imported text".into(), | ||
| 1104 | Default::default(), | ||
| 1105 | )]) | ||
| 1106 | .unwrap(); | ||
| 1107 | let id = onestore::ExGuid { | ||
| 1108 | guid: [5; 16], | ||
| 1109 | n: 1, | ||
| 1110 | }; | ||
| 1111 | let mut objects = vec![ | ||
| 1112 | PageObject::Outline(Outline { | ||
| 1113 | id, | ||
| 1114 | title: false, | ||
| 1115 | min_width: None, | ||
| 1116 | layout: onestore::document::Layout { | ||
| 1117 | x: Some(30.0), | ||
| 1118 | y: Some(40.0), | ||
| 1119 | max_width: Some(180.0), | ||
| 1120 | ..Default::default() | ||
| 1121 | }, | ||
| 1122 | indents: vec![18.0, 0.0], | ||
| 1123 | paragraphs: document.nodes().to_vec(), | ||
| 1124 | unsupported: Vec::new(), | ||
| 1125 | }), | ||
| 1126 | PageObject::Unsupported(Unsupported { | ||
| 1127 | id: Default::default(), | ||
| 1128 | jcid: 0xdead, | ||
| 1129 | layout: onestore::document::Layout { | ||
| 1130 | x: Some(20.0), | ||
| 1131 | y: Some(20.0), | ||
| 1132 | max_width: Some(200.0), | ||
| 1133 | max_height: Some(100.0), | ||
| 1134 | ..Default::default() | ||
| 1135 | }, | ||
| 1136 | }), | ||
| 1137 | ]; | ||
| 1138 | if !readonly_on_top { | ||
| 1139 | objects.reverse(); | ||
| 1140 | } | ||
| 1141 | let page = Page { | ||
| 1142 | identity: None, | ||
| 1143 | created: None, | ||
| 1144 | title: String::new(), | ||
| 1145 | margin_origin: [0.0; 2], | ||
| 1146 | definitions: Default::default(), | ||
| 1147 | objects, | ||
| 1148 | }; | ||
| 1149 | let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap(); | ||
| 1150 | let scene = (scene, [0.0; 2]); | ||
| 1151 | let imported_layout = editor | ||
| 1152 | .active_outline() | ||
| 1153 | .layouts() | ||
| 1154 | .next() | ||
| 1155 | .unwrap() | ||
| 1156 | .1 | ||
| 1157 | .text | ||
| 1158 | .id(); | ||
| 1159 | let texts: Vec<_> = | ||
| 1160 | page_primitives(&editor, Some(&scene), None, None, paint(false, 1.0, 1.0)) | ||
| 1161 | .unwrap() | ||
| 1162 | .into_iter() | ||
| 1163 | .filter_map(|p| match p { | ||
| 1164 | Primitive::Text { layout, .. } => Some(layout.id()), | ||
| 1165 | _ => None, | ||
| 1166 | }) | ||
| 1167 | .collect(); | ||
| 1168 | assert_eq!(texts.len(), 2); | ||
| 1169 | assert_eq!(texts[usize::from(!readonly_on_top)], imported_layout); | ||
| 1170 | let expected = if readonly_on_top { | ||
| 1171 | Hit::ReadOnly(0) | ||
| 1172 | } else { | ||
| 1173 | Hit::Text { | ||
| 1174 | id, | ||
| 1175 | point: [10.0, 5.0], | ||
| 1176 | } | ||
| 1177 | }; | ||
| 1178 | assert_eq!( | ||
| 1179 | page_hit_test(&editor, Some(&scene), [40.0, 45.0], 1.0), | ||
| 1180 | Some(expected) | ||
| 1181 | ); | ||
| 1182 | let header = page_hit_test(&editor, Some(&scene), [40.0, 32.0], 1.0); | ||
| 1183 | if readonly_on_top { | ||
| 1184 | assert_eq!(header, Some(Hit::ReadOnly(0))); | ||
| 1185 | } else { | ||
| 1186 | assert_eq!( | ||
| 1187 | header, | ||
| 1188 | Some(Hit::Handle { | ||
| 1189 | id, | ||
| 1190 | grab: [10.0, -8.0] | ||
| 1191 | }) | ||
| 1192 | ); | ||
| 1193 | } | ||
| 1194 | let annotation = editor | ||
| 1195 | .create_outline(&mut engine, [30.0, 40.0], 180.0) | ||
| 1196 | .unwrap(); | ||
| 1197 | assert_eq!( | ||
| 1198 | page_hit_test(&editor, Some(&scene), [40.0, 45.0], 1.0), | ||
| 1199 | Some(Hit::Text { | ||
| 1200 | id: annotation, | ||
| 1201 | point: [10.0, 5.0] | ||
| 1202 | }) | ||
| 1203 | ); | ||
| 1204 | assert_eq!( | ||
| 1205 | page_hit_test(&editor, Some(&scene), [40.0, 32.0], 1.0), | ||
| 1206 | Some(Hit::Handle { | ||
| 1207 | id: annotation, | ||
| 1208 | grab: [10.0, -8.0] | ||
| 1209 | }) | ||
| 1210 | ); | ||
| 1211 | let texts: Vec<_> = | ||
| 1212 | page_primitives(&editor, Some(&scene), None, None, paint(false, 1.0, 1.0)) | ||
| 1213 | .unwrap() | ||
| 1214 | .into_iter() | ||
| 1215 | .filter_map(|p| match p { | ||
| 1216 | Primitive::Text { layout, .. } => Some(layout.id()), | ||
| 1217 | _ => None, | ||
| 1218 | }) | ||
| 1219 | .collect(); | ||
| 1220 | assert_eq!( | ||
| 1221 | *texts.last().unwrap(), | ||
| 1222 | editor | ||
| 1223 | .active_outline() | ||
| 1224 | .layouts() | ||
| 1225 | .next() | ||
| 1226 | .unwrap() | ||
| 1227 | .1 | ||
| 1228 | .text | ||
| 1229 | .id() | ||
| 1230 | ); | ||
| 1231 | editor.move_outline(annotation, [500.0, 500.0]).unwrap(); | ||
| 1232 | assert!( | ||
| 1233 | !matches!(page_hit_test(&editor, Some(&scene), [40.0,45.0], 1.0), Some(Hit::Text { id, .. }) if id == annotation) | ||
| 1234 | ); | ||
| 1235 | editor.undo(&mut engine).unwrap(); | ||
| 1236 | assert_eq!( | ||
| 1237 | page_hit_test(&editor, Some(&scene), [40.0, 45.0], 1.0), | ||
| 1238 | Some(Hit::Text { | ||
| 1239 | id: annotation, | ||
| 1240 | point: [10.0, 5.0] | ||
| 1241 | }) | ||
| 1242 | ); | ||
| 1243 | editor.undo(&mut engine).unwrap(); | ||
| 1244 | assert_eq!(editor.outlines().len(), 1); | ||
| 1245 | assert_eq!(editor.active_outline().document(), &document); | ||
| 1246 | assert_eq!( | ||
| 1247 | page_hit_test(&editor, Some(&scene), [10.0, 10.0], 1.0), | ||
| 1248 | None | ||
| 1249 | ); | ||
| 1250 | } | ||
| 1251 | } | ||
| 1252 | |||
| 1253 | #[test] | ||
| 1254 | fn read_only_focus_accepts_navigation_without_edit_shortcuts() { | ||
| 1255 | for modifiers in [ | ||
| 1256 | Modifiers::default(), | ||
| 1257 | Modifiers { | ||
| 1258 | shift: true, | ||
| 1259 | ..Modifiers::default() | ||
| 1260 | }, | ||
| 1261 | Modifiers { | ||
| 1262 | option: true, | ||
| 1263 | ..Modifiers::default() | ||
| 1264 | }, | ||
| 1265 | Modifiers { | ||
| 1266 | command: true, | ||
| 1267 | ..Modifiers::default() | ||
| 1268 | }, | ||
| 1269 | Modifiers { | ||
| 1270 | command: true, | ||
| 1271 | shift: true, | ||
| 1272 | ..Modifiers::default() | ||
| 1273 | }, | ||
| 1274 | Modifiers { | ||
| 1275 | command: true, | ||
| 1276 | option: true, | ||
| 1277 | ..Modifiers::default() | ||
| 1278 | }, | ||
| 1279 | ] { | ||
| 1280 | for key in [ | ||
| 1281 | Key::Character("a".into()), | ||
| 1282 | Key::Character("v".into()), | ||
| 1283 | Key::Character("c".into()), | ||
| 1284 | Key::Character("x".into()), | ||
| 1285 | Key::Character("z".into()), | ||
| 1286 | Key::Character("🌳".into()), | ||
| 1287 | Key::Named(NamedKey::Backspace), | ||
| 1288 | Key::Named(NamedKey::Delete), | ||
| 1289 | Key::Named(NamedKey::Enter), | ||
| 1290 | Key::Named(NamedKey::ArrowLeft), | ||
| 1291 | Key::Named(NamedKey::ArrowRight), | ||
| 1292 | Key::Named(NamedKey::ArrowUp), | ||
| 1293 | Key::Named(NamedKey::ArrowDown), | ||
| 1294 | ] { | ||
| 1295 | assert!( | ||
| 1296 | !read_only_shortcut(&key, modifiers), | ||
| 1297 | "{key:?} {modifiers:?}" | ||
| 1298 | ); | ||
| 1299 | } | ||
| 1300 | } | ||
| 1301 | for (key, modifiers) in [ | ||
| 1302 | (Key::Named(NamedKey::Escape), Modifiers::default()), | ||
| 1303 | ( | ||
| 1304 | Key::Named(NamedKey::Tab), | ||
| 1305 | Modifiers { | ||
| 1306 | control: true, | ||
| 1307 | ..Modifiers::default() | ||
| 1308 | }, | ||
| 1309 | ), | ||
| 1310 | ( | ||
| 1311 | Key::Named(NamedKey::Tab), | ||
| 1312 | Modifiers { | ||
| 1313 | control: true, | ||
| 1314 | shift: true, | ||
| 1315 | ..Modifiers::default() | ||
| 1316 | }, | ||
| 1317 | ), | ||
| 1318 | ( | ||
| 1319 | Key::Character("N".into()), | ||
| 1320 | Modifiers { | ||
| 1321 | command: true, | ||
| 1322 | shift: true, | ||
| 1323 | ..Modifiers::default() | ||
| 1324 | }, | ||
| 1325 | ), | ||
| 1326 | ( | ||
| 1327 | Key::Character("+".into()), | ||
| 1328 | Modifiers { | ||
| 1329 | command: true, | ||
| 1330 | ..Modifiers::default() | ||
| 1331 | }, | ||
| 1332 | ), | ||
| 1333 | ( | ||
| 1334 | Key::Character("0".into()), | ||
| 1335 | Modifiers { | ||
| 1336 | command: true, | ||
| 1337 | ..Modifiers::default() | ||
| 1338 | }, | ||
| 1339 | ), | ||
| 1340 | ( | ||
| 1341 | Key::Character("-".into()), | ||
| 1342 | Modifiers { | ||
| 1343 | command: true, | ||
| 1344 | ..Modifiers::default() | ||
| 1345 | }, | ||
| 1346 | ), | ||
| 1347 | ] { | ||
| 1348 | assert!(read_only_shortcut(&key, modifiers)); | ||
| 1349 | } | ||
| 1350 | assert!(!read_only_shortcut( | ||
| 1351 | &Key::Character("n".into()), | ||
| 1352 | Modifiers { | ||
| 1353 | command: true, | ||
| 1354 | ..Modifiers::default() | ||
| 1355 | } | ||
| 1356 | )); | ||
| 1357 | assert!(!read_only_shortcut( | ||
| 1358 | &Key::Named(NamedKey::Tab), | ||
| 1359 | Modifiers::default() | ||
| 1360 | )); | ||
| 1361 | } | ||
| 1362 | |||
| 1363 | #[test] | ||
| 1364 | fn read_only_focus_retires_text_overlays_and_draws_a_scaled_focus_border() { | ||
| 1365 | use onestore::page::{PageObject, Unsupported}; | ||
| 1366 | let mut engine = TextEngine::default(); | ||
| 1367 | let mut editor = CanvasEditor::new( | ||
| 1368 | &mut engine, | ||
| 1369 | TextDocument::new(vec![Paragraph::new("Draft".into(), Default::default())]).unwrap(), | ||
| 1370 | 240.0, | ||
| 1371 | ) | ||
| 1372 | .unwrap(); | ||
| 1373 | let page = Page { | ||
| 1374 | identity: None, | ||
| 1375 | created: None, | ||
| 1376 | title: String::new(), | ||
| 1377 | margin_origin: [0.0; 2], | ||
| 1378 | definitions: Default::default(), | ||
| 1379 | objects: vec![PageObject::Unsupported(Unsupported { | ||
| 1380 | id: Default::default(), | ||
| 1381 | jcid: 0xdead, | ||
| 1382 | layout: onestore::document::Layout { | ||
| 1383 | x: Some(100.0), | ||
| 1384 | y: Some(80.0), | ||
| 1385 | max_width: Some(180.0), | ||
| 1386 | max_height: Some(80.0), | ||
| 1387 | ..Default::default() | ||
| 1388 | }, | ||
| 1389 | })], | ||
| 1390 | }; | ||
| 1391 | let scene = (PageScene::new(page, &mut engine).unwrap(), [20.0, 30.0]); | ||
| 1392 | let original = editor.active_outline().document().clone(); | ||
| 1393 | let rectangles = |primitives: Vec<Primitive<'_>>| { | ||
| 1394 | primitives | ||
| 1395 | .into_iter() | ||
| 1396 | .filter_map(|p| match p { | ||
| 1397 | Primitive::Rect { rect, color } | Primitive::RoundedRect { rect, color, .. } => { | ||
| 1398 | Some((rect, color)) | ||
| 1399 | } | ||
| 1400 | _ => None, | ||
| 1401 | }) | ||
| 1402 | .collect::<Vec<_>>() | ||
| 1403 | }; | ||
| 1404 | for scale in [1.0, 2.0] { | ||
| 1405 | let text = rectangles( | ||
| 1406 | page_primitives(&editor, Some(&scene), None, None, paint(true, scale, 1.0)).unwrap(), | ||
| 1407 | ); | ||
| 1408 | assert!(text.iter().any(|(_, color)| *color == COLORS.caret)); | ||
| 1409 | let selected = rectangles( | ||
| 1410 | page_primitives( | ||
| 1411 | &editor, | ||
| 1412 | Some(&scene), | ||
| 1413 | None, | ||
| 1414 | Some(ObjectFocus::ReadOnly(0)), | ||
| 1415 | paint(true, scale, 1.0), | ||
| 1416 | ) | ||
| 1417 | .unwrap(), | ||
| 1418 | ); | ||
| 1419 | assert!(!selected.iter().any(|(_, color)| *color == COLORS.caret)); | ||
| 1420 | assert_eq!( | ||
| 1421 | selected, | ||
| 1422 | rectangles( | ||
| 1423 | page_primitives( | ||
| 1424 | &editor, | ||
| 1425 | Some(&scene), | ||
| 1426 | None, | ||
| 1427 | Some(ObjectFocus::ReadOnly(0)), | ||
| 1428 | paint(false, scale, 1.0) | ||
| 1429 | ) | ||
| 1430 | .unwrap() | ||
| 1431 | ) | ||
| 1432 | ); | ||
| 1433 | assert_eq!( | ||
| 1434 | selected.last().unwrap().0, | ||
| 1435 | [300.0 - 2.0 / scale, 110.0, 300.0, 190.0] | ||
| 1436 | ); | ||
| 1437 | } | ||
| 1438 | editor.select_all().unwrap(); | ||
| 1439 | let text = rectangles( | ||
| 1440 | page_primitives(&editor, Some(&scene), None, None, paint(false, 1.0, 1.0)).unwrap(), | ||
| 1441 | ); | ||
| 1442 | assert!(text.iter().any(|(_, color)| *color == COLORS.selection)); | ||
| 1443 | let selected = rectangles( | ||
| 1444 | page_primitives( | ||
| 1445 | &editor, | ||
| 1446 | Some(&scene), | ||
| 1447 | None, | ||
| 1448 | Some(ObjectFocus::ReadOnly(0)), | ||
| 1449 | paint(true, 1.0, 1.0), | ||
| 1450 | ) | ||
| 1451 | .unwrap(), | ||
| 1452 | ); | ||
| 1453 | assert!(!selected.iter().any(|(_, color)| *color == COLORS.selection)); | ||
| 1454 | assert_eq!(editor.active_outline().document(), &original); | ||
| 1455 | } | ||
| 1456 | |||
| 1457 | /// A view at 100% on a 1x display over one outline holding "Before", a 40 x 30 pt picture and | ||
| 1458 | /// "After" at (36, 36), with the picture's identity. | ||
| 1459 | fn picture_view() -> (PageView, onestore::ExGuid) { | ||
| 1460 | use onestore::page::{Image, PageObject, ParagraphContent}; | ||
| 1461 | let mut engine = TextEngine::default(); | ||
| 1462 | let mut source = TextOutline::new( | ||
| 1463 | &mut engine, | ||
| 1464 | TextDocument::new(vec![ | ||
| 1465 | Paragraph::new("Before".into(), Default::default()), | ||
| 1466 | Paragraph::new("After".into(), Default::default()), | ||
| 1467 | ]) | ||
| 1468 | .unwrap(), | ||
| 1469 | 240.0, | ||
| 1470 | [36.0, 36.0], | ||
| 1471 | ) | ||
| 1472 | .unwrap() | ||
| 1473 | .snapshot(); | ||
| 1474 | let mut picture = source.paragraphs[0].clone(); | ||
| 1475 | picture.id = onestore::page::text::new_id().unwrap(); | ||
| 1476 | let id = onestore::page::text::new_id().unwrap(); | ||
| 1477 | picture.content = ParagraphContent::Image(Image { | ||
| 1478 | size: Some([40.0, 30.0]), | ||
| 1479 | id, | ||
| 1480 | layout: Default::default(), | ||
| 1481 | bytes: None, | ||
| 1482 | alt: None, | ||
| 1483 | background: false, | ||
| 1484 | }); | ||
| 1485 | source.paragraphs.insert(1, picture); | ||
| 1486 | let editor = CanvasEditor::from_page( | ||
| 1487 | Page { | ||
| 1488 | title: String::new(), | ||
| 1489 | identity: None, | ||
| 1490 | created: None, | ||
| 1491 | margin_origin: [36.0, 14.4], | ||
| 1492 | definitions: Default::default(), | ||
| 1493 | objects: vec![PageObject::Outline(source)], | ||
| 1494 | }, | ||
| 1495 | &mut engine, | ||
| 1496 | ) | ||
| 1497 | .unwrap(); | ||
| 1498 | let mut view = PageView::new( | ||
| 1499 | editor, | ||
| 1500 | engine, | ||
| 1501 | None, | ||
| 1502 | [800, 600], | ||
| 1503 | 1.0, | ||
| 1504 | Duration::from_millis(500), | ||
| 1505 | ); | ||
| 1506 | view.viewport.scale = 1.0; | ||
| 1507 | view.viewport.origin = [0.0; 2]; | ||
| 1508 | (view, id) | ||
| 1509 | } | ||
| 1510 | |||
| 1511 | fn click(view: &mut PageView, point: [f32; 2], now: Instant) { | ||
| 1512 | let _ = view.pointer_moved(point).unwrap(); | ||
| 1513 | assert!(view.pointer_pressed(now).unwrap().changed); | ||
| 1514 | assert!(view.pointer_released().unwrap().changed); | ||
| 1515 | } | ||
| 1516 | |||
| 1517 | #[test] | ||
| 1518 | fn events_route_through_the_view_as_the_host_delivers_them() { | ||
| 1519 | let (mut view, picture) = picture_view(); | ||
| 1520 | let now = Instant::now(); | ||
| 1521 | let outline = view.editor.active_outline().id; | ||
| 1522 | let bounds = view.editor.active_outline().bounds(); | ||
| 1523 | |||
| 1524 | // Typing at a clicked caret edits the outline and asks for nothing else. | ||
| 1525 | click( | ||
| 1526 | &mut view, | ||
| 1527 | [bounds.x0 as f32 + 2.0, bounds.y0 as f32 + 6.0], | ||
| 1528 | now, | ||
| 1529 | ); | ||
| 1530 | assert_eq!(view.cursor(), Cursor::Text); | ||
| 1531 | let typed = view.key(&Key::Character("X".into()), Some("X")).unwrap(); | ||
| 1532 | assert!(typed.changed && typed.request.is_none()); | ||
| 1533 | assert!( | ||
| 1534 | view.editor | ||
| 1535 | .active_outline() | ||
| 1536 | .document() | ||
| 1537 | .paragraphs() | ||
| 1538 | .next() | ||
| 1539 | .unwrap() | ||
| 1540 | .text() | ||
| 1541 | .starts_with('X') | ||
| 1542 | ); | ||
| 1543 | |||
| 1544 | // Command-C asks the host to copy; Command-V asks it to paste, then commits its text. | ||
| 1545 | let _ = view | ||
| 1546 | .modifiers_changed(Modifiers { | ||
| 1547 | shift: true, | ||
| 1548 | ..Modifiers::default() | ||
| 1549 | }) | ||
| 1550 | .unwrap(); | ||
| 1551 | let _ = view.key(&Key::Named(NamedKey::ArrowRight), None).unwrap(); | ||
| 1552 | let _ = view | ||
| 1553 | .modifiers_changed(Modifiers { | ||
| 1554 | command: true, | ||
| 1555 | ..Modifiers::default() | ||
| 1556 | }) | ||
| 1557 | .unwrap(); | ||
| 1558 | assert_eq!( | ||
| 1559 | view.key(&Key::Character("c".into()), None).unwrap().request, | ||
| 1560 | Some(Request::Copy("B".into())) | ||
| 1561 | ); | ||
| 1562 | assert_eq!( | ||
| 1563 | view.key(&Key::Character("v".into()), None).unwrap().request, | ||
| 1564 | Some(Request::Paste) | ||
| 1565 | ); | ||
| 1566 | assert!(view.commit_text("line\nbreak".into()).unwrap().changed); | ||
| 1567 | assert_eq!( | ||
| 1568 | view.editor.active_outline().document().paragraphs().count(), | ||
| 1569 | 3 | ||
| 1570 | ); | ||
| 1571 | let _ = view.modifiers_changed(Modifiers::default()).unwrap(); | ||
| 1572 | |||
| 1573 | // A click on the picture selects it, stops text input, and Delete removes it. | ||
| 1574 | let (origin, size) = view.editor.image_placement(picture).unwrap(); | ||
| 1575 | let center = [origin[0] + size[0] / 2.0, origin[1] + size[1] / 2.0]; | ||
| 1576 | click(&mut view, center, now + Duration::from_secs(1)); | ||
| 1577 | assert_eq!(view.object_focus(), Some(ObjectFocus::Image(picture))); | ||
| 1578 | assert!(!view.accepts_text()); | ||
| 1579 | assert_eq!(view.cursor(), Cursor::Move); | ||
| 1580 | assert_eq!( | ||
| 1581 | view.commit_text("ignored".into()).unwrap(), | ||
| 1582 | Response::default() | ||
| 1583 | ); | ||
| 1584 | assert!( | ||
| 1585 | view.key(&Key::Named(NamedKey::Delete), None) | ||
| 1586 | .unwrap() | ||
| 1587 | .changed | ||
| 1588 | ); | ||
| 1589 | assert!(view.editor.image_placement(picture).is_none()); | ||
| 1590 | assert!(view.accepts_text()); | ||
| 1591 | |||
| 1592 | // The header drags the outline onto the grid anchored at the page margin. | ||
| 1593 | let bounds = view.editor.active_outline().bounds(); | ||
| 1594 | let header = [(bounds.x0 + bounds.x1) as f32 / 2.0, bounds.y0 as f32 - 8.0]; | ||
| 1595 | let _ = view.pointer_moved(header).unwrap(); | ||
| 1596 | let _ = view.pointer_pressed(now + Duration::from_secs(2)).unwrap(); | ||
| 1597 | assert_eq!(view.cursor(), Cursor::Move); | ||
| 1598 | let _ = view | ||
| 1599 | .pointer_moved([header[0] + 30.0, header[1] + 20.0]) | ||
| 1600 | .unwrap(); | ||
| 1601 | assert_eq!(view.outline_preview().map(|(id, _)| id), Some(outline)); | ||
| 1602 | let _ = view.pointer_released().unwrap(); | ||
| 1603 | let origin = view.editor.active_outline().origin(); | ||
| 1604 | assert_eq!(origin, [72.0, 50.4]); | ||
| 1605 | |||
| 1606 | // An empty spot places a new caret there, 7 px above the pointer on the grid. | ||
| 1607 | click(&mut view, [400.0, 300.0], now + Duration::from_secs(3)); | ||
| 1608 | let caret = view.editor.caret_outline().unwrap().origin(); | ||
| 1609 | assert_eq!(caret, snap_to_grid([400.0, 293.0], [36.0, 14.4])); | ||
| 1610 | } | ||
crates/canvas/src/lib.rs+2| ... | @@ -7,6 +7,8 @@ pub mod outline; | ... | @@ -7,6 +7,8 @@ pub mod outline; |
| 7 | pub mod date; | 7 | pub mod date; |
| 8 | #[cfg(feature = "gpu")] | 8 | #[cfg(feature = "gpu")] |
| 9 | pub mod gpu; | 9 | pub mod gpu; |
| 10 | #[cfg(feature = "interaction")] | ||
| 11 | pub mod interaction; | ||
| 10 | 12 | ||
| 11 | /// Parley's caret affinity mapped onto the page model's hidden-field affinity. | 13 | /// Parley's caret affinity mapped onto the page model's hidden-field affinity. |
| 12 | pub fn affinity(affinity: parley::Affinity) -> onestore::page::text::Affinity { | 14 | pub fn affinity(affinity: parley::Affinity) -> onestore::page::text::Affinity { |
crates/snowbound/Cargo.toml+1-3| ... | @@ -5,7 +5,7 @@ edition = "2024" | ... | @@ -5,7 +5,7 @@ edition = "2024" |
| 5 | publish = false | 5 | publish = false |
| 6 | 6 | ||
| 7 | [dependencies] | 7 | [dependencies] |
| 8 | canvas = { path = "../canvas", features = ["gpu"] } | 8 | canvas = { path = "../canvas", features = ["interaction"] } |
| 9 | onestore = { path = "../onestore" } | 9 | onestore = { path = "../onestore" } |
| 10 | notebook = { path = "../notebook" } | 10 | notebook = { path = "../notebook" } |
| 11 | pollster = "0.4" | 11 | pollster = "0.4" |
| ... | @@ -19,5 +19,3 @@ objc2 = "0.5.2" | ... | @@ -19,5 +19,3 @@ objc2 = "0.5.2" |
| 19 | objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSAlert", "NSButton", "NSControl", "NSView", "NSResponder", "NSWindow", "NSApplication", "NSEvent", "NSColor", "NSColorSpace", "NSDatePicker", "NSDatePickerCell"] } | 19 | objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSAlert", "NSButton", "NSControl", "NSView", "NSResponder", "NSWindow", "NSApplication", "NSEvent", "NSColor", "NSColorSpace", "NSDatePicker", "NSDatePickerCell"] } |
| 20 | objc2-foundation = { version = "0.2.2", default-features = false, features = ["NSString", "NSThread", "NSAttributedString", "NSDate", "NSCalendar", "NSDateFormatter", "NSLocale", "NSTimeZone"] } | 20 | objc2-foundation = { version = "0.2.2", default-features = false, features = ["NSString", "NSThread", "NSAttributedString", "NSDate", "NSCalendar", "NSDateFormatter", "NSLocale", "NSTimeZone"] } |
| 21 | 21 | ||
| 22 | [dev-dependencies] | ||
| 23 | accesskit_consumer = "0.39.0" |
crates/snowbound/src/accessibility.rs deleted-1478| ... | @@ -1,1478 +0,0 @@ | ||
| 1 | use accesskit::{ | ||
| 2 | Action, Affine, Node, NodeId, Rect, Role, TextDirection, TextSelection, TreeId, TreeInfo, | ||
| 3 | TreeUpdate, | ||
| 4 | }; | ||
| 5 | use canvas::gpu::Viewport; | ||
| 6 | use canvas::{ | ||
| 7 | date::DateField, | ||
| 8 | document::TextPosition, | ||
| 9 | editor::{CanvasEditor, Selection, TextOutline}, | ||
| 10 | }; | ||
| 11 | use onestore::page::text::EditError; | ||
| 12 | use parley::Affinity; | ||
| 13 | use std::{collections::HashMap, ops::Range}; | ||
| 14 | |||
| 15 | pub const ROOT: NodeId = NodeId(0); | ||
| 16 | |||
| 17 | struct Run { | ||
| 18 | outline: onestore::ExGuid, | ||
| 19 | id: NodeId, | ||
| 20 | node: Node, | ||
| 21 | positions: Vec<TextPosition>, | ||
| 22 | } | ||
| 23 | |||
| 24 | #[derive(Hash, PartialEq, Eq)] | ||
| 25 | struct ParagraphKey { | ||
| 26 | layout: u64, | ||
| 27 | source: usize, | ||
| 28 | next: Option<usize>, | ||
| 29 | origin: [u32; 2], | ||
| 30 | } | ||
| 31 | |||
| 32 | pub struct Accessibility { | ||
| 33 | outlines: Vec<(onestore::ExGuid, NodeId)>, | ||
| 34 | runs: Vec<Run>, | ||
| 35 | paragraphs: HashMap<ParagraphKey, Range<usize>>, | ||
| 36 | read_only: Vec<NodeId>, | ||
| 37 | dates: Vec<(DateField, NodeId)>, | ||
| 38 | next_id: u64, | ||
| 39 | } | ||
| 40 | |||
| 41 | impl Default for Accessibility { | ||
| 42 | fn default() -> Self { | ||
| 43 | Self { | ||
| 44 | outlines: Vec::new(), | ||
| 45 | runs: Vec::new(), | ||
| 46 | paragraphs: HashMap::new(), | ||
| 47 | read_only: Vec::new(), | ||
| 48 | dates: Vec::new(), | ||
| 49 | next_id: 1, | ||
| 50 | } | ||
| 51 | } | ||
| 52 | } | ||
| 53 | |||
| 54 | impl Accessibility { | ||
| 55 | /// Retire published runs without recycling their IDs. | ||
| 56 | pub fn deactivate(&mut self) { | ||
| 57 | self.runs.clear(); | ||
| 58 | self.paragraphs.clear(); | ||
| 59 | self.outlines.clear(); | ||
| 60 | self.read_only.clear(); | ||
| 61 | self.dates.clear(); | ||
| 62 | } | ||
| 63 | |||
| 64 | pub fn append_page_fields( | ||
| 65 | &mut self, | ||
| 66 | update: &mut TreeUpdate, | ||
| 67 | scene: Option<&(canvas::gpu::page::PageScene, [f32; 2])>, | ||
| 68 | editor: &CanvasEditor, | ||
| 69 | viewport: Viewport, | ||
| 70 | focus: Option<usize>, | ||
| 71 | ) { | ||
| 72 | let mut count = 0; | ||
| 73 | if let Some((scene, offset)) = scene { | ||
| 74 | for (index, object) in scene.read_only(Some(editor)).enumerate() { | ||
| 75 | if index == self.read_only.len() { | ||
| 76 | let id = self.allocate(); | ||
| 77 | self.read_only.push(id); | ||
| 78 | } | ||
| 79 | let id = self.read_only[index]; | ||
| 80 | let mut node = Node::new(Role::Label); | ||
| 81 | node.set_value(object.message); | ||
| 82 | node.set_read_only(); | ||
| 83 | node.add_action(Action::Focus); | ||
| 84 | if focus == Some(index) { | ||
| 85 | update.focus = id; | ||
| 86 | } | ||
| 87 | let [x0, y0, x1, y1] = object.rect(); | ||
| 88 | let x = |v| f64::from((v + offset[0]) * viewport.scale + viewport.origin[0]); | ||
| 89 | let y = |v| f64::from((v + offset[1]) * viewport.scale + viewport.origin[1]); | ||
| 90 | node.set_bounds(Rect::new(x(x0), y(y0), x(x1), y(y1))); | ||
| 91 | update | ||
| 92 | .nodes | ||
| 93 | .iter_mut() | ||
| 94 | .find(|(id, _)| *id == ROOT) | ||
| 95 | .unwrap() | ||
| 96 | .1 | ||
| 97 | .push_child(id); | ||
| 98 | update.nodes.push((id, node)); | ||
| 99 | count += 1; | ||
| 100 | } | ||
| 101 | } | ||
| 102 | self.read_only.truncate(count); | ||
| 103 | let mut date_count = 0; | ||
| 104 | if let Some((scene, offset)) = scene { | ||
| 105 | for (index, (field, rect)) in scene.date_fields(editor).enumerate() { | ||
| 106 | if index == self.dates.len() { | ||
| 107 | let id = self.allocate(); | ||
| 108 | self.dates.push((field, id)); | ||
| 109 | } | ||
| 110 | self.dates[index].0 = field; | ||
| 111 | let id = self.dates[index].1; | ||
| 112 | let mut node = Node::new(Role::Button); | ||
| 113 | node.set_label(crate::DATE_LABELS[field as usize]); | ||
| 114 | node.set_value( | ||
| 115 | editor.date().unwrap().source().paragraphs[index] | ||
| 116 | .text() | ||
| 117 | .unwrap() | ||
| 118 | .text | ||
| 119 | .text(), | ||
| 120 | ); | ||
| 121 | node.add_action(Action::Click); | ||
| 122 | let x = |v| f64::from((v + offset[0]) * viewport.scale + viewport.origin[0]); | ||
| 123 | let y = |v| f64::from((v + offset[1]) * viewport.scale + viewport.origin[1]); | ||
| 124 | node.set_bounds(Rect::new(x(rect[0]), y(rect[1]), x(rect[2]), y(rect[3]))); | ||
| 125 | update | ||
| 126 | .nodes | ||
| 127 | .iter_mut() | ||
| 128 | .find(|(id, _)| *id == ROOT) | ||
| 129 | .unwrap() | ||
| 130 | .1 | ||
| 131 | .push_child(id); | ||
| 132 | update.nodes.push((id, node)); | ||
| 133 | date_count += 1; | ||
| 134 | } | ||
| 135 | } | ||
| 136 | self.dates.truncate(date_count); | ||
| 137 | } | ||
| 138 | |||
| 139 | fn allocate(&mut self) -> NodeId { | ||
| 140 | let id = NodeId(self.next_id); | ||
| 141 | self.next_id = self | ||
| 142 | .next_id | ||
| 143 | .checked_add(1) | ||
| 144 | .expect("Accessibility node IDs exhausted"); | ||
| 145 | id | ||
| 146 | } | ||
| 147 | |||
| 148 | pub fn read_only_for_node(&self, node: NodeId) -> Option<usize> { | ||
| 149 | self.read_only.iter().position(|id| *id == node) | ||
| 150 | } | ||
| 151 | |||
| 152 | pub fn date_for_node(&self, node: NodeId) -> Option<DateField> { | ||
| 153 | self.dates | ||
| 154 | .iter() | ||
| 155 | .find(|(_, id)| *id == node) | ||
| 156 | .map(|(field, _)| *field) | ||
| 157 | } | ||
| 158 | |||
| 159 | pub fn outline_for_node(&self, node: NodeId) -> Option<onestore::ExGuid> { | ||
| 160 | self.outlines | ||
| 161 | .iter() | ||
| 162 | .find(|(_, id)| *id == node) | ||
| 163 | .map(|(outline, _)| *outline) | ||
| 164 | } | ||
| 165 | |||
| 166 | pub fn update( | ||
| 167 | &mut self, | ||
| 168 | editor: &CanvasEditor, | ||
| 169 | viewport: Viewport, | ||
| 170 | title: &str, | ||
| 171 | preview: Option<(onestore::ExGuid, [f32; 2])>, | ||
| 172 | ) -> Result<TreeUpdate, EditError> { | ||
| 173 | let mut runs = Vec::new(); | ||
| 174 | let mut outlines = Vec::new(); | ||
| 175 | let mut paragraph_ranges = HashMap::new(); | ||
| 176 | for outline in editor.visible_outlines().chain(editor.caret_outline()) { | ||
| 177 | let field = self | ||
| 178 | .outlines | ||
| 179 | .iter() | ||
| 180 | .find(|(id, _)| *id == outline.id) | ||
| 181 | .map(|(_, node)| *node) | ||
| 182 | .unwrap_or_else(|| self.allocate()); | ||
| 183 | outlines.push((outline.id, field)); | ||
| 184 | let mut paragraphs = outline.layouts().peekable(); | ||
| 185 | while let Some((paragraph, shaped)) = paragraphs.next() { | ||
| 186 | let layout = &shaped.text; | ||
| 187 | let origin = shaped.origin; | ||
| 188 | let key = ParagraphKey { | ||
| 189 | layout: layout.id(), | ||
| 190 | source: paragraph, | ||
| 191 | next: paragraphs.peek().map(|(next, _)| *next), | ||
| 192 | origin: origin.map(f32::to_bits), | ||
| 193 | }; | ||
| 194 | let start = runs.len(); | ||
| 195 | if let Some(range) = self.paragraphs.get(&key) { | ||
| 196 | runs.extend( | ||
| 197 | self.runs[range.clone()] | ||
| 198 | .iter() | ||
| 199 | .map(|run| (run.outline, run.node.clone(), run.positions.clone())), | ||
| 200 | ); | ||
| 201 | paragraph_ranges.insert(key, start..runs.len()); | ||
| 202 | continue; | ||
| 203 | } | ||
| 204 | let projection = &shaped.projection; | ||
| 205 | let text = projection.text().text(); | ||
| 206 | let boundaries: Vec<_> = projection.source_boundaries().collect(); | ||
| 207 | let source_position = |byte| -> Result<TextPosition, EditError> { | ||
| 208 | let index = boundaries | ||
| 209 | .binary_search_by_key(&byte, |(byte, _)| *byte) | ||
| 210 | .map_err(|_| EditError::InvalidRange)?; | ||
| 211 | Ok(TextPosition { | ||
| 212 | paragraph, | ||
| 213 | offset: boundaries[index].1, | ||
| 214 | }) | ||
| 215 | }; | ||
| 216 | let line_count = layout.lines().count(); | ||
| 217 | for (line_index, (line, bounds)) in layout.lines().enumerate() { | ||
| 218 | let line_start = runs.len(); | ||
| 219 | let mut x = line.metrics().offset; | ||
| 220 | let mut logical_runs = Vec::new(); | ||
| 221 | for run in line.runs() { | ||
| 222 | let advance = run.advance(); | ||
| 223 | logical_runs.push((run, x)); | ||
| 224 | x += advance; | ||
| 225 | } | ||
| 226 | logical_runs.sort_by_key(|(run, _)| run.text_range().start); | ||
| 227 | for (run, x) in logical_runs { | ||
| 228 | // AccessKit stores each character's byte length in a u8. | ||
| 229 | let mut characters = Vec::new(); | ||
| 230 | for cluster in run.clusters() { | ||
| 231 | let range = cluster.text_range(); | ||
| 232 | if range.is_empty() || range.start == text.len() { | ||
| 233 | continue; | ||
| 234 | } | ||
| 235 | let cluster_text = | ||
| 236 | text.get(range.clone()).ok_or(EditError::InvalidRange)?; | ||
| 237 | if range.len() <= u8::MAX as usize { | ||
| 238 | characters.push(( | ||
| 239 | range, | ||
| 240 | cluster.advance(), | ||
| 241 | cluster.is_word_boundary(), | ||
| 242 | )); | ||
| 243 | } else { | ||
| 244 | for (index, (offset, ch)) in cluster_text.char_indices().enumerate() | ||
| 245 | { | ||
| 246 | characters.push(( | ||
| 247 | range.start + offset..range.start + offset + ch.len_utf8(), | ||
| 248 | if index == 0 { cluster.advance() } else { 0.0 }, | ||
| 249 | index == 0 && cluster.is_word_boundary(), | ||
| 250 | )); | ||
| 251 | } | ||
| 252 | } | ||
| 253 | } | ||
| 254 | let mut advance = 0.0; | ||
| 255 | for chunk in characters.chunks(256) { | ||
| 256 | let width: f32 = chunk.iter().map(|(_, width, _)| width).sum(); | ||
| 257 | let left = x + if run.is_rtl() { | ||
| 258 | run.advance() - advance - width | ||
| 259 | } else { | ||
| 260 | advance | ||
| 261 | }; | ||
| 262 | let mut node = Node::new(Role::TextRun); | ||
| 263 | node.set_bounds(Rect::new( | ||
| 264 | f64::from(left + origin[0]), | ||
| 265 | f64::from(bounds.top + origin[1]), | ||
| 266 | f64::from(left + width + origin[0]), | ||
| 267 | f64::from(bounds.top + bounds.height + origin[1]), | ||
| 268 | )); | ||
| 269 | node.set_text_direction(if run.is_rtl() { | ||
| 270 | TextDirection::RightToLeft | ||
| 271 | } else { | ||
| 272 | TextDirection::LeftToRight | ||
| 273 | }); | ||
| 274 | node.set_font_size(run.font_size()); | ||
| 275 | let start = chunk[0].0.start; | ||
| 276 | let end = chunk.last().unwrap().0.end; | ||
| 277 | node.set_value(text.get(start..end).ok_or(EditError::InvalidRange)?); | ||
| 278 | node.set_character_lengths( | ||
| 279 | chunk | ||
| 280 | .iter() | ||
| 281 | .map(|(range, _, _)| u8::try_from(range.len()).unwrap()) | ||
| 282 | .collect::<Vec<_>>(), | ||
| 283 | ); | ||
| 284 | let mut offset = 0.0; | ||
| 285 | node.set_character_positions( | ||
| 286 | chunk | ||
| 287 | .iter() | ||
| 288 | .map(|(_, width, _)| { | ||
| 289 | let position = offset; | ||
| 290 | offset += width; | ||
| 291 | position | ||
| 292 | }) | ||
| 293 | .collect::<Vec<_>>(), | ||
| 294 | ); | ||
| 295 | node.set_character_widths( | ||
| 296 | chunk.iter().map(|(_, width, _)| *width).collect::<Vec<_>>(), | ||
| 297 | ); | ||
| 298 | node.set_word_starts( | ||
| 299 | chunk | ||
| 300 | .iter() | ||
| 301 | .enumerate() | ||
| 302 | .filter_map(|(index, (_, _, word))| word.then_some(index as u8)) | ||
| 303 | .collect::<Vec<_>>(), | ||
| 304 | ); | ||
| 305 | let positions = chunk | ||
| 306 | .iter() | ||
| 307 | .map(|(range, _, _)| source_position(range.start)) | ||
| 308 | .chain(std::iter::once(source_position(end))) | ||
| 309 | .collect::<Result<Vec<_>, _>>()?; | ||
| 310 | runs.push((outline.id, node, positions)); | ||
| 311 | advance += width; | ||
| 312 | } | ||
| 313 | } | ||
| 314 | // Empty lines still need a text position for selection and insertion. | ||
| 315 | if runs.len() == line_start { | ||
| 316 | let mut node = Node::new(Role::TextRun); | ||
| 317 | node.set_value(""); | ||
| 318 | node.set_character_lengths(Vec::<u8>::new()); | ||
| 319 | node.set_character_positions(Vec::<f32>::new()); | ||
| 320 | node.set_character_widths(Vec::<f32>::new()); | ||
| 321 | node.set_text_direction(TextDirection::LeftToRight); | ||
| 322 | node.set_bounds(Rect::new( | ||
| 323 | f64::from(origin[0]), | ||
| 324 | f64::from(bounds.top + origin[1]), | ||
| 325 | f64::from(origin[0]), | ||
| 326 | f64::from(bounds.top + bounds.height + origin[1]), | ||
| 327 | )); | ||
| 328 | runs.push(( | ||
| 329 | outline.id, | ||
| 330 | node, | ||
| 331 | vec![source_position(bounds.source.start)?], | ||
| 332 | )); | ||
| 333 | } | ||
| 334 | if line_index + 1 == line_count | ||
| 335 | && let Some((next, _)) = paragraphs.peek() | ||
| 336 | { | ||
| 337 | let (_, node, source_positions) = runs.last_mut().unwrap(); | ||
| 338 | let mut value = node.value().unwrap().to_owned(); | ||
| 339 | value.push('\n'); | ||
| 340 | node.set_value(value); | ||
| 341 | let mut lengths = node.character_lengths().to_vec(); | ||
| 342 | lengths.push(1); | ||
| 343 | node.set_character_lengths(lengths); | ||
| 344 | if let Some(rect) = node.bounds() { | ||
| 345 | let mut positions = | ||
| 346 | node.character_positions().unwrap_or_default().to_vec(); | ||
| 347 | positions.push(rect.width() as f32); | ||
| 348 | node.set_character_positions(positions); | ||
| 349 | let mut widths = node.character_widths().unwrap_or_default().to_vec(); | ||
| 350 | widths.push(4.0); | ||
| 351 | node.set_character_widths(widths); | ||
| 352 | } | ||
| 353 | source_positions.push(TextPosition { | ||
| 354 | paragraph: *next, | ||
| 355 | offset: 0, | ||
| 356 | }); | ||
| 357 | } | ||
| 358 | } | ||
| 359 | if !shaped.tags.is_empty() { | ||
| 360 | let descriptions = shaped | ||
| 361 | .tags | ||
| 362 | .iter() | ||
| 363 | .map(|tag| { | ||
| 364 | use canvas::outline::TagIcon; | ||
| 365 | let fallback = match tag.icon { | ||
| 366 | TagIcon::CheckBox { .. } => "To do", | ||
| 367 | TagIcon::Question => "Question", | ||
| 368 | TagIcon::Music => "Music", | ||
| 369 | TagIcon::Exclamation => "Critical", | ||
| 370 | TagIcon::RedSquare => "Project A", | ||
| 371 | TagIcon::YellowSquare => "Project B", | ||
| 372 | TagIcon::BlueSquare => "Project C", | ||
| 373 | }; | ||
| 374 | let label = if tag.label.is_empty() { | ||
| 375 | fallback | ||
| 376 | } else { | ||
| 377 | &tag.label | ||
| 378 | }; | ||
| 379 | let state = match tag.icon { | ||
| 380 | TagIcon::CheckBox { checked: true } => ", completed", | ||
| 381 | TagIcon::CheckBox { checked: false } => ", incomplete", | ||
| 382 | _ => "", | ||
| 383 | }; | ||
| 384 | format!( | ||
| 385 | "{label}{state}{}", | ||
| 386 | if tag.disabled { ", disabled" } else { "" } | ||
| 387 | ) | ||
| 388 | }) | ||
| 389 | .collect::<Vec<_>>() | ||
| 390 | .join("; "); | ||
| 391 | runs[start].1.set_description(descriptions); | ||
| 392 | } | ||
| 393 | paragraph_ranges.insert(key, start..runs.len()); | ||
| 394 | } | ||
| 395 | } | ||
| 396 | let mut runs: Vec<_> = runs | ||
| 397 | .into_iter() | ||
| 398 | .enumerate() | ||
| 399 | .map(|(index, (outline, mut node, positions))| { | ||
| 400 | node.clear_next_on_line(); | ||
| 401 | node.clear_previous_on_line(); | ||
| 402 | let id = match self.runs.get(index) { | ||
| 403 | Some(old) | ||
| 404 | if old.outline == outline | ||
| 405 | && old.positions == positions | ||
| 406 | && old.node.value() == node.value() => | ||
| 407 | { | ||
| 408 | old.id | ||
| 409 | } | ||
| 410 | _ => self.allocate(), | ||
| 411 | }; | ||
| 412 | Run { | ||
| 413 | outline, | ||
| 414 | id, | ||
| 415 | node, | ||
| 416 | positions, | ||
| 417 | } | ||
| 418 | }) | ||
| 419 | .collect(); | ||
| 420 | for index in 1..runs.len() { | ||
| 421 | if runs[index - 1].outline == runs[index].outline | ||
| 422 | && runs[index - 1].node.bounds().map(|r| r.y0) | ||
| 423 | == runs[index].node.bounds().map(|r| r.y0) | ||
| 424 | { | ||
| 425 | let (before, after) = runs.split_at_mut(index); | ||
| 426 | before[index - 1].node.set_next_on_line(after[0].id); | ||
| 427 | after[0].node.set_previous_on_line(before[index - 1].id); | ||
| 428 | } | ||
| 429 | } | ||
| 430 | self.runs = runs; | ||
| 431 | self.paragraphs = paragraph_ranges; | ||
| 432 | self.outlines = outlines; | ||
| 433 | let mut root = Node::new(Role::Window); | ||
| 434 | root.set_label(title); | ||
| 435 | root.set_children(self.outlines.iter().map(|(_, id)| *id).collect::<Vec<_>>()); | ||
| 436 | root.set_bounds(Rect::new( | ||
| 437 | 0.0, | ||
| 438 | 0.0, | ||
| 439 | viewport.size[0] as f64, | ||
| 440 | viewport.size[1] as f64, | ||
| 441 | )); | ||
| 442 | let mut nodes = vec![(ROOT, root)]; | ||
| 443 | let mut focus = ROOT; | ||
| 444 | for (index, (outline, (_, id))) in editor | ||
| 445 | .visible_outlines() | ||
| 446 | .chain(editor.caret_outline()) | ||
| 447 | .zip(&self.outlines) | ||
| 448 | .enumerate() | ||
| 449 | { | ||
| 450 | let mut field = Node::new(Role::MultilineTextInput); | ||
| 451 | field.set_label(if outline.title { | ||
| 452 | "Page title".into() | ||
| 453 | } else if editor | ||
| 454 | .caret_outline() | ||
| 455 | .is_some_and(|caret| caret.id == outline.id) | ||
| 456 | { | ||
| 457 | "Text input".into() | ||
| 458 | } else { | ||
| 459 | format!("Text outline {}", index + 1) | ||
| 460 | }); | ||
| 461 | field.add_action(Action::Focus); | ||
| 462 | field.add_action(Action::SetTextSelection); | ||
| 463 | field.add_action(Action::ReplaceSelectedText); | ||
| 464 | field.add_action(Action::SetValue); | ||
| 465 | let runs = self.runs.iter().filter(|run| run.outline == outline.id); | ||
| 466 | field.set_value( | ||
| 467 | runs.clone() | ||
| 468 | .filter_map(|run| run.node.value()) | ||
| 469 | .collect::<String>(), | ||
| 470 | ); | ||
| 471 | field.set_children(runs.map(|run| run.id).collect::<Vec<_>>()); | ||
| 472 | let origin = preview | ||
| 473 | .filter(|(id, _)| *id == outline.id) | ||
| 474 | .map(|(_, origin)| origin) | ||
| 475 | .unwrap_or_else(|| outline.origin()); | ||
| 476 | field.set_transform(Affine::new([ | ||
| 477 | f64::from(viewport.scale), | ||
| 478 | 0.0, | ||
| 479 | 0.0, | ||
| 480 | f64::from(viewport.scale), | ||
| 481 | f64::from(viewport.origin[0]) + f64::from(origin[0]) * f64::from(viewport.scale), | ||
| 482 | f64::from(viewport.origin[1]) + f64::from(origin[1]) * f64::from(viewport.scale), | ||
| 483 | ])); | ||
| 484 | let bounds = outline.bounds(); | ||
| 485 | field.set_bounds(Rect::new(0.0, 0.0, bounds.width(), bounds.height())); | ||
| 486 | if outline.id == editor.active_outline().id { | ||
| 487 | let Selection { | ||
| 488 | positions: [anchor, caret], | ||
| 489 | affinities, | ||
| 490 | } = editor.selection(); | ||
| 491 | field.set_text_selection(TextSelection { | ||
| 492 | anchor: self.position(outline, anchor, affinities[0])?, | ||
| 493 | focus: self.position(outline, caret, affinities[1])?, | ||
| 494 | }); | ||
| 495 | focus = *id; | ||
| 496 | } | ||
| 497 | nodes.push((*id, field)); | ||
| 498 | } | ||
| 499 | nodes.extend(self.runs.iter().map(|run| (run.id, run.node.clone()))); | ||
| 500 | Ok(TreeUpdate { | ||
| 501 | nodes, | ||
| 502 | tree: Some(TreeInfo::new(ROOT)), | ||
| 503 | tree_id: TreeId::ROOT, | ||
| 504 | focus, | ||
| 505 | }) | ||
| 506 | } | ||
| 507 | |||
| 508 | fn position( | ||
| 509 | &self, | ||
| 510 | outline: &TextOutline, | ||
| 511 | mut position: TextPosition, | ||
| 512 | affinity: Affinity, | ||
| 513 | ) -> Result<accesskit::TextPosition, EditError> { | ||
| 514 | let shaped = outline.paragraph_layout(position.paragraph)?; | ||
| 515 | let projection = &shaped.projection; | ||
| 516 | let layout = &shaped.text; | ||
| 517 | let origin = shaped.origin; | ||
| 518 | let cursor = layout.cursor( | ||
| 519 | projection | ||
| 520 | .text() | ||
| 521 | .byte_offset(projection.visible_offset(position.offset)?)?, | ||
| 522 | affinity, | ||
| 523 | ); | ||
| 524 | position.offset = projection.source_offset( | ||
| 525 | projection.text().utf16_offset(cursor.index())?, | ||
| 526 | onestore::page::text::Affinity::Downstream, | ||
| 527 | )?; | ||
| 528 | let caret = layout.caret(cursor, 0.0); | ||
| 529 | let mut positions = self | ||
| 530 | .runs | ||
| 531 | .iter() | ||
| 532 | .filter(|run| { | ||
| 533 | run.outline == outline.id | ||
| 534 | && run | ||
| 535 | .node | ||
| 536 | .bounds() | ||
| 537 | .is_some_and(|rect| rect.y0 == f64::from(caret.y0 as f32 + origin[1])) | ||
| 538 | }) | ||
| 539 | .filter_map(|run| { | ||
| 540 | run.positions | ||
| 541 | .iter() | ||
| 542 | .position(|p| *p == position) | ||
| 543 | .map(|character_index| accesskit::TextPosition { | ||
| 544 | node: run.id, | ||
| 545 | character_index, | ||
| 546 | }) | ||
| 547 | }); | ||
| 548 | match affinity { | ||
| 549 | Affinity::Upstream => positions.next(), | ||
| 550 | Affinity::Downstream => positions.next_back(), | ||
| 551 | } | ||
| 552 | .ok_or(EditError::InvalidRange) | ||
| 553 | } | ||
| 554 | |||
| 555 | pub fn selection( | ||
| 556 | &self, | ||
| 557 | outline: onestore::ExGuid, | ||
| 558 | selection: TextSelection, | ||
| 559 | ) -> Result<Selection, EditError> { | ||
| 560 | let position = |position: accesskit::TextPosition| { | ||
| 561 | let run = self | ||
| 562 | .runs | ||
| 563 | .iter() | ||
| 564 | .find(|run| run.outline == outline && run.id == position.node) | ||
| 565 | .ok_or(EditError::InvalidRange)?; | ||
| 566 | let source = *run | ||
| 567 | .positions | ||
| 568 | .get(position.character_index) | ||
| 569 | .ok_or(EditError::InvalidRange)?; | ||
| 570 | let affinity = if position.character_index > 0 | ||
| 571 | && position.character_index + 1 == run.positions.len() | ||
| 572 | { | ||
| 573 | Affinity::Upstream | ||
| 574 | } else { | ||
| 575 | Affinity::Downstream | ||
| 576 | }; | ||
| 577 | Ok::<_, EditError>((source, affinity)) | ||
| 578 | }; | ||
| 579 | let (anchor, anchor_affinity) = position(selection.anchor)?; | ||
| 580 | let (focus, focus_affinity) = position(selection.focus)?; | ||
| 581 | Ok(Selection { | ||
| 582 | positions: [anchor, focus], | ||
| 583 | affinities: [anchor_affinity, focus_affinity], | ||
| 584 | }) | ||
| 585 | } | ||
| 586 | } | ||
| 587 | |||
| 588 | #[cfg(test)] | ||
| 589 | mod tests { | ||
| 590 | use super::*; | ||
| 591 | use canvas::{document::TextDocument, layout::TextEngine}; | ||
| 592 | use onestore::document::Format; | ||
| 593 | use onestore::page::text::Paragraph; | ||
| 594 | |||
| 595 | #[test] | ||
| 596 | fn title_flow_updates_read_only_hit_and_accessibility_bounds() { | ||
| 597 | use onestore::page::{Page, PageObject, Title, Unsupported}; | ||
| 598 | let mut engine = TextEngine::default(); | ||
| 599 | let mut title = TextOutline::new( | ||
| 600 | &mut engine, | ||
| 601 | TextDocument::new(vec![Paragraph::new( | ||
| 602 | "Header".into(), | ||
| 603 | Format { | ||
| 604 | font: Some("Arial".into()), | ||
| 605 | font_size: Some(8.0), | ||
| 606 | line_spacing: Some(20.0), | ||
| 607 | ..Default::default() | ||
| 608 | }, | ||
| 609 | )]) | ||
| 610 | .unwrap(), | ||
| 611 | 468.0, | ||
| 612 | [0.0; 2], | ||
| 613 | ) | ||
| 614 | .unwrap() | ||
| 615 | .snapshot(); | ||
| 616 | title.title = true; | ||
| 617 | let page = Page { | ||
| 618 | identity: None, | ||
| 619 | title: "Header".into(), | ||
| 620 | created: None, | ||
| 621 | margin_origin: [36.0, 14.4], | ||
| 622 | definitions: Default::default(), | ||
| 623 | objects: vec![ | ||
| 624 | PageObject::Title(Title { | ||
| 625 | id: Default::default(), | ||
| 626 | date: None, | ||
| 627 | layout: Default::default(), | ||
| 628 | outlines: vec![title], | ||
| 629 | }), | ||
| 630 | PageObject::Unsupported(Unsupported { | ||
| 631 | id: Default::default(), | ||
| 632 | jcid: 0xdead, | ||
| 633 | layout: onestore::document::Layout { | ||
| 634 | x: Some(120.0), | ||
| 635 | y: Some(80.0), | ||
| 636 | max_width: Some(200.0), | ||
| 637 | max_height: Some(60.0), | ||
| 638 | ..Default::default() | ||
| 639 | }, | ||
| 640 | }), | ||
| 641 | ], | ||
| 642 | }; | ||
| 643 | let (scene, mut editor) = | ||
| 644 | canvas::gpu::page::PageScene::from_page(page, &mut engine).unwrap(); | ||
| 645 | let scene = (scene, [30.0, 40.0]); | ||
| 646 | let viewport = Viewport { | ||
| 647 | size: [800, 600], | ||
| 648 | origin: [10.0, -20.0], | ||
| 649 | scale: 2.0, | ||
| 650 | }; | ||
| 651 | let mut access = Accessibility::default(); | ||
| 652 | let mut identity = None; | ||
| 653 | for phase in 0..4 { | ||
| 654 | match phase { | ||
| 655 | 1 => { | ||
| 656 | editor.select_all().unwrap(); | ||
| 657 | editor.insert(&mut engine, "Header\u{000b}Second\u{000b}Third\u{000b}Fourth\u{000b}Fifth\u{000b}Sixth").unwrap(); | ||
| 658 | } | ||
| 659 | 2 => { | ||
| 660 | editor.undo(&mut engine).unwrap(); | ||
| 661 | } | ||
| 662 | 3 => { | ||
| 663 | editor.redo(&mut engine).unwrap(); | ||
| 664 | } | ||
| 665 | _ => {} | ||
| 666 | } | ||
| 667 | let mut update = access.update(&editor, viewport, "Test", None).unwrap(); | ||
| 668 | access.append_page_fields(&mut update, Some(&scene), &editor, viewport, Some(0)); | ||
| 669 | let (id, node) = update | ||
| 670 | .nodes | ||
| 671 | .iter() | ||
| 672 | .find(|(_, node)| node.role() == Role::Label) | ||
| 673 | .unwrap(); | ||
| 674 | if let Some(identity) = identity { | ||
| 675 | assert_eq!(*id, identity); | ||
| 676 | } | ||
| 677 | identity = Some(*id); | ||
| 678 | let y = if phase % 2 == 0 { 80.0 } else { 145.92 }; | ||
| 679 | let expected = Rect::new( | ||
| 680 | 310.0, | ||
| 681 | f64::from((y + 40.0) * 2.0 - 20.0), | ||
| 682 | 710.0, | ||
| 683 | f64::from((y + 100.0) * 2.0 - 20.0), | ||
| 684 | ); | ||
| 685 | let bounds = node.bounds().unwrap(); | ||
| 686 | assert_eq!(bounds.x0, expected.x0); | ||
| 687 | assert_eq!(bounds.x1, expected.x1); | ||
| 688 | assert!((bounds.y0 - expected.y0).abs() < 0.001); | ||
| 689 | assert!((bounds.y1 - expected.y1).abs() < 0.001); | ||
| 690 | assert_eq!( | ||
| 691 | crate::page_hit_test(&editor, Some(&scene), [155.0, y + 45.0], 1.0), | ||
| 692 | Some(crate::Hit::ReadOnly(0)) | ||
| 693 | ); | ||
| 694 | if phase % 2 == 1 { | ||
| 695 | assert_ne!( | ||
| 696 | crate::page_hit_test(&editor, Some(&scene), [155.0, 125.0], 1.0), | ||
| 697 | Some(crate::Hit::ReadOnly(0)) | ||
| 698 | ); | ||
| 699 | } | ||
| 700 | } | ||
| 701 | } | ||
| 702 | |||
| 703 | #[test] | ||
| 704 | fn read_only_objects_keep_accessibility_identity_through_edits_and_view_changes() { | ||
| 705 | use canvas::gpu::page::PageScene; | ||
| 706 | use onestore::page::{Page, PageObject, Unsupported}; | ||
| 707 | let page = Page { | ||
| 708 | identity: None, | ||
| 709 | created: None, | ||
| 710 | title: String::new(), | ||
| 711 | margin_origin: [0.0; 2], | ||
| 712 | definitions: Default::default(), | ||
| 713 | objects: vec![PageObject::Unsupported(Unsupported { | ||
| 714 | id: Default::default(), | ||
| 715 | jcid: 0xdead, | ||
| 716 | layout: onestore::document::Layout { | ||
| 717 | x: Some(10.0), | ||
| 718 | y: Some(20.0), | ||
| 719 | max_width: Some(200.0), | ||
| 720 | max_height: Some(60.0), | ||
| 721 | ..Default::default() | ||
| 722 | }, | ||
| 723 | })], | ||
| 724 | }; | ||
| 725 | let mut engine = TextEngine::default(); | ||
| 726 | let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap(); | ||
| 727 | let scene = (scene, [30.0, 40.0]); | ||
| 728 | let mut access = Accessibility::default(); | ||
| 729 | let mut identity = None; | ||
| 730 | for scale in [1.0, 2.0, 0.5] { | ||
| 731 | editor.insert(&mut engine, "annotation ").unwrap(); | ||
| 732 | let viewport = Viewport { | ||
| 733 | size: [800, 600], | ||
| 734 | origin: [-200.0, 10.0], | ||
| 735 | scale, | ||
| 736 | }; | ||
| 737 | let mut update = access.update(&editor, viewport, "Test", None).unwrap(); | ||
| 738 | access.append_page_fields(&mut update, Some(&scene), &editor, viewport, None); | ||
| 739 | let (id, node) = update | ||
| 740 | .nodes | ||
| 741 | .iter() | ||
| 742 | .find(|(_, node)| node.role() == Role::Label) | ||
| 743 | .unwrap(); | ||
| 744 | if let Some(identity) = identity { | ||
| 745 | assert_eq!(*id, identity); | ||
| 746 | } | ||
| 747 | identity = Some(*id); | ||
| 748 | assert_eq!(node.value(), Some("Unsupported content\nRead-only")); | ||
| 749 | assert!(node.is_read_only()); | ||
| 750 | assert!(node.supports_action(Action::Focus)); | ||
| 751 | for action in [ | ||
| 752 | Action::SetValue, | ||
| 753 | Action::ReplaceSelectedText, | ||
| 754 | Action::SetTextSelection, | ||
| 755 | ] { | ||
| 756 | assert!(!node.supports_action(action)); | ||
| 757 | } | ||
| 758 | assert_eq!( | ||
| 759 | node.bounds(), | ||
| 760 | Some(Rect::new( | ||
| 761 | f64::from(40.0 * scale - 200.0), | ||
| 762 | f64::from(60.0 * scale + 10.0), | ||
| 763 | f64::from(240.0 * scale - 200.0), | ||
| 764 | f64::from(120.0 * scale + 10.0) | ||
| 765 | )) | ||
| 766 | ); | ||
| 767 | assert_eq!(access.outline_for_node(*id), None); | ||
| 768 | let tree = accesskit_consumer::Tree::new(update, true); | ||
| 769 | assert_eq!( | ||
| 770 | tree.state().focus().unwrap().document_range().text(), | ||
| 771 | "annotation " | ||
| 772 | ); | ||
| 773 | let mut focused = access.update(&editor, viewport, "Test", None).unwrap(); | ||
| 774 | access.append_page_fields(&mut focused, Some(&scene), &editor, viewport, Some(0)); | ||
| 775 | assert_eq!(focused.focus, identity.unwrap()); | ||
| 776 | assert_eq!(access.read_only_for_node(focused.focus), Some(0)); | ||
| 777 | let tree = accesskit_consumer::Tree::new(focused, true); | ||
| 778 | assert_eq!( | ||
| 779 | tree.state().focus().unwrap().value().as_deref(), | ||
| 780 | Some("Unsupported content\nRead-only") | ||
| 781 | ); | ||
| 782 | editor.undo(&mut engine).unwrap(); | ||
| 783 | } | ||
| 784 | access.deactivate(); | ||
| 785 | assert!(access.read_only.is_empty()); | ||
| 786 | } | ||
| 787 | |||
| 788 | #[test] | ||
| 789 | fn tag_descriptions_preserve_plain_text_and_survive_edit_undo_and_cache_reuse() { | ||
| 790 | use onestore::page::{Definition, Outline}; | ||
| 791 | use onestore::{ | ||
| 792 | ExGuid, | ||
| 793 | document::{Kind, Layout, Tag}, | ||
| 794 | }; | ||
| 795 | use std::collections::BTreeMap; | ||
| 796 | let text = TextDocument::new( | ||
| 797 | ["task one", "task two", "question", "music"] | ||
| 798 | .into_iter() | ||
| 799 | .map(|text| Paragraph::new(text.into(), Format::default())) | ||
| 800 | .collect(), | ||
| 801 | ) | ||
| 802 | .unwrap(); | ||
| 803 | let mut nodes = text.nodes().to_vec(); | ||
| 804 | let mut definitions = BTreeMap::new(); | ||
| 805 | for (index, (shape, label, status)) in [ | ||
| 806 | (3, Some("Rehearsal"), 0), | ||
| 807 | (3, Some("Rehearsal"), 1), | ||
| 808 | (15, None, 1), | ||
| 809 | (121, None, 3), | ||
| 810 | ] | ||
| 811 | .into_iter() | ||
| 812 | .enumerate() | ||
| 813 | { | ||
| 814 | let id = ExGuid { | ||
| 815 | n: index as u32 + 500, | ||
| 816 | ..ExGuid::default() | ||
| 817 | }; | ||
| 818 | definitions.insert( | ||
| 819 | id, | ||
| 820 | Definition { | ||
| 821 | kind: Kind::TagDefinition { | ||
| 822 | label: label.map(str::to_owned), | ||
| 823 | action_type: None, | ||
| 824 | shape: Some(shape), | ||
| 825 | color: None, | ||
| 826 | highlight: None, | ||
| 827 | }, | ||
| 828 | format: Format::default(), | ||
| 829 | }, | ||
| 830 | ); | ||
| 831 | nodes[index].text_mut().unwrap().tags.push(Tag { | ||
| 832 | definition: Some(id), | ||
| 833 | action_type: None, | ||
| 834 | status, | ||
| 835 | created: None, | ||
| 836 | completed: None, | ||
| 837 | start: None, | ||
| 838 | due: None, | ||
| 839 | task_id: None, | ||
| 840 | extra_set: 0, | ||
| 841 | }); | ||
| 842 | } | ||
| 843 | let mut engine = TextEngine::default(); | ||
| 844 | let mut editor = CanvasEditor::from_outlines( | ||
| 845 | &mut engine, | ||
| 846 | vec![Outline { | ||
| 847 | title: false, | ||
| 848 | min_width: None, | ||
| 849 | id: ExGuid { | ||
| 850 | n: 900, | ||
| 851 | ..ExGuid::default() | ||
| 852 | }, | ||
| 853 | layout: Layout { | ||
| 854 | max_width: Some(120.0), | ||
| 855 | ..Layout::default() | ||
| 856 | }, | ||
| 857 | indents: vec![18.0, 0.0], | ||
| 858 | paragraphs: nodes, | ||
| 859 | unsupported: Vec::new(), | ||
| 860 | }], | ||
| 861 | definitions, | ||
| 862 | ) | ||
| 863 | .unwrap(); | ||
| 864 | let mut access = Accessibility::default(); | ||
| 865 | let viewport = Viewport { | ||
| 866 | size: [800, 600], | ||
| 867 | origin: [48.0; 2], | ||
| 868 | scale: 2.0, | ||
| 869 | }; | ||
| 870 | for step in 0..4 { | ||
| 871 | if step == 2 { | ||
| 872 | editor.insert(&mut engine, "new ").unwrap(); | ||
| 873 | } | ||
| 874 | if step == 3 { | ||
| 875 | editor.undo(&mut engine).unwrap(); | ||
| 876 | } | ||
| 877 | let update = access.update(&editor, viewport, "Test", None).unwrap(); | ||
| 878 | assert_eq!( | ||
| 879 | update | ||
| 880 | .nodes | ||
| 881 | .iter() | ||
| 882 | .filter_map(|(_, n)| n.description()) | ||
| 883 | .collect::<Vec<_>>(), | ||
| 884 | [ | ||
| 885 | "Rehearsal, incomplete", | ||
| 886 | "Rehearsal, completed", | ||
| 887 | "Question", | ||
| 888 | "Music, disabled" | ||
| 889 | ] | ||
| 890 | ); | ||
| 891 | let tree = accesskit_consumer::Tree::new(update, true); | ||
| 892 | let text = tree.state().focus().unwrap().document_range().text(); | ||
| 893 | assert_eq!( | ||
| 894 | text, | ||
| 895 | if step == 2 { | ||
| 896 | "new task one\ntask two\nquestion\nmusic" | ||
| 897 | } else { | ||
| 898 | "task one\ntask two\nquestion\nmusic" | ||
| 899 | } | ||
| 900 | ); | ||
| 901 | } | ||
| 902 | } | ||
| 903 | |||
| 904 | #[test] | ||
| 905 | fn collapsed_paragraphs_keep_visible_accessibility_ranges_in_source_coordinates() { | ||
| 906 | let document = TextDocument::new( | ||
| 907 | [ | ||
| 908 | "root", | ||
| 909 | "folded", | ||
| 910 | "hidden", | ||
| 911 | "last", | ||
| 912 | "folded tail", | ||
| 913 | "hidden tail", | ||
| 914 | ] | ||
| 915 | .into_iter() | ||
| 916 | .map(|text| Paragraph::new(text.into(), Format::default())) | ||
| 917 | .collect(), | ||
| 918 | ) | ||
| 919 | .unwrap(); | ||
| 920 | let mut nodes = document.nodes().to_vec(); | ||
| 921 | for (child, parent, level) in [(1, 0, 2), (2, 1, 3), (4, 3, 2), (5, 4, 3)] { | ||
| 922 | nodes[child].parent = Some(nodes[parent].id); | ||
| 923 | nodes[child].level = level; | ||
| 924 | } | ||
| 925 | nodes[1].collapsed = true; | ||
| 926 | nodes[4].collapsed = true; | ||
| 927 | let mut engine = TextEngine::default(); | ||
| 928 | let mut editor = | ||
| 929 | CanvasEditor::new(&mut engine, TextDocument::from_nodes(nodes).unwrap(), 180.0) | ||
| 930 | .unwrap(); | ||
| 931 | let mut access = Accessibility::default(); | ||
| 932 | let viewport = Viewport { | ||
| 933 | size: [900, 700], | ||
| 934 | scale: 2.0, | ||
| 935 | origin: [13.0, 19.0], | ||
| 936 | }; | ||
| 937 | let update = access.update(&editor, viewport, "Test", None).unwrap(); | ||
| 938 | let tree = accesskit_consumer::Tree::new(update, true); | ||
| 939 | let field = tree.state().focus().unwrap(); | ||
| 940 | assert_eq!( | ||
| 941 | field.document_range().text(), | ||
| 942 | "root\nfolded\nlast\nfolded tail" | ||
| 943 | ); | ||
| 944 | assert!( | ||
| 945 | access | ||
| 946 | .runs | ||
| 947 | .iter() | ||
| 948 | .flat_map(|run| &run.positions) | ||
| 949 | .all(|position| ![2, 5].contains(&position.paragraph)) | ||
| 950 | ); | ||
| 951 | let source = access | ||
| 952 | .selection( | ||
| 953 | editor.active_outline().id, | ||
| 954 | field.document_range().to_text_selection(), | ||
| 955 | ) | ||
| 956 | .unwrap(); | ||
| 957 | assert_eq!( | ||
| 958 | source.positions[0], | ||
| 959 | TextPosition { | ||
| 960 | paragraph: 0, | ||
| 961 | offset: 0 | ||
| 962 | } | ||
| 963 | ); | ||
| 964 | assert_eq!( | ||
| 965 | source.positions[1], | ||
| 966 | TextPosition { | ||
| 967 | paragraph: 4, | ||
| 968 | offset: 11 | ||
| 969 | } | ||
| 970 | ); | ||
| 971 | editor.select(source).unwrap(); | ||
| 972 | let update = access.update(&editor, viewport, "Test", None).unwrap(); | ||
| 973 | let tree = accesskit_consumer::Tree::new(update, true); | ||
| 974 | assert_eq!( | ||
| 975 | tree.state() | ||
| 976 | .focus() | ||
| 977 | .unwrap() | ||
| 978 | .text_selection() | ||
| 979 | .unwrap() | ||
| 980 | .text(), | ||
| 981 | "root\nfolded\nlast\nfolded tail" | ||
| 982 | ); | ||
| 983 | editor | ||
| 984 | .select( | ||
| 985 | [TextPosition { | ||
| 986 | paragraph: 4, | ||
| 987 | offset: 0, | ||
| 988 | }; 2] | ||
| 989 | .into(), | ||
| 990 | ) | ||
| 991 | .unwrap(); | ||
| 992 | access.update(&editor, viewport, "Test", None).unwrap(); | ||
| 993 | let caret = editor.caret(1.0).unwrap(); | ||
| 994 | assert_eq!(caret.x0, 27.0); | ||
| 995 | let position = access | ||
| 996 | .position( | ||
| 997 | editor.active_outline(), | ||
| 998 | editor.selection().positions[1], | ||
| 999 | Affinity::Downstream, | ||
| 1000 | ) | ||
| 1001 | .unwrap(); | ||
| 1002 | let run = access | ||
| 1003 | .runs | ||
| 1004 | .iter() | ||
| 1005 | .find(|run| run.id == position.node) | ||
| 1006 | .unwrap(); | ||
| 1007 | assert_eq!(run.node.bounds().unwrap().x0, 27.0); | ||
| 1008 | assert_eq!(run.node.bounds().unwrap().y0, caret.y0); | ||
| 1009 | } | ||
| 1010 | |||
| 1011 | #[test] | ||
| 1012 | fn consumer_reads_unicode_blank_paragraphs_and_hidden_fields() { | ||
| 1013 | let cases = [ | ||
| 1014 | "", | ||
| 1015 | "a\n\nשלום\nend", | ||
| 1016 | "café e\u{301} 🌳 👩👩👧👦 words wrap here", | ||
| 1017 | "\n\n", | ||
| 1018 | "a\nb\n", | ||
| 1019 | ]; | ||
| 1020 | let mut engine = TextEngine::default(); | ||
| 1021 | for text in cases | ||
| 1022 | .into_iter() | ||
| 1023 | .map(str::to_owned) | ||
| 1024 | .chain([format!("a{}", "\u{301}".repeat(300)), "x".repeat(600)]) | ||
| 1025 | { | ||
| 1026 | for width in [24.0, 480.0, 10000.0] { | ||
| 1027 | let document = TextDocument::new( | ||
| 1028 | text.split('\n') | ||
| 1029 | .map(|line| { | ||
| 1030 | Paragraph::from_runs([ | ||
| 1031 | ( | ||
| 1032 | String::from("secret"), | ||
| 1033 | Format { | ||
| 1034 | hidden: Some(true), | ||
| 1035 | ..Format::default() | ||
| 1036 | }, | ||
| 1037 | ), | ||
| 1038 | (line.to_owned(), Format::default()), | ||
| 1039 | ]) | ||
| 1040 | }) | ||
| 1041 | .collect(), | ||
| 1042 | ) | ||
| 1043 | .unwrap(); | ||
| 1044 | let mut editor = CanvasEditor::new(&mut engine, document, width).unwrap(); | ||
| 1045 | let mut access = Accessibility::default(); | ||
| 1046 | let viewport = Viewport { | ||
| 1047 | size: [800, 600], | ||
| 1048 | origin: [48.0; 2], | ||
| 1049 | scale: 2.0, | ||
| 1050 | }; | ||
| 1051 | let update = access.update(&editor, viewport, "Test", None).unwrap(); | ||
| 1052 | for (_, node) in &update.nodes { | ||
| 1053 | if node.role() == Role::TextRun { | ||
| 1054 | assert_eq!( | ||
| 1055 | node.character_lengths() | ||
| 1056 | .iter() | ||
| 1057 | .map(|n| *n as usize) | ||
| 1058 | .sum::<usize>(), | ||
| 1059 | node.value().unwrap().len() | ||
| 1060 | ); | ||
| 1061 | if let Some(positions) = node.character_positions() { | ||
| 1062 | assert_eq!(positions.len(), node.character_lengths().len()); | ||
| 1063 | } | ||
| 1064 | if let Some(widths) = node.character_widths() { | ||
| 1065 | assert_eq!(widths.len(), node.character_lengths().len()); | ||
| 1066 | } | ||
| 1067 | } | ||
| 1068 | } | ||
| 1069 | let tree = accesskit_consumer::Tree::new(update, true); | ||
| 1070 | let field = tree.state().focus().unwrap(); | ||
| 1071 | assert_eq!(field.document_range().text(), text, "width {width}"); | ||
| 1072 | let selection = field.document_range().to_text_selection(); | ||
| 1073 | let source_selection = access | ||
| 1074 | .selection(editor.active_outline().id, selection) | ||
| 1075 | .unwrap(); | ||
| 1076 | editor.select(source_selection).unwrap(); | ||
| 1077 | let selected = access.update(&editor, viewport, "Test", None).unwrap(); | ||
| 1078 | let tree = accesskit_consumer::Tree::new(selected, true); | ||
| 1079 | assert_eq!( | ||
| 1080 | tree.state() | ||
| 1081 | .focus() | ||
| 1082 | .unwrap() | ||
| 1083 | .text_selection() | ||
| 1084 | .unwrap() | ||
| 1085 | .text(), | ||
| 1086 | text | ||
| 1087 | ); | ||
| 1088 | let stable_ids: Vec<_> = access.runs.iter().map(|run| run.id).collect(); | ||
| 1089 | access | ||
| 1090 | .update( | ||
| 1091 | &editor, | ||
| 1092 | Viewport { | ||
| 1093 | scale: 3.0, | ||
| 1094 | ..viewport | ||
| 1095 | }, | ||
| 1096 | "Test", | ||
| 1097 | None, | ||
| 1098 | ) | ||
| 1099 | .unwrap(); | ||
| 1100 | assert_eq!( | ||
| 1101 | stable_ids, | ||
| 1102 | access.runs.iter().map(|run| run.id).collect::<Vec<_>>() | ||
| 1103 | ); | ||
| 1104 | access.deactivate(); | ||
| 1105 | access.update(&editor, viewport, "Test", None).unwrap(); | ||
| 1106 | assert!( | ||
| 1107 | access | ||
| 1108 | .selection(editor.active_outline().id, selection) | ||
| 1109 | .is_err() | ||
| 1110 | ); | ||
| 1111 | let selection = access | ||
| 1112 | .update(&editor, viewport, "Test", None) | ||
| 1113 | .unwrap() | ||
| 1114 | .nodes[1] | ||
| 1115 | .1 | ||
| 1116 | .text_selection() | ||
| 1117 | .copied() | ||
| 1118 | .unwrap(); | ||
| 1119 | editor.insert(&mut engine, "replacement").unwrap(); | ||
| 1120 | access.update(&editor, viewport, "Test", None).unwrap(); | ||
| 1121 | assert!( | ||
| 1122 | access | ||
| 1123 | .selection(editor.active_outline().id, selection) | ||
| 1124 | .is_err() | ||
| 1125 | ); | ||
| 1126 | editor.undo(&mut engine).unwrap(); | ||
| 1127 | let update = access.update(&editor, viewport, "Test", None).unwrap(); | ||
| 1128 | let tree = accesskit_consumer::Tree::new(update, true); | ||
| 1129 | assert_eq!(tree.state().focus().unwrap().document_range().text(), text); | ||
| 1130 | } | ||
| 1131 | } | ||
| 1132 | } | ||
| 1133 | |||
| 1134 | #[test] | ||
| 1135 | fn caret_geometry_uses_visible_offsets_and_canvas_line_metrics() { | ||
| 1136 | let mut engine = TextEngine::default(); | ||
| 1137 | let document = TextDocument::new(vec![ | ||
| 1138 | Paragraph::from_runs([ | ||
| 1139 | ("abc ".into(), Format::default()), | ||
| 1140 | ( | ||
| 1141 | "hidden field".into(), | ||
| 1142 | Format { | ||
| 1143 | hidden: Some(true), | ||
| 1144 | ..Format::default() | ||
| 1145 | }, | ||
| 1146 | ), | ||
| 1147 | ("e\u{301} 🌳 words wrap here".into(), Format::default()), | ||
| 1148 | ]), | ||
| 1149 | Paragraph::new("".into(), Format::default()), | ||
| 1150 | Paragraph::new("abc שלום def عالم end".into(), Format::default()), | ||
| 1151 | Paragraph::new("last\nline\n".into(), Format::default()), | ||
| 1152 | ]) | ||
| 1153 | .unwrap(); | ||
| 1154 | let positions: Vec<_> = document | ||
| 1155 | .paragraphs() | ||
| 1156 | .enumerate() | ||
| 1157 | .flat_map(|(paragraph, text)| { | ||
| 1158 | text.text() | ||
| 1159 | .char_indices() | ||
| 1160 | .map(|(byte, _)| text.utf16_offset(byte).unwrap()) | ||
| 1161 | .chain(std::iter::once( | ||
| 1162 | text.utf16_offset(text.text().len()).unwrap(), | ||
| 1163 | )) | ||
| 1164 | .map(move |offset| TextPosition { paragraph, offset }) | ||
| 1165 | }) | ||
| 1166 | .collect(); | ||
| 1167 | let mut editor = CanvasEditor::new(&mut engine, document, 48.0).unwrap(); | ||
| 1168 | let mut access = Accessibility::default(); | ||
| 1169 | let viewport = Viewport { | ||
| 1170 | size: [800, 600], | ||
| 1171 | origin: [31.0, -57.0], | ||
| 1172 | scale: 2.5, | ||
| 1173 | }; | ||
| 1174 | for (position, affinity) in positions.into_iter().flat_map(|position| { | ||
| 1175 | [Affinity::Upstream, Affinity::Downstream].map(|affinity| (position, affinity)) | ||
| 1176 | }) { | ||
| 1177 | editor | ||
| 1178 | .select(Selection { | ||
| 1179 | positions: [position; 2], | ||
| 1180 | affinities: [affinity; 2], | ||
| 1181 | }) | ||
| 1182 | .unwrap(); | ||
| 1183 | let caret = editor.caret(0.0).unwrap(); | ||
| 1184 | let update = access.update(&editor, viewport, "Test", None).unwrap(); | ||
| 1185 | let tree = accesskit_consumer::Tree::new(update, true); | ||
| 1186 | let field = tree.state().focus().unwrap(); | ||
| 1187 | assert_eq!( | ||
| 1188 | field.document_range().text(), | ||
| 1189 | "abc e\u{301} 🌳 words wrap here\n\nabc שלום def عالم end\nlast\nline\n" | ||
| 1190 | ); | ||
| 1191 | let rects = field.text_selection().unwrap().bounding_boxes(); | ||
| 1192 | assert_eq!(rects.len(), 1, "{position:?}"); | ||
| 1193 | let rect = rects[0]; | ||
| 1194 | for (actual, expected) in [ | ||
| 1195 | (rect.x0, caret.x0 * 2.5 + 31.0), | ||
| 1196 | (rect.y0, caret.y0 * 2.5 - 57.0), | ||
| 1197 | (rect.y1, caret.y1 * 2.5 - 57.0), | ||
| 1198 | ] { | ||
| 1199 | assert!( | ||
| 1200 | (actual - expected).abs() < 0.0001, | ||
| 1201 | "{position:?} {affinity:?}: {rect:?} vs {caret:?}" | ||
| 1202 | ); | ||
| 1203 | } | ||
| 1204 | let selection = access | ||
| 1205 | .selection( | ||
| 1206 | editor.active_outline().id, | ||
| 1207 | field.text_selection().unwrap().to_text_selection(), | ||
| 1208 | ) | ||
| 1209 | .unwrap(); | ||
| 1210 | editor.select(selection).unwrap(); | ||
| 1211 | assert_eq!( | ||
| 1212 | editor.caret(0.0).unwrap(), | ||
| 1213 | caret, | ||
| 1214 | "{position:?} {affinity:?}: AX selection moved the caret" | ||
| 1215 | ); | ||
| 1216 | } | ||
| 1217 | } | ||
| 1218 | #[test] | ||
| 1219 | fn keyboard_and_accessibility_preserve_visual_caret_at_wraps() { | ||
| 1220 | use canvas::editor::Movement; | ||
| 1221 | let mut engine = TextEngine::default(); | ||
| 1222 | let document = TextDocument::new(vec![ | ||
| 1223 | Paragraph::new( | ||
| 1224 | "first words wrap here and continue".into(), | ||
| 1225 | Format::default(), | ||
| 1226 | ), | ||
| 1227 | Paragraph::new("abc שלום def عالم end".into(), Format::default()), | ||
| 1228 | Paragraph::new("last\nline".into(), Format::default()), | ||
| 1229 | ]) | ||
| 1230 | .unwrap(); | ||
| 1231 | let mut editor = CanvasEditor::new(&mut engine, document, 72.0).unwrap(); | ||
| 1232 | let mut access = Accessibility::default(); | ||
| 1233 | let viewport = Viewport { | ||
| 1234 | size: [800, 600], | ||
| 1235 | origin: [0.0; 2], | ||
| 1236 | scale: 1.0, | ||
| 1237 | }; | ||
| 1238 | for movement in [ | ||
| 1239 | Movement::LineEnd, | ||
| 1240 | Movement::Right, | ||
| 1241 | Movement::Left, | ||
| 1242 | Movement::Down, | ||
| 1243 | Movement::LineStart, | ||
| 1244 | Movement::LineEnd, | ||
| 1245 | Movement::Down, | ||
| 1246 | Movement::Down, | ||
| 1247 | Movement::Down, | ||
| 1248 | Movement::LineEnd, | ||
| 1249 | Movement::Left, | ||
| 1250 | Movement::Left, | ||
| 1251 | Movement::Right, | ||
| 1252 | Movement::LineStart, | ||
| 1253 | Movement::Up, | ||
| 1254 | Movement::Right, | ||
| 1255 | Movement::LineEnd, | ||
| 1256 | ] { | ||
| 1257 | editor.move_selection(&mut engine, movement, false).unwrap(); | ||
| 1258 | let caret = editor.caret(0.0).unwrap(); | ||
| 1259 | let tree = accesskit_consumer::Tree::new( | ||
| 1260 | access.update(&editor, viewport, "Test", None).unwrap(), | ||
| 1261 | true, | ||
| 1262 | ); | ||
| 1263 | let field = tree.state().focus().unwrap(); | ||
| 1264 | let selection = field.text_selection().unwrap(); | ||
| 1265 | let rects = selection.bounding_boxes(); | ||
| 1266 | assert_eq!(rects.len(), 1); | ||
| 1267 | let rect = rects[0]; | ||
| 1268 | for (actual, expected) in [ | ||
| 1269 | (rect.x0, caret.x0), | ||
| 1270 | (rect.y0, caret.y0), | ||
| 1271 | (rect.y1, caret.y1), | ||
| 1272 | ] { | ||
| 1273 | assert!( | ||
| 1274 | (actual - expected).abs() < 0.0001, | ||
| 1275 | "{movement:?}: {rect:?} vs {caret:?}" | ||
| 1276 | ); | ||
| 1277 | } | ||
| 1278 | let source_selection = access | ||
| 1279 | .selection(editor.active_outline().id, selection.to_text_selection()) | ||
| 1280 | .unwrap(); | ||
| 1281 | editor.select(source_selection).unwrap(); | ||
| 1282 | let restored = editor.caret(0.0).unwrap(); | ||
| 1283 | assert_eq!( | ||
| 1284 | restored, caret, | ||
| 1285 | "{movement:?}: AX selection moved the caret" | ||
| 1286 | ); | ||
| 1287 | } | ||
| 1288 | } | ||
| 1289 | #[test] | ||
| 1290 | fn cached_runs_match_reconstruction_through_editing_and_view_changes() { | ||
| 1291 | let mut engine = TextEngine::default(); | ||
| 1292 | let document = TextDocument::new( | ||
| 1293 | [ | ||
| 1294 | "one two three", | ||
| 1295 | "", | ||
| 1296 | "אבג 日本 👩🏽💻 e\u{301}", | ||
| 1297 | "last paragraph", | ||
| 1298 | ] | ||
| 1299 | .into_iter() | ||
| 1300 | .map(|text| Paragraph::new(text.into(), Format::default())) | ||
| 1301 | .collect(), | ||
| 1302 | ) | ||
| 1303 | .unwrap(); | ||
| 1304 | let mut editor = CanvasEditor::new(&mut engine, document, 72.0).unwrap(); | ||
| 1305 | let mut cached = Accessibility::default(); | ||
| 1306 | let mut rebuilt = Accessibility::default(); | ||
| 1307 | let mut compare = |editor: &CanvasEditor, step: usize| { | ||
| 1308 | let viewport = Viewport { | ||
| 1309 | size: [800, 600], | ||
| 1310 | origin: [step as f32 * 0.125, -17.25], | ||
| 1311 | scale: 0.75 + (step % 5) as f32 * 0.5, | ||
| 1312 | }; | ||
| 1313 | let preview = step | ||
| 1314 | .is_multiple_of(2) | ||
| 1315 | .then_some((editor.active_outline().id, [13.5, -7.25])); | ||
| 1316 | for repeat in 0..2 { | ||
| 1317 | rebuilt.paragraphs.clear(); | ||
| 1318 | let actual = cached.update(editor, viewport, "Test", preview).unwrap(); | ||
| 1319 | let expected = rebuilt.update(editor, viewport, "Test", preview).unwrap(); | ||
| 1320 | assert_eq!(actual.nodes, expected.nodes, "step {step}, repeat {repeat}"); | ||
| 1321 | assert_eq!(actual.focus, expected.focus); | ||
| 1322 | let tree = accesskit_consumer::Tree::new(actual, true); | ||
| 1323 | let field = tree.state().focus().unwrap(); | ||
| 1324 | let selection = field.document_range().to_text_selection(); | ||
| 1325 | assert_eq!( | ||
| 1326 | cached | ||
| 1327 | .selection(editor.active_outline().id, selection) | ||
| 1328 | .unwrap(), | ||
| 1329 | rebuilt | ||
| 1330 | .selection(editor.active_outline().id, selection) | ||
| 1331 | .unwrap() | ||
| 1332 | ); | ||
| 1333 | let count = editor | ||
| 1334 | .outlines() | ||
| 1335 | .iter() | ||
| 1336 | .map(|o| o.layouts().count()) | ||
| 1337 | .sum::<usize>(); | ||
| 1338 | assert_eq!(cached.paragraphs.len(), count); | ||
| 1339 | } | ||
| 1340 | }; | ||
| 1341 | compare(&editor, 0); | ||
| 1342 | let mut seed = 0x923e_u64; | ||
| 1343 | let mut next = || { | ||
| 1344 | seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); | ||
| 1345 | (seed >> 32) as usize | ||
| 1346 | }; | ||
| 1347 | for step in 1..=160 { | ||
| 1348 | let id = editor.outlines()[next() % editor.outlines().len()].id; | ||
| 1349 | editor.focus_outline(id).unwrap(); | ||
| 1350 | let positions: Vec<_> = editor | ||
| 1351 | .active_outline() | ||
| 1352 | .document() | ||
| 1353 | .paragraphs() | ||
| 1354 | .enumerate() | ||
| 1355 | .flat_map(|(paragraph, text)| { | ||
| 1356 | (0..=text.text().len()).filter_map(move |byte| { | ||
| 1357 | text.utf16_offset(byte) | ||
| 1358 | .ok() | ||
| 1359 | .map(|offset| TextPosition { paragraph, offset }) | ||
| 1360 | }) | ||
| 1361 | }) | ||
| 1362 | .collect(); | ||
| 1363 | let anchor = positions[next() % positions.len()]; | ||
| 1364 | let focus = if step % 3 == 0 { | ||
| 1365 | positions[next() % positions.len()] | ||
| 1366 | } else { | ||
| 1367 | anchor | ||
| 1368 | }; | ||
| 1369 | editor.select([anchor, focus].into()).unwrap(); | ||
| 1370 | match step % 10 { | ||
| 1371 | 0 => editor.insert(&mut engine, "\n").unwrap(), | ||
| 1372 | 1 => editor.insert(&mut engine, "abc 👩🏽💻 שלום e\u{301}").unwrap(), | ||
| 1373 | 2 => { | ||
| 1374 | editor.delete(&mut engine, true).unwrap(); | ||
| 1375 | } | ||
| 1376 | 3 => editor | ||
| 1377 | .resize(&mut engine, 48.0 + (step % 7) as f32 * 31.25) | ||
| 1378 | .unwrap(), | ||
| 1379 | 4 => { | ||
| 1380 | editor | ||
| 1381 | .create_outline(&mut engine, [step as f32, -5.0], 97.0) | ||
| 1382 | .unwrap(); | ||
| 1383 | editor.insert(&mut engine, "annotation").unwrap(); | ||
| 1384 | } | ||
| 1385 | 5 => { | ||
| 1386 | editor.undo(&mut engine).unwrap(); | ||
| 1387 | } | ||
| 1388 | 6 => { | ||
| 1389 | editor.redo(&mut engine).unwrap(); | ||
| 1390 | } | ||
| 1391 | 7 => editor.move_outline(id, [17.5, step as f32]).unwrap(), | ||
| 1392 | 8 => { | ||
| 1393 | editor.compose(&mut engine, "仮\n名".into(), 1..1).unwrap(); | ||
| 1394 | compare(&editor, step); | ||
| 1395 | editor.cancel_composition(&mut engine).unwrap(); | ||
| 1396 | } | ||
| 1397 | _ => {} | ||
| 1398 | } | ||
| 1399 | compare(&editor, step); | ||
| 1400 | } | ||
| 1401 | while editor.undo(&mut engine).unwrap() { | ||
| 1402 | compare(&editor, 161); | ||
| 1403 | } | ||
| 1404 | while editor.redo(&mut engine).unwrap() { | ||
| 1405 | compare(&editor, 162); | ||
| 1406 | } | ||
| 1407 | } | ||
| 1408 | #[test] | ||
| 1409 | fn outlines_have_independent_accessibility_identity_and_selection() { | ||
| 1410 | let mut engine = TextEngine::default(); | ||
| 1411 | let document = | ||
| 1412 | TextDocument::new(vec![Paragraph::new("lyrics".into(), Format::default())]).unwrap(); | ||
| 1413 | let mut editor = CanvasEditor::new(&mut engine, document, 240.0).unwrap(); | ||
| 1414 | let first = editor.active_outline().id; | ||
| 1415 | let second = editor | ||
| 1416 | .create_outline(&mut engine, [300.0, 40.0], 120.0) | ||
| 1417 | .unwrap(); | ||
| 1418 | editor.insert(&mut engine, "annotation 🌳").unwrap(); | ||
| 1419 | let mut access = Accessibility::default(); | ||
| 1420 | let viewport = Viewport { | ||
| 1421 | size: [1000, 720], | ||
| 1422 | scale: 2.0, | ||
| 1423 | origin: [48.0; 2], | ||
| 1424 | }; | ||
| 1425 | let update = access.update(&editor, viewport, "Test", None).unwrap(); | ||
| 1426 | let fields: Vec<_> = update | ||
| 1427 | .nodes | ||
| 1428 | .iter() | ||
| 1429 | .filter(|(_, node)| node.role() == Role::MultilineTextInput) | ||
| 1430 | .map(|(id, node)| (*id, node.value().unwrap().to_owned())) | ||
| 1431 | .collect(); | ||
| 1432 | assert_eq!( | ||
| 1433 | fields | ||
| 1434 | .iter() | ||
| 1435 | .map(|(_, text)| text.as_str()) | ||
| 1436 | .collect::<Vec<_>>(), | ||
| 1437 | ["lyrics", "annotation 🌳"] | ||
| 1438 | ); | ||
| 1439 | assert_ne!(fields[0].0, fields[1].0); | ||
| 1440 | assert_eq!(access.outline_for_node(fields[0].0), Some(first)); | ||
| 1441 | assert_eq!(access.outline_for_node(fields[1].0), Some(second)); | ||
| 1442 | let tree = accesskit_consumer::Tree::new(update, true); | ||
| 1443 | let field = tree.state().focus().unwrap(); | ||
| 1444 | assert_eq!(field.document_range().text(), "annotation 🌳"); | ||
| 1445 | let selection = field.document_range().to_text_selection(); | ||
| 1446 | assert!(access.selection(first, selection).is_err()); | ||
| 1447 | let source_selection = access.selection(second, selection).unwrap(); | ||
| 1448 | editor.select(source_selection).unwrap(); | ||
| 1449 | let preview = access | ||
| 1450 | .update(&editor, viewport, "Test", Some((second, [400.0, 50.0]))) | ||
| 1451 | .unwrap(); | ||
| 1452 | let preview_tree = accesskit_consumer::Tree::new(preview, true); | ||
| 1453 | let preview_field = preview_tree.state().focus().unwrap(); | ||
| 1454 | let rect = preview_field.document_range().bounding_boxes()[0]; | ||
| 1455 | assert_eq!(rect.x0, 848.0); | ||
| 1456 | assert_eq!(rect.y0, 148.0); | ||
| 1457 | assert_eq!(editor.active_outline().origin(), [300.0, 40.0]); | ||
| 1458 | editor.move_outline(second, [400.0, 50.0]).unwrap(); | ||
| 1459 | access.update(&editor, viewport, "Test", None).unwrap(); | ||
| 1460 | assert_eq!(access.outline_for_node(fields[1].0), Some(second)); | ||
| 1461 | assert!(access.selection(second, selection).is_ok()); | ||
| 1462 | editor.undo(&mut engine).unwrap(); | ||
| 1463 | editor.undo(&mut engine).unwrap(); | ||
| 1464 | editor.undo(&mut engine).unwrap(); | ||
| 1465 | let update = access.update(&editor, viewport, "Test", None).unwrap(); | ||
| 1466 | assert_eq!( | ||
| 1467 | update | ||
| 1468 | .nodes | ||
| 1469 | .iter() | ||
| 1470 | .filter(|(_, node)| node.role() == Role::MultilineTextInput) | ||
| 1471 | .count(), | ||
| 1472 | 1 | ||
| 1473 | ); | ||
| 1474 | assert_eq!(access.outline_for_node(fields[1].0), None); | ||
| 1475 | assert!(access.selection(second, selection).is_err()); | ||
| 1476 | assert_eq!(access.outline_for_node(fields[0].0), Some(first)); | ||
| 1477 | } | ||
| 1478 | } | ||
crates/snowbound/src/macos.rs+1-1| ... | @@ -116,7 +116,7 @@ pub fn edit_date( | ... | @@ -116,7 +116,7 @@ pub fn edit_date( |
| 116 | picker.setMinDate(Some(&NSDate::dateWithTimeIntervalSince1970( | 116 | picker.setMinDate(Some(&NSDate::dateWithTimeIntervalSince1970( |
| 117 | -11_644_473_600.0, | 117 | -11_644_473_600.0, |
| 118 | ))); | 118 | ))); |
| 119 | let label = NSString::from_str(crate::DATE_LABELS[field as usize]); | 119 | let label = NSString::from_str(canvas::interaction::DATE_LABELS[field as usize]); |
| 120 | let _: () = msg_send![&picker, setAccessibilityLabel: &*label]; | 120 | let _: () = msg_send![&picker, setAccessibilityLabel: &*label]; |
| 121 | picker.sizeToFit(); | 121 | picker.sizeToFit(); |
| 122 | let alert = NSAlert::new(mtm); | 122 | let alert = NSAlert::new(mtm); |
crates/snowbound/src/main.rs+235-3080| ... | @@ -1,39 +1,29 @@ | ... | @@ -1,39 +1,29 @@ |
| 1 | mod accessibility; | ||
| 2 | mod macos; | 1 | mod macos; |
| 3 | #[cfg(test)] | ||
| 4 | mod profile; | ||
| 5 | mod scroll; | ||
| 6 | 2 | ||
| 7 | use canvas::gpu::{Primitive, Renderer, Stroke, Viewport, page::PageScene}; | 3 | use canvas::gpu::{Renderer, page::PageScene}; |
| 4 | use canvas::interaction::{ | ||
| 5 | Cursor, Key as PageKey, Modifiers, NamedKey as PageNamedKey, PageView, Request, Response, | ||
| 6 | TextColors, accessibility, | ||
| 7 | }; | ||
| 8 | use canvas::{ | 8 | use canvas::{ |
| 9 | date::DateField, | 9 | date::DateField, |
| 10 | document::TextDocument, | 10 | document::TextDocument, |
| 11 | editor::{ | 11 | editor::{CanvasEditor, DEFAULT_OUTLINE_WIDTH, TextOutline}, |
| 12 | CanvasEditor, DEFAULT_OUTLINE_WIDTH, Movement, Selection, SelectionUnit, TextOutline, | ||
| 13 | }, | ||
| 14 | layout::TextEngine, | 12 | layout::TextEngine, |
| 15 | }; | 13 | }; |
| 16 | use onestore::document::Format; | 14 | use onestore::document::Format; |
| 17 | use onestore::page::Page; | 15 | use onestore::page::Page; |
| 18 | use onestore::page::text::Paragraph; | 16 | use onestore::page::text::Paragraph; |
| 19 | use std::{ | 17 | use std::{error::Error, path::PathBuf, sync::Arc, time::Instant}; |
| 20 | error::Error, | ||
| 21 | path::PathBuf, | ||
| 22 | sync::Arc, | ||
| 23 | time::{Duration, Instant}, | ||
| 24 | }; | ||
| 25 | use winit::{ | 18 | use winit::{ |
| 26 | application::ApplicationHandler, | 19 | application::ApplicationHandler, |
| 27 | dpi::{LogicalSize, PhysicalPosition, PhysicalSize}, | 20 | dpi::{LogicalSize, PhysicalPosition, PhysicalSize}, |
| 28 | event::{ElementState, Ime, MouseButton, MouseScrollDelta, WindowEvent}, | 21 | event::{ElementState, Ime, MouseButton, MouseScrollDelta, WindowEvent}, |
| 29 | event_loop::{ActiveEventLoop, ControlFlow, EventLoopProxy}, | 22 | event_loop::{ActiveEventLoop, ControlFlow, EventLoopProxy}, |
| 30 | keyboard::{Key, ModifiersState, NamedKey}, | 23 | keyboard::{Key, ModifiersState, NamedKey}, |
| 31 | window::{Window, WindowId}, | 24 | window::{CursorIcon, Window, WindowId}, |
| 32 | }; | 25 | }; |
| 33 | 26 | ||
| 34 | const HANDLE_HEIGHT: f32 = 6.75; | ||
| 35 | const DATE_LABELS: [&str; 2] = ["Page date", "Page time"]; | ||
| 36 | |||
| 37 | #[derive(Debug)] | 27 | #[derive(Debug)] |
| 38 | enum UserEvent { | 28 | enum UserEvent { |
| 39 | Quit, | 29 | Quit, |
| ... | @@ -55,11 +45,54 @@ fn trace_input(event: &impl std::fmt::Debug) { | ... | @@ -55,11 +45,54 @@ fn trace_input(event: &impl std::fmt::Debug) { |
| 55 | } | 45 | } |
| 56 | } | 46 | } |
| 57 | 47 | ||
| 58 | fn read_only_shortcut(key: &Key, modifiers: ModifiersState) -> bool { | 48 | fn page_key(key: &Key) -> PageKey { |
| 59 | key == &Key::Named(NamedKey::Escape) | 49 | match key { |
| 60 | || (modifiers.control_key() && key == &Key::Named(NamedKey::Tab)) | 50 | Key::Character(text) => PageKey::Character(text.to_string()), |
| 61 | || (modifiers.super_key() | 51 | Key::Named(named) => PageKey::Named(match named { |
| 62 | && matches!(key, Key::Character(value) if matches!(value.as_str(), "+" | "=" | "-" | "0") || (modifiers.shift_key() && value.eq_ignore_ascii_case("n")))) | 52 | NamedKey::Escape => PageNamedKey::Escape, |
| 53 | NamedKey::Tab => PageNamedKey::Tab, | ||
| 54 | NamedKey::Space => PageNamedKey::Space, | ||
| 55 | NamedKey::Enter => PageNamedKey::Enter, | ||
| 56 | NamedKey::Backspace => PageNamedKey::Backspace, | ||
| 57 | NamedKey::Delete => PageNamedKey::Delete, | ||
| 58 | NamedKey::ArrowLeft => PageNamedKey::ArrowLeft, | ||
| 59 | NamedKey::ArrowRight => PageNamedKey::ArrowRight, | ||
| 60 | NamedKey::ArrowUp => PageNamedKey::ArrowUp, | ||
| 61 | NamedKey::ArrowDown => PageNamedKey::ArrowDown, | ||
| 62 | NamedKey::Home => PageNamedKey::Home, | ||
| 63 | NamedKey::End => PageNamedKey::End, | ||
| 64 | NamedKey::Alt | ||
| 65 | | NamedKey::AltGraph | ||
| 66 | | NamedKey::Control | ||
| 67 | | NamedKey::Shift | ||
| 68 | | NamedKey::Super | ||
| 69 | | NamedKey::Meta => PageNamedKey::Modifier, | ||
| 70 | _ => PageNamedKey::Other, | ||
| 71 | }), | ||
| 72 | _ => PageKey::Named(PageNamedKey::Other), | ||
| 73 | } | ||
| 74 | } | ||
| 75 | |||
| 76 | fn page_modifiers(modifiers: ModifiersState) -> Modifiers { | ||
| 77 | Modifiers { | ||
| 78 | shift: modifiers.shift_key(), | ||
| 79 | control: modifiers.control_key(), | ||
| 80 | option: modifiers.alt_key(), | ||
| 81 | command: modifiers.super_key(), | ||
| 82 | } | ||
| 83 | } | ||
| 84 | |||
| 85 | fn cursor_icon(cursor: Cursor) -> CursorIcon { | ||
| 86 | match cursor { | ||
| 87 | Cursor::Default => CursorIcon::Default, | ||
| 88 | Cursor::Text => CursorIcon::Text, | ||
| 89 | Cursor::Pointer => CursorIcon::Pointer, | ||
| 90 | Cursor::Move => CursorIcon::Move, | ||
| 91 | Cursor::EwResize => CursorIcon::EwResize, | ||
| 92 | Cursor::NsResize => CursorIcon::NsResize, | ||
| 93 | Cursor::NwseResize => CursorIcon::NwseResize, | ||
| 94 | Cursor::NeswResize => CursorIcon::NeswResize, | ||
| 95 | } | ||
| 63 | } | 96 | } |
| 64 | 97 | ||
| 65 | struct App { | 98 | struct App { |
| ... | @@ -105,103 +138,25 @@ impl Session { | ... | @@ -105,103 +138,25 @@ impl Session { |
| 105 | } | 138 | } |
| 106 | } | 139 | } |
| 107 | 140 | ||
| 108 | enum Drag { | ||
| 109 | Text { | ||
| 110 | anchor: Selection, | ||
| 111 | unit: SelectionUnit, | ||
| 112 | }, | ||
| 113 | Scrollbar { | ||
| 114 | axis: usize, | ||
| 115 | grab: f32, | ||
| 116 | }, | ||
| 117 | Resize { | ||
| 118 | outline: Option<Box<TextOutline>>, | ||
| 119 | grab: f32, | ||
| 120 | }, | ||
| 121 | Outline { | ||
| 122 | id: onestore::ExGuid, | ||
| 123 | grab: [f32; 2], | ||
| 124 | pending_press: Option<[f32; 2]>, | ||
| 125 | }, | ||
| 126 | Image { | ||
| 127 | id: onestore::ExGuid, | ||
| 128 | handle: [i8; 2], | ||
| 129 | /// Document point of the press. | ||
| 130 | press: [f32; 2], | ||
| 131 | pending_press: Option<[f32; 2]>, | ||
| 132 | }, | ||
| 133 | } | ||
| 134 | |||
| 135 | #[derive(Clone, Copy)] | ||
| 136 | enum PointerFeedback<'a> { | ||
| 137 | Hover(onestore::ExGuid), | ||
| 138 | Move(onestore::ExGuid, [f32; 2]), | ||
| 139 | Resize(&'a TextOutline), | ||
| 140 | /// A picture being moved or resized, drawn at this origin and size. | ||
| 141 | Image(onestore::ExGuid, [f32; 2], [f32; 2]), | ||
| 142 | } | ||
| 143 | |||
| 144 | /// A non-text object holding focus, which hides the text caret and suspends typing. | ||
| 145 | #[derive(Clone, Copy, Debug, PartialEq)] | ||
| 146 | enum ObjectFocus { | ||
| 147 | ReadOnly(usize), | ||
| 148 | Image(onestore::ExGuid), | ||
| 149 | } | ||
| 150 | |||
| 151 | impl ObjectFocus { | ||
| 152 | fn read_only(self) -> Option<usize> { | ||
| 153 | match self { | ||
| 154 | Self::ReadOnly(index) => Some(index), | ||
| 155 | Self::Image(_) => None, | ||
| 156 | } | ||
| 157 | } | ||
| 158 | } | ||
| 159 | |||
| 160 | struct State { | 141 | struct State { |
| 161 | window: Arc<Window>, | 142 | window: Arc<Window>, |
| 162 | instance: wgpu::Instance, | 143 | instance: wgpu::Instance, |
| 163 | surface: wgpu::Surface<'static>, | 144 | surface: wgpu::Surface<'static>, |
| 164 | config: wgpu::SurfaceConfiguration, | 145 | config: wgpu::SurfaceConfiguration, |
| 165 | renderer: Renderer, | 146 | renderer: Renderer, |
| 166 | engine: TextEngine, | 147 | view: PageView, |
| 167 | editor: CanvasEditor, | ||
| 168 | session: Option<Session>, | 148 | session: Option<Session>, |
| 169 | initial: Vec<(onestore::ExGuid, TextDocument)>, | 149 | initial: Vec<(onestore::ExGuid, TextDocument)>, |
| 170 | initial_layouts: Vec<(onestore::ExGuid, onestore::document::Layout)>, | 150 | initial_layouts: Vec<(onestore::ExGuid, onestore::document::Layout)>, |
| 171 | initial_date: Option<u64>, | 151 | initial_date: Option<u64>, |
| 172 | scene: Option<(PageScene, [f32; 2])>, | ||
| 173 | viewport: Viewport, | ||
| 174 | display_scale: f32, | ||
| 175 | pointer: [f32; 2], | ||
| 176 | pointer_inside: bool, | ||
| 177 | last_click: Option<(Instant, [f32; 2], u8)>, | ||
| 178 | drag: Option<Drag>, | ||
| 179 | object_focus: Option<ObjectFocus>, | ||
| 180 | modifiers: ModifiersState, | ||
| 181 | focused: bool, | ||
| 182 | occluded: bool, | 152 | occluded: bool, |
| 183 | caret: bool, | 153 | ime_allowed: bool, |
| 184 | blink_at: Instant, | ||
| 185 | clipboard: arboard::Clipboard, | 154 | clipboard: arboard::Clipboard, |
| 186 | access_adapter: accesskit_winit::Adapter, | 155 | access_adapter: accesskit_winit::Adapter, |
| 187 | accessibility: accessibility::Accessibility, | 156 | accessibility: accessibility::Accessibility, |
| 188 | } | 157 | } |
| 189 | 158 | ||
| 190 | impl State { | 159 | impl State { |
| 191 | fn edit_date(&mut self, field: DateField) -> Result<(), Box<dyn Error>> { | ||
| 192 | let Some(date) = self.editor.date() else { | ||
| 193 | return Ok(()); | ||
| 194 | }; | ||
| 195 | let timestamp = date.timestamp(); | ||
| 196 | self.editor.finish_composition(); | ||
| 197 | macos::clear_marked_text(&self.window); | ||
| 198 | self.drag = None; | ||
| 199 | if let Some((timestamp, text)) = macos::edit_date(timestamp, field)? { | ||
| 200 | self.editor.change_date(&mut self.engine, timestamp, text)?; | ||
| 201 | } | ||
| 202 | self.changed() | ||
| 203 | } | ||
| 204 | |||
| 205 | async fn new( | 160 | async fn new( |
| 206 | event_loop: &ActiveEventLoop, | 161 | event_loop: &ActiveEventLoop, |
| 207 | proxy: EventLoopProxy<UserEvent>, | 162 | proxy: EventLoopProxy<UserEvent>, |
| ... | @@ -309,7 +264,7 @@ impl State { | ... | @@ -309,7 +264,7 @@ impl State { |
| 309 | .collect(); | 264 | .collect(); |
| 310 | let dpr = window.scale_factor() as f32; | 265 | let dpr = window.scale_factor() as f32; |
| 311 | window.set_ime_allowed(true); | 266 | window.set_ime_allowed(true); |
| 312 | window.set_cursor(winit::window::CursorIcon::Text); | 267 | window.set_cursor(CursorIcon::Text); |
| 313 | window.request_redraw(); | 268 | window.request_redraw(); |
| 314 | eprintln!("Canvas GPU: {:?}; scale factor {dpr}", adapter.get_info()); | 269 | eprintln!("Canvas GPU: {:?}; scale factor {dpr}", adapter.get_info()); |
| 315 | Ok(Self { | 270 | Ok(Self { |
| ... | @@ -318,187 +273,99 @@ impl State { | ... | @@ -318,187 +273,99 @@ impl State { |
| 318 | surface, | 273 | surface, |
| 319 | config, | 274 | config, |
| 320 | renderer, | 275 | renderer, |
| 321 | engine, | 276 | view: PageView::new( |
| 322 | editor, | 277 | editor, |
| 278 | engine, | ||
| 279 | scene, | ||
| 280 | [size.width, size.height], | ||
| 281 | dpr, | ||
| 282 | macos::double_click_interval(), | ||
| 283 | ), | ||
| 323 | session, | 284 | session, |
| 324 | initial, | 285 | initial, |
| 325 | initial_date, | 286 | initial_date, |
| 326 | initial_layouts, | 287 | initial_layouts, |
| 327 | scene, | ||
| 328 | viewport: Viewport { | ||
| 329 | size: [size.width, size.height], | ||
| 330 | scale: dpr * 96.0 / 72.0, | ||
| 331 | origin: [48.0 * dpr; 2], | ||
| 332 | }, | ||
| 333 | display_scale: dpr, | ||
| 334 | pointer: [0.0; 2], | ||
| 335 | pointer_inside: false, | ||
| 336 | last_click: None, | ||
| 337 | drag: None, | ||
| 338 | object_focus: None, | ||
| 339 | modifiers: ModifiersState::empty(), | ||
| 340 | focused: true, | ||
| 341 | occluded: false, | 288 | occluded: false, |
| 342 | caret: true, | 289 | ime_allowed: true, |
| 343 | blink_at: Instant::now() + Duration::from_millis(500), | ||
| 344 | clipboard: arboard::Clipboard::new()?, | 290 | clipboard: arboard::Clipboard::new()?, |
| 345 | access_adapter, | 291 | access_adapter, |
| 346 | accessibility: accessibility::Accessibility::default(), | 292 | accessibility: accessibility::Accessibility::default(), |
| 347 | }) | 293 | }) |
| 348 | } | 294 | } |
| 349 | 295 | ||
| 350 | fn preview(&self) -> Option<(onestore::ExGuid, [f32; 2])> { | 296 | /// Carries out what a page event asked of the platform and follows its changes: the |
| 351 | let Drag::Outline { | 297 | /// input method's position, accessibility, saving, the window title and a repaint. |
| 352 | id, | 298 | fn respond(&mut self, response: Response) -> Result<(), Box<dyn Error>> { |
| 353 | grab, | 299 | match response.request { |
| 354 | pending_press, | 300 | Some(Request::EditDate(field)) => self.edit_date(field)?, |
| 355 | } = self.drag.as_ref()? | 301 | Some(Request::Copy(text)) => self.clipboard.set_text(text)?, |
| 356 | else { | 302 | Some(Request::Paste) => { |
| 357 | return None; | 303 | let text = self.clipboard.get_text()?; |
| 358 | }; | 304 | let response = self.view.commit_text(text)?; |
| 359 | let origin = self | 305 | self.respond(response)?; |
| 360 | .editor | 306 | } |
| 361 | .outlines() | 307 | Some(Request::CharacterPalette) => macos::show_character_palette(), |
| 362 | .iter() | 308 | None => {} |
| 363 | .find(|outline| outline.id == *id)? | 309 | } |
| 364 | .origin(); | 310 | if self.ime_allowed != self.view.accepts_text() { |
| 365 | if pending_press.is_some() { | 311 | self.ime_allowed = self.view.accepts_text(); |
| 366 | return Some((*id, origin)); | 312 | self.window.set_ime_allowed(self.ime_allowed); |
| 313 | } | ||
| 314 | self.window.set_cursor(cursor_icon(self.view.cursor())); | ||
| 315 | if response.changed { | ||
| 316 | let [x0, y0, x1, y1] = self.view.caret_area()?; | ||
| 317 | self.window.set_ime_cursor_area( | ||
| 318 | PhysicalPosition::new(x0, y0), | ||
| 319 | PhysicalSize::new(x1 - x0, y1 - y0), | ||
| 320 | ); | ||
| 321 | self.update_accessibility()?; | ||
| 322 | self.persist()?; | ||
| 323 | self.title_from_page(); | ||
| 367 | } | 324 | } |
| 368 | let point = self.viewport.document_point(self.pointer); | 325 | if response.redraw { |
| 369 | let position = [point[0] - grab[0], point[1] - grab[1]]; | 326 | self.window.request_redraw(); |
| 370 | Some(( | 327 | } |
| 371 | *id, | 328 | Ok(()) |
| 372 | if self.modifiers.alt_key() { | ||
| 373 | position | ||
| 374 | } else { | ||
| 375 | snap_to_grid(position, self.editor.margin_origin()) | ||
| 376 | }, | ||
| 377 | )) | ||
| 378 | } | 329 | } |
| 379 | 330 | ||
| 380 | fn image_preview(&self) -> Option<(onestore::ExGuid, [f32; 2], [f32; 2])> { | 331 | /// A temporary page's window takes its title's first line. |
| 381 | let Drag::Image { | 332 | fn title_from_page(&self) { |
| 382 | id, | 333 | let editor = &self.view.editor; |
| 383 | handle, | 334 | if self.session.is_some() || !editor.active_outline().title { |
| 384 | press, | 335 | return; |
| 385 | pending_press: None, | ||
| 386 | } = self.drag.as_ref()? | ||
| 387 | else { | ||
| 388 | return None; | ||
| 389 | }; | ||
| 390 | let (origin, size) = self.editor.image_placement(*id)?; | ||
| 391 | let point = self.viewport.document_point(self.pointer); | ||
| 392 | let delta = [point[0] - press[0], point[1] - press[1]]; | ||
| 393 | if *handle != [0, 0] { | ||
| 394 | let (origin, size) = resize_image(origin, size, *handle, delta); | ||
| 395 | return Some((*id, origin, size)); | ||
| 396 | } | ||
| 397 | // A picture in an outline's flow keeps its place. | ||
| 398 | if self.editor.image_in_outline(*id) { | ||
| 399 | return Some((*id, origin, size)); | ||
| 400 | } | 336 | } |
| 401 | let origin = [origin[0] + delta[0], origin[1] + delta[1]]; | 337 | let title = editor |
| 402 | let origin = if self.modifiers.alt_key() { | 338 | .active_outline() |
| 403 | origin | 339 | .document() |
| 340 | .paragraphs() | ||
| 341 | .next() | ||
| 342 | .unwrap() | ||
| 343 | .text() | ||
| 344 | .split(['\u{000b}', '\n', '\r']) | ||
| 345 | .next() | ||
| 346 | .unwrap(); | ||
| 347 | let title = if title.trim().is_empty() { | ||
| 348 | "Untitled" | ||
| 404 | } else { | 349 | } else { |
| 405 | snap_to_grid(origin, self.editor.margin_origin()) | 350 | title |
| 406 | }; | 351 | }; |
| 407 | Some((*id, origin, size)) | 352 | let title = format!("{title} · Temporary page"); |
| 408 | } | 353 | if self.window.title() != title { |
| 409 | 354 | self.window.set_title(&title); | |
| 410 | /// The selected picture's handles lie above the page; its body keeps its paint order. | ||
| 411 | fn hit_test(&self, point: [f32; 2]) -> Option<Hit> { | ||
| 412 | let pixel = self.display_scale / self.viewport.scale; | ||
| 413 | if let Some(ObjectFocus::Image(id)) = self.object_focus | ||
| 414 | && let Some((origin, size)) = self.editor.image_placement(id) | ||
| 415 | && let Some(handle) = image_handle_at(image_rect(origin, size), pixel, point) | ||
| 416 | { | ||
| 417 | return Some(Hit::Image { id, handle }); | ||
| 418 | } | 355 | } |
| 419 | page_hit_test(&self.editor, self.scene.as_ref(), point, pixel) | ||
| 420 | } | 356 | } |
| 421 | 357 | ||
| 422 | fn set_object_focus(&mut self, focus: Option<ObjectFocus>) { | 358 | fn edit_date(&mut self, field: DateField) -> Result<(), Box<dyn Error>> { |
| 423 | if self.object_focus != focus { | 359 | let Some(date) = self.view.editor.date() else { |
| 424 | self.editor.finish_composition(); | 360 | return Ok(()); |
| 425 | self.drag = None; | ||
| 426 | self.object_focus = focus; | ||
| 427 | self.window.set_ime_allowed(focus.is_none()); | ||
| 428 | } | ||
| 429 | } | ||
| 430 | |||
| 431 | /// Page coordinates of a focused object. | ||
| 432 | fn object_rect(&self, focus: ObjectFocus) -> [f32; 4] { | ||
| 433 | let (scene, offset) = self.scene.as_ref().unwrap(); | ||
| 434 | let [x0, y0, x1, y1] = match focus { | ||
| 435 | ObjectFocus::ReadOnly(index) => scene | ||
| 436 | .read_only(Some(&self.editor)) | ||
| 437 | .nth(index) | ||
| 438 | .unwrap() | ||
| 439 | .rect(), | ||
| 440 | ObjectFocus::Image(id) => { | ||
| 441 | let (origin, size) = self.editor.image_placement(id).unwrap(); | ||
| 442 | image_rect(origin, size) | ||
| 443 | } | ||
| 444 | }; | 361 | }; |
| 445 | [ | 362 | let timestamp = date.timestamp(); |
| 446 | x0 + offset[0], | 363 | self.view.editor.finish_composition(); |
| 447 | y0 + offset[1], | 364 | macos::clear_marked_text(&self.window); |
| 448 | x1 + offset[0], | 365 | if let Some((timestamp, text)) = macos::edit_date(timestamp, field)? { |
| 449 | y1 + offset[1], | 366 | let response = self.view.change_date(timestamp, text)?; |
| 450 | ] | 367 | self.respond(response)?; |
| 451 | } | ||
| 452 | |||
| 453 | fn changed(&mut self) -> Result<(), Box<dyn Error>> { | ||
| 454 | self.scroll().clamp(&mut self.viewport); | ||
| 455 | self.caret = true; | ||
| 456 | self.blink_at = Instant::now() + Duration::from_millis(500); | ||
| 457 | let mut rect = self.editor.caret(1.0)?; | ||
| 458 | let origin = self | ||
| 459 | .preview() | ||
| 460 | .map(|(_, origin)| origin) | ||
| 461 | .unwrap_or_else(|| self.editor.active_outline().origin()); | ||
| 462 | rect.x0 += f64::from(origin[0]); | ||
| 463 | rect.y0 += f64::from(origin[1]); | ||
| 464 | rect.x1 += f64::from(origin[0]); | ||
| 465 | rect.y1 += f64::from(origin[1]); | ||
| 466 | let v = self.viewport; | ||
| 467 | self.window.set_ime_cursor_area( | ||
| 468 | PhysicalPosition::new( | ||
| 469 | rect.x0 * f64::from(v.scale) + f64::from(v.origin[0]), | ||
| 470 | rect.y0 * f64::from(v.scale) + f64::from(v.origin[1]), | ||
| 471 | ), | ||
| 472 | PhysicalSize::new( | ||
| 473 | (rect.width() * f64::from(v.scale)).max(1.0), | ||
| 474 | (rect.height() * f64::from(v.scale)).max(1.0), | ||
| 475 | ), | ||
| 476 | ); | ||
| 477 | self.update_accessibility()?; | ||
| 478 | self.persist()?; | ||
| 479 | if self.session.is_none() && self.editor.active_outline().title { | ||
| 480 | let title = self | ||
| 481 | .editor | ||
| 482 | .active_outline() | ||
| 483 | .document() | ||
| 484 | .paragraphs() | ||
| 485 | .next() | ||
| 486 | .unwrap() | ||
| 487 | .text() | ||
| 488 | .split(['\u{000b}', '\n', '\r']) | ||
| 489 | .next() | ||
| 490 | .unwrap(); | ||
| 491 | let title = if title.trim().is_empty() { | ||
| 492 | "Untitled" | ||
| 493 | } else { | ||
| 494 | title | ||
| 495 | }; | ||
| 496 | let title = format!("{title} · Temporary page"); | ||
| 497 | if self.window.title() != title { | ||
| 498 | self.window.set_title(&title); | ||
| 499 | } | ||
| 500 | } | 368 | } |
| 501 | self.window.request_redraw(); | ||
| 502 | Ok(()) | 369 | Ok(()) |
| 503 | } | 370 | } |
| 504 | 371 | ||
| ... | @@ -508,7 +375,7 @@ impl State { | ... | @@ -508,7 +375,7 @@ impl State { |
| 508 | let Some(session) = &mut self.session else { | 375 | let Some(session) = &mut self.session else { |
| 509 | return Ok(()); | 376 | return Ok(()); |
| 510 | }; | 377 | }; |
| 511 | let after = self.editor.page()?; | 378 | let after = self.view.editor.page()?; |
| 512 | match session | 379 | match session |
| 513 | .section | 380 | .section |
| 514 | .save(session.space, &session.before, &after, "snowbound")? | 381 | .save(session.space, &session.before, &after, "snowbound")? |
| ... | @@ -530,12 +397,9 @@ impl State { | ... | @@ -530,12 +397,9 @@ impl State { |
| 530 | return Ok(()); | 397 | return Ok(()); |
| 531 | }; | 398 | }; |
| 532 | let page = session.section.page(session.space)?; | 399 | let page = session.section.page(session.space)?; |
| 533 | let (scene, editor) = PageScene::from_page(page.clone(), &mut self.engine)?; | 400 | let (scene, editor) = PageScene::from_page(page.clone(), &mut self.view.engine)?; |
| 534 | session.before = page; | 401 | session.before = page; |
| 535 | self.editor = editor; | 402 | self.view.replace(editor, Some((scene, [0.0; 2]))); |
| 536 | self.scene = Some((scene, [0.0; 2])); | ||
| 537 | self.drag = None; | ||
| 538 | self.object_focus = None; | ||
| 539 | self.window.set_title(&session.window_title()); | 403 | self.window.set_title(&session.window_title()); |
| 540 | self.update_accessibility()?; | 404 | self.update_accessibility()?; |
| 541 | self.window.request_redraw(); | 405 | self.window.request_redraw(); |
| ... | @@ -557,7 +421,7 @@ impl State { | ... | @@ -557,7 +421,7 @@ impl State { |
| 557 | return Ok(()); | 421 | return Ok(()); |
| 558 | }; | 422 | }; |
| 559 | let reviewed = if keep_mine { | 423 | let reviewed = if keep_mine { |
| 560 | self.editor.page()? | 424 | self.view.editor.page()? |
| 561 | } else { | 425 | } else { |
| 562 | session.section.remote_page(session.space)? | 426 | session.section.remote_page(session.space)? |
| 563 | }; | 427 | }; |
| ... | @@ -610,21 +474,21 @@ impl State { | ... | @@ -610,21 +474,21 @@ impl State { |
| 610 | 474 | ||
| 611 | fn update_accessibility(&mut self) -> Result<(), Box<dyn Error>> { | 475 | fn update_accessibility(&mut self) -> Result<(), Box<dyn Error>> { |
| 612 | let mut error = None; | 476 | let mut error = None; |
| 613 | let preview = self.preview(); | 477 | let view = &self.view; |
| 614 | self.access_adapter.update_if_active(|| { | 478 | self.access_adapter.update_if_active(|| { |
| 615 | match self.accessibility.update( | 479 | match self.accessibility.update( |
| 616 | &self.editor, | 480 | &view.editor, |
| 617 | self.viewport, | 481 | view.viewport, |
| 618 | &self.window.title(), | 482 | &self.window.title(), |
| 619 | preview, | 483 | view.outline_preview(), |
| 620 | ) { | 484 | ) { |
| 621 | Ok(mut update) => { | 485 | Ok(mut update) => { |
| 622 | self.accessibility.append_page_fields( | 486 | self.accessibility.append_page_fields( |
| 623 | &mut update, | 487 | &mut update, |
| 624 | self.scene.as_ref(), | 488 | view.scene.as_ref(), |
| 625 | &self.editor, | 489 | &view.editor, |
| 626 | self.viewport, | 490 | view.viewport, |
| 627 | self.object_focus.and_then(ObjectFocus::read_only), | 491 | view.object_focus().and_then(|focus| focus.read_only()), |
| 628 | ); | 492 | ); |
| 629 | update | 493 | update |
| 630 | } | 494 | } |
| ... | @@ -662,138 +526,46 @@ impl State { | ... | @@ -662,138 +526,46 @@ impl State { |
| 662 | } | 526 | } |
| 663 | if let Some(index) = self.accessibility.read_only_for_node(request.target_node) { | 527 | if let Some(index) = self.accessibility.read_only_for_node(request.target_node) { |
| 664 | if request.action == Action::Focus { | 528 | if request.action == Action::Focus { |
| 665 | self.set_object_focus(Some(ObjectFocus::ReadOnly(index))); | ||
| 666 | self.window.focus_window(); | 529 | self.window.focus_window(); |
| 667 | self.reveal_focus()?; | 530 | let response = self.view.focus_read_only(index)?; |
| 668 | self.changed()?; | 531 | self.respond(response)?; |
| 669 | } | 532 | } |
| 670 | return Ok(()); | 533 | return Ok(()); |
| 671 | } | 534 | } |
| 672 | let Some(outline) = self.accessibility.outline_for_node(request.target_node) else { | 535 | let Some(outline) = self.accessibility.outline_for_node(request.target_node) else { |
| 673 | return Ok(()); | 536 | return Ok(()); |
| 674 | }; | 537 | }; |
| 538 | let view = &mut self.view; | ||
| 675 | match (request.action, request.data) { | 539 | match (request.action, request.data) { |
| 676 | (Action::Focus, _) => { | 540 | (Action::Focus, _) => { |
| 677 | self.editor.focus_outline(outline)?; | 541 | view.editor.focus_outline(outline)?; |
| 678 | self.window.focus_window(); | 542 | self.window.focus_window(); |
| 679 | } | 543 | } |
| 680 | (Action::SetTextSelection, Some(ActionData::SetTextSelection(selection))) => { | 544 | (Action::SetTextSelection, Some(ActionData::SetTextSelection(selection))) => { |
| 681 | let selection = self.accessibility.selection(outline, selection)?; | 545 | let selection = self.accessibility.selection(outline, selection)?; |
| 682 | self.editor.focus_outline(outline)?; | 546 | view.editor.focus_outline(outline)?; |
| 683 | self.editor.select(selection)?; | 547 | view.editor.select(selection)?; |
| 684 | trace_input(&self.editor.selection()); | 548 | trace_input(&view.editor.selection()); |
| 685 | } | 549 | } |
| 686 | (Action::SetValue, Some(ActionData::Value(text))) => { | 550 | (Action::SetValue, Some(ActionData::Value(text))) => { |
| 687 | self.editor.focus_outline(outline)?; | 551 | view.editor.focus_outline(outline)?; |
| 688 | self.editor.select_all()?; | 552 | view.editor.select_all()?; |
| 689 | self.editor.commit_text(&mut self.engine, text.into())?; | 553 | view.editor.commit_text(&mut view.engine, text.into())?; |
| 690 | } | 554 | } |
| 691 | (Action::ReplaceSelectedText, Some(ActionData::Value(text))) => { | 555 | (Action::ReplaceSelectedText, Some(ActionData::Value(text))) => { |
| 692 | self.editor.focus_outline(outline)?; | 556 | view.editor.focus_outline(outline)?; |
| 693 | self.editor.commit_text(&mut self.engine, text.into())?; | 557 | view.editor.commit_text(&mut view.engine, text.into())?; |
| 694 | } | 558 | } |
| 695 | _ => return Ok(()), | 559 | _ => return Ok(()), |
| 696 | } | 560 | } |
| 697 | self.set_object_focus(None); | 561 | let response = self.view.focus_text()?; |
| 698 | self.reveal_focus()?; | 562 | self.respond(response) |
| 699 | self.changed() | ||
| 700 | } | ||
| 701 | |||
| 702 | fn reveal_focus(&mut self) -> Result<(), Box<dyn Error>> { | ||
| 703 | if self.viewport.size.contains(&0) { | ||
| 704 | return Ok(()); | ||
| 705 | } | ||
| 706 | let rect = if let Some(focus) = self.object_focus { | ||
| 707 | let [x0, y0, x1, y1] = self.object_rect(focus).map(f64::from); | ||
| 708 | parley::BoundingBox { x0, y0, x1, y1 } | ||
| 709 | } else { | ||
| 710 | let mut rect = self.editor.caret(1.0)?; | ||
| 711 | let origin = self.editor.active_outline().origin(); | ||
| 712 | rect.x0 += f64::from(origin[0]); | ||
| 713 | rect.x1 += f64::from(origin[0]); | ||
| 714 | rect.y0 += f64::from(origin[1]); | ||
| 715 | rect.y1 += f64::from(origin[1]); | ||
| 716 | rect | ||
| 717 | }; | ||
| 718 | let outline = self.editor.active_outline().bounds(); | ||
| 719 | for (axis, (mut start, mut end)) in [(rect.x0, rect.x1), (rect.y0, rect.y1)] | ||
| 720 | .into_iter() | ||
| 721 | .enumerate() | ||
| 722 | { | ||
| 723 | let size = f64::from(self.viewport.size[axis]); | ||
| 724 | let margin = f64::from(16.0 * self.display_scale).min(size * 0.25); | ||
| 725 | let (outline_start, outline_end) = | ||
| 726 | [(outline.x0, outline.x1), (outline.y0, outline.y1)][axis]; | ||
| 727 | let outline_start = outline_start.min(start); | ||
| 728 | let outline_end = outline_end.max(end); | ||
| 729 | if self.object_focus.is_none() | ||
| 730 | && (outline_end - outline_start) * f64::from(self.viewport.scale) | ||
| 731 | <= size - margin * 2.0 | ||
| 732 | { | ||
| 733 | start = outline_start; | ||
| 734 | end = outline_end; | ||
| 735 | } | ||
| 736 | let start = | ||
| 737 | start * f64::from(self.viewport.scale) + f64::from(self.viewport.origin[axis]); | ||
| 738 | let end = end * f64::from(self.viewport.scale) + f64::from(self.viewport.origin[axis]); | ||
| 739 | let shift = if start < margin { | ||
| 740 | margin - start | ||
| 741 | } else if end > size - margin { | ||
| 742 | size - margin - end | ||
| 743 | } else { | ||
| 744 | 0.0 | ||
| 745 | }; | ||
| 746 | self.viewport.origin[axis] += shift as f32; | ||
| 747 | } | ||
| 748 | Ok(()) | ||
| 749 | } | ||
| 750 | |||
| 751 | fn zoom(&mut self, factor: f32) { | ||
| 752 | let point = self.viewport.document_point(self.pointer); | ||
| 753 | let dpr = self.window.scale_factor() as f32; | ||
| 754 | self.viewport.scale = (self.viewport.scale * factor).clamp(dpr / 3.0, dpr * 16.0 / 3.0); | ||
| 755 | self.viewport.origin = [ | ||
| 756 | self.pointer[0] - point[0] * self.viewport.scale, | ||
| 757 | self.pointer[1] - point[1] * self.viewport.scale, | ||
| 758 | ]; | ||
| 759 | } | ||
| 760 | |||
| 761 | fn scroll(&self) -> scroll::Scroll { | ||
| 762 | let editable = self | ||
| 763 | .editor | ||
| 764 | .visible_outlines() | ||
| 765 | .chain(self.editor.caret_outline()) | ||
| 766 | .map(|outline| { | ||
| 767 | let rect = outline.bounds(); | ||
| 768 | let offset = self | ||
| 769 | .scene | ||
| 770 | .as_ref() | ||
| 771 | .filter(|_| self.editor.has_page_outline(outline.id)) | ||
| 772 | .map(|(_, offset)| *offset) | ||
| 773 | .unwrap_or([0.0; 2]); | ||
| 774 | [ | ||
| 775 | rect.x0 as f32 + offset[0], | ||
| 776 | rect.y0 as f32 + offset[1], | ||
| 777 | rect.x1 as f32 + offset[0], | ||
| 778 | rect.y1 as f32 + offset[1], | ||
| 779 | ] | ||
| 780 | }); | ||
| 781 | let fixed = self.scene.iter().flat_map(|(scene, offset)| { | ||
| 782 | scene.content_bounds(&self.editor).map(|rect| { | ||
| 783 | [ | ||
| 784 | rect[0] + offset[0], | ||
| 785 | rect[1] + offset[1], | ||
| 786 | rect[2] + offset[0], | ||
| 787 | rect[3] + offset[1], | ||
| 788 | ] | ||
| 789 | }) | ||
| 790 | }); | ||
| 791 | scroll::Scroll::new(self.viewport, editable.chain(fixed)) | ||
| 792 | } | 563 | } |
| 793 | 564 | ||
| 794 | fn draw(&mut self) -> Result<(), Box<dyn Error>> { | 565 | fn draw(&mut self) -> Result<(), Box<dyn Error>> { |
| 795 | trace_input(&("Draw", self.viewport.origin, self.viewport.scale)); | 566 | let viewport = self.view.viewport; |
| 796 | if self.occluded || self.viewport.size.contains(&0) { | 567 | trace_input(&("Draw", viewport.origin, viewport.scale)); |
| 568 | if self.occluded || viewport.size.contains(&0) { | ||
| 797 | return Ok(()); | 569 | return Ok(()); |
| 798 | } | 570 | } |
| 799 | let mut reconfigure = false; | 571 | let mut reconfigure = false; |
| ... | @@ -827,56 +599,12 @@ impl State { | ... | @@ -827,56 +599,12 @@ impl State { |
| 827 | return Err("Canvas surface validation failed".into()); | 599 | return Err("Canvas surface validation failed".into()); |
| 828 | } | 600 | } |
| 829 | }; | 601 | }; |
| 830 | let mut primitives = page_primitives( | 602 | let [caret, selection] = macos::text_colors(); |
| 831 | &self.editor, | 603 | let primitives = self.view.primitives(TextColors { caret, selection })?; |
| 832 | self.scene.as_ref(), | ||
| 833 | match &self.drag { | ||
| 834 | Some(Drag::Resize { | ||
| 835 | outline: Some(outline), | ||
| 836 | .. | ||
| 837 | }) => Some(PointerFeedback::Resize(outline)), | ||
| 838 | _ => self | ||
| 839 | .preview() | ||
| 840 | .map(|(id, origin)| PointerFeedback::Move(id, origin)) | ||
| 841 | .or_else(|| { | ||
| 842 | self.image_preview() | ||
| 843 | .map(|(id, origin, size)| PointerFeedback::Image(id, origin, size)) | ||
| 844 | }) | ||
| 845 | .or_else(|| { | ||
| 846 | if !self.pointer_inside { | ||
| 847 | return None; | ||
| 848 | } | ||
| 849 | match page_hit_test( | ||
| 850 | &self.editor, | ||
| 851 | self.scene.as_ref(), | ||
| 852 | self.viewport.document_point(self.pointer), | ||
| 853 | self.display_scale / self.viewport.scale, | ||
| 854 | ) { | ||
| 855 | Some( | ||
| 856 | Hit::Text { id, .. } | ||
| 857 | | Hit::Handle { id, .. } | ||
| 858 | | Hit::Resize { id, .. }, | ||
| 859 | ) => Some(PointerFeedback::Hover(id)), | ||
| 860 | _ => None, | ||
| 861 | } | ||
| 862 | }), | ||
| 863 | }, | ||
| 864 | self.object_focus, | ||
| 865 | self.caret | ||
| 866 | && self.focused | ||
| 867 | && !matches!( | ||
| 868 | self.drag, | ||
| 869 | Some(Drag::Outline { .. } | Drag::Resize { .. } | Drag::Image { .. }) | ||
| 870 | ), | ||
| 871 | self.viewport.scale, | ||
| 872 | self.display_scale, | ||
| 873 | )?; | ||
| 874 | self.scroll() | ||
| 875 | .append(self.viewport, self.display_scale, &mut primitives); | ||
| 876 | self.renderer | 604 | self.renderer |
| 877 | .draw( | 605 | .draw( |
| 878 | &frame.texture.create_view(&Default::default()), | 606 | &frame.texture.create_view(&Default::default()), |
| 879 | self.viewport, | 607 | viewport, |
| 880 | &primitives, | 608 | &primitives, |
| 881 | ) | 609 | ) |
| 882 | .map_err(|error| format!("Canvas drawing failed: {error:?}"))?; | 610 | .map_err(|error| format!("Canvas drawing failed: {error:?}"))?; |
| ... | @@ -890,353 +618,42 @@ impl State { | ... | @@ -890,353 +618,42 @@ impl State { |
| 890 | } | 618 | } |
| 891 | 619 | ||
| 892 | fn key(&mut self, key: &Key, text: Option<&str>) -> Result<(), Box<dyn Error>> { | 620 | fn key(&mut self, key: &Key, text: Option<&str>) -> Result<(), Box<dyn Error>> { |
| 893 | let shift = self.modifiers.shift_key(); | 621 | let modifiers = self.view.modifiers(); |
| 894 | let command = self.modifiers.super_key(); | 622 | if modifiers.command |
| 895 | let option = self.modifiers.alt_key(); | 623 | && modifiers.shift |
| 896 | if let Some(ObjectFocus::Image(id)) = self.object_focus | 624 | && self.session.is_some() |
| 897 | && matches!(key, Key::Named(NamedKey::Backspace | NamedKey::Delete)) | 625 | && self.view.accepts_text() |
| 898 | { | 626 | && let Key::Character(character) = key |
| 899 | self.editor.remove_image(&mut self.engine, id)?; | ||
| 900 | self.set_object_focus(None); | ||
| 901 | return self.changed(); | ||
| 902 | } | ||
| 903 | if let Some(ObjectFocus::Image(id)) = self.object_focus | ||
| 904 | && !(shift || command || option || self.modifiers.control_key()) | ||
| 905 | && let Key::Named(NamedKey::ArrowLeft | NamedKey::ArrowRight) = key | ||
| 906 | && self.editor.step_from_image( | ||
| 907 | &mut self.engine, | ||
| 908 | id, | ||
| 909 | key == &Key::Named(NamedKey::ArrowRight), | ||
| 910 | )? | ||
| 911 | { | 627 | { |
| 912 | self.set_object_focus(None); | 628 | if character.eq_ignore_ascii_case("k") { |
| 913 | self.reveal_focus()?; | 629 | return self.resolve_conflict(true); |
| 914 | return self.changed(); | ||
| 915 | } | ||
| 916 | if let Some(focus) = self.object_focus { | ||
| 917 | let undo = matches!(focus, ObjectFocus::Image(_)) | ||
| 918 | && command | ||
| 919 | && matches!(key, Key::Character(value) if value.eq_ignore_ascii_case("z")); | ||
| 920 | if !undo && !read_only_shortcut(key, self.modifiers) { | ||
| 921 | return Ok(()); | ||
| 922 | } | ||
| 923 | if key == &Key::Named(NamedKey::Escape) { | ||
| 924 | self.set_object_focus(None); | ||
| 925 | self.reveal_focus()?; | ||
| 926 | return self.changed(); | ||
| 927 | } | ||
| 928 | } | ||
| 929 | if command && self.modifiers.control_key() && key == &Key::Named(NamedKey::Space) { | ||
| 930 | macos::show_character_palette(); | ||
| 931 | return Ok(()); | ||
| 932 | } | ||
| 933 | if command && shift && self.session.is_some() { | ||
| 934 | match key { | ||
| 935 | Key::Character(character) if character.eq_ignore_ascii_case("k") => { | ||
| 936 | return self.resolve_conflict(true); | ||
| 937 | } | ||
| 938 | Key::Character(character) if character.eq_ignore_ascii_case("t") => { | ||
| 939 | return self.resolve_conflict(false); | ||
| 940 | } | ||
| 941 | _ => {} | ||
| 942 | } | ||
| 943 | } | ||
| 944 | if matches!( | ||
| 945 | self.drag, | ||
| 946 | Some(Drag::Outline { .. } | Drag::Resize { .. } | Drag::Image { .. }) | ||
| 947 | ) { | ||
| 948 | if matches!( | ||
| 949 | key, | ||
| 950 | Key::Named( | ||
| 951 | NamedKey::Alt | ||
| 952 | | NamedKey::AltGraph | ||
| 953 | | NamedKey::Control | ||
| 954 | | NamedKey::Shift | ||
| 955 | | NamedKey::Super | ||
| 956 | | NamedKey::Meta | ||
| 957 | ) | ||
| 958 | ) { | ||
| 959 | return Ok(()); | ||
| 960 | } | ||
| 961 | self.drag = None; | ||
| 962 | if key == &Key::Named(NamedKey::Escape) { | ||
| 963 | return self.changed(); | ||
| 964 | } | ||
| 965 | } | ||
| 966 | if command && option { | ||
| 967 | let delta = match key { | ||
| 968 | Key::Named(NamedKey::ArrowLeft) => Some([-1.0, 0.0]), | ||
| 969 | Key::Named(NamedKey::ArrowRight) => Some([1.0, 0.0]), | ||
| 970 | Key::Named(NamedKey::ArrowUp) => Some([0.0, -1.0]), | ||
| 971 | Key::Named(NamedKey::ArrowDown) => Some([0.0, 1.0]), | ||
| 972 | _ => None, | ||
| 973 | }; | ||
| 974 | if let Some(delta) = delta { | ||
| 975 | let outline = self.editor.active_outline(); | ||
| 976 | let origin = outline.origin(); | ||
| 977 | let step = if shift { 10.0 } else { 1.0 }; | ||
| 978 | self.editor.move_outline( | ||
| 979 | outline.id, | ||
| 980 | [origin[0] + delta[0] * step, origin[1] + delta[1] * step], | ||
| 981 | )?; | ||
| 982 | self.reveal_focus()?; | ||
| 983 | return self.changed(); | ||
| 984 | } | ||
| 985 | } | ||
| 986 | if self.modifiers.control_key() && key == &Key::Named(NamedKey::Tab) { | ||
| 987 | let outlines = self.editor.outlines(); | ||
| 988 | let count = outlines.len() | ||
| 989 | + self | ||
| 990 | .scene | ||
| 991 | .as_ref() | ||
| 992 | .map_or(0, |(scene, _)| scene.read_only(Some(&self.editor)).count()); | ||
| 993 | if count == 0 { | ||
| 994 | return self.changed(); | ||
| 995 | } | ||
| 996 | let index = self | ||
| 997 | .object_focus | ||
| 998 | .and_then(ObjectFocus::read_only) | ||
| 999 | .map_or_else( | ||
| 1000 | || { | ||
| 1001 | outlines | ||
| 1002 | .iter() | ||
| 1003 | .position(|outline| outline.id == self.editor.active_outline().id) | ||
| 1004 | }, | ||
| 1005 | |index| Some(outlines.len() + index), | ||
| 1006 | ); | ||
| 1007 | let next = index.map_or(if shift { count - 1 } else { 0 }, |index| { | ||
| 1008 | if shift { | ||
| 1009 | (index + count - 1) % count | ||
| 1010 | } else { | ||
| 1011 | (index + 1) % count | ||
| 1012 | } | ||
| 1013 | }); | ||
| 1014 | if next < outlines.len() { | ||
| 1015 | self.editor.focus_outline(outlines[next].id)?; | ||
| 1016 | self.set_object_focus(None); | ||
| 1017 | } else { | ||
| 1018 | self.set_object_focus(Some(ObjectFocus::ReadOnly(next - outlines.len()))); | ||
| 1019 | } | ||
| 1020 | self.reveal_focus()?; | ||
| 1021 | return self.changed(); | ||
| 1022 | } | ||
| 1023 | if command && let Key::Character(key) = key { | ||
| 1024 | match key.to_lowercase().as_str() { | ||
| 1025 | "n" if shift => { | ||
| 1026 | let position = if let Some(focus) = self.object_focus { | ||
| 1027 | let rect = self.object_rect(focus); | ||
| 1028 | [rect[2] + 24.0, rect[1]] | ||
| 1029 | } else { | ||
| 1030 | let bounds = self.editor.active_outline().bounds(); | ||
| 1031 | [bounds.x1 as f32 + 24.0, bounds.y0 as f32] | ||
| 1032 | }; | ||
| 1033 | self.editor.place_caret( | ||
| 1034 | &mut self.engine, | ||
| 1035 | snap_to_grid(position, self.editor.margin_origin()), | ||
| 1036 | DEFAULT_OUTLINE_WIDTH, | ||
| 1037 | )?; | ||
| 1038 | self.set_object_focus(None); | ||
| 1039 | } | ||
| 1040 | "a" => self.editor.select_all()?, | ||
| 1041 | "z" => { | ||
| 1042 | if shift { | ||
| 1043 | self.editor.redo(&mut self.engine)?; | ||
| 1044 | } else { | ||
| 1045 | self.editor.undo(&mut self.engine)?; | ||
| 1046 | } | ||
| 1047 | if let Some(ObjectFocus::Image(id)) = self.object_focus | ||
| 1048 | && self.editor.image_placement(id).is_none() | ||
| 1049 | { | ||
| 1050 | self.set_object_focus(None); | ||
| 1051 | } | ||
| 1052 | } | ||
| 1053 | "c" | "x" => { | ||
| 1054 | let [anchor, focus] = self.editor.selection().positions; | ||
| 1055 | let selected = self | ||
| 1056 | .editor | ||
| 1057 | .active_outline() | ||
| 1058 | .document() | ||
| 1059 | .slice(anchor.min(focus)..anchor.max(focus))?; | ||
| 1060 | let text = selected | ||
| 1061 | .iter() | ||
| 1062 | .map(|paragraph| { | ||
| 1063 | paragraph | ||
| 1064 | .project() | ||
| 1065 | .map(|projection| projection.text().text().to_owned()) | ||
| 1066 | }) | ||
| 1067 | .collect::<Result<Vec<_>, _>>()? | ||
| 1068 | .join("\n"); | ||
| 1069 | if !text.is_empty() { | ||
| 1070 | self.clipboard.set_text(text)?; | ||
| 1071 | if key.eq_ignore_ascii_case("x") { | ||
| 1072 | self.editor.insert(&mut self.engine, "")?; | ||
| 1073 | } | ||
| 1074 | } | ||
| 1075 | } | ||
| 1076 | "v" => { | ||
| 1077 | let text = self.clipboard.get_text()?; | ||
| 1078 | self.editor.commit_text(&mut self.engine, text)?; | ||
| 1079 | } | ||
| 1080 | "+" | "=" => { | ||
| 1081 | self.zoom(1.1); | ||
| 1082 | return self.changed(); | ||
| 1083 | } | ||
| 1084 | "-" => { | ||
| 1085 | self.zoom(1.0 / 1.1); | ||
| 1086 | return self.changed(); | ||
| 1087 | } | ||
| 1088 | "0" => { | ||
| 1089 | self.zoom((self.display_scale * 96.0 / 72.0) / self.viewport.scale); | ||
| 1090 | return self.changed(); | ||
| 1091 | } | ||
| 1092 | _ => return Ok(()), | ||
| 1093 | } | 630 | } |
| 1094 | } else { | 631 | if character.eq_ignore_ascii_case("t") { |
| 1095 | let movement = match key { | 632 | return self.resolve_conflict(false); |
| 1096 | Key::Named(NamedKey::ArrowLeft) => Some(if command { | ||
| 1097 | Movement::LineStart | ||
| 1098 | } else if option { | ||
| 1099 | Movement::WordLeft | ||
| 1100 | } else { | ||
| 1101 | Movement::Left | ||
| 1102 | }), | ||
| 1103 | Key::Named(NamedKey::ArrowRight) => Some(if command { | ||
| 1104 | Movement::LineEnd | ||
| 1105 | } else if option { | ||
| 1106 | Movement::WordRight | ||
| 1107 | } else { | ||
| 1108 | Movement::Right | ||
| 1109 | }), | ||
| 1110 | Key::Named(NamedKey::ArrowUp) => Some(if command { | ||
| 1111 | Movement::DocumentStart | ||
| 1112 | } else if option { | ||
| 1113 | Movement::ParagraphStart | ||
| 1114 | } else { | ||
| 1115 | Movement::Up | ||
| 1116 | }), | ||
| 1117 | Key::Named(NamedKey::ArrowDown) => Some(if command { | ||
| 1118 | Movement::DocumentEnd | ||
| 1119 | } else if option { | ||
| 1120 | Movement::ParagraphEnd | ||
| 1121 | } else { | ||
| 1122 | Movement::Down | ||
| 1123 | }), | ||
| 1124 | Key::Named(NamedKey::Home) if shift => Some(Movement::DocumentStart), | ||
| 1125 | Key::Named(NamedKey::End) if shift => Some(Movement::DocumentEnd), | ||
| 1126 | Key::Character(key) if self.modifiers.control_key() && !option => { | ||
| 1127 | match key.as_str() { | ||
| 1128 | "a" => Some(Movement::LineStart), | ||
| 1129 | "e" => Some(Movement::LineEnd), | ||
| 1130 | "b" => Some(Movement::Left), | ||
| 1131 | "f" => Some(Movement::Right), | ||
| 1132 | "p" => Some(Movement::Up), | ||
| 1133 | "n" => Some(Movement::Down), | ||
| 1134 | _ => None, | ||
| 1135 | } | ||
| 1136 | } | ||
| 1137 | _ => None, | ||
| 1138 | }; | ||
| 1139 | if let Some(movement) = movement { | ||
| 1140 | self.editor | ||
| 1141 | .move_selection(&mut self.engine, movement, shift)?; | ||
| 1142 | } else if self.editor.marked_range().is_none() { | ||
| 1143 | match key { | ||
| 1144 | Key::Named(NamedKey::Backspace) if command || option => { | ||
| 1145 | self.editor.delete_to( | ||
| 1146 | &mut self.engine, | ||
| 1147 | if command { | ||
| 1148 | Movement::LineStart | ||
| 1149 | } else { | ||
| 1150 | Movement::WordLeft | ||
| 1151 | }, | ||
| 1152 | )?; | ||
| 1153 | } | ||
| 1154 | Key::Named(NamedKey::Delete) if command || option => { | ||
| 1155 | self.editor.delete_to( | ||
| 1156 | &mut self.engine, | ||
| 1157 | if command { | ||
| 1158 | Movement::LineEnd | ||
| 1159 | } else { | ||
| 1160 | Movement::WordRight | ||
| 1161 | }, | ||
| 1162 | )?; | ||
| 1163 | } | ||
| 1164 | Key::Named(NamedKey::Backspace) => { | ||
| 1165 | self.editor.delete(&mut self.engine, true)?; | ||
| 1166 | } | ||
| 1167 | Key::Named(NamedKey::Delete) => { | ||
| 1168 | self.editor.delete(&mut self.engine, false)?; | ||
| 1169 | } | ||
| 1170 | Key::Named(NamedKey::Home | NamedKey::End) => { | ||
| 1171 | let limits = self.scroll(); | ||
| 1172 | self.viewport.origin[1] = -if key == &Key::Named(NamedKey::Home) { | ||
| 1173 | limits.min[1] | ||
| 1174 | } else { | ||
| 1175 | limits.max[1] | ||
| 1176 | }; | ||
| 1177 | return self.changed(); | ||
| 1178 | } | ||
| 1179 | Key::Character(key) if self.modifiers.control_key() && !option => { | ||
| 1180 | match key.as_str() { | ||
| 1181 | "h" => { | ||
| 1182 | self.editor.delete(&mut self.engine, true)?; | ||
| 1183 | } | ||
| 1184 | "d" => { | ||
| 1185 | self.editor.delete(&mut self.engine, false)?; | ||
| 1186 | } | ||
| 1187 | "k" if !self | ||
| 1188 | .editor | ||
| 1189 | .delete_to(&mut self.engine, Movement::LineEnd)? => | ||
| 1190 | { | ||
| 1191 | self.editor.delete(&mut self.engine, false)?; | ||
| 1192 | } | ||
| 1193 | _ => {} | ||
| 1194 | } | ||
| 1195 | } | ||
| 1196 | Key::Named(NamedKey::Enter) => { | ||
| 1197 | self.editor.enter(&mut self.engine, shift)?; | ||
| 1198 | } | ||
| 1199 | Key::Named(NamedKey::Tab) => { | ||
| 1200 | self.editor.tab(&mut self.engine, shift)?; | ||
| 1201 | } | ||
| 1202 | _ if !command && !self.modifiers.control_key() => { | ||
| 1203 | if let Some(text) = text | ||
| 1204 | .filter(|text| !text.is_empty() && !text.chars().any(char::is_control)) | ||
| 1205 | { | ||
| 1206 | self.editor.insert(&mut self.engine, text)?; | ||
| 1207 | } | ||
| 1208 | } | ||
| 1209 | _ => {} | ||
| 1210 | } | ||
| 1211 | } else if key == &Key::Named(NamedKey::Escape) { | ||
| 1212 | self.editor.cancel_composition(&mut self.engine)?; | ||
| 1213 | } | 633 | } |
| 1214 | } | 634 | } |
| 1215 | self.reveal_focus()?; | 635 | let response = self.view.key(&page_key(key), text)?; |
| 1216 | self.changed() | 636 | self.respond(response) |
| 1217 | } | 637 | } |
| 1218 | } | 638 | } |
| 1219 | 639 | ||
| 1220 | impl App { | 640 | impl App { |
| 1221 | fn close(&self, event_loop: &ActiveEventLoop) { | 641 | fn close(&self, event_loop: &ActiveEventLoop) { |
| 1222 | if self.state.as_ref().is_none_or(|state| { | 642 | if self.state.as_ref().is_none_or(|state| { |
| 643 | let editor = &state.view.editor; | ||
| 1223 | state.session.is_some() | 644 | state.session.is_some() |
| 1224 | || state | 645 | || editor.caret_outline().is_none_or(TextOutline::is_empty) |
| 1225 | .editor | 646 | && state.initial_date == editor.date().map(|date| date.timestamp()) |
| 1226 | .caret_outline() | ||
| 1227 | .is_none_or(TextOutline::is_empty) | ||
| 1228 | && state.initial_date == state.editor.date().map(|date| date.timestamp()) | ||
| 1229 | && state | 647 | && state |
| 1230 | .initial_layouts | 648 | .initial_layouts |
| 1231 | .iter() | 649 | .iter() |
| 1232 | .map(|(id, layout)| (*id, layout)) | 650 | .map(|(id, layout)| (*id, layout)) |
| 1233 | .eq(state.editor.object_layouts()) | 651 | .eq(editor.object_layouts()) |
| 1234 | && state | 652 | && state |
| 1235 | .initial | 653 | .initial |
| 1236 | .iter() | 654 | .iter() |
| 1237 | .map(|(id, document)| (id, document)) | 655 | .map(|(id, document)| (id, document)) |
| 1238 | .eq(state | 656 | .eq(editor |
| 1239 | .editor | ||
| 1240 | .outlines() | 657 | .outlines() |
| 1241 | .iter() | 658 | .iter() |
| 1242 | .map(|outline| (&outline.id, outline.document()))) | 659 | .map(|outline| (&outline.id, outline.document()))) |
| ... | @@ -1251,16 +668,11 @@ impl ApplicationHandler<UserEvent> for App { | ... | @@ -1251,16 +668,11 @@ impl ApplicationHandler<UserEvent> for App { |
| 1251 | fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) { | 668 | fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) { |
| 1252 | let event = match event { | 669 | let event = match event { |
| 1253 | UserEvent::InsertText(text) => { | 670 | UserEvent::InsertText(text) => { |
| 1254 | if let Some(state) = &mut self.state | 671 | if let Some(state) = &mut self.state { |
| 1255 | && state.object_focus.is_none() | 672 | let result = state |
| 1256 | && !text.is_empty() | 673 | .view |
| 1257 | && !text.chars().any(char::is_control) | 674 | .insert_text(text) |
| 1258 | { | 675 | .and_then(|response| state.respond(response)); |
| 1259 | let result = (|| -> Result<(), Box<dyn Error>> { | ||
| 1260 | state.editor.commit_text(&mut state.engine, text)?; | ||
| 1261 | state.reveal_focus()?; | ||
| 1262 | state.changed() | ||
| 1263 | })(); | ||
| 1264 | if let Err(error) = result { | 676 | if let Err(error) = result { |
| 1265 | eprintln!("{error}"); | 677 | eprintln!("{error}"); |
| 1266 | } | 678 | } |
| ... | @@ -1287,7 +699,7 @@ impl ApplicationHandler<UserEvent> for App { | ... | @@ -1287,7 +699,7 @@ impl ApplicationHandler<UserEvent> for App { |
| 1287 | if event.window_id != state.window.id() { | 699 | if event.window_id != state.window.id() { |
| 1288 | return; | 700 | return; |
| 1289 | } | 701 | } |
| 1290 | let was_marked = state.editor.marked_range().is_some(); | 702 | let was_marked = state.view.editor.marked_range().is_some(); |
| 1291 | let result = match event.window_event { | 703 | let result = match event.window_event { |
| 1292 | accesskit_winit::WindowEvent::InitialTreeRequested => state.update_accessibility(), | 704 | accesskit_winit::WindowEvent::InitialTreeRequested => state.update_accessibility(), |
| 1293 | accesskit_winit::WindowEvent::ActionRequested(request) => state.access_action(request), | 705 | accesskit_winit::WindowEvent::ActionRequested(request) => state.access_action(request), |
| ... | @@ -1296,7 +708,7 @@ impl ApplicationHandler<UserEvent> for App { | ... | @@ -1296,7 +708,7 @@ impl ApplicationHandler<UserEvent> for App { |
| 1296 | Ok(()) | 708 | Ok(()) |
| 1297 | } | 709 | } |
| 1298 | }; | 710 | }; |
| 1299 | if was_marked && state.editor.marked_range().is_none() { | 711 | if was_marked && state.view.editor.marked_range().is_none() { |
| 1300 | macos::clear_marked_text(&state.window); | 712 | macos::clear_marked_text(&state.window); |
| 1301 | } | 713 | } |
| 1302 | if let Err(error) = result { | 714 | if let Err(error) = result { |
| ... | @@ -1345,333 +757,82 @@ impl ApplicationHandler<UserEvent> for App { | ... | @@ -1345,333 +757,82 @@ impl ApplicationHandler<UserEvent> for App { |
| 1345 | return; | 757 | return; |
| 1346 | }; | 758 | }; |
| 1347 | let was_marked = | 759 | let was_marked = |
| 1348 | !matches!(event, WindowEvent::Ime(_)) && state.editor.marked_range().is_some(); | 760 | !matches!(event, WindowEvent::Ime(_)) && state.view.editor.marked_range().is_some(); |
| 1349 | let result = (|| -> Result<(), Box<dyn Error>> { | 761 | let result = (|| -> Result<(), Box<dyn Error>> { |
| 1350 | match event { | 762 | let response = match event { |
| 1351 | WindowEvent::RedrawRequested => state.draw()?, | 763 | WindowEvent::RedrawRequested => return state.draw(), |
| 1352 | WindowEvent::Resized(size) => { | 764 | WindowEvent::Resized(size) => { |
| 1353 | state.viewport.size = [size.width, size.height]; | ||
| 1354 | if size.width > 0 && size.height > 0 { | 765 | if size.width > 0 && size.height > 0 { |
| 1355 | state.config.width = size.width; | 766 | state.config.width = size.width; |
| 1356 | state.config.height = size.height; | 767 | state.config.height = size.height; |
| 1357 | state | 768 | state |
| 1358 | .surface | 769 | .surface |
| 1359 | .configure(&state.renderer.device, &state.config); | 770 | .configure(&state.renderer.device, &state.config); |
| 1360 | state.changed()?; | 771 | } |
| 1361 | // Present inside AppKit's resize transaction; a redraw on the next turn | 772 | let response = state.view.resized([size.width, size.height])?; |
| 1362 | // lets the window show the previous frame at the new size. | 773 | let changed = response.changed; |
| 774 | state.respond(response)?; | ||
| 775 | // Present inside AppKit's resize transaction; a redraw on the next turn | ||
| 776 | // lets the window show the previous frame at the new size. | ||
| 777 | if changed { | ||
| 1363 | state.draw()?; | 778 | state.draw()?; |
| 1364 | } | 779 | } |
| 780 | return Ok(()); | ||
| 1365 | } | 781 | } |
| 1366 | WindowEvent::ScaleFactorChanged { scale_factor, .. } => { | 782 | WindowEvent::ScaleFactorChanged { scale_factor, .. } => { |
| 1367 | let scale = scale_factor as f32; | ||
| 1368 | let ratio = scale / state.display_scale; | ||
| 1369 | state.viewport.scale *= ratio; | ||
| 1370 | state.viewport.origin[0] *= ratio; | ||
| 1371 | state.viewport.origin[1] *= ratio; | ||
| 1372 | state.display_scale = scale; | ||
| 1373 | state.renderer.clear_glyph_cache(); | 783 | state.renderer.clear_glyph_cache(); |
| 1374 | state.changed()?; | 784 | state.view.scale_factor_changed(scale_factor as f32)? |
| 1375 | } | ||
| 1376 | WindowEvent::Focused(focused) => { | ||
| 1377 | state.focused = focused; | ||
| 1378 | if !focused { | ||
| 1379 | state.drag = None; | ||
| 1380 | } | ||
| 1381 | state.changed()?; | ||
| 1382 | } | 785 | } |
| 786 | WindowEvent::Focused(focused) => state.view.focus_changed(focused)?, | ||
| 1383 | WindowEvent::Occluded(occluded) => { | 787 | WindowEvent::Occluded(occluded) => { |
| 1384 | trace_input(&("Window occluded", occluded)); | 788 | trace_input(&("Window occluded", occluded)); |
| 1385 | state.occluded = occluded; | 789 | state.occluded = occluded; |
| 1386 | if !occluded { | 790 | if occluded { |
| 1387 | state.changed()?; | 791 | return Ok(()); |
| 1388 | } | 792 | } |
| 1389 | } | 793 | Response { |
| 1390 | WindowEvent::ModifiersChanged(modifiers) => { | 794 | changed: true, |
| 1391 | state.modifiers = modifiers.state(); | 795 | redraw: true, |
| 1392 | if matches!(state.drag, Some(Drag::Outline { .. })) { | 796 | request: None, |
| 1393 | state.changed()?; | ||
| 1394 | } | ||
| 1395 | } | ||
| 1396 | WindowEvent::CursorLeft { .. } => { | ||
| 1397 | state.pointer_inside = false; | ||
| 1398 | state.window.request_redraw(); | ||
| 1399 | } | ||
| 1400 | WindowEvent::CursorMoved { position, .. } => { | ||
| 1401 | state.pointer_inside = true; | ||
| 1402 | state.pointer = [position.x as f32, position.y as f32]; | ||
| 1403 | let hit = state.hit_test(state.viewport.document_point(state.pointer)); | ||
| 1404 | let scrollbar = state | ||
| 1405 | .scroll() | ||
| 1406 | .hit_test(state.viewport, state.display_scale, state.pointer) | ||
| 1407 | .is_some(); | ||
| 1408 | state.window.set_cursor(match (&state.drag, hit) { | ||
| 1409 | (Some(Drag::Scrollbar { .. }), _) => winit::window::CursorIcon::Default, | ||
| 1410 | (None, _) if scrollbar => winit::window::CursorIcon::Default, | ||
| 1411 | (Some(Drag::Image { handle, .. }), _) => handle_cursor(*handle), | ||
| 1412 | (None, Some(Hit::Image { handle, .. })) => handle_cursor(handle), | ||
| 1413 | (Some(Drag::Resize { .. }), _) | (None, Some(Hit::Resize { .. })) => { | ||
| 1414 | winit::window::CursorIcon::EwResize | ||
| 1415 | } | ||
| 1416 | (Some(Drag::Outline { .. }), _) | (None, Some(Hit::Handle { .. })) => { | ||
| 1417 | winit::window::CursorIcon::Move | ||
| 1418 | } | ||
| 1419 | (None, Some(Hit::Date(_))) => winit::window::CursorIcon::Pointer, | ||
| 1420 | (None, Some(Hit::ReadOnly(_))) => winit::window::CursorIcon::Default, | ||
| 1421 | _ => winit::window::CursorIcon::Text, | ||
| 1422 | }); | ||
| 1423 | match &mut state.drag { | ||
| 1424 | Some(Drag::Scrollbar { axis, grab }) => { | ||
| 1425 | let (axis, grab) = (*axis, *grab); | ||
| 1426 | state.scroll().drag( | ||
| 1427 | &mut state.viewport, | ||
| 1428 | state.display_scale, | ||
| 1429 | axis, | ||
| 1430 | state.pointer[axis], | ||
| 1431 | grab, | ||
| 1432 | ); | ||
| 1433 | state.changed()?; | ||
| 1434 | } | ||
| 1435 | Some(Drag::Text { anchor, unit }) => { | ||
| 1436 | let point = state.viewport.document_point(state.pointer); | ||
| 1437 | let origin = state.editor.active_outline().origin(); | ||
| 1438 | let target = state.editor.selection_at( | ||
| 1439 | point[0] - origin[0], | ||
| 1440 | point[1] - origin[1], | ||
| 1441 | *unit, | ||
| 1442 | )?; | ||
| 1443 | state | ||
| 1444 | .editor | ||
| 1445 | .select(drag_selection(*anchor, target, *unit))?; | ||
| 1446 | state.changed()?; | ||
| 1447 | } | ||
| 1448 | Some( | ||
| 1449 | Drag::Outline { pending_press, .. } | Drag::Image { pending_press, .. }, | ||
| 1450 | ) => { | ||
| 1451 | if pending_press.is_some_and(|press| { | ||
| 1452 | (0..2).any(|axis| { | ||
| 1453 | (state.pointer[axis] - press[axis]).abs() | ||
| 1454 | > 2.0 * state.display_scale | ||
| 1455 | }) | ||
| 1456 | }) { | ||
| 1457 | *pending_press = None; | ||
| 1458 | } | ||
| 1459 | state.changed()?; | ||
| 1460 | } | ||
| 1461 | Some(Drag::Resize { outline, grab }) => { | ||
| 1462 | let point = state.viewport.document_point(state.pointer); | ||
| 1463 | let width = | ||
| 1464 | (point[0] - state.editor.active_outline().origin()[0] - *grab) | ||
| 1465 | .max(36.0); | ||
| 1466 | if outline.is_some() | ||
| 1467 | || (width - state.editor.active_outline().bounds().width() as f32) | ||
| 1468 | .abs() | ||
| 1469 | * state.viewport.scale | ||
| 1470 | > 2.0 * state.display_scale | ||
| 1471 | { | ||
| 1472 | *outline = Some(Box::new( | ||
| 1473 | state.editor.preview_resize(&mut state.engine, width)?, | ||
| 1474 | )); | ||
| 1475 | state.changed()?; | ||
| 1476 | } | ||
| 1477 | } | ||
| 1478 | None => state.window.request_redraw(), | ||
| 1479 | } | 797 | } |
| 1480 | } | 798 | } |
| 799 | WindowEvent::ModifiersChanged(modifiers) => state | ||
| 800 | .view | ||
| 801 | .modifiers_changed(page_modifiers(modifiers.state()))?, | ||
| 802 | WindowEvent::CursorLeft { .. } => state.view.pointer_left(), | ||
| 803 | WindowEvent::CursorMoved { position, .. } => state | ||
| 804 | .view | ||
| 805 | .pointer_moved([position.x as f32, position.y as f32])?, | ||
| 1481 | WindowEvent::MouseInput { | 806 | WindowEvent::MouseInput { |
| 1482 | state: button_state, | 807 | state: ElementState::Pressed, |
| 1483 | button: MouseButton::Left, | 808 | button: MouseButton::Left, |
| 1484 | .. | 809 | .. |
| 1485 | } => { | 810 | } => state.view.pointer_pressed(Instant::now())?, |
| 1486 | let point = state.viewport.document_point(state.pointer); | 811 | WindowEvent::MouseInput { |
| 1487 | if button_state == ElementState::Pressed { | 812 | state: ElementState::Released, |
| 1488 | let now = Instant::now(); | 813 | button: MouseButton::Left, |
| 1489 | let count = state | 814 | .. |
| 1490 | .last_click | 815 | } => state.view.pointer_released()?, |
| 1491 | .filter(|(time, point, _)| { | ||
| 1492 | now.duration_since(*time) <= macos::double_click_interval() | ||
| 1493 | && (0..2).all(|axis| { | ||
| 1494 | (point[axis] - state.pointer[axis]).abs() | ||
| 1495 | <= 4.0 * state.display_scale | ||
| 1496 | }) | ||
| 1497 | }) | ||
| 1498 | .map_or(1, |(_, _, count)| (count % 3) + 1); | ||
| 1499 | state.last_click = Some((now, state.pointer, count)); | ||
| 1500 | let scrollbar = state | ||
| 1501 | .scroll() | ||
| 1502 | .hit_test(state.viewport, state.display_scale, state.pointer) | ||
| 1503 | .map(|(axis, grab)| Drag::Scrollbar { axis, grab }); | ||
| 1504 | if scrollbar.is_some() { | ||
| 1505 | state.drag = scrollbar; | ||
| 1506 | } else { | ||
| 1507 | match state.hit_test(point) { | ||
| 1508 | Some(Hit::Date(field)) => state.edit_date(field)?, | ||
| 1509 | Some(Hit::ReadOnly(index)) => { | ||
| 1510 | state.set_object_focus(Some(ObjectFocus::ReadOnly(index))) | ||
| 1511 | } | ||
| 1512 | Some(Hit::Image { id, handle }) => { | ||
| 1513 | state.set_object_focus(Some(ObjectFocus::Image(id))); | ||
| 1514 | state.drag = Some(Drag::Image { | ||
| 1515 | id, | ||
| 1516 | handle, | ||
| 1517 | press: point, | ||
| 1518 | pending_press: Some(state.pointer), | ||
| 1519 | }); | ||
| 1520 | } | ||
| 1521 | Some(Hit::Handle { id, grab }) => { | ||
| 1522 | state.set_object_focus(None); | ||
| 1523 | state.editor.focus_outline(id)?; | ||
| 1524 | state.drag = Some(Drag::Outline { | ||
| 1525 | id, | ||
| 1526 | grab, | ||
| 1527 | pending_press: Some(state.pointer), | ||
| 1528 | }); | ||
| 1529 | } | ||
| 1530 | Some(Hit::Resize { id, grab }) => { | ||
| 1531 | state.set_object_focus(None); | ||
| 1532 | state.editor.focus_outline(id)?; | ||
| 1533 | state.drag = Some(Drag::Resize { | ||
| 1534 | outline: None, | ||
| 1535 | grab, | ||
| 1536 | }); | ||
| 1537 | } | ||
| 1538 | Some(Hit::Text { id, point }) => { | ||
| 1539 | let extend = state.modifiers.shift_key() | ||
| 1540 | && state.object_focus.is_none() | ||
| 1541 | && id == state.editor.active_outline().id; | ||
| 1542 | state.set_object_focus(None); | ||
| 1543 | state.editor.focus_outline(id)?; | ||
| 1544 | let previous = state.editor.selection(); | ||
| 1545 | state.editor.select_below(&mut state.engine, id, point)?; | ||
| 1546 | let unit = match count { | ||
| 1547 | 2 => SelectionUnit::Word, | ||
| 1548 | 3 => SelectionUnit::Paragraph, | ||
| 1549 | _ => SelectionUnit::Grapheme, | ||
| 1550 | }; | ||
| 1551 | let selection = | ||
| 1552 | state.editor.selection_at(point[0], point[1], unit)?; | ||
| 1553 | let selection = if extend { | ||
| 1554 | Selection { | ||
| 1555 | positions: [ | ||
| 1556 | previous.positions[0], | ||
| 1557 | selection.positions[1], | ||
| 1558 | ], | ||
| 1559 | affinities: [ | ||
| 1560 | previous.affinities[0], | ||
| 1561 | selection.affinities[1], | ||
| 1562 | ], | ||
| 1563 | } | ||
| 1564 | } else { | ||
| 1565 | selection | ||
| 1566 | }; | ||
| 1567 | state.editor.select(selection)?; | ||
| 1568 | state.drag = Some(Drag::Text { | ||
| 1569 | anchor: selection, | ||
| 1570 | unit, | ||
| 1571 | }); | ||
| 1572 | } | ||
| 1573 | None => { | ||
| 1574 | let position = [ | ||
| 1575 | point[0], | ||
| 1576 | point[1] - 7.0 * state.display_scale / state.viewport.scale, | ||
| 1577 | ]; | ||
| 1578 | let position = if state.modifiers.alt_key() { | ||
| 1579 | position | ||
| 1580 | } else { | ||
| 1581 | snap_to_grid(position, state.editor.margin_origin()) | ||
| 1582 | }; | ||
| 1583 | state.editor.place_caret( | ||
| 1584 | &mut state.engine, | ||
| 1585 | position, | ||
| 1586 | DEFAULT_OUTLINE_WIDTH, | ||
| 1587 | )?; | ||
| 1588 | state.set_object_focus(None); | ||
| 1589 | state.drag = Some(Drag::Text { | ||
| 1590 | anchor: state.editor.selection(), | ||
| 1591 | unit: SelectionUnit::Grapheme, | ||
| 1592 | }); | ||
| 1593 | } | ||
| 1594 | } | ||
| 1595 | } | ||
| 1596 | } else { | ||
| 1597 | let preview = state.preview(); | ||
| 1598 | if let Some((id, origin, size)) = state.image_preview() { | ||
| 1599 | state | ||
| 1600 | .editor | ||
| 1601 | .place_image(&mut state.engine, id, origin, size)?; | ||
| 1602 | } | ||
| 1603 | if let Some(Drag::Resize { | ||
| 1604 | outline: Some(outline), | ||
| 1605 | .. | ||
| 1606 | }) = state.drag.take() | ||
| 1607 | { | ||
| 1608 | state | ||
| 1609 | .editor | ||
| 1610 | .resize(&mut state.engine, outline.bounds().width() as f32)?; | ||
| 1611 | } | ||
| 1612 | if let Some((id, origin)) = preview { | ||
| 1613 | state.editor.move_outline(id, origin)?; | ||
| 1614 | } | ||
| 1615 | } | ||
| 1616 | state.changed()?; | ||
| 1617 | } | ||
| 1618 | WindowEvent::MouseWheel { delta, .. } => { | 816 | WindowEvent::MouseWheel { delta, .. } => { |
| 1619 | let dpr = state.window.scale_factor() as f32; | 817 | let dpr = state.window.scale_factor() as f32; |
| 1620 | let delta = match delta { | 818 | state.view.wheel(match delta { |
| 1621 | MouseScrollDelta::LineDelta(x, y) => [x * 32.0 * dpr, y * 32.0 * dpr], | 819 | MouseScrollDelta::LineDelta(x, y) => [x * 32.0 * dpr, y * 32.0 * dpr], |
| 1622 | MouseScrollDelta::PixelDelta(p) => [p.x as f32, p.y as f32], | 820 | MouseScrollDelta::PixelDelta(p) => [p.x as f32, p.y as f32], |
| 1623 | }; | 821 | })? |
| 1624 | if state.modifiers.super_key() { | ||
| 1625 | state.zoom((delta[1] * 0.005).exp()); | ||
| 1626 | } else { | ||
| 1627 | state.viewport.origin[0] += delta[0]; | ||
| 1628 | state.viewport.origin[1] += delta[1]; | ||
| 1629 | } | ||
| 1630 | state.changed()?; | ||
| 1631 | } | 822 | } |
| 1632 | WindowEvent::KeyboardInput { event, .. } | 823 | WindowEvent::KeyboardInput { event, .. } |
| 1633 | if event.state == ElementState::Pressed => | 824 | if event.state == ElementState::Pressed => |
| 1634 | { | 825 | { |
| 1635 | state.key(&event.logical_key, event.text.as_deref())?; | 826 | return state.key(&event.logical_key, event.text.as_deref()); |
| 1636 | } | ||
| 1637 | WindowEvent::Ime(_) if state.object_focus.is_some() => {} | ||
| 1638 | WindowEvent::Ime(Ime::Preedit(text, cursor)) => { | ||
| 1639 | if text.is_empty() { | ||
| 1640 | state.editor.cancel_composition(&mut state.engine)?; | ||
| 1641 | } else { | ||
| 1642 | let (start, end) = cursor.unwrap_or((text.len(), text.len())); | ||
| 1643 | let start: u32 = text | ||
| 1644 | .get(..start) | ||
| 1645 | .ok_or("IME range splits UTF-8")? | ||
| 1646 | .encode_utf16() | ||
| 1647 | .count() | ||
| 1648 | .try_into()?; | ||
| 1649 | let end: u32 = text | ||
| 1650 | .get(..end) | ||
| 1651 | .ok_or("IME range splits UTF-8")? | ||
| 1652 | .encode_utf16() | ||
| 1653 | .count() | ||
| 1654 | .try_into()?; | ||
| 1655 | state.editor.compose(&mut state.engine, text, start..end)?; | ||
| 1656 | } | ||
| 1657 | state.reveal_focus()?; | ||
| 1658 | state.changed()?; | ||
| 1659 | } | ||
| 1660 | WindowEvent::Ime(Ime::Commit(text)) => { | ||
| 1661 | state.editor.commit_text(&mut state.engine, text)?; | ||
| 1662 | state.reveal_focus()?; | ||
| 1663 | state.changed()?; | ||
| 1664 | } | ||
| 1665 | WindowEvent::Ime(Ime::Disabled) => { | ||
| 1666 | state.editor.cancel_composition(&mut state.engine)?; | ||
| 1667 | state.reveal_focus()?; | ||
| 1668 | state.changed()?; | ||
| 1669 | } | 827 | } |
| 1670 | _ => {} | 828 | WindowEvent::Ime(Ime::Preedit(text, cursor)) => state.view.compose(text, cursor)?, |
| 1671 | } | 829 | WindowEvent::Ime(Ime::Commit(text)) => state.view.commit_text(text)?, |
| 1672 | Ok(()) | 830 | WindowEvent::Ime(Ime::Disabled) => state.view.cancel_composition()?, |
| 831 | _ => return Ok(()), | ||
| 832 | }; | ||
| 833 | state.respond(response) | ||
| 1673 | })(); | 834 | })(); |
| 1674 | if was_marked && state.editor.marked_range().is_none() { | 835 | if was_marked && state.view.editor.marked_range().is_none() { |
| 1675 | macos::clear_marked_text(&state.window); | 836 | macos::clear_marked_text(&state.window); |
| 1676 | } | 837 | } |
| 1677 | if let Err(error) = result { | 838 | if let Err(error) = result { |
| ... | @@ -1683,617 +844,18 @@ impl ApplicationHandler<UserEvent> for App { | ... | @@ -1683,617 +844,18 @@ impl ApplicationHandler<UserEvent> for App { |
| 1683 | let Some(state) = &mut self.state else { | 844 | let Some(state) = &mut self.state else { |
| 1684 | return; | 845 | return; |
| 1685 | }; | 846 | }; |
| 1686 | let [anchor, focus] = state.editor.selection().positions; | 847 | let (repaint, next) = if state.occluded { |
| 1687 | if state.focused && !state.occluded && state.object_focus.is_none() && anchor == focus { | 848 | (false, None) |
| 1688 | let now = Instant::now(); | ||
| 1689 | if now >= state.blink_at { | ||
| 1690 | state.caret = !state.caret; | ||
| 1691 | state.blink_at = now + Duration::from_millis(500); | ||
| 1692 | state.window.request_redraw(); | ||
| 1693 | } | ||
| 1694 | event_loop.set_control_flow(ControlFlow::WaitUntil(state.blink_at)); | ||
| 1695 | } else { | 849 | } else { |
| 1696 | event_loop.set_control_flow(ControlFlow::Wait); | 850 | state.view.blink(Instant::now()) |
| 1697 | } | ||
| 1698 | } | ||
| 1699 | } | ||
| 1700 | |||
| 1701 | #[derive(Debug, PartialEq)] | ||
| 1702 | enum Hit { | ||
| 1703 | Date(DateField), | ||
| 1704 | Resize { | ||
| 1705 | id: onestore::ExGuid, | ||
| 1706 | grab: f32, | ||
| 1707 | }, | ||
| 1708 | Text { | ||
| 1709 | id: onestore::ExGuid, | ||
| 1710 | point: [f32; 2], | ||
| 1711 | }, | ||
| 1712 | Handle { | ||
| 1713 | id: onestore::ExGuid, | ||
| 1714 | grab: [f32; 2], | ||
| 1715 | }, | ||
| 1716 | ReadOnly(usize), | ||
| 1717 | /// `handle` is [0, 0] on the picture and a direction on the selected picture's handles. | ||
| 1718 | Image { | ||
| 1719 | id: onestore::ExGuid, | ||
| 1720 | handle: [i8; 2], | ||
| 1721 | }, | ||
| 1722 | } | ||
| 1723 | |||
| 1724 | fn page_hit_test( | ||
| 1725 | editor: &CanvasEditor, | ||
| 1726 | scene: Option<&(PageScene, [f32; 2])>, | ||
| 1727 | point: [f32; 2], | ||
| 1728 | pixel: f32, | ||
| 1729 | ) -> Option<Hit> { | ||
| 1730 | /// Grips and text resolve front to back before any outline's padding, as a native | ||
| 1731 | /// width handle stays reachable under the next outline's left padding; the typing room | ||
| 1732 | /// below an outline comes last. | ||
| 1733 | #[derive(Clone, Copy, PartialEq)] | ||
| 1734 | enum Layer { | ||
| 1735 | Grips, | ||
| 1736 | Body, | ||
| 1737 | Below, | ||
| 1738 | } | ||
| 1739 | let hit = |outline: &TextOutline, offset: [f32; 2], layer: Layer| { | ||
| 1740 | if layer == Layer::Below { | ||
| 1741 | let outline = editor | ||
| 1742 | .outlines() | ||
| 1743 | .iter() | ||
| 1744 | .find(|source| source.id == outline.id)?; | ||
| 1745 | let local = [ | ||
| 1746 | point[0] - offset[0] - outline.origin()[0], | ||
| 1747 | point[1] - offset[1] - outline.origin()[1], | ||
| 1748 | ]; | ||
| 1749 | return outline.contains_extension(local).then_some(Hit::Text { | ||
| 1750 | id: outline.id, | ||
| 1751 | point: local, | ||
| 1752 | }); | ||
| 1753 | } | ||
| 1754 | let (bounds, body_top) = outline_chrome(outline, pixel); | ||
| 1755 | let local = [point[0] - offset[0], point[1] - offset[1]]; | ||
| 1756 | let [x, y] = local; | ||
| 1757 | if layer == Layer::Grips { | ||
| 1758 | // Width handles: 15 px inside to 6 px outside the header's right end, and 6 px | ||
| 1759 | // either side of the right border below it. | ||
| 1760 | let inside = if y < body_top { 15.0 } else { 6.0 }; | ||
| 1761 | if !outline.title | ||
| 1762 | && (bounds[2] - inside * pixel..=bounds[2] + 6.0 * pixel).contains(&x) | ||
| 1763 | && (bounds[1]..=bounds[3]).contains(&y) | ||
| 1764 | { | ||
| 1765 | return Some(Hit::Resize { | ||
| 1766 | id: outline.id, | ||
| 1767 | grab: point[0] - outline.bounds().x1 as f32, | ||
| 1768 | }); | ||
| 1769 | } | ||
| 1770 | if x >= bounds[0] && x <= bounds[2] && y >= bounds[1] && y < body_top { | ||
| 1771 | return Some(Hit::Handle { | ||
| 1772 | id: outline.id, | ||
| 1773 | grab: [ | ||
| 1774 | point[0] - outline.origin()[0], | ||
| 1775 | point[1] - outline.origin()[1], | ||
| 1776 | ], | ||
| 1777 | }); | ||
| 1778 | } | ||
| 1779 | let text = outline.bounds(); | ||
| 1780 | if !(text.x0..=text.x1).contains(&f64::from(x)) | ||
| 1781 | || !(text.y0..=text.y1).contains(&f64::from(y)) | ||
| 1782 | { | ||
| 1783 | return None; | ||
| 1784 | } | ||
| 1785 | } | ||
| 1786 | let inner = [x - outline.origin()[0], y - outline.origin()[1]]; | ||
| 1787 | if let Some(picture) = outline.shaped().objects.iter().find(|object| { | ||
| 1788 | matches!(object.kind, canvas::outline::ObjectKind::Picture) | ||
| 1789 | && (object.rect[0]..=object.rect[2]).contains(&inner[0]) | ||
| 1790 | && (object.rect[1]..=object.rect[3]).contains(&inner[1]) | ||
| 1791 | }) { | ||
| 1792 | return Some(Hit::Image { | ||
| 1793 | id: picture.id, | ||
| 1794 | handle: [0, 0], | ||
| 1795 | }); | ||
| 1796 | } | ||
| 1797 | if (x >= bounds[0] && x <= bounds[2] && y >= body_top && y <= bounds[3]) | ||
| 1798 | || outline.layouts().any(|(_, paragraph)| { | ||
| 1799 | paragraph.tags.iter().any(|tag| { | ||
| 1800 | let origin = outline.origin(); | ||
| 1801 | let x = origin[0] + outline.shaped().tag_column_offset() + tag.origin[0]; | ||
| 1802 | let y = origin[1] + paragraph.origin[1] + tag.origin[1]; | ||
| 1803 | let size = canvas::outline::ParagraphTag::SIZE; | ||
| 1804 | (x..=x + size).contains(&local[0]) && (y..=y + size).contains(&local[1]) | ||
| 1805 | }) | ||
| 1806 | }) | ||
| 1807 | { | ||
| 1808 | return Some(Hit::Text { | ||
| 1809 | id: outline.id, | ||
| 1810 | point: [ | ||
| 1811 | local[0] - outline.origin()[0], | ||
| 1812 | local[1] - outline.origin()[1], | ||
| 1813 | ], | ||
| 1814 | }); | ||
| 1815 | } | ||
| 1816 | None | ||
| 1817 | }; | ||
| 1818 | let pass = |layer| { | ||
| 1819 | if let Some(hit) = editor | ||
| 1820 | .visible_outlines() | ||
| 1821 | .rev() | ||
| 1822 | .filter(|outline| scene.is_none() || !editor.has_page_outline(outline.id)) | ||
| 1823 | .find_map(|outline| hit(outline, [0.0; 2], layer)) | ||
| 1824 | { | ||
| 1825 | return Some(hit); | ||
| 1826 | } | ||
| 1827 | let (scene, offset) = scene?; | ||
| 1828 | match scene.hit_test( | ||
| 1829 | [point[0] - offset[0], point[1] - offset[1]], | ||
| 1830 | Some(editor), | ||
| 1831 | |id| { | ||
| 1832 | editor | ||
| 1833 | .visible_outlines() | ||
| 1834 | .find(|outline| outline.id == id) | ||
| 1835 | .and_then(|outline| hit(outline, *offset, layer)) | ||
| 1836 | }, | ||
| 1837 | )? { | ||
| 1838 | canvas::gpu::page::SceneHit::Outline(hit) => Some(hit), | ||
| 1839 | canvas::gpu::page::SceneHit::Date(field) => Some(Hit::Date(field)), | ||
| 1840 | canvas::gpu::page::SceneHit::ReadOnly(index) => Some(Hit::ReadOnly(index)), | ||
| 1841 | canvas::gpu::page::SceneHit::Image(id) => Some(Hit::Image { id, handle: [0, 0] }), | ||
| 1842 | } | ||
| 1843 | }; | ||
| 1844 | [Layer::Grips, Layer::Body, Layer::Below] | ||
| 1845 | .into_iter() | ||
| 1846 | .find_map(pass) | ||
| 1847 | } | ||
| 1848 | |||
| 1849 | fn page_primitives<'a>( | ||
| 1850 | editor: &'a CanvasEditor, | ||
| 1851 | scene: Option<&'a (PageScene, [f32; 2])>, | ||
| 1852 | preview: Option<PointerFeedback<'a>>, | ||
| 1853 | object_focus: Option<ObjectFocus>, | ||
| 1854 | show_caret: bool, | ||
| 1855 | scale: f32, | ||
| 1856 | display_scale: f32, | ||
| 1857 | ) -> Result<Vec<Primitive<'a>>, Box<dyn Error>> { | ||
| 1858 | let mut primitives = Vec::new(); | ||
| 1859 | let paint = |id, offset: [f32; 2], primitives: &mut Vec<_>| { | ||
| 1860 | let outline = editor | ||
| 1861 | .visible_outlines() | ||
| 1862 | .find(|outline| outline.id == id) | ||
| 1863 | .ok_or(canvas::gpu::page::SceneError::MissingOutline)?; | ||
| 1864 | let outline = match preview { | ||
| 1865 | Some(PointerFeedback::Resize(resized)) if resized.id == outline.id => resized, | ||
| 1866 | _ => outline, | ||
| 1867 | }; | ||
| 1868 | let origin = match preview { | ||
| 1869 | Some(PointerFeedback::Move(id, origin)) if id == outline.id => origin, | ||
| 1870 | _ => outline.origin(), | ||
| 1871 | }; | 851 | }; |
| 1872 | if (object_focus.is_none() && outline.id == editor.active_outline().id) | 852 | if repaint { |
| 1873 | || matches!(preview, Some(PointerFeedback::Hover(id) | PointerFeedback::Move(id, _)) if id == outline.id) | 853 | state.window.request_redraw(); |
| 1874 | || matches!(preview, Some(PointerFeedback::Resize(resized)) if resized.id == outline.id) | ||
| 1875 | { | ||
| 1876 | append_outline_chrome( | ||
| 1877 | outline, | ||
| 1878 | [origin[0] + offset[0], origin[1] + offset[1]], | ||
| 1879 | display_scale / scale, | ||
| 1880 | primitives, | ||
| 1881 | ); | ||
| 1882 | } | ||
| 1883 | append_outline( | ||
| 1884 | (object_focus.is_none() | ||
| 1885 | && outline.id == editor.active_outline().id | ||
| 1886 | && !matches!(preview, Some(PointerFeedback::Resize(_)))) | ||
| 1887 | .then_some(editor), | ||
| 1888 | outline, | ||
| 1889 | [origin[0] + offset[0], origin[1] + offset[1]], | ||
| 1890 | show_caret, | ||
| 1891 | scale, | ||
| 1892 | display_scale / scale, | ||
| 1893 | primitives, | ||
| 1894 | )?; | ||
| 1895 | if let Some((scene, _)) = scene { | ||
| 1896 | let moving = match preview { | ||
| 1897 | Some(PointerFeedback::Image(id, origin, size)) => { | ||
| 1898 | let [x0, y0, x1, y1] = image_rect(origin, size); | ||
| 1899 | Some(( | ||
| 1900 | id, | ||
| 1901 | [ | ||
| 1902 | x0 + offset[0], | ||
| 1903 | y0 + offset[1], | ||
| 1904 | x1 + offset[0], | ||
| 1905 | y1 + offset[1], | ||
| 1906 | ], | ||
| 1907 | )) | ||
| 1908 | } | ||
| 1909 | _ => None, | ||
| 1910 | }; | ||
| 1911 | scene.append_outline_objects( | ||
| 1912 | outline.shaped(), | ||
| 1913 | [origin[0] + offset[0], origin[1] + offset[1]], | ||
| 1914 | moving, | ||
| 1915 | primitives, | ||
| 1916 | ); | ||
| 1917 | } | ||
| 1918 | Ok::<_, Box<dyn Error>>(()) | ||
| 1919 | }; | ||
| 1920 | if let Some((scene, origin)) = scene { | ||
| 1921 | let moving = match preview { | ||
| 1922 | Some(PointerFeedback::Image(id, origin, size)) => Some((id, image_rect(origin, size))), | ||
| 1923 | _ => None, | ||
| 1924 | }; | ||
| 1925 | scene.append_primitives_with(&mut primitives, *origin, Some(editor), moving, &paint)?; | ||
| 1926 | } | ||
| 1927 | for outline in editor | ||
| 1928 | .visible_outlines() | ||
| 1929 | .filter(|outline| scene.is_none() || !editor.has_page_outline(outline.id)) | ||
| 1930 | { | ||
| 1931 | paint(outline.id, [0.0; 2], &mut primitives)?; | ||
| 1932 | } | ||
| 1933 | if object_focus.is_none() | ||
| 1934 | && let Some(outline) = editor.caret_outline() | ||
| 1935 | { | ||
| 1936 | append_outline( | ||
| 1937 | Some(editor), | ||
| 1938 | outline, | ||
| 1939 | outline.origin(), | ||
| 1940 | show_caret, | ||
| 1941 | scale, | ||
| 1942 | display_scale / scale, | ||
| 1943 | &mut primitives, | ||
| 1944 | )?; | ||
| 1945 | } | ||
| 1946 | if let Some(ObjectFocus::Image(id)) = object_focus { | ||
| 1947 | let (origin, size) = match preview { | ||
| 1948 | Some(PointerFeedback::Image(moving, origin, size)) if moving == id => (origin, size), | ||
| 1949 | _ => editor | ||
| 1950 | .image_placement(id) | ||
| 1951 | .ok_or("The selected picture is missing.")?, | ||
| 1952 | }; | ||
| 1953 | append_image_chrome( | ||
| 1954 | image_rect(origin, size), | ||
| 1955 | display_scale / scale, | ||
| 1956 | &mut primitives, | ||
| 1957 | ); | ||
| 1958 | } | ||
| 1959 | if let Some(ObjectFocus::ReadOnly(index)) = object_focus { | ||
| 1960 | let (scene, offset) = scene.unwrap(); | ||
| 1961 | let [x0, y0, x1, y1] = scene.read_only(Some(editor)).nth(index).unwrap().rect(); | ||
| 1962 | let [x0, y0, x1, y1] = [ | ||
| 1963 | x0 + offset[0], | ||
| 1964 | y0 + offset[1], | ||
| 1965 | x1 + offset[0], | ||
| 1966 | y1 + offset[1], | ||
| 1967 | ]; | ||
| 1968 | let border = 2.0 / scale; | ||
| 1969 | for rect in [ | ||
| 1970 | [x0, y0, x1, y0 + border], | ||
| 1971 | [x0, y1 - border, x1, y1], | ||
| 1972 | [x0, y0, x0 + border, y1], | ||
| 1973 | [x1 - border, y0, x1, y1], | ||
| 1974 | ] { | ||
| 1975 | primitives.push(Primitive::Rect { | ||
| 1976 | rect, | ||
| 1977 | color: [0.25, 0.45, 0.7, 1.0], | ||
| 1978 | }); | ||
| 1979 | } | ||
| 1980 | } | ||
| 1981 | Ok(primitives) | ||
| 1982 | } | ||
| 1983 | |||
| 1984 | /// Native picture handles sit on a selection border drawn 5 px outside the picture, named | ||
| 1985 | /// by their direction from its center. | ||
| 1986 | fn image_handles(rect: [f32; 4], pixel: f32) -> impl Iterator<Item = ([i8; 2], [f32; 2])> { | ||
| 1987 | let border = [ | ||
| 1988 | rect[0] - 5.0 * pixel, | ||
| 1989 | rect[1] - 5.0 * pixel, | ||
| 1990 | rect[2] + 5.0 * pixel, | ||
| 1991 | rect[3] + 5.0 * pixel, | ||
| 1992 | ]; | ||
| 1993 | [-1, 0, 1] | ||
| 1994 | .into_iter() | ||
| 1995 | .flat_map(|y| [-1, 0, 1].map(|x| [x, y])) | ||
| 1996 | .filter(|handle| *handle != [0, 0]) | ||
| 1997 | .map(move |handle| { | ||
| 1998 | ( | ||
| 1999 | handle, | ||
| 2000 | std::array::from_fn(|axis| { | ||
| 2001 | let [start, end] = [border[axis], border[axis + 2]]; | ||
| 2002 | start + (end - start) * f32::from(handle[axis] + 1) * 0.5 | ||
| 2003 | }), | ||
| 2004 | ) | ||
| 2005 | }) | ||
| 2006 | } | ||
| 2007 | |||
| 2008 | fn image_handle_at(rect: [f32; 4], pixel: f32, point: [f32; 2]) -> Option<[i8; 2]> { | ||
| 2009 | image_handles(rect, pixel) | ||
| 2010 | .find(|(_, center)| (0..2).all(|axis| (point[axis] - center[axis]).abs() <= 5.0 * pixel)) | ||
| 2011 | .map(|(handle, _)| handle) | ||
| 2012 | } | ||
| 2013 | |||
| 2014 | fn handle_cursor(handle: [i8; 2]) -> winit::window::CursorIcon { | ||
| 2015 | use winit::window::CursorIcon; | ||
| 2016 | match handle { | ||
| 2017 | [0, 0] => CursorIcon::Move, | ||
| 2018 | [_, 0] => CursorIcon::EwResize, | ||
| 2019 | [0, _] => CursorIcon::NsResize, | ||
| 2020 | [x, y] if x == y => CursorIcon::NwseResize, | ||
| 2021 | _ => CursorIcon::NeswResize, | ||
| 2022 | } | ||
| 2023 | } | ||
| 2024 | |||
| 2025 | fn image_rect(origin: [f32; 2], size: [f32; 2]) -> [f32; 4] { | ||
| 2026 | [ | ||
| 2027 | origin[0], | ||
| 2028 | origin[1], | ||
| 2029 | origin[0] + size[0], | ||
| 2030 | origin[1] + size[1], | ||
| 2031 | ] | ||
| 2032 | } | ||
| 2033 | |||
| 2034 | /// Drags a picture's `handle` by `delta`: edges stretch one axis, corners keep the aspect | ||
| 2035 | /// ratio at the larger of the two scales, and the opposite side stays fixed. | ||
| 2036 | fn resize_image( | ||
| 2037 | origin: [f32; 2], | ||
| 2038 | size: [f32; 2], | ||
| 2039 | handle: [i8; 2], | ||
| 2040 | delta: [f32; 2], | ||
| 2041 | ) -> ([f32; 2], [f32; 2]) { | ||
| 2042 | let mut scale: [f32; 2] = std::array::from_fn(|axis| { | ||
| 2043 | (size[axis] + f32::from(handle[axis]) * delta[axis]) / size[axis] | ||
| 2044 | }); | ||
| 2045 | if !handle.contains(&0) { | ||
| 2046 | scale = [scale[0].max(scale[1]); 2]; | ||
| 2047 | } | ||
| 2048 | let resized: [f32; 2] = std::array::from_fn(|axis| (size[axis] * scale[axis]).max(1.0)); | ||
| 2049 | let origin = std::array::from_fn(|axis| { | ||
| 2050 | if handle[axis] < 0 { | ||
| 2051 | origin[axis] + size[axis] - resized[axis] | ||
| 2052 | } else { | ||
| 2053 | origin[axis] | ||
| 2054 | } | ||
| 2055 | }); | ||
| 2056 | (origin, resized) | ||
| 2057 | } | ||
| 2058 | |||
| 2059 | fn append_image_chrome(rect: [f32; 4], pixel: f32, primitives: &mut Vec<Primitive<'_>>) { | ||
| 2060 | let mut tint = canvas::gpu::colorref(0x00e0d2e6); | ||
| 2061 | // Native pictures take this tint at 25% in sRGB; 10% in linear light matches it. | ||
| 2062 | tint[3] = 0.1; | ||
| 2063 | primitives.push(Primitive::Rect { rect, color: tint }); | ||
| 2064 | primitives.push(Primitive::RoundedRect { | ||
| 2065 | rect: [ | ||
| 2066 | rect[0] - 5.0 * pixel, | ||
| 2067 | rect[1] - 5.0 * pixel, | ||
| 2068 | rect[2] + 5.0 * pixel, | ||
| 2069 | rect[3] + 5.0 * pixel, | ||
| 2070 | ], | ||
| 2071 | radius: [0.0; 2], | ||
| 2072 | stroke: Some(Stroke::Dashed(pixel)), | ||
| 2073 | color: canvas::gpu::colorref(0x00ff9a31), | ||
| 2074 | }); | ||
| 2075 | for (handle, [x, y]) in image_handles(rect, pixel) { | ||
| 2076 | let (half, radius) = if handle.contains(&0) { | ||
| 2077 | (3.5, 0.0) | ||
| 2078 | } else { | ||
| 2079 | (4.0, 4.0) | ||
| 2080 | }; | ||
| 2081 | let rect = [ | ||
| 2082 | x - half * pixel, | ||
| 2083 | y - half * pixel, | ||
| 2084 | x + half * pixel, | ||
| 2085 | y + half * pixel, | ||
| 2086 | ]; | ||
| 2087 | primitives.push(Primitive::RoundedRect { | ||
| 2088 | rect, | ||
| 2089 | radius: [radius * pixel; 2], | ||
| 2090 | stroke: None, | ||
| 2091 | color: canvas::gpu::colorref(0x00ffefe7), | ||
| 2092 | }); | ||
| 2093 | primitives.push(Primitive::RoundedRect { | ||
| 2094 | rect, | ||
| 2095 | radius: [radius * pixel; 2], | ||
| 2096 | stroke: Some(Stroke::Solid(pixel)), | ||
| 2097 | color: canvas::gpu::colorref(0x00dea67b), | ||
| 2098 | }); | ||
| 2099 | } | ||
| 2100 | } | ||
| 2101 | |||
| 2102 | fn drag_selection(anchor: Selection, target: Selection, unit: SelectionUnit) -> Selection { | ||
| 2103 | if unit == SelectionUnit::Grapheme { | ||
| 2104 | return Selection { | ||
| 2105 | positions: [anchor.positions[0], target.positions[1]], | ||
| 2106 | affinities: [anchor.affinities[0], target.affinities[1]], | ||
| 2107 | }; | ||
| 2108 | } | ||
| 2109 | let backwards = target.positions[0] < anchor.positions[0].min(anchor.positions[1]); | ||
| 2110 | let start = usize::from((anchor.positions[0] > anchor.positions[1]) != backwards); | ||
| 2111 | let end = usize::from((target.positions[0] > target.positions[1]) == backwards); | ||
| 2112 | Selection { | ||
| 2113 | positions: [anchor.positions[start], target.positions[end]], | ||
| 2114 | affinities: [anchor.affinities[start], target.affinities[end]], | ||
| 2115 | } | ||
| 2116 | } | ||
| 2117 | |||
| 2118 | fn snap_to_grid(point: [f32; 2], margin: [f32; 2]) -> [f32; 2] { | ||
| 2119 | std::array::from_fn(|axis| { | ||
| 2120 | let offset = margin[axis]; | ||
| 2121 | let cell = (point[axis] - offset) / 18.0; | ||
| 2122 | let nearest = cell.round(); | ||
| 2123 | // Native midpoints remain free; account for the source coordinate's float precision. | ||
| 2124 | if ((cell - nearest).abs() - 0.5).abs() <= f32::EPSILON * cell.abs().max(1.0) { | ||
| 2125 | point[axis] | ||
| 2126 | } else { | ||
| 2127 | nearest * 18.0 + offset | ||
| 2128 | } | ||
| 2129 | }) | ||
| 2130 | } | ||
| 2131 | |||
| 2132 | fn outline_chrome(outline: &TextOutline, pixel: f32) -> ([f32; 4], f32) { | ||
| 2133 | let bounds = outline.bounds(); | ||
| 2134 | // Native chrome combines page-scaled gutters with a fixed screen inset. | ||
| 2135 | let inset = 5.0 * pixel; | ||
| 2136 | let body_top = bounds.y0 as f32 - inset; | ||
| 2137 | if outline.title { | ||
| 2138 | let (_, paragraph) = outline.layouts().last().unwrap(); | ||
| 2139 | let bottom = outline.origin()[1] + paragraph.origin[1] + paragraph.text.height(); | ||
| 2140 | return ( | ||
| 2141 | [ | ||
| 2142 | bounds.x0 as f32 - inset - 6.0 * pixel, | ||
| 2143 | body_top, | ||
| 2144 | bounds.x1 as f32 + inset - 2.0 * pixel, | ||
| 2145 | bottom + inset, | ||
| 2146 | ], | ||
| 2147 | body_top, | ||
| 2148 | ); | ||
| 2149 | } | ||
| 2150 | ( | ||
| 2151 | [ | ||
| 2152 | bounds.x0 as f32 - 7.5 - inset, | ||
| 2153 | body_top - HANDLE_HEIGHT, | ||
| 2154 | bounds.x1 as f32 + inset, | ||
| 2155 | bounds.y1 as f32 + HANDLE_HEIGHT + inset, | ||
| 2156 | ], | ||
| 2157 | body_top, | ||
| 2158 | ) | ||
| 2159 | } | ||
| 2160 | |||
| 2161 | fn append_outline_chrome( | ||
| 2162 | outline: &TextOutline, | ||
| 2163 | origin: [f32; 2], | ||
| 2164 | pixel: f32, | ||
| 2165 | primitives: &mut Vec<Primitive<'_>>, | ||
| 2166 | ) { | ||
| 2167 | let [x, y] = origin; | ||
| 2168 | let (bounds, body_top) = outline_chrome(outline, pixel); | ||
| 2169 | let [dx, dy] = [x - outline.origin()[0], y - outline.origin()[1]]; | ||
| 2170 | let [left, top, right, bottom] = [ | ||
| 2171 | bounds[0] + dx, | ||
| 2172 | bounds[1] + dy, | ||
| 2173 | bounds[2] + dx, | ||
| 2174 | bounds[3] + dy, | ||
| 2175 | ]; | ||
| 2176 | let body_top = body_top + dy; | ||
| 2177 | if outline.title { | ||
| 2178 | primitives.push(Primitive::RoundedRect { | ||
| 2179 | rect: [left, top, right, bottom], | ||
| 2180 | radius: [6.0 * pixel, (bottom - top) * 0.5], | ||
| 2181 | stroke: Some(Stroke::Dashed(pixel)), | ||
| 2182 | color: canvas::gpu::colorref(0x007f7f7f), | ||
| 2183 | }); | ||
| 2184 | return; | ||
| 2185 | } | ||
| 2186 | primitives.push(Primitive::RoundedRect { | ||
| 2187 | rect: [left, top, right, body_top], | ||
| 2188 | radius: [3.0 * pixel; 2], | ||
| 2189 | stroke: None, | ||
| 2190 | color: canvas::gpu::colorref(0x00e8ebed), | ||
| 2191 | }); | ||
| 2192 | primitives.push(Primitive::RoundedRect { | ||
| 2193 | rect: [right - 9.0 * pixel, top, right, body_top], | ||
| 2194 | radius: [3.0 * pixel; 2], | ||
| 2195 | stroke: None, | ||
| 2196 | color: canvas::gpu::colorref(0x00e5dee7), | ||
| 2197 | }); | ||
| 2198 | primitives.push(Primitive::RoundedRect { | ||
| 2199 | rect: [left, top, right, bottom], | ||
| 2200 | radius: [3.0 * pixel; 2], | ||
| 2201 | stroke: Some(Stroke::Solid(pixel)), | ||
| 2202 | color: canvas::gpu::colorref(0x00d9cfd8), | ||
| 2203 | }); | ||
| 2204 | let middle = (top + body_top) * 0.5; | ||
| 2205 | for offset in [-3.0, 0.0, 3.0] { | ||
| 2206 | let center = (left + right) * 0.5 + offset * pixel; | ||
| 2207 | primitives.push(Primitive::RoundedRect { | ||
| 2208 | rect: [ | ||
| 2209 | center - pixel * 0.5, | ||
| 2210 | middle - pixel * 0.5, | ||
| 2211 | center + pixel * 0.5, | ||
| 2212 | middle + pixel * 0.5, | ||
| 2213 | ], | ||
| 2214 | radius: [pixel * 0.5; 2], | ||
| 2215 | stroke: None, | ||
| 2216 | color: canvas::gpu::colorref(0x00b4a5b4), | ||
| 2217 | }); | ||
| 2218 | } | ||
| 2219 | for column in [0.0, 1.0, 2.0] { | ||
| 2220 | let half = (column + 0.5) * pixel; | ||
| 2221 | for x in [ | ||
| 2222 | right - (8.0 - column) * pixel, | ||
| 2223 | right - (2.0 + column) * pixel, | ||
| 2224 | ] { | ||
| 2225 | primitives.push(Primitive::Rect { | ||
| 2226 | rect: [x, middle - half, x + pixel, middle + half], | ||
| 2227 | color: canvas::gpu::colorref(0x00b4a5b4), | ||
| 2228 | }); | ||
| 2229 | } | 854 | } |
| 855 | event_loop.set_control_flow(next.map_or(ControlFlow::Wait, ControlFlow::WaitUntil)); | ||
| 2230 | } | 856 | } |
| 2231 | } | 857 | } |
| 2232 | 858 | ||
| 2233 | fn append_outline<'a>( | ||
| 2234 | editor: Option<&'a CanvasEditor>, | ||
| 2235 | outline: &'a TextOutline, | ||
| 2236 | origin: [f32; 2], | ||
| 2237 | show_caret: bool, | ||
| 2238 | scale: f32, | ||
| 2239 | pixel: f32, | ||
| 2240 | primitives: &mut Vec<Primitive<'a>>, | ||
| 2241 | ) -> Result<(), Box<dyn Error>> { | ||
| 2242 | let [x, y] = origin; | ||
| 2243 | outline.shaped().append_table_primitives(primitives, origin); | ||
| 2244 | outline | ||
| 2245 | .shaped() | ||
| 2246 | .append_background_primitives(primitives, origin); | ||
| 2247 | if let Some(editor) = editor { | ||
| 2248 | let [_, selection_color] = macos::text_colors(); | ||
| 2249 | for rect in editor.selection_rects()? { | ||
| 2250 | primitives.push(Primitive::Rect { | ||
| 2251 | rect: [ | ||
| 2252 | rect.x0 as f32 + x, | ||
| 2253 | rect.y0 as f32 + y, | ||
| 2254 | rect.x1 as f32 + x, | ||
| 2255 | rect.y1 as f32 + y, | ||
| 2256 | ], | ||
| 2257 | color: selection_color, | ||
| 2258 | }); | ||
| 2259 | } | ||
| 2260 | } | ||
| 2261 | for (index, (_, paragraph)) in outline.layouts().enumerate() { | ||
| 2262 | outline | ||
| 2263 | .shaped() | ||
| 2264 | .append_paragraph_primitives(index, paragraph, origin, primitives); | ||
| 2265 | } | ||
| 2266 | if let Some(editor) = editor { | ||
| 2267 | for rect in editor.marked_rects()? { | ||
| 2268 | primitives.push(Primitive::Rect { | ||
| 2269 | rect: [ | ||
| 2270 | rect.x0 as f32 + x, | ||
| 2271 | rect.y1 as f32 + y - 1.0 / scale, | ||
| 2272 | rect.x1 as f32 + x, | ||
| 2273 | rect.y1 as f32 + y, | ||
| 2274 | ], | ||
| 2275 | color: [0.0, 0.0, 0.0, 1.0], | ||
| 2276 | }); | ||
| 2277 | } | ||
| 2278 | let [anchor, focus] = editor.selection().positions; | ||
| 2279 | if show_caret && anchor == focus { | ||
| 2280 | let rect = editor.caret(2.0 * pixel)?; | ||
| 2281 | primitives.push(Primitive::RoundedRect { | ||
| 2282 | rect: [ | ||
| 2283 | rect.x0 as f32 + x, | ||
| 2284 | rect.y0 as f32 + y, | ||
| 2285 | rect.x1 as f32 + x, | ||
| 2286 | rect.y1 as f32 + y, | ||
| 2287 | ], | ||
| 2288 | radius: [pixel; 2], | ||
| 2289 | stroke: None, | ||
| 2290 | color: macos::text_colors()[0], | ||
| 2291 | }); | ||
| 2292 | } | ||
| 2293 | } | ||
| 2294 | Ok(()) | ||
| 2295 | } | ||
| 2296 | |||
| 2297 | fn main() -> Result<(), Box<dyn Error>> { | 859 | fn main() -> Result<(), Box<dyn Error>> { |
| 2298 | let mut args = std::env::args_os().skip(1); | 860 | let mut args = std::env::args_os().skip(1); |
| 2299 | let mut positional = Vec::new(); | 861 | let mut positional = Vec::new(); |
| ... | @@ -2407,1410 +969,3 @@ fn main() -> Result<(), Box<dyn Error>> { | ... | @@ -2407,1410 +969,3 @@ fn main() -> Result<(), Box<dyn Error>> { |
| 2407 | event_loop.run_app(&mut app)?; | 969 | event_loop.run_app(&mut app)?; |
| 2408 | app.startup_error.map_or(Ok(()), Err) | 970 | app.startup_error.map_or(Ok(()), Err) |
| 2409 | } | 971 | } |
| 2410 | |||
| 2411 | #[cfg(test)] | ||
| 2412 | mod tests { | ||
| 2413 | use super::*; | ||
| 2414 | |||
| 2415 | #[test] | ||
| 2416 | fn date_buttons_keep_accessibility_identity_and_match_mouse_hits_after_reflow() { | ||
| 2417 | let mut engine = TextEngine::default(); | ||
| 2418 | let mut fields = [vec!["Header"], vec!["Monday", "6:14 AM"]].map(|text| { | ||
| 2419 | TextOutline::new( | ||
| 2420 | &mut engine, | ||
| 2421 | TextDocument::new( | ||
| 2422 | text.into_iter() | ||
| 2423 | .map(|text| Paragraph::new(text.into(), Default::default())) | ||
| 2424 | .collect(), | ||
| 2425 | ) | ||
| 2426 | .unwrap(), | ||
| 2427 | 468.0, | ||
| 2428 | [0.0; 2], | ||
| 2429 | ) | ||
| 2430 | .unwrap() | ||
| 2431 | .snapshot() | ||
| 2432 | }); | ||
| 2433 | fields[0].title = true; | ||
| 2434 | let page = Page { | ||
| 2435 | identity: None, | ||
| 2436 | title: "Header".into(), | ||
| 2437 | created: Some(1), | ||
| 2438 | margin_origin: [36.0, 14.4], | ||
| 2439 | definitions: Default::default(), | ||
| 2440 | objects: vec![onestore::page::PageObject::Title(onestore::page::Title { | ||
| 2441 | id: onestore::ExGuid::default(), | ||
| 2442 | date: Some(fields[1].id), | ||
| 2443 | layout: Default::default(), | ||
| 2444 | outlines: fields.into(), | ||
| 2445 | })], | ||
| 2446 | }; | ||
| 2447 | let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap(); | ||
| 2448 | let scene = (scene, [20.0, 40.0]); | ||
| 2449 | let viewport = Viewport { | ||
| 2450 | size: [1000, 700], | ||
| 2451 | scale: 2.0, | ||
| 2452 | origin: [-30.0, -50.0], | ||
| 2453 | }; | ||
| 2454 | let mut access = accessibility::Accessibility::default(); | ||
| 2455 | let mut identities = Vec::new(); | ||
| 2456 | for phase in 0..3 { | ||
| 2457 | if phase == 1 { | ||
| 2458 | editor | ||
| 2459 | .insert(&mut engine, &"A wrapped title ".repeat(25)) | ||
| 2460 | .unwrap(); | ||
| 2461 | } | ||
| 2462 | if phase == 2 { | ||
| 2463 | editor | ||
| 2464 | .change_date(&mut engine, 2, ["Wednesday".into(), "8:40 AM".into()]) | ||
| 2465 | .unwrap(); | ||
| 2466 | } | ||
| 2467 | let mut update = access.update(&editor, viewport, "Header", None).unwrap(); | ||
| 2468 | access.append_page_fields(&mut update, Some(&scene), &editor, viewport, None); | ||
| 2469 | let buttons: Vec<_> = update | ||
| 2470 | .nodes | ||
| 2471 | .iter() | ||
| 2472 | .filter(|(_, node)| node.role() == accesskit::Role::Button) | ||
| 2473 | .collect(); | ||
| 2474 | assert_eq!(buttons.len(), 2); | ||
| 2475 | for (index, (id, node)) in buttons.iter().enumerate() { | ||
| 2476 | let field = [DateField::Date, DateField::Time][index]; | ||
| 2477 | assert_eq!(access.date_for_node(*id), Some(field)); | ||
| 2478 | assert!(node.supports_action(accesskit::Action::Click)); | ||
| 2479 | let rect = node.bounds().unwrap(); | ||
| 2480 | let point = viewport.document_point([ | ||
| 2481 | ((rect.x0 + rect.x1) * 0.5) as f32, | ||
| 2482 | ((rect.y0 + rect.y1) * 0.5) as f32, | ||
| 2483 | ]); | ||
| 2484 | assert_eq!( | ||
| 2485 | page_hit_test(&editor, Some(&scene), point, 0.5), | ||
| 2486 | Some(Hit::Date(field)) | ||
| 2487 | ); | ||
| 2488 | if phase == 0 { | ||
| 2489 | identities.push(*id); | ||
| 2490 | } else { | ||
| 2491 | assert_eq!(*id, identities[index]); | ||
| 2492 | } | ||
| 2493 | } | ||
| 2494 | if phase == 2 { | ||
| 2495 | assert_eq!(buttons[1].1.value(), Some("8:40 AM")); | ||
| 2496 | } | ||
| 2497 | accesskit_consumer::Tree::new(update, true); | ||
| 2498 | } | ||
| 2499 | } | ||
| 2500 | |||
| 2501 | #[test] | ||
| 2502 | fn extension_hits_yield_to_objects_and_keep_their_source_coordinate_frame() { | ||
| 2503 | let mut engine = TextEngine::default(); | ||
| 2504 | let upper = TextOutline::new( | ||
| 2505 | &mut engine, | ||
| 2506 | TextDocument::new(vec![Paragraph::new("Upper".into(), Default::default())]).unwrap(), | ||
| 2507 | 240.0, | ||
| 2508 | [36.0, 90.0], | ||
| 2509 | ) | ||
| 2510 | .unwrap(); | ||
| 2511 | let bottom = upper.bounds().y1 as f32; | ||
| 2512 | let id = upper.id; | ||
| 2513 | let lower = TextOutline::new( | ||
| 2514 | &mut engine, | ||
| 2515 | TextDocument::new(vec![Paragraph::new("Lower".into(), Default::default())]).unwrap(), | ||
| 2516 | 240.0, | ||
| 2517 | [36.0, bottom + 20.0], | ||
| 2518 | ) | ||
| 2519 | .unwrap(); | ||
| 2520 | let lower_id = lower.id; | ||
| 2521 | let source = upper.snapshot(); | ||
| 2522 | let mut editor = | ||
| 2523 | CanvasEditor::from_text_outlines(vec![lower, upper], Default::default(), None).unwrap(); | ||
| 2524 | assert!( | ||
| 2525 | matches!(page_hit_test(&editor, None, [60.0, bottom + 22.0], 0.75), Some(Hit::Text { id, .. }) if id == lower_id) | ||
| 2526 | ); | ||
| 2527 | editor.move_outline(lower_id, [600.0, 500.0]).unwrap(); | ||
| 2528 | for pixel in [0.375, 0.75, 1.5] { | ||
| 2529 | let hit = page_hit_test(&editor, None, [60.0, bottom + 26.0], pixel).unwrap(); | ||
| 2530 | assert!( | ||
| 2531 | matches!(hit, Hit::Text { id: hit_id, point } if hit_id == id && (point[1] - (bottom + 26.0 - 90.0)).abs() < 0.001) | ||
| 2532 | ); | ||
| 2533 | assert_eq!( | ||
| 2534 | page_hit_test(&editor, None, [60.0, bottom + 30.0], pixel), | ||
| 2535 | None | ||
| 2536 | ); | ||
| 2537 | } | ||
| 2538 | let page = onestore::page::Page { | ||
| 2539 | identity: None, | ||
| 2540 | title: String::new(), | ||
| 2541 | created: None, | ||
| 2542 | margin_origin: [0.0; 2], | ||
| 2543 | definitions: Default::default(), | ||
| 2544 | objects: vec![onestore::page::PageObject::Outline(source)], | ||
| 2545 | }; | ||
| 2546 | let (scene, editor) = PageScene::from_page(page, &mut engine).unwrap(); | ||
| 2547 | let offset = [50.0, 300.0]; | ||
| 2548 | assert!( | ||
| 2549 | matches!(page_hit_test(&editor, Some(&(scene, offset)), [60.0 + offset[0], bottom + 26.0 + offset[1]], 0.75), Some(Hit::Text { id: hit_id, .. }) if hit_id == id) | ||
| 2550 | ); | ||
| 2551 | } | ||
| 2552 | |||
| 2553 | #[test] | ||
| 2554 | fn title_chrome_selects_text_and_exposes_a_named_editable_field() { | ||
| 2555 | let mut engine = TextEngine::default(); | ||
| 2556 | let mut source = TextOutline::new( | ||
| 2557 | &mut engine, | ||
| 2558 | TextDocument::new(vec![Paragraph::new( | ||
| 2559 | "Header".into(), | ||
| 2560 | Format { | ||
| 2561 | font_size: Some(8.0), | ||
| 2562 | line_spacing: Some(20.751953), | ||
| 2563 | ..Default::default() | ||
| 2564 | }, | ||
| 2565 | )]) | ||
| 2566 | .unwrap(), | ||
| 2567 | 468.0, | ||
| 2568 | [36.0, 14.4], | ||
| 2569 | ) | ||
| 2570 | .unwrap() | ||
| 2571 | .snapshot(); | ||
| 2572 | source.title = true; | ||
| 2573 | source.min_width = Some(162.0); | ||
| 2574 | source.layout.max_width = None; | ||
| 2575 | source.layout.width_set_by_user = None; | ||
| 2576 | source.layout.max_height = Some(21.6); | ||
| 2577 | let outline = TextOutline::from_outline(&mut engine, &source, &Default::default()).unwrap(); | ||
| 2578 | let editor = | ||
| 2579 | CanvasEditor::from_text_outlines(vec![outline], Default::default(), None).unwrap(); | ||
| 2580 | assert_eq!(editor.active_outline().bounds().width(), 162.0); | ||
| 2581 | assert_eq!(editor.active_outline().snapshot().min_width, Some(162.0)); | ||
| 2582 | let (native_rect, _) = outline_chrome(editor.active_outline(), 0.75); | ||
| 2583 | for (axis, (actual, expected)) in native_rect | ||
| 2584 | .iter() | ||
| 2585 | .zip([85.0, 98.0, 315.0, 134.0]) | ||
| 2586 | .enumerate() | ||
| 2587 | { | ||
| 2588 | let origin = if axis % 2 == 0 { 48.0 } else { 83.0 }; | ||
| 2589 | assert!((actual / 0.75 + origin - expected).abs() < 1.5); | ||
| 2590 | } | ||
| 2591 | let id = editor.active_outline().id; | ||
| 2592 | let (rect, _) = outline_chrome(editor.active_outline(), 1.0); | ||
| 2593 | for point in [ | ||
| 2594 | [rect[0] + 1.0, rect[1] + 1.0], | ||
| 2595 | [rect[2] - 1.0, rect[1] + 1.0], | ||
| 2596 | [40.0, 20.0], | ||
| 2597 | ] { | ||
| 2598 | assert!( | ||
| 2599 | matches!(page_hit_test(&editor, None, point, 1.0), Some(Hit::Text { id: hit, .. }) if hit == id) | ||
| 2600 | ); | ||
| 2601 | } | ||
| 2602 | let primitives = page_primitives(&editor, None, None, None, false, 1.0, 1.0).unwrap(); | ||
| 2603 | let borders = primitives | ||
| 2604 | .iter() | ||
| 2605 | .filter_map(|p| match p { | ||
| 2606 | Primitive::RoundedRect { stroke, .. } => Some(*stroke), | ||
| 2607 | _ => None, | ||
| 2608 | }) | ||
| 2609 | .collect::<Vec<_>>(); | ||
| 2610 | assert_eq!(borders, [Some(Stroke::Dashed(1.0))]); | ||
| 2611 | assert!(primitives.iter().any(|primitive| matches!(primitive, Primitive::RoundedRect { rect, radius, stroke: Some(Stroke::Dashed(_)), .. } if radius[0] == 6.0 && radius[1] * 2.0 == rect[3] - rect[1]))); | ||
| 2612 | let mut access = accessibility::Accessibility::default(); | ||
| 2613 | let update = access | ||
| 2614 | .update( | ||
| 2615 | &editor, | ||
| 2616 | Viewport { | ||
| 2617 | size: [800, 600], | ||
| 2618 | scale: 1.0, | ||
| 2619 | origin: [0.0; 2], | ||
| 2620 | }, | ||
| 2621 | "Header", | ||
| 2622 | None, | ||
| 2623 | ) | ||
| 2624 | .unwrap(); | ||
| 2625 | assert!(update.nodes.iter().any(|(_, node)| node.role() | ||
| 2626 | == accesskit::Role::MultilineTextInput | ||
| 2627 | && node.label() == Some("Page title"))); | ||
| 2628 | } | ||
| 2629 | |||
| 2630 | #[test] | ||
| 2631 | fn provisional_lines_share_one_drawn_hit_tested_and_accessible_outline() { | ||
| 2632 | let mut engine = TextEngine::default(); | ||
| 2633 | let mut editor = CanvasEditor::new( | ||
| 2634 | &mut engine, | ||
| 2635 | TextDocument::new(vec![Paragraph::new("body".into(), Format::default())]).unwrap(), | ||
| 2636 | 240.0, | ||
| 2637 | ) | ||
| 2638 | .unwrap(); | ||
| 2639 | let id = editor.active_outline().id; | ||
| 2640 | for _ in 0..2 { | ||
| 2641 | editor | ||
| 2642 | .move_selection(&mut engine, Movement::Down, false) | ||
| 2643 | .unwrap(); | ||
| 2644 | } | ||
| 2645 | let primitives = page_primitives(&editor, None, None, None, true, 1.0, 1.0).unwrap(); | ||
| 2646 | assert_eq!( | ||
| 2647 | primitives | ||
| 2648 | .iter() | ||
| 2649 | .filter(|p| matches!(p, Primitive::Text { .. })) | ||
| 2650 | .count(), | ||
| 2651 | 3 | ||
| 2652 | ); | ||
| 2653 | let caret = editor.caret(1.0).unwrap(); | ||
| 2654 | assert!( | ||
| 2655 | matches!(page_hit_test(&editor, None, [caret.x0 as f32, ((caret.y0 + caret.y1) * 0.5) as f32], 1.0), Some(Hit::Text { id: hit, .. }) if hit == id) | ||
| 2656 | ); | ||
| 2657 | let mut access = accessibility::Accessibility::default(); | ||
| 2658 | let update = access | ||
| 2659 | .update( | ||
| 2660 | &editor, | ||
| 2661 | Viewport { | ||
| 2662 | size: [800, 600], | ||
| 2663 | scale: 1.0, | ||
| 2664 | origin: [0.0; 2], | ||
| 2665 | }, | ||
| 2666 | "Test", | ||
| 2667 | None, | ||
| 2668 | ) | ||
| 2669 | .unwrap(); | ||
| 2670 | let fields = update | ||
| 2671 | .nodes | ||
| 2672 | .iter() | ||
| 2673 | .filter(|(_, node)| node.role() == accesskit::Role::MultilineTextInput) | ||
| 2674 | .collect::<Vec<_>>(); | ||
| 2675 | assert_eq!(fields.len(), 1); | ||
| 2676 | assert_eq!(fields[0].1.value(), Some("body\n\n")); | ||
| 2677 | assert_eq!(editor.outlines()[0].document().nodes().len(), 1); | ||
| 2678 | editor | ||
| 2679 | .place_caret(&mut engine, [300.0, 200.0], 240.0) | ||
| 2680 | .unwrap(); | ||
| 2681 | assert_eq!( | ||
| 2682 | editor | ||
| 2683 | .visible_outlines() | ||
| 2684 | .next() | ||
| 2685 | .unwrap() | ||
| 2686 | .document() | ||
| 2687 | .nodes() | ||
| 2688 | .len(), | ||
| 2689 | 1 | ||
| 2690 | ); | ||
| 2691 | } | ||
| 2692 | |||
| 2693 | #[test] | ||
| 2694 | fn chrome_follows_focus_hover_and_drag_without_changing_hit_geometry() { | ||
| 2695 | let mut engine = TextEngine::default(); | ||
| 2696 | let outlines = [[0.0, 0.0], [220.0, 0.0]] | ||
| 2697 | .into_iter() | ||
| 2698 | .map(|origin| { | ||
| 2699 | TextOutline::new( | ||
| 2700 | &mut engine, | ||
| 2701 | TextDocument::new(vec![Paragraph::new("text".into(), Format::default())]) | ||
| 2702 | .unwrap(), | ||
| 2703 | 100.0, | ||
| 2704 | origin, | ||
| 2705 | ) | ||
| 2706 | .unwrap() | ||
| 2707 | }) | ||
| 2708 | .collect(); | ||
| 2709 | let editor = CanvasEditor::from_text_outlines(outlines, Default::default(), None).unwrap(); | ||
| 2710 | let second = editor.outlines()[1].id; | ||
| 2711 | let borders = |feedback| { | ||
| 2712 | page_primitives(&editor, None, feedback, None, false, 1.0, 1.0) | ||
| 2713 | .unwrap() | ||
| 2714 | .into_iter() | ||
| 2715 | .filter_map(|p| match p { | ||
| 2716 | Primitive::RoundedRect { | ||
| 2717 | rect, | ||
| 2718 | stroke: Some(Stroke::Solid(1.0)), | ||
| 2719 | .. | ||
| 2720 | } => Some(rect), | ||
| 2721 | _ => None, | ||
| 2722 | }) | ||
| 2723 | .collect::<Vec<_>>() | ||
| 2724 | }; | ||
| 2725 | assert_eq!(borders(None).len(), 1); | ||
| 2726 | let hovered = borders(Some(PointerFeedback::Hover(second))); | ||
| 2727 | assert_eq!(hovered.len(), 2); | ||
| 2728 | let moved = borders(Some(PointerFeedback::Move(second, [240.0, 50.0]))); | ||
| 2729 | assert_eq!(moved[1][0] - hovered[1][0], 20.0); | ||
| 2730 | assert_eq!(moved[1][1] - hovered[1][1], 50.0); | ||
| 2731 | assert!( | ||
| 2732 | matches!(page_hit_test(&editor, None, [225.0, 5.0], 1.0), Some(Hit::Text { id, .. }) if id == second) | ||
| 2733 | ); | ||
| 2734 | } | ||
| 2735 | |||
| 2736 | #[test] | ||
| 2737 | fn a_covered_width_handle_stays_reachable_beside_the_outline_above_it() { | ||
| 2738 | let mut engine = TextEngine::default(); | ||
| 2739 | let pixel = 0.75; | ||
| 2740 | let mut first = TextOutline::new( | ||
| 2741 | &mut engine, | ||
| 2742 | TextDocument::new(vec![Paragraph::new("text".into(), Format::default())]).unwrap(), | ||
| 2743 | 100.0, | ||
| 2744 | [36.0, 68.4], | ||
| 2745 | ) | ||
| 2746 | .unwrap(); | ||
| 2747 | let editor = | ||
| 2748 | CanvasEditor::from_text_outlines(vec![first.clone()], Default::default(), None) | ||
| 2749 | .unwrap(); | ||
| 2750 | first = editor.preview_resize(&mut engine, 226.5).unwrap(); | ||
| 2751 | let (frame, body_top) = outline_chrome(&first, pixel); | ||
| 2752 | // The casual night page: the next outline starts 7.5 pt right of the first one. | ||
| 2753 | let second = TextOutline::new( | ||
| 2754 | &mut engine, | ||
| 2755 | TextDocument::new(vec![Paragraph::new("text".into(), Format::default())]).unwrap(), | ||
| 2756 | 100.0, | ||
| 2757 | [first.bounds().x1 as f32 + 7.5, 68.4], | ||
| 2758 | ) | ||
| 2759 | .unwrap(); | ||
| 2760 | let (first_id, second_id) = (first.id, second.id); | ||
| 2761 | let editor = | ||
| 2762 | CanvasEditor::from_text_outlines(vec![first, second], Default::default(), None) | ||
| 2763 | .unwrap(); | ||
| 2764 | let header = (frame[1] + body_top) / 2.0; | ||
| 2765 | let hit = |x| page_hit_test(&editor, None, [x, header], pixel); | ||
| 2766 | assert!( | ||
| 2767 | matches!(hit(frame[2] - 12.0 * pixel), Some(Hit::Resize { id, .. }) if id == first_id) | ||
| 2768 | ); | ||
| 2769 | assert!( | ||
| 2770 | matches!(hit(frame[2] - 2.0 * pixel), Some(Hit::Handle { id, .. }) if id == second_id) | ||
| 2771 | ); | ||
| 2772 | let body = |x| page_hit_test(&editor, None, [x, body_top + 20.0], pixel); | ||
| 2773 | assert!( | ||
| 2774 | matches!(body(frame[2] - 4.0 * pixel), Some(Hit::Resize { id, .. }) if id == first_id) | ||
| 2775 | ); | ||
| 2776 | assert!( | ||
| 2777 | matches!(body(frame[2] - 8.0 * pixel), Some(Hit::Text { id, .. }) if id == second_id) | ||
| 2778 | ); | ||
| 2779 | assert!( | ||
| 2780 | matches!(body(frame[2] - 12.0 * pixel), Some(Hit::Text { id, .. }) if id == first_id) | ||
| 2781 | ); | ||
| 2782 | } | ||
| 2783 | |||
| 2784 | #[test] | ||
| 2785 | fn a_picture_in_an_outline_takes_the_click_over_its_text() { | ||
| 2786 | use onestore::page::{Image, Page, PageObject, ParagraphContent}; | ||
| 2787 | let mut engine = TextEngine::default(); | ||
| 2788 | let mut source = TextOutline::new( | ||
| 2789 | &mut engine, | ||
| 2790 | TextDocument::new(vec![ | ||
| 2791 | Paragraph::new("Before".into(), Default::default()), | ||
| 2792 | Paragraph::new("After".into(), Default::default()), | ||
| 2793 | ]) | ||
| 2794 | .unwrap(), | ||
| 2795 | 240.0, | ||
| 2796 | [36.0, 36.0], | ||
| 2797 | ) | ||
| 2798 | .unwrap() | ||
| 2799 | .snapshot(); | ||
| 2800 | let mut picture = source.paragraphs[0].clone(); | ||
| 2801 | picture.id = onestore::page::text::new_id().unwrap(); | ||
| 2802 | let id = onestore::page::text::new_id().unwrap(); | ||
| 2803 | picture.content = ParagraphContent::Image(Image { | ||
| 2804 | size: Some([40.0, 30.0]), | ||
| 2805 | id, | ||
| 2806 | layout: Default::default(), | ||
| 2807 | bytes: None, | ||
| 2808 | alt: None, | ||
| 2809 | background: false, | ||
| 2810 | }); | ||
| 2811 | source.paragraphs.insert(1, picture); | ||
| 2812 | let editor = CanvasEditor::from_page( | ||
| 2813 | Page { | ||
| 2814 | title: String::new(), | ||
| 2815 | identity: None, | ||
| 2816 | created: None, | ||
| 2817 | margin_origin: [36.0, 14.4], | ||
| 2818 | definitions: Default::default(), | ||
| 2819 | objects: vec![PageObject::Outline(source)], | ||
| 2820 | }, | ||
| 2821 | &mut engine, | ||
| 2822 | ) | ||
| 2823 | .unwrap(); | ||
| 2824 | let (origin, size) = editor.image_placement(id).unwrap(); | ||
| 2825 | let center = [origin[0] + size[0] / 2.0, origin[1] + size[1] / 2.0]; | ||
| 2826 | assert_eq!( | ||
| 2827 | page_hit_test(&editor, None, center, 1.0), | ||
| 2828 | Some(Hit::Image { id, handle: [0, 0] }) | ||
| 2829 | ); | ||
| 2830 | assert!(matches!( | ||
| 2831 | page_hit_test(&editor, None, [origin[0] + 2.0, origin[1] - 4.0], 1.0), | ||
| 2832 | Some(Hit::Text { .. }) | ||
| 2833 | )); | ||
| 2834 | } | ||
| 2835 | |||
| 2836 | #[test] | ||
| 2837 | fn picture_handles_resize_as_onenote_does() { | ||
| 2838 | // Native drags of the 333 x 200.1 pt mockup on "av: casual night in the trees". | ||
| 2839 | let (origin, size) = ([468.0, 86.4], [333.0, 200.1]); | ||
| 2840 | let (corner_origin, corner) = resize_image(origin, size, [1, -1], [-75.0, 30.75]); | ||
| 2841 | assert!((corner[0] - 281.827).abs() < 0.01 && (corner[1] - 169.351).abs() < 0.01); | ||
| 2842 | assert!((corner_origin[1] - 117.15).abs() < 0.01 && corner_origin[0] == 468.0); | ||
| 2843 | assert_eq!( | ||
| 2844 | resize_image(origin, size, [1, 0], [46.5, 10.0]), | ||
| 2845 | (origin, [379.5, 200.1]) | ||
| 2846 | ); | ||
| 2847 | let (left_origin, left) = resize_image(origin, size, [-1, 0], [400.0, 0.0]); | ||
| 2848 | assert_eq!(left, [1.0, 200.1]); | ||
| 2849 | assert_eq!(left_origin[0], 468.0 + 333.0 - 1.0); | ||
| 2850 | let pixel = 0.75; | ||
| 2851 | let rect = image_rect(origin, size); | ||
| 2852 | assert_eq!( | ||
| 2853 | image_handle_at(rect, pixel, [468.0 - 3.75, 86.4 - 3.75]), | ||
| 2854 | Some([-1, -1]) | ||
| 2855 | ); | ||
| 2856 | assert_eq!( | ||
| 2857 | image_handle_at(rect, pixel, [468.0 + 166.5, 286.5 + 3.75]), | ||
| 2858 | Some([0, 1]) | ||
| 2859 | ); | ||
| 2860 | assert_eq!(image_handle_at(rect, pixel, [600.0, 150.0]), None); | ||
| 2861 | } | ||
| 2862 | |||
| 2863 | #[test] | ||
| 2864 | fn grouped_drag_keeps_the_initial_word_when_reversing_direction() { | ||
| 2865 | let selection = |start, end| { | ||
| 2866 | Selection::from([start, end].map(|offset| canvas::document::TextPosition { | ||
| 2867 | paragraph: 0, | ||
| 2868 | offset, | ||
| 2869 | })) | ||
| 2870 | }; | ||
| 2871 | let anchor = selection(6, 10); | ||
| 2872 | assert_eq!( | ||
| 2873 | drag_selection(anchor, selection(11, 16), SelectionUnit::Word).positions, | ||
| 2874 | selection(6, 16).positions | ||
| 2875 | ); | ||
| 2876 | assert_eq!( | ||
| 2877 | drag_selection(anchor, selection(0, 5), SelectionUnit::Word).positions, | ||
| 2878 | selection(10, 0).positions | ||
| 2879 | ); | ||
| 2880 | assert_eq!( | ||
| 2881 | drag_selection(anchor, anchor, SelectionUnit::Word).positions, | ||
| 2882 | anchor.positions | ||
| 2883 | ); | ||
| 2884 | let anchor = selection(10, 3); | ||
| 2885 | assert_eq!( | ||
| 2886 | drag_selection(anchor, selection(7, 7), SelectionUnit::Grapheme).positions, | ||
| 2887 | selection(10, 7).positions | ||
| 2888 | ); | ||
| 2889 | } | ||
| 2890 | |||
| 2891 | const DEFAULT_MARGIN: [f32; 2] = [36.0, 14.4]; | ||
| 2892 | |||
| 2893 | #[test] | ||
| 2894 | fn native_grid_follows_the_page_margin_origin() { | ||
| 2895 | // "av: casual night in the trees": margin 36.75; a 22.5 x 15 pt drag lands one cell on. | ||
| 2896 | let margin = [36.75, 14.4]; | ||
| 2897 | let result = snap_to_grid([468.75 + 22.5, 86.4 + 15.0], margin); | ||
| 2898 | assert!((result[0] - 486.75).abs() < 0.0001); | ||
| 2899 | assert!((result[1] - 104.4).abs() < 0.0001); | ||
| 2900 | } | ||
| 2901 | |||
| 2902 | #[test] | ||
| 2903 | fn native_grid_matches_drag_offsets_midpoints_and_zoom() { | ||
| 2904 | for (pixels, points) in [ | ||
| 2905 | (5.0, 0.0), | ||
| 2906 | (6.0, 0.0), | ||
| 2907 | (7.0, 0.0), | ||
| 2908 | (8.0, 0.0), | ||
| 2909 | (11.0, 0.0), | ||
| 2910 | (12.0, 9.0), | ||
| 2911 | (13.0, 18.0), | ||
| 2912 | (17.0, 18.0), | ||
| 2913 | (18.0, 18.0), | ||
| 2914 | (19.0, 18.0), | ||
| 2915 | (25.0, 18.0), | ||
| 2916 | (37.0, 36.0), | ||
| 2917 | (-6.0, 0.0), | ||
| 2918 | (-7.0, 0.0), | ||
| 2919 | (-12.0, -9.0), | ||
| 2920 | (-13.0, -18.0), | ||
| 2921 | (-19.0, -18.0), | ||
| 2922 | ] { | ||
| 2923 | let origin = [486.0, 230.40001]; | ||
| 2924 | let proposed = origin.map(|v| v + pixels * 72.0 / 96.0); | ||
| 2925 | let result = snap_to_grid(proposed, DEFAULT_MARGIN); | ||
| 2926 | for axis in 0..2 { | ||
| 2927 | assert!((result[axis] - origin[axis] - points).abs() < 0.00004); | ||
| 2928 | } | ||
| 2929 | } | ||
| 2930 | for pixels in [6.0, 7.0, 12.0, 13.0, 19.0] { | ||
| 2931 | let result = snap_to_grid( | ||
| 2932 | [491.25 + pixels * 0.75, 235.65 + pixels * 0.75], | ||
| 2933 | DEFAULT_MARGIN, | ||
| 2934 | ); | ||
| 2935 | assert!((result[0] - 504.0).abs() < 0.00004); | ||
| 2936 | assert!((result[1] - 248.4).abs() < 0.00004); | ||
| 2937 | } | ||
| 2938 | for (scale, delta, expected) in [ | ||
| 2939 | (96.0 / 72.0, [27.0, 31.0], [54.0, 104.4]), | ||
| 2940 | (96.0 / 72.0, [13.0, 17.0], [54.0, 104.4]), | ||
| 2941 | (192.0 / 72.0, [13.0, 17.0], [36.0, 104.4]), | ||
| 2942 | ] { | ||
| 2943 | for dpr in [1.0, 2.0] { | ||
| 2944 | let origin = [36.0, 90.0]; | ||
| 2945 | let proposed = | ||
| 2946 | std::array::from_fn(|axis| origin[axis] + delta[axis] * dpr / (scale * dpr)); | ||
| 2947 | let result = snap_to_grid(proposed, DEFAULT_MARGIN); | ||
| 2948 | for axis in 0..2 { | ||
| 2949 | assert!((result[axis] - expected[axis]).abs() < 0.00004); | ||
| 2950 | } | ||
| 2951 | } | ||
| 2952 | } | ||
| 2953 | for point in [[9.0, 5.4], [-9.0, -12.6], [423.0, 239.4]] { | ||
| 2954 | assert_eq!(snap_to_grid(point, DEFAULT_MARGIN), point); | ||
| 2955 | } | ||
| 2956 | } | ||
| 2957 | |||
| 2958 | #[test] | ||
| 2959 | fn native_fresh_click_grid_uses_screen_offset_at_both_zoom_levels() { | ||
| 2960 | for (x, y, scale, expected) in [ | ||
| 2961 | (900.0, 500.0, 96.0 / 72.0, [639.0, 302.4]), | ||
| 2962 | (901.0, 500.0, 96.0 / 72.0, [648.0, 302.4]), | ||
| 2963 | (906.0, 500.0, 96.0 / 72.0, [648.0, 302.4]), | ||
| 2964 | (913.0, 500.0, 96.0 / 72.0, [648.0, 302.4]), | ||
| 2965 | (900.0, 501.0, 96.0 / 72.0, [639.0, 302.4]), | ||
| 2966 | (900.0, 506.0, 96.0 / 72.0, [639.0, 320.4]), | ||
| 2967 | (900.0, 520.0, 96.0 / 72.0, [639.0, 320.4]), | ||
| 2968 | (900.0, 500.0, 192.0 / 72.0, [324.0, 158.4]), | ||
| 2969 | ] { | ||
| 2970 | for dpr in [1.0, 2.0] { | ||
| 2971 | let viewport = Viewport { | ||
| 2972 | size: [(1600.0 * dpr) as u32, (900.0 * dpr) as u32], | ||
| 2973 | origin: [48.0 * dpr, 83.0 * dpr], | ||
| 2974 | scale: scale * dpr, | ||
| 2975 | }; | ||
| 2976 | let point = viewport.document_point([x * dpr, y * dpr]); | ||
| 2977 | let result = snap_to_grid( | ||
| 2978 | [point[0], point[1] - 7.0 * dpr / viewport.scale], | ||
| 2979 | DEFAULT_MARGIN, | ||
| 2980 | ); | ||
| 2981 | for axis in 0..2 { | ||
| 2982 | assert!((result[axis] - expected[axis]).abs() < 0.00004); | ||
| 2983 | } | ||
| 2984 | } | ||
| 2985 | } | ||
| 2986 | } | ||
| 2987 | |||
| 2988 | #[test] | ||
| 2989 | fn blank_caret_and_composition_have_accessible_text_without_outline_chrome() { | ||
| 2990 | let mut engine = TextEngine::default(); | ||
| 2991 | let mut editor = CanvasEditor::new( | ||
| 2992 | &mut engine, | ||
| 2993 | TextDocument::new(vec![Paragraph::new(String::new(), Default::default())]).unwrap(), | ||
| 2994 | 240.0, | ||
| 2995 | ) | ||
| 2996 | .unwrap(); | ||
| 2997 | editor | ||
| 2998 | .place_caret(&mut engine, [120.0, 100.0], 240.0) | ||
| 2999 | .unwrap(); | ||
| 3000 | let mut access = accessibility::Accessibility::default(); | ||
| 3001 | let viewport = Viewport { | ||
| 3002 | size: [1000, 720], | ||
| 3003 | scale: 96.0 / 72.0, | ||
| 3004 | origin: [48.0; 2], | ||
| 3005 | }; | ||
| 3006 | let initial = access.update(&editor, viewport, "Page", None).unwrap(); | ||
| 3007 | let id = editor.active_outline().id; | ||
| 3008 | assert_eq!(access.outline_for_node(initial.focus), Some(id)); | ||
| 3009 | let rectangles = |editor: &CanvasEditor| { | ||
| 3010 | page_primitives(editor, None, None, None, true, viewport.scale, 1.0) | ||
| 3011 | .unwrap() | ||
| 3012 | .into_iter() | ||
| 3013 | .filter_map(|p| match p { | ||
| 3014 | Primitive::Rect { rect, color } | ||
| 3015 | | Primitive::RoundedRect { rect, color, .. } => Some((rect, color)), | ||
| 3016 | _ => None, | ||
| 3017 | }) | ||
| 3018 | .collect::<Vec<_>>() | ||
| 3019 | }; | ||
| 3020 | assert_eq!(rectangles(&editor).len(), 1); | ||
| 3021 | assert_eq!( | ||
| 3022 | page_hit_test(&editor, None, [120.0, 100.0], 1.0 / viewport.scale), | ||
| 3023 | None | ||
| 3024 | ); | ||
| 3025 | editor.compose(&mut engine, "に".into(), 1..1).unwrap(); | ||
| 3026 | assert!(editor.outlines().is_empty()); | ||
| 3027 | assert!( | ||
| 3028 | rectangles(&editor) | ||
| 3029 | .iter() | ||
| 3030 | .all(|(_, color)| *color == [0.0, 0.0, 0.0, 1.0] | ||
| 3031 | || *color == macos::text_colors()[0]) | ||
| 3032 | ); | ||
| 3033 | let preedit = access.update(&editor, viewport, "Page", None).unwrap(); | ||
| 3034 | assert_eq!(preedit.focus, initial.focus); | ||
| 3035 | editor.commit_text(&mut engine, "日本".into()).unwrap(); | ||
| 3036 | let committed = access.update(&editor, viewport, "Page", None).unwrap(); | ||
| 3037 | assert_eq!(committed.focus, initial.focus); | ||
| 3038 | assert!(rectangles(&editor).len() > 1); | ||
| 3039 | editor.undo(&mut engine).unwrap(); | ||
| 3040 | assert!(editor.outlines().is_empty()); | ||
| 3041 | assert_eq!(rectangles(&editor).len(), 1); | ||
| 3042 | let empty = access.update(&editor, viewport, "Page", None).unwrap(); | ||
| 3043 | assert_eq!(empty.focus, committed.focus); | ||
| 3044 | assert_eq!(access.outline_for_node(committed.focus), Some(id)); | ||
| 3045 | editor.redo(&mut engine).unwrap(); | ||
| 3046 | editor.select_all().unwrap(); | ||
| 3047 | editor.delete(&mut engine, true).unwrap(); | ||
| 3048 | assert!(editor.outlines().is_empty()); | ||
| 3049 | assert_eq!(rectangles(&editor).len(), 1); | ||
| 3050 | let retired = access.update(&editor, viewport, "Page", None).unwrap(); | ||
| 3051 | assert_eq!(retired.focus, committed.focus); | ||
| 3052 | assert_eq!(access.outline_for_node(retired.focus), Some(id)); | ||
| 3053 | editor.undo(&mut engine).unwrap(); | ||
| 3054 | assert!(rectangles(&editor).len() > 1); | ||
| 3055 | let restored = access.update(&editor, viewport, "Page", None).unwrap(); | ||
| 3056 | assert_eq!(restored.focus, committed.focus); | ||
| 3057 | editor.redo(&mut engine).unwrap(); | ||
| 3058 | assert_eq!(rectangles(&editor).len(), 1); | ||
| 3059 | assert!(editor.caret_outline().unwrap().is_empty()); | ||
| 3060 | editor.insert(&mut engine, " ").unwrap(); | ||
| 3061 | assert!(editor.caret_outline().unwrap().is_empty()); | ||
| 3062 | editor.compose(&mut engine, "に".into(), 1..1).unwrap(); | ||
| 3063 | assert!(!editor.caret_outline().unwrap().is_empty()); | ||
| 3064 | } | ||
| 3065 | |||
| 3066 | #[test] | ||
| 3067 | fn table_glyphs_highlights_and_selection_share_cell_paint_bounds() { | ||
| 3068 | let mut engine = TextEngine::default(); | ||
| 3069 | let mut editor = CanvasEditor::new( | ||
| 3070 | &mut engine, | ||
| 3071 | TextDocument::new(vec![Paragraph::new( | ||
| 3072 | "M".into(), | ||
| 3073 | Format { | ||
| 3074 | font_size: Some(130.0), | ||
| 3075 | highlight: Some(0xffff), | ||
| 3076 | ..Default::default() | ||
| 3077 | }, | ||
| 3078 | )]) | ||
| 3079 | .unwrap(), | ||
| 3080 | 180.0, | ||
| 3081 | ) | ||
| 3082 | .unwrap(); | ||
| 3083 | editor | ||
| 3084 | .move_selection(&mut engine, Movement::LineEnd, false) | ||
| 3085 | .unwrap(); | ||
| 3086 | editor.tab(&mut engine, false).unwrap(); | ||
| 3087 | editor.insert(&mut engine, "R").unwrap(); | ||
| 3088 | editor.tab(&mut engine, true).unwrap(); | ||
| 3089 | let mut primitives = Vec::new(); | ||
| 3090 | append_outline( | ||
| 3091 | Some(&editor), | ||
| 3092 | editor.active_outline(), | ||
| 3093 | [24.0, 48.0], | ||
| 3094 | false, | ||
| 3095 | 1.0, | ||
| 3096 | 1.0, | ||
| 3097 | &mut primitives, | ||
| 3098 | ) | ||
| 3099 | .unwrap(); | ||
| 3100 | let bounds = editor.active_outline().shaped().tables[0] | ||
| 3101 | .cells | ||
| 3102 | .iter() | ||
| 3103 | .map(|cell| { | ||
| 3104 | let [left, top, right, bottom] = cell.text_bounds(); | ||
| 3105 | [left + 24.0, top + 48.0, right + 24.0, bottom + 48.0] | ||
| 3106 | }) | ||
| 3107 | .collect::<Vec<_>>(); | ||
| 3108 | let text = primitives | ||
| 3109 | .iter() | ||
| 3110 | .filter_map(|primitive| match primitive { | ||
| 3111 | Primitive::Text { clip, .. } => Some(clip.unwrap()), | ||
| 3112 | _ => None, | ||
| 3113 | }) | ||
| 3114 | .collect::<Vec<_>>(); | ||
| 3115 | assert_eq!(text, bounds); | ||
| 3116 | let highlights = primitives | ||
| 3117 | .iter() | ||
| 3118 | .filter_map(|primitive| match primitive { | ||
| 3119 | Primitive::Rect { rect, color } if *color == canvas::gpu::colorref(0xffff) => { | ||
| 3120 | Some(*rect) | ||
| 3121 | } | ||
| 3122 | _ => None, | ||
| 3123 | }) | ||
| 3124 | .collect::<Vec<_>>(); | ||
| 3125 | assert_eq!(highlights.len(), 2); | ||
| 3126 | for (rect, bounds) in highlights.iter().zip(&bounds) { | ||
| 3127 | assert_eq!(rect[2], bounds[2]); | ||
| 3128 | assert!(rect[0] >= bounds[0] && rect[1] >= bounds[1] && rect[3] <= bounds[3]); | ||
| 3129 | } | ||
| 3130 | let selection = primitives | ||
| 3131 | .iter() | ||
| 3132 | .find_map(|primitive| match primitive { | ||
| 3133 | Primitive::Rect { rect, color } if *color == macos::text_colors()[1] => Some(*rect), | ||
| 3134 | _ => None, | ||
| 3135 | }) | ||
| 3136 | .unwrap(); | ||
| 3137 | assert_eq!(selection[2], bounds[0][2]); | ||
| 3138 | assert!(editor.caret(1.0).unwrap().x0 + 24.0 > f64::from(bounds[0][2])); | ||
| 3139 | } | ||
| 3140 | |||
| 3141 | #[test] | ||
| 3142 | fn editable_tables_paint_borders_before_selection_and_cell_text() { | ||
| 3143 | let mut engine = TextEngine::default(); | ||
| 3144 | let mut editor = CanvasEditor::new( | ||
| 3145 | &mut engine, | ||
| 3146 | TextDocument::new(vec![Paragraph::new("Left".into(), Format::default())]).unwrap(), | ||
| 3147 | 180.0, | ||
| 3148 | ) | ||
| 3149 | .unwrap(); | ||
| 3150 | editor | ||
| 3151 | .move_selection(&mut engine, Movement::LineEnd, false) | ||
| 3152 | .unwrap(); | ||
| 3153 | editor.tab(&mut engine, false).unwrap(); | ||
| 3154 | editor.insert(&mut engine, "Right").unwrap(); | ||
| 3155 | editor.tab(&mut engine, true).unwrap(); | ||
| 3156 | let mut primitives = Vec::new(); | ||
| 3157 | append_outline( | ||
| 3158 | Some(&editor), | ||
| 3159 | editor.active_outline(), | ||
| 3160 | [24.0, 48.0], | ||
| 3161 | false, | ||
| 3162 | 1.0, | ||
| 3163 | 1.0, | ||
| 3164 | &mut primitives, | ||
| 3165 | ) | ||
| 3166 | .unwrap(); | ||
| 3167 | let color = canvas::gpu::colorref(0x00a3a3a3); | ||
| 3168 | assert!(matches!(primitives[0], Primitive::RoundedRect { | ||
| 3169 | stroke: Some(canvas::gpu::Stroke::Solid(0.75)), color: actual, .. | ||
| 3170 | } if actual == color)); | ||
| 3171 | assert!(matches!(primitives[1], Primitive::Rect { color: actual, .. } if actual == color)); | ||
| 3172 | assert!( | ||
| 3173 | matches!(primitives[2], Primitive::Rect { color, .. } if color == macos::text_colors()[1]) | ||
| 3174 | ); | ||
| 3175 | let painted = primitives | ||
| 3176 | .iter() | ||
| 3177 | .filter_map(|primitive| match primitive { | ||
| 3178 | Primitive::Text { layout, origin, .. } => Some((layout.id(), *origin)), | ||
| 3179 | _ => None, | ||
| 3180 | }) | ||
| 3181 | .collect::<Vec<_>>(); | ||
| 3182 | let expected = editor | ||
| 3183 | .active_outline() | ||
| 3184 | .layouts() | ||
| 3185 | .map(|(_, paragraph)| { | ||
| 3186 | ( | ||
| 3187 | paragraph.text.id(), | ||
| 3188 | [paragraph.origin[0] + 24.0, paragraph.origin[1] + 48.0], | ||
| 3189 | ) | ||
| 3190 | }) | ||
| 3191 | .collect::<Vec<_>>(); | ||
| 3192 | assert_eq!(painted, expected); | ||
| 3193 | assert_eq!(painted.len(), 2); | ||
| 3194 | } | ||
| 3195 | |||
| 3196 | #[test] | ||
| 3197 | fn resize_preview_paints_reflowed_text_without_changing_the_editor() { | ||
| 3198 | let mut engine = TextEngine::default(); | ||
| 3199 | let editor = CanvasEditor::new( | ||
| 3200 | &mut engine, | ||
| 3201 | TextDocument::new(vec![Paragraph::new( | ||
| 3202 | "A paragraph with enough words to wrap when the resize handle moves inward.".into(), | ||
| 3203 | Format::default(), | ||
| 3204 | )]) | ||
| 3205 | .unwrap(), | ||
| 3206 | 240.0, | ||
| 3207 | ) | ||
| 3208 | .unwrap(); | ||
| 3209 | let original = editor.active_outline().snapshot(); | ||
| 3210 | let original_layout = editor | ||
| 3211 | .active_outline() | ||
| 3212 | .paragraph_layout(0) | ||
| 3213 | .unwrap() | ||
| 3214 | .text | ||
| 3215 | .id(); | ||
| 3216 | let resized = editor.preview_resize(&mut engine, 72.0).unwrap(); | ||
| 3217 | let primitives = page_primitives( | ||
| 3218 | &editor, | ||
| 3219 | None, | ||
| 3220 | Some(PointerFeedback::Resize(&resized)), | ||
| 3221 | None, | ||
| 3222 | false, | ||
| 3223 | 96.0 / 72.0, | ||
| 3224 | 1.0, | ||
| 3225 | ) | ||
| 3226 | .unwrap(); | ||
| 3227 | let painted = primitives | ||
| 3228 | .iter() | ||
| 3229 | .find_map(|primitive| match primitive { | ||
| 3230 | Primitive::Text { layout, .. } => Some(*layout), | ||
| 3231 | _ => None, | ||
| 3232 | }) | ||
| 3233 | .unwrap(); | ||
| 3234 | assert_eq!(painted.id(), resized.paragraph_layout(0).unwrap().text.id()); | ||
| 3235 | assert!( | ||
| 3236 | painted.lines().count() | ||
| 3237 | > editor | ||
| 3238 | .active_outline() | ||
| 3239 | .paragraph_layout(0) | ||
| 3240 | .unwrap() | ||
| 3241 | .text | ||
| 3242 | .lines() | ||
| 3243 | .count() | ||
| 3244 | ); | ||
| 3245 | assert_eq!(editor.active_outline().layout(), &original.layout); | ||
| 3246 | assert_eq!( | ||
| 3247 | editor | ||
| 3248 | .active_outline() | ||
| 3249 | .paragraph_layout(0) | ||
| 3250 | .unwrap() | ||
| 3251 | .text | ||
| 3252 | .id(), | ||
| 3253 | original_layout | ||
| 3254 | ); | ||
| 3255 | assert!(primitives.iter().any( | ||
| 3256 | |primitive| matches!(primitive, Primitive::RoundedRect { stroke, .. } if stroke.is_some()) | ||
| 3257 | )); | ||
| 3258 | } | ||
| 3259 | |||
| 3260 | #[test] | ||
| 3261 | fn native_chrome_geometry_preserves_text_and_matches_hit_regions_across_zoom_and_dpi() { | ||
| 3262 | let mut engine = TextEngine::default(); | ||
| 3263 | let format = Format { | ||
| 3264 | font: Some("Arial".into()), | ||
| 3265 | font_size: Some(11.0), | ||
| 3266 | ..Default::default() | ||
| 3267 | }; | ||
| 3268 | let mut editor = CanvasEditor::new(&mut engine, TextDocument::new(vec![ | ||
| 3269 | Paragraph::new("HAMBURGEFONTS abcdefghijklmnopqrstuvwxyz 0123456789 HAMBURGEFONTS abcdefghijklmnopqrstuvwxyz 0123456789 HAMBURGEFONTS abcdefghijklmnopqrstuvwxyz 0123456789".into(), format.clone()), | ||
| 3270 | Paragraph::new("Second paragraph abcdefghijklmnopqrstuvwxyz.".into(), format), | ||
| 3271 | ]).unwrap(), 220.0).unwrap(); | ||
| 3272 | let id = editor.active_outline().id; | ||
| 3273 | editor.move_outline(id, [36.0, 90.0]).unwrap(); | ||
| 3274 | let document = editor.active_outline().document().clone(); | ||
| 3275 | let layout_ids: Vec<_> = editor | ||
| 3276 | .active_outline() | ||
| 3277 | .layouts() | ||
| 3278 | .map(|(_, p)| p.text.id()) | ||
| 3279 | .collect(); | ||
| 3280 | for (zoom, expected) in [ | ||
| 3281 | (0.5, [62.0, 134.0, 223.0, 219.0]), | ||
| 3282 | (1.0, [81.0, 189.0, 394.0, 348.0]), | ||
| 3283 | (1.5, [101.0, 245.0, 565.0, 479.0]), | ||
| 3284 | (2.0, [120.0, 300.0, 736.0, 608.0]), | ||
| 3285 | ] { | ||
| 3286 | for dpr in [1.0, 2.0] { | ||
| 3287 | let scale = zoom * (96.0 / 72.0) * dpr; | ||
| 3288 | let pixel = dpr / scale; | ||
| 3289 | let (frame, body_top) = outline_chrome(editor.active_outline(), pixel); | ||
| 3290 | for (index, coordinate) in frame.iter().enumerate() { | ||
| 3291 | let origin = if index % 2 == 0 { 48.0 } else { 83.0 }; | ||
| 3292 | let screen = coordinate * scale / dpr + origin; | ||
| 3293 | assert!( | ||
| 3294 | (screen - expected[index]).abs() <= 1.5, | ||
| 3295 | "zoom={zoom} dpr={dpr} edge={index}: {screen} vs {}", | ||
| 3296 | expected[index] | ||
| 3297 | ); | ||
| 3298 | } | ||
| 3299 | let header = [36.0, (frame[1] + body_top) / 2.0]; | ||
| 3300 | assert!( | ||
| 3301 | matches!(page_hit_test(&editor, None, header, pixel), Some(Hit::Handle {id: hit, ..}) if hit == id) | ||
| 3302 | ); | ||
| 3303 | assert!(matches!( | ||
| 3304 | page_hit_test(&editor, None, [frame[2] - 4.0 * pixel, header[1]], pixel), | ||
| 3305 | Some(Hit::Resize { id: hit, .. }) if hit == id | ||
| 3306 | )); | ||
| 3307 | let padding = [frame[0] + pixel, 90.0]; | ||
| 3308 | assert!( | ||
| 3309 | matches!(page_hit_test(&editor, None, padding, pixel), Some(Hit::Text {id: hit, ..}) if hit == id) | ||
| 3310 | ); | ||
| 3311 | assert_eq!( | ||
| 3312 | page_hit_test(&editor, None, [frame[0] - pixel, 90.0], pixel), | ||
| 3313 | None | ||
| 3314 | ); | ||
| 3315 | let primitives = | ||
| 3316 | page_primitives(&editor, None, None, None, false, scale, dpr).unwrap(); | ||
| 3317 | assert!( | ||
| 3318 | matches!(&primitives[0], Primitive::RoundedRect {rect, ..} if *rect == [frame[0], frame[1], frame[2], body_top]) | ||
| 3319 | ); | ||
| 3320 | assert!( | ||
| 3321 | primitives.iter().any( | ||
| 3322 | |p| matches!(p, Primitive::Text {origin, ..} if *origin == [36.0, 90.0]) | ||
| 3323 | ) | ||
| 3324 | ); | ||
| 3325 | } | ||
| 3326 | } | ||
| 3327 | assert_eq!(editor.active_outline().document(), &document); | ||
| 3328 | assert_eq!( | ||
| 3329 | editor | ||
| 3330 | .active_outline() | ||
| 3331 | .layouts() | ||
| 3332 | .map(|(_, p)| p.text.id()) | ||
| 3333 | .collect::<Vec<_>>(), | ||
| 3334 | layout_ids | ||
| 3335 | ); | ||
| 3336 | } | ||
| 3337 | |||
| 3338 | #[test] | ||
| 3339 | fn a_later_text_body_takes_priority_over_an_earlier_drag_handle() { | ||
| 3340 | let mut engine = TextEngine::default(); | ||
| 3341 | let mut editor = CanvasEditor::new( | ||
| 3342 | &mut engine, | ||
| 3343 | TextDocument::new(vec![Paragraph::new("first".into(), Default::default())]).unwrap(), | ||
| 3344 | 240.0, | ||
| 3345 | ) | ||
| 3346 | .unwrap(); | ||
| 3347 | let first = editor.active_outline().id; | ||
| 3348 | editor.move_outline(first, [0.0, 20.0]).unwrap(); | ||
| 3349 | assert_eq!( | ||
| 3350 | page_hit_test(&editor, None, [5.0, 12.0], 1.0), | ||
| 3351 | Some(Hit::Handle { | ||
| 3352 | id: first, | ||
| 3353 | grab: [5.0, -8.0] | ||
| 3354 | }) | ||
| 3355 | ); | ||
| 3356 | let second = editor | ||
| 3357 | .create_outline(&mut engine, [0.0, 10.0], 240.0) | ||
| 3358 | .unwrap(); | ||
| 3359 | assert_eq!( | ||
| 3360 | page_hit_test(&editor, None, [5.0, 12.0], 1.0), | ||
| 3361 | Some(Hit::Text { | ||
| 3362 | id: second, | ||
| 3363 | point: [5.0, 2.0] | ||
| 3364 | }) | ||
| 3365 | ); | ||
| 3366 | editor.undo(&mut engine).unwrap(); | ||
| 3367 | assert_eq!( | ||
| 3368 | page_hit_test(&editor, None, [5.0, 12.0], 1.0), | ||
| 3369 | Some(Hit::Handle { | ||
| 3370 | id: first, | ||
| 3371 | grab: [5.0, -8.0] | ||
| 3372 | }) | ||
| 3373 | ); | ||
| 3374 | } | ||
| 3375 | |||
| 3376 | #[test] | ||
| 3377 | fn overlapping_objects_follow_paint_order_through_creation_movement_and_undo() { | ||
| 3378 | use onestore::page::{Outline, PageObject, Unsupported}; | ||
| 3379 | for readonly_on_top in [false, true] { | ||
| 3380 | let mut engine = TextEngine::default(); | ||
| 3381 | let document = TextDocument::new(vec![Paragraph::new( | ||
| 3382 | "Imported text".into(), | ||
| 3383 | Default::default(), | ||
| 3384 | )]) | ||
| 3385 | .unwrap(); | ||
| 3386 | let id = onestore::ExGuid { | ||
| 3387 | guid: [5; 16], | ||
| 3388 | n: 1, | ||
| 3389 | }; | ||
| 3390 | let mut objects = vec![ | ||
| 3391 | PageObject::Outline(Outline { | ||
| 3392 | id, | ||
| 3393 | title: false, | ||
| 3394 | min_width: None, | ||
| 3395 | layout: onestore::document::Layout { | ||
| 3396 | x: Some(30.0), | ||
| 3397 | y: Some(40.0), | ||
| 3398 | max_width: Some(180.0), | ||
| 3399 | ..Default::default() | ||
| 3400 | }, | ||
| 3401 | indents: vec![18.0, 0.0], | ||
| 3402 | paragraphs: document.nodes().to_vec(), | ||
| 3403 | unsupported: Vec::new(), | ||
| 3404 | }), | ||
| 3405 | PageObject::Unsupported(Unsupported { | ||
| 3406 | id: Default::default(), | ||
| 3407 | jcid: 0xdead, | ||
| 3408 | layout: onestore::document::Layout { | ||
| 3409 | x: Some(20.0), | ||
| 3410 | y: Some(20.0), | ||
| 3411 | max_width: Some(200.0), | ||
| 3412 | max_height: Some(100.0), | ||
| 3413 | ..Default::default() | ||
| 3414 | }, | ||
| 3415 | }), | ||
| 3416 | ]; | ||
| 3417 | if !readonly_on_top { | ||
| 3418 | objects.reverse(); | ||
| 3419 | } | ||
| 3420 | let page = Page { | ||
| 3421 | identity: None, | ||
| 3422 | created: None, | ||
| 3423 | title: String::new(), | ||
| 3424 | margin_origin: [0.0; 2], | ||
| 3425 | definitions: Default::default(), | ||
| 3426 | objects, | ||
| 3427 | }; | ||
| 3428 | let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap(); | ||
| 3429 | let scene = (scene, [0.0; 2]); | ||
| 3430 | let imported_layout = editor | ||
| 3431 | .active_outline() | ||
| 3432 | .layouts() | ||
| 3433 | .next() | ||
| 3434 | .unwrap() | ||
| 3435 | .1 | ||
| 3436 | .text | ||
| 3437 | .id(); | ||
| 3438 | let texts: Vec<_> = page_primitives(&editor, Some(&scene), None, None, false, 1.0, 1.0) | ||
| 3439 | .unwrap() | ||
| 3440 | .into_iter() | ||
| 3441 | .filter_map(|p| match p { | ||
| 3442 | Primitive::Text { layout, .. } => Some(layout.id()), | ||
| 3443 | _ => None, | ||
| 3444 | }) | ||
| 3445 | .collect(); | ||
| 3446 | assert_eq!(texts.len(), 2); | ||
| 3447 | assert_eq!(texts[usize::from(!readonly_on_top)], imported_layout); | ||
| 3448 | let expected = if readonly_on_top { | ||
| 3449 | Hit::ReadOnly(0) | ||
| 3450 | } else { | ||
| 3451 | Hit::Text { | ||
| 3452 | id, | ||
| 3453 | point: [10.0, 5.0], | ||
| 3454 | } | ||
| 3455 | }; | ||
| 3456 | assert_eq!( | ||
| 3457 | page_hit_test(&editor, Some(&scene), [40.0, 45.0], 1.0), | ||
| 3458 | Some(expected) | ||
| 3459 | ); | ||
| 3460 | let header = page_hit_test(&editor, Some(&scene), [40.0, 32.0], 1.0); | ||
| 3461 | if readonly_on_top { | ||
| 3462 | assert_eq!(header, Some(Hit::ReadOnly(0))); | ||
| 3463 | } else { | ||
| 3464 | assert_eq!( | ||
| 3465 | header, | ||
| 3466 | Some(Hit::Handle { | ||
| 3467 | id, | ||
| 3468 | grab: [10.0, -8.0] | ||
| 3469 | }) | ||
| 3470 | ); | ||
| 3471 | } | ||
| 3472 | let annotation = editor | ||
| 3473 | .create_outline(&mut engine, [30.0, 40.0], 180.0) | ||
| 3474 | .unwrap(); | ||
| 3475 | assert_eq!( | ||
| 3476 | page_hit_test(&editor, Some(&scene), [40.0, 45.0], 1.0), | ||
| 3477 | Some(Hit::Text { | ||
| 3478 | id: annotation, | ||
| 3479 | point: [10.0, 5.0] | ||
| 3480 | }) | ||
| 3481 | ); | ||
| 3482 | assert_eq!( | ||
| 3483 | page_hit_test(&editor, Some(&scene), [40.0, 32.0], 1.0), | ||
| 3484 | Some(Hit::Handle { | ||
| 3485 | id: annotation, | ||
| 3486 | grab: [10.0, -8.0] | ||
| 3487 | }) | ||
| 3488 | ); | ||
| 3489 | let texts: Vec<_> = page_primitives(&editor, Some(&scene), None, None, false, 1.0, 1.0) | ||
| 3490 | .unwrap() | ||
| 3491 | .into_iter() | ||
| 3492 | .filter_map(|p| match p { | ||
| 3493 | Primitive::Text { layout, .. } => Some(layout.id()), | ||
| 3494 | _ => None, | ||
| 3495 | }) | ||
| 3496 | .collect(); | ||
| 3497 | assert_eq!( | ||
| 3498 | *texts.last().unwrap(), | ||
| 3499 | editor | ||
| 3500 | .active_outline() | ||
| 3501 | .layouts() | ||
| 3502 | .next() | ||
| 3503 | .unwrap() | ||
| 3504 | .1 | ||
| 3505 | .text | ||
| 3506 | .id() | ||
| 3507 | ); | ||
| 3508 | editor.move_outline(annotation, [500.0, 500.0]).unwrap(); | ||
| 3509 | assert!( | ||
| 3510 | !matches!(page_hit_test(&editor, Some(&scene), [40.0,45.0], 1.0), Some(Hit::Text { id, .. }) if id == annotation) | ||
| 3511 | ); | ||
| 3512 | editor.undo(&mut engine).unwrap(); | ||
| 3513 | assert_eq!( | ||
| 3514 | page_hit_test(&editor, Some(&scene), [40.0, 45.0], 1.0), | ||
| 3515 | Some(Hit::Text { | ||
| 3516 | id: annotation, | ||
| 3517 | point: [10.0, 5.0] | ||
| 3518 | }) | ||
| 3519 | ); | ||
| 3520 | editor.undo(&mut engine).unwrap(); | ||
| 3521 | assert_eq!(editor.outlines().len(), 1); | ||
| 3522 | assert_eq!(editor.active_outline().document(), &document); | ||
| 3523 | assert_eq!( | ||
| 3524 | page_hit_test(&editor, Some(&scene), [10.0, 10.0], 1.0), | ||
| 3525 | None | ||
| 3526 | ); | ||
| 3527 | } | ||
| 3528 | } | ||
| 3529 | |||
| 3530 | #[test] | ||
| 3531 | fn read_only_focus_accepts_navigation_without_edit_shortcuts() { | ||
| 3532 | for modifiers in [ | ||
| 3533 | ModifiersState::empty(), | ||
| 3534 | ModifiersState::SHIFT, | ||
| 3535 | ModifiersState::ALT, | ||
| 3536 | ModifiersState::SUPER, | ||
| 3537 | ModifiersState::SUPER | ModifiersState::SHIFT, | ||
| 3538 | ModifiersState::SUPER | ModifiersState::ALT, | ||
| 3539 | ] { | ||
| 3540 | for key in [ | ||
| 3541 | Key::Character("a".into()), | ||
| 3542 | Key::Character("v".into()), | ||
| 3543 | Key::Character("c".into()), | ||
| 3544 | Key::Character("x".into()), | ||
| 3545 | Key::Character("z".into()), | ||
| 3546 | Key::Character("🌳".into()), | ||
| 3547 | Key::Named(NamedKey::Backspace), | ||
| 3548 | Key::Named(NamedKey::Delete), | ||
| 3549 | Key::Named(NamedKey::Enter), | ||
| 3550 | Key::Named(NamedKey::ArrowLeft), | ||
| 3551 | Key::Named(NamedKey::ArrowRight), | ||
| 3552 | Key::Named(NamedKey::ArrowUp), | ||
| 3553 | Key::Named(NamedKey::ArrowDown), | ||
| 3554 | ] { | ||
| 3555 | assert!( | ||
| 3556 | !read_only_shortcut(&key, modifiers), | ||
| 3557 | "{key:?} {modifiers:?}" | ||
| 3558 | ); | ||
| 3559 | } | ||
| 3560 | } | ||
| 3561 | for (key, modifiers) in [ | ||
| 3562 | (Key::Named(NamedKey::Escape), ModifiersState::empty()), | ||
| 3563 | (Key::Named(NamedKey::Tab), ModifiersState::CONTROL), | ||
| 3564 | ( | ||
| 3565 | Key::Named(NamedKey::Tab), | ||
| 3566 | ModifiersState::CONTROL | ModifiersState::SHIFT, | ||
| 3567 | ), | ||
| 3568 | ( | ||
| 3569 | Key::Character("N".into()), | ||
| 3570 | ModifiersState::SUPER | ModifiersState::SHIFT, | ||
| 3571 | ), | ||
| 3572 | (Key::Character("+".into()), ModifiersState::SUPER), | ||
| 3573 | (Key::Character("0".into()), ModifiersState::SUPER), | ||
| 3574 | (Key::Character("-".into()), ModifiersState::SUPER), | ||
| 3575 | ] { | ||
| 3576 | assert!(read_only_shortcut(&key, modifiers)); | ||
| 3577 | } | ||
| 3578 | assert!(!read_only_shortcut( | ||
| 3579 | &Key::Character("n".into()), | ||
| 3580 | ModifiersState::SUPER | ||
| 3581 | )); | ||
| 3582 | assert!(!read_only_shortcut( | ||
| 3583 | &Key::Named(NamedKey::Tab), | ||
| 3584 | ModifiersState::empty() | ||
| 3585 | )); | ||
| 3586 | } | ||
| 3587 | |||
| 3588 | #[test] | ||
| 3589 | fn read_only_focus_retires_text_overlays_and_draws_a_scaled_focus_border() { | ||
| 3590 | use onestore::page::{PageObject, Unsupported}; | ||
| 3591 | let mut engine = TextEngine::default(); | ||
| 3592 | let mut editor = CanvasEditor::new( | ||
| 3593 | &mut engine, | ||
| 3594 | TextDocument::new(vec![Paragraph::new("Draft".into(), Default::default())]).unwrap(), | ||
| 3595 | 240.0, | ||
| 3596 | ) | ||
| 3597 | .unwrap(); | ||
| 3598 | let page = Page { | ||
| 3599 | identity: None, | ||
| 3600 | created: None, | ||
| 3601 | title: String::new(), | ||
| 3602 | margin_origin: [0.0; 2], | ||
| 3603 | definitions: Default::default(), | ||
| 3604 | objects: vec![PageObject::Unsupported(Unsupported { | ||
| 3605 | id: Default::default(), | ||
| 3606 | jcid: 0xdead, | ||
| 3607 | layout: onestore::document::Layout { | ||
| 3608 | x: Some(100.0), | ||
| 3609 | y: Some(80.0), | ||
| 3610 | max_width: Some(180.0), | ||
| 3611 | max_height: Some(80.0), | ||
| 3612 | ..Default::default() | ||
| 3613 | }, | ||
| 3614 | })], | ||
| 3615 | }; | ||
| 3616 | let scene = (PageScene::new(page, &mut engine).unwrap(), [20.0, 30.0]); | ||
| 3617 | let original = editor.active_outline().document().clone(); | ||
| 3618 | let rectangles = |primitives: Vec<Primitive<'_>>| { | ||
| 3619 | primitives | ||
| 3620 | .into_iter() | ||
| 3621 | .filter_map(|p| match p { | ||
| 3622 | Primitive::Rect { rect, color } | ||
| 3623 | | Primitive::RoundedRect { rect, color, .. } => Some((rect, color)), | ||
| 3624 | _ => None, | ||
| 3625 | }) | ||
| 3626 | .collect::<Vec<_>>() | ||
| 3627 | }; | ||
| 3628 | for scale in [1.0, 2.0] { | ||
| 3629 | let text = rectangles( | ||
| 3630 | page_primitives(&editor, Some(&scene), None, None, true, scale, 1.0).unwrap(), | ||
| 3631 | ); | ||
| 3632 | assert!( | ||
| 3633 | text.iter() | ||
| 3634 | .any(|(_, color)| *color == macos::text_colors()[0]) | ||
| 3635 | ); | ||
| 3636 | let selected = rectangles( | ||
| 3637 | page_primitives( | ||
| 3638 | &editor, | ||
| 3639 | Some(&scene), | ||
| 3640 | None, | ||
| 3641 | Some(ObjectFocus::ReadOnly(0)), | ||
| 3642 | true, | ||
| 3643 | scale, | ||
| 3644 | 1.0, | ||
| 3645 | ) | ||
| 3646 | .unwrap(), | ||
| 3647 | ); | ||
| 3648 | assert!( | ||
| 3649 | !selected | ||
| 3650 | .iter() | ||
| 3651 | .any(|(_, color)| *color == macos::text_colors()[0]) | ||
| 3652 | ); | ||
| 3653 | assert_eq!( | ||
| 3654 | selected, | ||
| 3655 | rectangles( | ||
| 3656 | page_primitives( | ||
| 3657 | &editor, | ||
| 3658 | Some(&scene), | ||
| 3659 | None, | ||
| 3660 | Some(ObjectFocus::ReadOnly(0)), | ||
| 3661 | false, | ||
| 3662 | scale, | ||
| 3663 | 1.0 | ||
| 3664 | ) | ||
| 3665 | .unwrap() | ||
| 3666 | ) | ||
| 3667 | ); | ||
| 3668 | assert_eq!( | ||
| 3669 | selected.last().unwrap().0, | ||
| 3670 | [300.0 - 2.0 / scale, 110.0, 300.0, 190.0] | ||
| 3671 | ); | ||
| 3672 | } | ||
| 3673 | editor.select_all().unwrap(); | ||
| 3674 | let text = rectangles( | ||
| 3675 | page_primitives(&editor, Some(&scene), None, None, false, 1.0, 1.0).unwrap(), | ||
| 3676 | ); | ||
| 3677 | assert!( | ||
| 3678 | text.iter() | ||
| 3679 | .any(|(_, color)| *color == macos::text_colors()[1]) | ||
| 3680 | ); | ||
| 3681 | let selected = rectangles( | ||
| 3682 | page_primitives( | ||
| 3683 | &editor, | ||
| 3684 | Some(&scene), | ||
| 3685 | None, | ||
| 3686 | Some(ObjectFocus::ReadOnly(0)), | ||
| 3687 | true, | ||
| 3688 | 1.0, | ||
| 3689 | 1.0, | ||
| 3690 | ) | ||
| 3691 | .unwrap(), | ||
| 3692 | ); | ||
| 3693 | assert!( | ||
| 3694 | !selected | ||
| 3695 | .iter() | ||
| 3696 | .any(|(_, color)| *color == macos::text_colors()[1]) | ||
| 3697 | ); | ||
| 3698 | assert_eq!(editor.active_outline().document(), &original); | ||
| 3699 | } | ||
| 3700 | |||
| 3701 | #[test] | ||
| 3702 | #[ignore = "requires CANVAS_TEST_SECTION and CANVAS_TEST_PAGE private fixture inputs"] | ||
| 3703 | fn imported_page_widgets_and_accessibility_follow_edits() { | ||
| 3704 | let bytes = std::fs::read(std::env::var_os("CANVAS_TEST_SECTION").unwrap()).unwrap(); | ||
| 3705 | let page = { | ||
| 3706 | let store = onestore::Store::parse(&bytes).unwrap(); | ||
| 3707 | let index = onestore::RevisionIndex::parse(&store).unwrap(); | ||
| 3708 | Page::from_document( | ||
| 3709 | &onestore::document::Document::parse(&index).unwrap(), | ||
| 3710 | &std::env::var("CANVAS_TEST_PAGE").unwrap(), | ||
| 3711 | ) | ||
| 3712 | .unwrap() | ||
| 3713 | }; | ||
| 3714 | drop(bytes); | ||
| 3715 | let mut engine = TextEngine::default(); | ||
| 3716 | if let Some(font) = std::env::var_os("CANVAS_TEST_SUBSTITUTE") { | ||
| 3717 | engine | ||
| 3718 | .register_substitute(parley::fontique::Blob::new(Arc::new( | ||
| 3719 | std::fs::read(font).unwrap(), | ||
| 3720 | ))) | ||
| 3721 | .unwrap(); | ||
| 3722 | } | ||
| 3723 | let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap(); | ||
| 3724 | let scene = (scene, [0.0; 2]); | ||
| 3725 | let viewport = Viewport { | ||
| 3726 | size: [1000, 720], | ||
| 3727 | scale: 96.0 / 72.0, | ||
| 3728 | origin: [48.0; 2], | ||
| 3729 | }; | ||
| 3730 | let mut tag_hits = 0; | ||
| 3731 | for outline in editor.outlines() { | ||
| 3732 | for (_, paragraph) in outline.layouts() { | ||
| 3733 | for tag in &paragraph.tags { | ||
| 3734 | let point = [ | ||
| 3735 | outline.origin()[0] | ||
| 3736 | + tag.origin[0] | ||
| 3737 | + canvas::outline::ParagraphTag::SIZE / 2.0, | ||
| 3738 | outline.origin()[1] | ||
| 3739 | + paragraph.origin[1] | ||
| 3740 | + tag.origin[1] | ||
| 3741 | + canvas::outline::ParagraphTag::SIZE / 2.0, | ||
| 3742 | ]; | ||
| 3743 | assert!( | ||
| 3744 | matches!(page_hit_test(&editor, Some(&scene), point, 1.0), Some(Hit::Text { id, .. }) if id == outline.id), | ||
| 3745 | "Tag center {point:?} did not target outline {:?}", | ||
| 3746 | outline.id | ||
| 3747 | ); | ||
| 3748 | tag_hits += 1; | ||
| 3749 | } | ||
| 3750 | } | ||
| 3751 | } | ||
| 3752 | eprintln!("Verified {tag_hits} imported tag-gutter targets"); | ||
| 3753 | let mut access = accessibility::Accessibility::default(); | ||
| 3754 | let tags = |editor: &CanvasEditor| { | ||
| 3755 | page_primitives(editor, Some(&scene), None, None, true, viewport.scale, 1.0) | ||
| 3756 | .unwrap() | ||
| 3757 | .into_iter() | ||
| 3758 | .filter_map(|primitive| match primitive { | ||
| 3759 | Primitive::Tag { tag, origin } => Some((tag.clone(), origin)), | ||
| 3760 | _ => None, | ||
| 3761 | }) | ||
| 3762 | .collect::<Vec<_>>() | ||
| 3763 | }; | ||
| 3764 | let original_tags = tags(&editor); | ||
| 3765 | let ids: Vec<_> = editor.outlines().iter().map(|outline| outline.id).collect(); | ||
| 3766 | for id in ids { | ||
| 3767 | editor.focus_outline(id).unwrap(); | ||
| 3768 | let original = editor.active_outline().document().clone(); | ||
| 3769 | editor | ||
| 3770 | .select( | ||
| 3771 | [canvas::document::TextPosition { | ||
| 3772 | paragraph: 0, | ||
| 3773 | offset: 0, | ||
| 3774 | }; 2] | ||
| 3775 | .into(), | ||
| 3776 | ) | ||
| 3777 | .unwrap(); | ||
| 3778 | let before = access.update(&editor, viewport, "Test", None).unwrap(); | ||
| 3779 | let before = accesskit_consumer::Tree::new(before, true); | ||
| 3780 | let before = before.state().focus().unwrap().document_range().text(); | ||
| 3781 | editor.insert(&mut engine, "CANVAS CHECK ").unwrap(); | ||
| 3782 | { | ||
| 3783 | let update = access.update(&editor, viewport, "Test", None).unwrap(); | ||
| 3784 | let tree = accesskit_consumer::Tree::new(update, true); | ||
| 3785 | assert_eq!( | ||
| 3786 | tree.state().focus().unwrap().document_range().text(), | ||
| 3787 | format!("CANVAS CHECK {before}") | ||
| 3788 | ); | ||
| 3789 | let primitives = | ||
| 3790 | page_primitives(&editor, Some(&scene), None, None, true, viewport.scale, 1.0) | ||
| 3791 | .unwrap(); | ||
| 3792 | assert!(!primitives.is_empty()); | ||
| 3793 | let edited_tags = tags(&editor); | ||
| 3794 | assert_eq!(edited_tags.len(), original_tags.len()); | ||
| 3795 | for ((edited, _), (original, _)) in edited_tags.iter().zip(&original_tags) { | ||
| 3796 | assert_eq!(edited.icon, original.icon); | ||
| 3797 | assert_eq!(edited.label, original.label); | ||
| 3798 | assert_eq!(edited.disabled, original.disabled); | ||
| 3799 | } | ||
| 3800 | } | ||
| 3801 | editor.undo(&mut engine).unwrap(); | ||
| 3802 | assert_eq!(editor.active_outline().document(), &original); | ||
| 3803 | assert_eq!(tags(&editor), original_tags); | ||
| 3804 | let update = access.update(&editor, viewport, "Test", None).unwrap(); | ||
| 3805 | let tree = accesskit_consumer::Tree::new(update, true); | ||
| 3806 | assert_eq!( | ||
| 3807 | tree.state().focus().unwrap().document_range().text(), | ||
| 3808 | before | ||
| 3809 | ); | ||
| 3810 | } | ||
| 3811 | eprintln!( | ||
| 3812 | "Rendered and restored {} imported outline widgets and accessibility fields", | ||
| 3813 | editor.outlines().len() | ||
| 3814 | ); | ||
| 3815 | } | ||
| 3816 | } |
crates/snowbound/src/profile.rs deleted-192| ... | @@ -1,192 +0,0 @@ | ||
| 1 | use super::*; | ||
| 2 | use canvas::document::TextPosition; | ||
| 3 | use std::{hint::black_box, process::Command}; | ||
| 4 | |||
| 5 | fn timed<T>(case: &str, phase: &str, sample: usize, operation: impl FnOnce() -> T) -> T { | ||
| 6 | let start = Instant::now(); | ||
| 7 | let result = operation(); | ||
| 8 | eprintln!( | ||
| 9 | "canvas_profile\t{case}\t{phase}\t{sample}\t{}", | ||
| 10 | start.elapsed().as_nanos() | ||
| 11 | ); | ||
| 12 | result | ||
| 13 | } | ||
| 14 | |||
| 15 | fn resident(case: &str, phase: &str, sample: usize) { | ||
| 16 | let output = Command::new("/bin/ps") | ||
| 17 | .args(["-o", "rss=", "-p", &std::process::id().to_string()]) | ||
| 18 | .output() | ||
| 19 | .unwrap(); | ||
| 20 | assert!(output.status.success()); | ||
| 21 | let kib: u64 = std::str::from_utf8(&output.stdout) | ||
| 22 | .unwrap() | ||
| 23 | .trim() | ||
| 24 | .parse() | ||
| 25 | .unwrap(); | ||
| 26 | eprintln!("canvas_rss_kib\t{case}\t{phase}\t{sample}\t{kib}"); | ||
| 27 | } | ||
| 28 | |||
| 29 | fn layout_ids(editor: &CanvasEditor) -> Vec<u64> { | ||
| 30 | editor | ||
| 31 | .outlines() | ||
| 32 | .iter() | ||
| 33 | .flat_map(|outline| outline.layouts().map(|(_, paragraph)| paragraph.text.id())) | ||
| 34 | .collect() | ||
| 35 | } | ||
| 36 | |||
| 37 | fn editing_cost( | ||
| 38 | case: &str, | ||
| 39 | engine: &mut TextEngine, | ||
| 40 | editor: &mut CanvasEditor, | ||
| 41 | scene: Option<&(PageScene, [f32; 2])>, | ||
| 42 | ) { | ||
| 43 | let viewport = Viewport { | ||
| 44 | size: [1000, 720], | ||
| 45 | scale: 96.0 / 72.0, | ||
| 46 | origin: [48.0; 2], | ||
| 47 | }; | ||
| 48 | let initial: Vec<_> = timed(case, "opening_snapshot", 0, || { | ||
| 49 | editor | ||
| 50 | .outlines() | ||
| 51 | .iter() | ||
| 52 | .map(|outline| outline.document().clone()) | ||
| 53 | .collect() | ||
| 54 | }); | ||
| 55 | let mut access = accessibility::Accessibility::default(); | ||
| 56 | let update = timed(case, "ax_first", 0, || { | ||
| 57 | access.update(editor, viewport, "Profile", None).unwrap() | ||
| 58 | }); | ||
| 59 | eprintln!("canvas_count\t{case}\tax_nodes\t0\t{}", update.nodes.len()); | ||
| 60 | drop(update); | ||
| 61 | let count: usize = editor | ||
| 62 | .outlines() | ||
| 63 | .iter() | ||
| 64 | .map(|outline| outline.document().nodes().len()) | ||
| 65 | .sum(); | ||
| 66 | eprintln!("canvas_count\t{case}\tparagraphs\t0\t{count}"); | ||
| 67 | for sample in 0..32 { | ||
| 68 | let index = sample % editor.outlines().len(); | ||
| 69 | let id = editor.outlines()[index].id; | ||
| 70 | editor.focus_outline(id).unwrap(); | ||
| 71 | let paragraph = if sample % 2 == 0 { | ||
| 72 | 0 | ||
| 73 | } else { | ||
| 74 | editor.active_outline().layouts().last().unwrap().0 | ||
| 75 | }; | ||
| 76 | editor | ||
| 77 | .select( | ||
| 78 | [TextPosition { | ||
| 79 | paragraph, | ||
| 80 | offset: 0, | ||
| 81 | }; 2] | ||
| 82 | .into(), | ||
| 83 | ) | ||
| 84 | .unwrap(); | ||
| 85 | let original_layouts = layout_ids(editor); | ||
| 86 | timed(case, "insert", sample, || { | ||
| 87 | editor.insert(engine, "x").unwrap() | ||
| 88 | }); | ||
| 89 | let edited_layouts = layout_ids(editor); | ||
| 90 | let changed = original_layouts | ||
| 91 | .iter() | ||
| 92 | .zip(&edited_layouts) | ||
| 93 | .filter(|(a, b)| a != b) | ||
| 94 | .count(); | ||
| 95 | assert_eq!(changed, 1); | ||
| 96 | eprintln!("canvas_count\t{case}\tchanged_layouts\t{sample}\t{changed}"); | ||
| 97 | let primitives = timed(case, "frame_primitives", sample, || { | ||
| 98 | let primitives = | ||
| 99 | page_primitives(editor, scene, None, None, true, viewport.scale, 1.0).unwrap(); | ||
| 100 | black_box(&primitives); | ||
| 101 | primitives.len() | ||
| 102 | }); | ||
| 103 | eprintln!("canvas_count\t{case}\tprimitives\t{sample}\t{primitives}"); | ||
| 104 | timed(case, "ax_insert", sample, || { | ||
| 105 | black_box(access.update(editor, viewport, "Profile", None).unwrap()); | ||
| 106 | }); | ||
| 107 | assert_eq!(edited_layouts, layout_ids(editor)); | ||
| 108 | timed(case, "undo", sample, || { | ||
| 109 | assert!(editor.undo(engine).unwrap()) | ||
| 110 | }); | ||
| 111 | timed(case, "ax_undo", sample, || { | ||
| 112 | black_box(access.update(editor, viewport, "Profile", None).unwrap()); | ||
| 113 | }); | ||
| 114 | assert_eq!(editor.active_outline().document(), &initial[index]); | ||
| 115 | } | ||
| 116 | resident(case, "after_edits", 0); | ||
| 117 | } | ||
| 118 | |||
| 119 | #[test] | ||
| 120 | #[ignore = "manual release canvas pipeline timing probe"] | ||
| 121 | fn canvas_pipeline_cost() { | ||
| 122 | if let Some(path) = std::env::var_os("CANVAS_TEST_SECTION") { | ||
| 123 | let bytes = timed("native", "read", 0, || std::fs::read(path).unwrap()); | ||
| 124 | let title = std::env::var("CANVAS_TEST_PAGE").unwrap(); | ||
| 125 | let mut engine = timed("native", "engine", 0, TextEngine::default); | ||
| 126 | if let Some(font) = std::env::var_os("CANVAS_TEST_SUBSTITUTE") { | ||
| 127 | timed("native", "font", 0, || { | ||
| 128 | engine | ||
| 129 | .register_substitute(parley::fontique::Blob::new(Arc::new( | ||
| 130 | std::fs::read(font).unwrap(), | ||
| 131 | ))) | ||
| 132 | .unwrap() | ||
| 133 | }); | ||
| 134 | } | ||
| 135 | let cycles = std::env::var("CANVAS_PROFILE_CYCLES") | ||
| 136 | .map(|value| value.parse::<usize>().unwrap()) | ||
| 137 | .unwrap_or(16); | ||
| 138 | assert!(cycles > 0); | ||
| 139 | for cycle in 0..cycles { | ||
| 140 | let page = timed("native", "parse", cycle, || { | ||
| 141 | let store = onestore::Store::parse(&bytes).unwrap(); | ||
| 142 | let index = onestore::RevisionIndex::parse(&store).unwrap(); | ||
| 143 | Page::from_document( | ||
| 144 | &onestore::document::Document::parse(&index).unwrap(), | ||
| 145 | &title, | ||
| 146 | ) | ||
| 147 | .unwrap() | ||
| 148 | }); | ||
| 149 | let (scene, mut editor) = timed("native", "scene", cycle, || { | ||
| 150 | PageScene::from_page(page, &mut engine).unwrap() | ||
| 151 | }); | ||
| 152 | let scene = (scene, [0.0; 2]); | ||
| 153 | if cycle == 0 { | ||
| 154 | editing_cost("native", &mut engine, &mut editor, Some(&scene)); | ||
| 155 | } else { | ||
| 156 | black_box( | ||
| 157 | page_primitives(&editor, Some(&scene), None, None, false, 96.0 / 72.0, 1.0) | ||
| 158 | .unwrap(), | ||
| 159 | ); | ||
| 160 | } | ||
| 161 | resident("native", "open", cycle); | ||
| 162 | drop(editor); | ||
| 163 | drop(scene); | ||
| 164 | resident("native", "closed", cycle); | ||
| 165 | } | ||
| 166 | } else { | ||
| 167 | for count in [100, 1_000, 5_000] { | ||
| 168 | let case = format!("synthetic_{count}"); | ||
| 169 | let mut engine = timed(&case, "engine", 0, TextEngine::default); | ||
| 170 | let document = timed(&case, "document", 0, || { | ||
| 171 | TextDocument::new( | ||
| 172 | (0..count) | ||
| 173 | .map(|_| { | ||
| 174 | Paragraph::new( | ||
| 175 | "A paragraph of plain text with several words.".into(), | ||
| 176 | Format::default(), | ||
| 177 | ) | ||
| 178 | }) | ||
| 179 | .collect(), | ||
| 180 | ) | ||
| 181 | .unwrap() | ||
| 182 | }); | ||
| 183 | let mut editor = timed(&case, "scene", 0, || { | ||
| 184 | CanvasEditor::new(&mut engine, document, 480.0).unwrap() | ||
| 185 | }); | ||
| 186 | editing_cost(&case, &mut engine, &mut editor, None); | ||
| 187 | drop(editor); | ||
| 188 | drop(engine); | ||
| 189 | resident(&case, "closed", 0); | ||
| 190 | } | ||
| 191 | } | ||
| 192 | } | ||
crates/snowbound/src/scroll.rs deleted-134| ... | @@ -1,134 +0,0 @@ | ||
| 1 | use canvas::gpu::{Primitive, Viewport}; | ||
| 2 | |||
| 3 | pub struct Scroll { | ||
| 4 | pub min: [f32; 2], | ||
| 5 | pub max: [f32; 2], | ||
| 6 | } | ||
| 7 | |||
| 8 | impl Scroll { | ||
| 9 | pub fn new(viewport: Viewport, bounds: impl Iterator<Item = [f32; 4]>) -> Self { | ||
| 10 | let mut min = [0.0_f32; 2]; | ||
| 11 | let mut max = [0.0_f32; 2]; | ||
| 12 | for rect in bounds { | ||
| 13 | for axis in 0..2 { | ||
| 14 | min[axis] = min[axis].min(rect[axis]); | ||
| 15 | max[axis] = max[axis].max(rect[axis + 2]); | ||
| 16 | } | ||
| 17 | } | ||
| 18 | // OneNote stops at the page origin or the object furthest above or left of it. | ||
| 19 | for axis in 0..2 { | ||
| 20 | min[axis] *= viewport.scale; | ||
| 21 | max[axis] = | ||
| 22 | ((max[axis] + 36.0) * viewport.scale - viewport.size[axis] as f32).max(min[axis]); | ||
| 23 | } | ||
| 24 | Self { min, max } | ||
| 25 | } | ||
| 26 | |||
| 27 | pub fn clamp(&self, viewport: &mut Viewport) { | ||
| 28 | for axis in 0..2 { | ||
| 29 | viewport.origin[axis] = -(-viewport.origin[axis]).clamp(self.min[axis], self.max[axis]); | ||
| 30 | } | ||
| 31 | } | ||
| 32 | |||
| 33 | pub fn thumb(&self, viewport: Viewport, dpr: f32, axis: usize) -> Option<[f32; 4]> { | ||
| 34 | let range = self.max[axis] - self.min[axis]; | ||
| 35 | let length = viewport.size[axis] as f32; | ||
| 36 | let track = length - 20.0 * dpr; | ||
| 37 | if range <= 0.0 || track <= 0.0 { | ||
| 38 | return None; | ||
| 39 | } | ||
| 40 | let size = (track * length / (length + range)) | ||
| 41 | .max(28.0 * dpr) | ||
| 42 | .min(track); | ||
| 43 | let position = (-viewport.origin[axis] - self.min[axis]) / range; | ||
| 44 | let start = 4.0 * dpr + position.clamp(0.0, 1.0) * (track - size); | ||
| 45 | let cross = viewport.size[1 - axis] as f32 - 4.0 * dpr; | ||
| 46 | Some(if axis == 0 { | ||
| 47 | [start, cross - 6.0 * dpr, start + size, cross] | ||
| 48 | } else { | ||
| 49 | [cross - 6.0 * dpr, start, cross, start + size] | ||
| 50 | }) | ||
| 51 | } | ||
| 52 | |||
| 53 | pub fn drag(&self, viewport: &mut Viewport, dpr: f32, axis: usize, pointer: f32, grab: f32) { | ||
| 54 | let Some(rect) = self.thumb(*viewport, dpr, axis) else { | ||
| 55 | return; | ||
| 56 | }; | ||
| 57 | let travel = viewport.size[axis] as f32 - 20.0 * dpr - (rect[axis + 2] - rect[axis]); | ||
| 58 | if travel > 0.0 { | ||
| 59 | let fraction = ((pointer - grab - 4.0 * dpr) / travel).clamp(0.0, 1.0); | ||
| 60 | viewport.origin[axis] = | ||
| 61 | -(self.min[axis] + fraction * (self.max[axis] - self.min[axis])); | ||
| 62 | } | ||
| 63 | } | ||
| 64 | |||
| 65 | pub fn hit_test(&self, viewport: Viewport, dpr: f32, point: [f32; 2]) -> Option<(usize, f32)> { | ||
| 66 | (0..2).find_map(|axis| { | ||
| 67 | let rect = self.thumb(viewport, dpr, axis)?; | ||
| 68 | let inset = 3.0 * dpr; | ||
| 69 | ((rect[0] - inset..=rect[2] + inset).contains(&point[0]) | ||
| 70 | && (rect[1] - inset..=rect[3] + inset).contains(&point[1])) | ||
| 71 | .then_some((axis, point[axis] - rect[axis])) | ||
| 72 | }) | ||
| 73 | } | ||
| 74 | |||
| 75 | pub fn append(&self, viewport: Viewport, dpr: f32, primitives: &mut Vec<Primitive<'_>>) { | ||
| 76 | for axis in 0..2 { | ||
| 77 | if let Some(rect) = self.thumb(viewport, dpr, axis) { | ||
| 78 | let start = viewport.document_point([rect[0], rect[1]]); | ||
| 79 | let end = viewport.document_point([rect[2], rect[3]]); | ||
| 80 | primitives.push(Primitive::RoundedRect { | ||
| 81 | rect: [start[0], start[1], end[0], end[1]], | ||
| 82 | radius: [3.0 * dpr / viewport.scale; 2], | ||
| 83 | stroke: None, | ||
| 84 | color: [0.18, 0.18, 0.18, 0.45], | ||
| 85 | }); | ||
| 86 | } | ||
| 87 | } | ||
| 88 | } | ||
| 89 | } | ||
| 90 | |||
| 91 | #[cfg(test)] | ||
| 92 | mod tests { | ||
| 93 | use super::*; | ||
| 94 | |||
| 95 | #[test] | ||
| 96 | fn bounds_include_negative_objects_and_clamp_both_ends() { | ||
| 97 | let mut viewport = Viewport { | ||
| 98 | size: [800, 600], | ||
| 99 | scale: 2.0, | ||
| 100 | origin: [9999.0; 2], | ||
| 101 | }; | ||
| 102 | let scroll = Scroll::new(viewport, [[-80.0, -20.0, 1000.0, 1200.0]].into_iter()); | ||
| 103 | scroll.clamp(&mut viewport); | ||
| 104 | assert_eq!(viewport.origin, [160.0, 40.0]); | ||
| 105 | viewport.origin = [-9999.0; 2]; | ||
| 106 | scroll.clamp(&mut viewport); | ||
| 107 | assert_eq!(viewport.origin, [-1272.0, -1872.0]); | ||
| 108 | } | ||
| 109 | |||
| 110 | #[test] | ||
| 111 | fn small_page_has_no_scrollbars_and_thumb_drag_reaches_endpoints() { | ||
| 112 | for dpr in [1.0, 2.0] { | ||
| 113 | let mut viewport = Viewport { | ||
| 114 | size: [(800.0 * dpr) as u32, (600.0 * dpr) as u32], | ||
| 115 | scale: dpr * 4.0 / 3.0, | ||
| 116 | origin: [48.0 * dpr; 2], | ||
| 117 | }; | ||
| 118 | let scroll = Scroll::new(viewport, [[0.0, 0.0, 72.0, 14.0]].into_iter()); | ||
| 119 | assert!(scroll.thumb(viewport, dpr, 0).is_none()); | ||
| 120 | assert!(scroll.thumb(viewport, dpr, 1).is_none()); | ||
| 121 | let scroll = Scroll::new(viewport, [[0.0, 0.0, 2000.0, 2000.0]].into_iter()); | ||
| 122 | for axis in 0..2 { | ||
| 123 | scroll.drag(&mut viewport, dpr, axis, -1000.0, 3.0 * dpr); | ||
| 124 | assert_eq!(-viewport.origin[axis], scroll.min[axis]); | ||
| 125 | let rect = scroll.thumb(viewport, dpr, axis).unwrap(); | ||
| 126 | assert!((rect[axis] - 4.0 * dpr).abs() < 0.001); | ||
| 127 | scroll.drag(&mut viewport, dpr, axis, 10000.0, 3.0 * dpr); | ||
| 128 | assert_eq!(-viewport.origin[axis], scroll.max[axis]); | ||
| 129 | let rect = scroll.thumb(viewport, dpr, axis).unwrap(); | ||
| 130 | assert!((rect[axis + 2] - (viewport.size[axis] as f32 - 16.0 * dpr)).abs() < 0.001); | ||
| 131 | } | ||
| 132 | } | ||
| 133 | } | ||
| 134 | } | ||
tools/canvas/README.md+9-6| ... | @@ -9,7 +9,7 @@ The host opens nonempty UTF-8 text as paragraphs in one temporary text outline. | ... | @@ -9,7 +9,7 @@ The host opens nonempty UTF-8 text as paragraphs in one temporary text outline. |
| 9 | ```sh | 9 | ```sh |
| 10 | python3 tools/canvas/build_macos.py | 10 | python3 tools/canvas/build_macos.py |
| 11 | "target/Snowbound.app/Contents/MacOS/Snowbound" | 11 | "target/Snowbound.app/Contents/MacOS/Snowbound" |
| 12 | cargo test -p canvas | 12 | cargo test -p canvas --features interaction |
| 13 | cargo test -p canvas --features gpu gpu:: -- --ignored | 13 | cargo test -p canvas --features gpu gpu:: -- --ignored |
| 14 | ``` | 14 | ``` |
| 15 | 15 | ||
| ... | @@ -32,7 +32,10 @@ canvas text edits · inverse history · composition · paragraph layout · | ... | @@ -32,7 +32,10 @@ canvas text edits · inverse history · composition · paragraph layout · |
| 32 | ↓ | 32 | ↓ |
| 33 | canvas::gpu Swash glyph rasterization · custom wgpu quads · bounded glyph/image caches | 33 | canvas::gpu Swash glyph rasterization · custom wgpu quads · bounded glyph/image caches |
| 34 | ↓ | 34 | ↓ |
| 35 | snowbound Winit window/input · AppKit quit guard · clipboard · AccessKit | 35 | canvas::interaction hit layers · drags · placement grid · picture handles · chrome · key routing |
| 36 | · scrolling and zoom · caret blink · AccessKit tree | ||
| 37 | ↓ | ||
| 38 | snowbound Winit window and events · AppKit pickers and quit guard · clipboard · saving | ||
| 36 | ``` | 39 | ``` |
| 37 | 40 | ||
| 38 | 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. | 41 | 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 | ... | @@ -99,7 +102,7 @@ CANVAS_TEST_SECTION=PRIVATE_SECTION_COPY CANVAS_TEST_PAGE=EXACT_PAGE_TITLE CANVA |
| 99 | 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. | 102 | 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. |
| 100 | 103 | ||
| 101 | ```sh | 104 | ```sh |
| 102 | cargo test -p snowbound | 105 | cargo test -p canvas --features interaction accessibility |
| 103 | SNOWBOUND_TRACE_INPUT=1 "target/Snowbound.app/Contents/MacOS/Snowbound" SYNTHETIC_TEXT_FILE | 106 | SNOWBOUND_TRACE_INPUT=1 "target/Snowbound.app/Contents/MacOS/Snowbound" SYNTHETIC_TEXT_FILE |
| 104 | ``` | 107 | ``` |
| 105 | 108 | ||
| ... | @@ -130,7 +133,7 @@ The host sleeps its caret timer while macOS reports the window occluded and requ | ... | @@ -130,7 +133,7 @@ The host sleeps its caret timer while macOS reports the window occluded and requ |
| 130 | The app's private integration test exercises the actual outline painter and accessibility consumer for each imported outline, then verifies text restoration through undo: | 133 | The app's private integration test exercises the actual outline painter and accessibility consumer for each imported outline, then verifies text restoration through undo: |
| 131 | 134 | ||
| 132 | ```sh | 135 | ```sh |
| 133 | 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 | 136 | 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 |
| 134 | ``` | 137 | ``` |
| 135 | 138 | ||
| 136 | ## Unsupported page content | 139 | ## Unsupported page content |
| ... | @@ -222,8 +225,8 @@ Full native captures, candidate font binaries/licenses and probe outputs remain | ... | @@ -222,8 +225,8 @@ Full native captures, candidate font binaries/licenses and probe outputs remain |
| 222 | 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. | 225 | 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. |
| 223 | 226 | ||
| 224 | ```sh | 227 | ```sh |
| 225 | cargo test --release -p snowbound canvas_pipeline_cost -- --ignored --nocapture --test-threads=1 | 228 | cargo test --release -p canvas --features interaction canvas_pipeline_cost -- --ignored --nocapture --test-threads=1 |
| 226 | 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 | 229 | 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 |
| 227 | ``` | 230 | ``` |
| 228 | 231 | ||
| 229 | 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. | 232 | 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. |