| 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 | 393 | name = "canvas" |
| 394 | 394 | version = "0.1.0" |
| 395 | 395 | dependencies = [ |
| 396 | "accesskit", | |
| 397 | "accesskit_consumer", | |
| 396 | 398 | "bytemuck", |
| 397 | 399 | "image", |
| 398 | 400 | "onestore", |
| ... | ... | @@ -2627,7 +2629,6 @@ name = "snowbound" |
| 2627 | 2629 | version = "0.1.0" |
| 2628 | 2630 | dependencies = [ |
| 2629 | 2631 | "accesskit", |
| 2630 | "accesskit_consumer", | |
| 2631 | 2632 | "accesskit_winit", |
| 2632 | 2633 | "arboard", |
| 2633 | 2634 | "canvas", |
crates/canvas/Cargo.toml+4| ... | ... | @@ -6,6 +6,8 @@ publish = false |
| 6 | 6 | |
| 7 | 7 | [features] |
| 8 | 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 | 12 | [dependencies] |
| 11 | 13 | onestore = { path = "../onestore" } |
| ... | ... | @@ -19,6 +21,7 @@ swash = { version = "0.2.10", optional = true } |
| 19 | 21 | wgpu = { workspace = true, optional = true } |
| 20 | 22 | image = { version = "0.25", default-features = false, features = ["gif", "jpeg", "png"], optional = true } |
| 21 | 23 | roxmltree = { version = "0.21", optional = true } |
| 24 | accesskit = { version = "0.25.0", optional = true } | |
| 22 | 25 | |
| 23 | 26 | [[bin]] |
| 24 | 27 | name = "layout-probe" |
| ... | ... | @@ -29,6 +32,7 @@ name = "page-probe" |
| 29 | 32 | path = "src/bin/page_probe.rs" |
| 30 | 33 | |
| 31 | 34 | [dev-dependencies] |
| 35 | accesskit_consumer = "0.39.0" | |
| 32 | 36 | pollster = "0.4" |
| 33 | 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 | 7 | pub mod date; |
| 8 | 8 | #[cfg(feature = "gpu")] |
| 9 | 9 | pub mod gpu; |
| 10 | #[cfg(feature = "interaction")] | |
| 11 | pub mod interaction; | |
| 10 | 12 | |
| 11 | 13 | /// Parley's caret affinity mapped onto the page model's hidden-field affinity. |
| 12 | 14 | pub fn affinity(affinity: parley::Affinity) -> onestore::page::text::Affinity { |
crates/snowbound/Cargo.toml+1-3| ... | ... | @@ -5,7 +5,7 @@ edition = "2024" |
| 5 | 5 | publish = false |
| 6 | 6 | |
| 7 | 7 | [dependencies] |
| 8 | canvas = { path = "../canvas", features = ["gpu"] } | |
| 8 | canvas = { path = "../canvas", features = ["interaction"] } | |
| 9 | 9 | onestore = { path = "../onestore" } |
| 10 | 10 | notebook = { path = "../notebook" } |
| 11 | 11 | pollster = "0.4" |
| ... | ... | @@ -19,5 +19,3 @@ objc2 = "0.5.2" |
| 19 | 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 | 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 | 116 | picker.setMinDate(Some(&NSDate::dateWithTimeIntervalSince1970( |
| 117 | 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 | 120 | let _: () = msg_send![&picker, setAccessibilityLabel: &*label]; |
| 121 | 121 | picker.sizeToFit(); |
| 122 | 122 | let alert = NSAlert::new(mtm); |
crates/snowbound/src/main.rs+235-3080| ... | ... | @@ -1,39 +1,29 @@ |
| 1 | mod accessibility; | |
| 2 | 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 | 8 | use canvas::{ |
| 9 | 9 | date::DateField, |
| 10 | 10 | document::TextDocument, |
| 11 | editor::{ | |
| 12 | CanvasEditor, DEFAULT_OUTLINE_WIDTH, Movement, Selection, SelectionUnit, TextOutline, | |
| 13 | }, | |
| 11 | editor::{CanvasEditor, DEFAULT_OUTLINE_WIDTH, TextOutline}, | |
| 14 | 12 | layout::TextEngine, |
| 15 | 13 | }; |
| 16 | 14 | use onestore::document::Format; |
| 17 | 15 | use onestore::page::Page; |
| 18 | 16 | use onestore::page::text::Paragraph; |
| 19 | use std::{ | |
| 20 | error::Error, | |
| 21 | path::PathBuf, | |
| 22 | sync::Arc, | |
| 23 | time::{Duration, Instant}, | |
| 24 | }; | |
| 17 | use std::{error::Error, path::PathBuf, sync::Arc, time::Instant}; | |
| 25 | 18 | use winit::{ |
| 26 | 19 | application::ApplicationHandler, |
| 27 | 20 | dpi::{LogicalSize, PhysicalPosition, PhysicalSize}, |
| 28 | 21 | event::{ElementState, Ime, MouseButton, MouseScrollDelta, WindowEvent}, |
| 29 | 22 | event_loop::{ActiveEventLoop, ControlFlow, EventLoopProxy}, |
| 30 | 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 | 27 | #[derive(Debug)] |
| 38 | 28 | enum UserEvent { |
| 39 | 29 | Quit, |
| ... | ... | @@ -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 { | |
| 59 | key == &Key::Named(NamedKey::Escape) | |
| 60 | || (modifiers.control_key() && key == &Key::Named(NamedKey::Tab)) | |
| 61 | || (modifiers.super_key() | |
| 62 | && matches!(key, Key::Character(value) if matches!(value.as_str(), "+" | "=" | "-" | "0") || (modifiers.shift_key() && value.eq_ignore_ascii_case("n")))) | |
| 48 | fn page_key(key: &Key) -> PageKey { | |
| 49 | match key { | |
| 50 | Key::Character(text) => PageKey::Character(text.to_string()), | |
| 51 | Key::Named(named) => PageKey::Named(match named { | |
| 52 | NamedKey::Escape => PageNamedKey::Escape, | |
| 53 | NamedKey::Tab => PageNamedKey::Tab, | |
| 54 | NamedKey::Space => PageNamedKey::Space, | |
| 55 | NamedKey::Enter => PageNamedKey::Enter, | |
| 56 | NamedKey::Backspace => PageNamedKey::Backspace, | |
| 57 | NamedKey::Delete => PageNamedKey::Delete, | |
| 58 | NamedKey::ArrowLeft => PageNamedKey::ArrowLeft, | |
| 59 | NamedKey::ArrowRight => PageNamedKey::ArrowRight, | |
| 60 | NamedKey::ArrowUp => PageNamedKey::ArrowUp, | |
| 61 | NamedKey::ArrowDown => PageNamedKey::ArrowDown, | |
| 62 | NamedKey::Home => PageNamedKey::Home, | |
| 63 | NamedKey::End => PageNamedKey::End, | |
| 64 | NamedKey::Alt | |
| 65 | | NamedKey::AltGraph | |
| 66 | | NamedKey::Control | |
| 67 | | NamedKey::Shift | |
| 68 | | NamedKey::Super | |
| 69 | | NamedKey::Meta => PageNamedKey::Modifier, | |
| 70 | _ => PageNamedKey::Other, | |
| 71 | }), | |
| 72 | _ => PageKey::Named(PageNamedKey::Other), | |
| 73 | } | |
| 74 | } | |
| 75 | ||
| 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 | 98 | struct App { |
| ... | ... | @@ -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 | 141 | struct State { |
| 161 | 142 | window: Arc<Window>, |
| 162 | 143 | instance: wgpu::Instance, |
| 163 | 144 | surface: wgpu::Surface<'static>, |
| 164 | 145 | config: wgpu::SurfaceConfiguration, |
| 165 | 146 | renderer: Renderer, |
| 166 | engine: TextEngine, | |
| 167 | editor: CanvasEditor, | |
| 147 | view: PageView, | |
| 168 | 148 | session: Option<Session>, |
| 169 | 149 | initial: Vec<(onestore::ExGuid, TextDocument)>, |
| 170 | 150 | initial_layouts: Vec<(onestore::ExGuid, onestore::document::Layout)>, |
| 171 | 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 | 152 | occluded: bool, |
| 183 | caret: bool, | |
| 184 | blink_at: Instant, | |
| 153 | ime_allowed: bool, | |
| 185 | 154 | clipboard: arboard::Clipboard, |
| 186 | 155 | access_adapter: accesskit_winit::Adapter, |
| 187 | 156 | accessibility: accessibility::Accessibility, |
| 188 | 157 | } |
| 189 | 158 | |
| 190 | 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 | 160 | async fn new( |
| 206 | 161 | event_loop: &ActiveEventLoop, |
| 207 | 162 | proxy: EventLoopProxy<UserEvent>, |
| ... | ... | @@ -309,7 +264,7 @@ impl State { |
| 309 | 264 | .collect(); |
| 310 | 265 | let dpr = window.scale_factor() as f32; |
| 311 | 266 | window.set_ime_allowed(true); |
| 312 | window.set_cursor(winit::window::CursorIcon::Text); | |
| 267 | window.set_cursor(CursorIcon::Text); | |
| 313 | 268 | window.request_redraw(); |
| 314 | 269 | eprintln!("Canvas GPU: {:?}; scale factor {dpr}", adapter.get_info()); |
| 315 | 270 | Ok(Self { |
| ... | ... | @@ -318,187 +273,99 @@ impl State { |
| 318 | 273 | surface, |
| 319 | 274 | config, |
| 320 | 275 | renderer, |
| 321 | engine, | |
| 322 | editor, | |
| 276 | view: PageView::new( | |
| 277 | editor, | |
| 278 | engine, | |
| 279 | scene, | |
| 280 | [size.width, size.height], | |
| 281 | dpr, | |
| 282 | macos::double_click_interval(), | |
| 283 | ), | |
| 323 | 284 | session, |
| 324 | 285 | initial, |
| 325 | 286 | initial_date, |
| 326 | 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 | 288 | occluded: false, |
| 342 | caret: true, | |
| 343 | blink_at: Instant::now() + Duration::from_millis(500), | |
| 289 | ime_allowed: true, | |
| 344 | 290 | clipboard: arboard::Clipboard::new()?, |
| 345 | 291 | access_adapter, |
| 346 | 292 | accessibility: accessibility::Accessibility::default(), |
| 347 | 293 | }) |
| 348 | 294 | } |
| 349 | 295 | |
| 350 | fn preview(&self) -> Option<(onestore::ExGuid, [f32; 2])> { | |
| 351 | let Drag::Outline { | |
| 352 | id, | |
| 353 | grab, | |
| 354 | pending_press, | |
| 355 | } = self.drag.as_ref()? | |
| 356 | else { | |
| 357 | return None; | |
| 358 | }; | |
| 359 | let origin = self | |
| 360 | .editor | |
| 361 | .outlines() | |
| 362 | .iter() | |
| 363 | .find(|outline| outline.id == *id)? | |
| 364 | .origin(); | |
| 365 | if pending_press.is_some() { | |
| 366 | return Some((*id, origin)); | |
| 296 | /// Carries out what a page event asked of the platform and follows its changes: the | |
| 297 | /// input method's position, accessibility, saving, the window title and a repaint. | |
| 298 | fn respond(&mut self, response: Response) -> Result<(), Box<dyn Error>> { | |
| 299 | match response.request { | |
| 300 | Some(Request::EditDate(field)) => self.edit_date(field)?, | |
| 301 | Some(Request::Copy(text)) => self.clipboard.set_text(text)?, | |
| 302 | Some(Request::Paste) => { | |
| 303 | let text = self.clipboard.get_text()?; | |
| 304 | let response = self.view.commit_text(text)?; | |
| 305 | self.respond(response)?; | |
| 306 | } | |
| 307 | Some(Request::CharacterPalette) => macos::show_character_palette(), | |
| 308 | None => {} | |
| 309 | } | |
| 310 | if self.ime_allowed != self.view.accepts_text() { | |
| 311 | self.ime_allowed = self.view.accepts_text(); | |
| 312 | self.window.set_ime_allowed(self.ime_allowed); | |
| 313 | } | |
| 314 | self.window.set_cursor(cursor_icon(self.view.cursor())); | |
| 315 | if response.changed { | |
| 316 | let [x0, y0, x1, y1] = self.view.caret_area()?; | |
| 317 | self.window.set_ime_cursor_area( | |
| 318 | PhysicalPosition::new(x0, y0), | |
| 319 | PhysicalSize::new(x1 - x0, y1 - y0), | |
| 320 | ); | |
| 321 | self.update_accessibility()?; | |
| 322 | self.persist()?; | |
| 323 | self.title_from_page(); | |
| 367 | 324 | } |
| 368 | let point = self.viewport.document_point(self.pointer); | |
| 369 | let position = [point[0] - grab[0], point[1] - grab[1]]; | |
| 370 | Some(( | |
| 371 | *id, | |
| 372 | if self.modifiers.alt_key() { | |
| 373 | position | |
| 374 | } else { | |
| 375 | snap_to_grid(position, self.editor.margin_origin()) | |
| 376 | }, | |
| 377 | )) | |
| 325 | if response.redraw { | |
| 326 | self.window.request_redraw(); | |
| 327 | } | |
| 328 | Ok(()) | |
| 378 | 329 | } |
| 379 | 330 | |
| 380 | fn image_preview(&self) -> Option<(onestore::ExGuid, [f32; 2], [f32; 2])> { | |
| 381 | let Drag::Image { | |
| 382 | id, | |
| 383 | handle, | |
| 384 | press, | |
| 385 | pending_press: None, | |
| 386 | } = self.drag.as_ref()? | |
| 387 | else { | |
| 388 | return None; | |
| 389 | }; | |
| 390 | let (origin, size) = self.editor.image_placement(*id)?; | |
| 391 | let point = self.viewport.document_point(self.pointer); | |
| 392 | let delta = [point[0] - press[0], point[1] - press[1]]; | |
| 393 | if *handle != [0, 0] { | |
| 394 | let (origin, size) = resize_image(origin, size, *handle, delta); | |
| 395 | return Some((*id, origin, size)); | |
| 396 | } | |
| 397 | // A picture in an outline's flow keeps its place. | |
| 398 | if self.editor.image_in_outline(*id) { | |
| 399 | return Some((*id, origin, size)); | |
| 331 | /// A temporary page's window takes its title's first line. | |
| 332 | fn title_from_page(&self) { | |
| 333 | let editor = &self.view.editor; | |
| 334 | if self.session.is_some() || !editor.active_outline().title { | |
| 335 | return; | |
| 400 | 336 | } |
| 401 | let origin = [origin[0] + delta[0], origin[1] + delta[1]]; | |
| 402 | let origin = if self.modifiers.alt_key() { | |
| 403 | origin | |
| 337 | let title = editor | |
| 338 | .active_outline() | |
| 339 | .document() | |
| 340 | .paragraphs() | |
| 341 | .next() | |
| 342 | .unwrap() | |
| 343 | .text() | |
| 344 | .split(['\u{000b}', '\n', '\r']) | |
| 345 | .next() | |
| 346 | .unwrap(); | |
| 347 | let title = if title.trim().is_empty() { | |
| 348 | "Untitled" | |
| 404 | 349 | } else { |
| 405 | snap_to_grid(origin, self.editor.margin_origin()) | |
| 350 | title | |
| 406 | 351 | }; |
| 407 | Some((*id, origin, size)) | |
| 408 | } | |
| 409 | ||
| 410 | /// The selected picture's handles lie above the page; its body keeps its paint order. | |
| 411 | fn hit_test(&self, point: [f32; 2]) -> Option<Hit> { | |
| 412 | let pixel = self.display_scale / self.viewport.scale; | |
| 413 | if let Some(ObjectFocus::Image(id)) = self.object_focus | |
| 414 | && let Some((origin, size)) = self.editor.image_placement(id) | |
| 415 | && let Some(handle) = image_handle_at(image_rect(origin, size), pixel, point) | |
| 416 | { | |
| 417 | return Some(Hit::Image { id, handle }); | |
| 352 | let title = format!("{title} · Temporary page"); | |
| 353 | if self.window.title() != title { | |
| 354 | self.window.set_title(&title); | |
| 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>) { | |
| 423 | if self.object_focus != focus { | |
| 424 | self.editor.finish_composition(); | |
| 425 | self.drag = None; | |
| 426 | self.object_focus = focus; | |
| 427 | self.window.set_ime_allowed(focus.is_none()); | |
| 428 | } | |
| 429 | } | |
| 430 | ||
| 431 | /// Page coordinates of a focused object. | |
| 432 | fn object_rect(&self, focus: ObjectFocus) -> [f32; 4] { | |
| 433 | let (scene, offset) = self.scene.as_ref().unwrap(); | |
| 434 | let [x0, y0, x1, y1] = match focus { | |
| 435 | ObjectFocus::ReadOnly(index) => scene | |
| 436 | .read_only(Some(&self.editor)) | |
| 437 | .nth(index) | |
| 438 | .unwrap() | |
| 439 | .rect(), | |
| 440 | ObjectFocus::Image(id) => { | |
| 441 | let (origin, size) = self.editor.image_placement(id).unwrap(); | |
| 442 | image_rect(origin, size) | |
| 443 | } | |
| 358 | fn edit_date(&mut self, field: DateField) -> Result<(), Box<dyn Error>> { | |
| 359 | let Some(date) = self.view.editor.date() else { | |
| 360 | return Ok(()); | |
| 444 | 361 | }; |
| 445 | [ | |
| 446 | x0 + offset[0], | |
| 447 | y0 + offset[1], | |
| 448 | x1 + offset[0], | |
| 449 | y1 + offset[1], | |
| 450 | ] | |
| 451 | } | |
| 452 | ||
| 453 | fn changed(&mut self) -> Result<(), Box<dyn Error>> { | |
| 454 | self.scroll().clamp(&mut self.viewport); | |
| 455 | self.caret = true; | |
| 456 | self.blink_at = Instant::now() + Duration::from_millis(500); | |
| 457 | let mut rect = self.editor.caret(1.0)?; | |
| 458 | let origin = self | |
| 459 | .preview() | |
| 460 | .map(|(_, origin)| origin) | |
| 461 | .unwrap_or_else(|| self.editor.active_outline().origin()); | |
| 462 | rect.x0 += f64::from(origin[0]); | |
| 463 | rect.y0 += f64::from(origin[1]); | |
| 464 | rect.x1 += f64::from(origin[0]); | |
| 465 | rect.y1 += f64::from(origin[1]); | |
| 466 | let v = self.viewport; | |
| 467 | self.window.set_ime_cursor_area( | |
| 468 | PhysicalPosition::new( | |
| 469 | rect.x0 * f64::from(v.scale) + f64::from(v.origin[0]), | |
| 470 | rect.y0 * f64::from(v.scale) + f64::from(v.origin[1]), | |
| 471 | ), | |
| 472 | PhysicalSize::new( | |
| 473 | (rect.width() * f64::from(v.scale)).max(1.0), | |
| 474 | (rect.height() * f64::from(v.scale)).max(1.0), | |
| 475 | ), | |
| 476 | ); | |
| 477 | self.update_accessibility()?; | |
| 478 | self.persist()?; | |
| 479 | if self.session.is_none() && self.editor.active_outline().title { | |
| 480 | let title = self | |
| 481 | .editor | |
| 482 | .active_outline() | |
| 483 | .document() | |
| 484 | .paragraphs() | |
| 485 | .next() | |
| 486 | .unwrap() | |
| 487 | .text() | |
| 488 | .split(['\u{000b}', '\n', '\r']) | |
| 489 | .next() | |
| 490 | .unwrap(); | |
| 491 | let title = if title.trim().is_empty() { | |
| 492 | "Untitled" | |
| 493 | } else { | |
| 494 | title | |
| 495 | }; | |
| 496 | let title = format!("{title} · Temporary page"); | |
| 497 | if self.window.title() != title { | |
| 498 | self.window.set_title(&title); | |
| 499 | } | |
| 362 | let timestamp = date.timestamp(); | |
| 363 | self.view.editor.finish_composition(); | |
| 364 | macos::clear_marked_text(&self.window); | |
| 365 | if let Some((timestamp, text)) = macos::edit_date(timestamp, field)? { | |
| 366 | let response = self.view.change_date(timestamp, text)?; | |
| 367 | self.respond(response)?; | |
| 500 | 368 | } |
| 501 | self.window.request_redraw(); | |
| 502 | 369 | Ok(()) |
| 503 | 370 | } |
| 504 | 371 | |
| ... | ... | @@ -508,7 +375,7 @@ impl State { |
| 508 | 375 | let Some(session) = &mut self.session else { |
| 509 | 376 | return Ok(()); |
| 510 | 377 | }; |
| 511 | let after = self.editor.page()?; | |
| 378 | let after = self.view.editor.page()?; | |
| 512 | 379 | match session |
| 513 | 380 | .section |
| 514 | 381 | .save(session.space, &session.before, &after, "snowbound")? |
| ... | ... | @@ -530,12 +397,9 @@ impl State { |
| 530 | 397 | return Ok(()); |
| 531 | 398 | }; |
| 532 | 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 | 401 | session.before = page; |
| 535 | self.editor = editor; | |
| 536 | self.scene = Some((scene, [0.0; 2])); | |
| 537 | self.drag = None; | |
| 538 | self.object_focus = None; | |
| 402 | self.view.replace(editor, Some((scene, [0.0; 2]))); | |
| 539 | 403 | self.window.set_title(&session.window_title()); |
| 540 | 404 | self.update_accessibility()?; |
| 541 | 405 | self.window.request_redraw(); |
| ... | ... | @@ -557,7 +421,7 @@ impl State { |
| 557 | 421 | return Ok(()); |
| 558 | 422 | }; |
| 559 | 423 | let reviewed = if keep_mine { |
| 560 | self.editor.page()? | |
| 424 | self.view.editor.page()? | |
| 561 | 425 | } else { |
| 562 | 426 | session.section.remote_page(session.space)? |
| 563 | 427 | }; |
| ... | ... | @@ -610,21 +474,21 @@ impl State { |
| 610 | 474 | |
| 611 | 475 | fn update_accessibility(&mut self) -> Result<(), Box<dyn Error>> { |
| 612 | 476 | let mut error = None; |
| 613 | let preview = self.preview(); | |
| 477 | let view = &self.view; | |
| 614 | 478 | self.access_adapter.update_if_active(|| { |
| 615 | 479 | match self.accessibility.update( |
| 616 | &self.editor, | |
| 617 | self.viewport, | |
| 480 | &view.editor, | |
| 481 | view.viewport, | |
| 618 | 482 | &self.window.title(), |
| 619 | preview, | |
| 483 | view.outline_preview(), | |
| 620 | 484 | ) { |
| 621 | 485 | Ok(mut update) => { |
| 622 | 486 | self.accessibility.append_page_fields( |
| 623 | 487 | &mut update, |
| 624 | self.scene.as_ref(), | |
| 625 | &self.editor, | |
| 626 | self.viewport, | |
| 627 | self.object_focus.and_then(ObjectFocus::read_only), | |
| 488 | view.scene.as_ref(), | |
| 489 | &view.editor, | |
| 490 | view.viewport, | |
| 491 | view.object_focus().and_then(|focus| focus.read_only()), | |
| 628 | 492 | ); |
| 629 | 493 | update |
| 630 | 494 | } |
| ... | ... | @@ -662,138 +526,46 @@ impl State { |
| 662 | 526 | } |
| 663 | 527 | if let Some(index) = self.accessibility.read_only_for_node(request.target_node) { |
| 664 | 528 | if request.action == Action::Focus { |
| 665 | self.set_object_focus(Some(ObjectFocus::ReadOnly(index))); | |
| 666 | 529 | self.window.focus_window(); |
| 667 | self.reveal_focus()?; | |
| 668 | self.changed()?; | |
| 530 | let response = self.view.focus_read_only(index)?; | |
| 531 | self.respond(response)?; | |
| 669 | 532 | } |
| 670 | 533 | return Ok(()); |
| 671 | 534 | } |
| 672 | 535 | let Some(outline) = self.accessibility.outline_for_node(request.target_node) else { |
| 673 | 536 | return Ok(()); |
| 674 | 537 | }; |
| 538 | let view = &mut self.view; | |
| 675 | 539 | match (request.action, request.data) { |
| 676 | 540 | (Action::Focus, _) => { |
| 677 | self.editor.focus_outline(outline)?; | |
| 541 | view.editor.focus_outline(outline)?; | |
| 678 | 542 | self.window.focus_window(); |
| 679 | 543 | } |
| 680 | 544 | (Action::SetTextSelection, Some(ActionData::SetTextSelection(selection))) => { |
| 681 | 545 | let selection = self.accessibility.selection(outline, selection)?; |
| 682 | self.editor.focus_outline(outline)?; | |
| 683 | self.editor.select(selection)?; | |
| 684 | trace_input(&self.editor.selection()); | |
| 546 | view.editor.focus_outline(outline)?; | |
| 547 | view.editor.select(selection)?; | |
| 548 | trace_input(&view.editor.selection()); | |
| 685 | 549 | } |
| 686 | 550 | (Action::SetValue, Some(ActionData::Value(text))) => { |
| 687 | self.editor.focus_outline(outline)?; | |
| 688 | self.editor.select_all()?; | |
| 689 | self.editor.commit_text(&mut self.engine, text.into())?; | |
| 551 | view.editor.focus_outline(outline)?; | |
| 552 | view.editor.select_all()?; | |
| 553 | view.editor.commit_text(&mut view.engine, text.into())?; | |
| 690 | 554 | } |
| 691 | 555 | (Action::ReplaceSelectedText, Some(ActionData::Value(text))) => { |
| 692 | self.editor.focus_outline(outline)?; | |
| 693 | self.editor.commit_text(&mut self.engine, text.into())?; | |
| 556 | view.editor.focus_outline(outline)?; | |
| 557 | view.editor.commit_text(&mut view.engine, text.into())?; | |
| 694 | 558 | } |
| 695 | 559 | _ => return Ok(()), |
| 696 | 560 | } |
| 697 | self.set_object_focus(None); | |
| 698 | self.reveal_focus()?; | |
| 699 | self.changed() | |
| 700 | } | |
| 701 | ||
| 702 | fn reveal_focus(&mut self) -> Result<(), Box<dyn Error>> { | |
| 703 | if self.viewport.size.contains(&0) { | |
| 704 | return Ok(()); | |
| 705 | } | |
| 706 | let rect = if let Some(focus) = self.object_focus { | |
| 707 | let [x0, y0, x1, y1] = self.object_rect(focus).map(f64::from); | |
| 708 | parley::BoundingBox { x0, y0, x1, y1 } | |
| 709 | } else { | |
| 710 | let mut rect = self.editor.caret(1.0)?; | |
| 711 | let origin = self.editor.active_outline().origin(); | |
| 712 | rect.x0 += f64::from(origin[0]); | |
| 713 | rect.x1 += f64::from(origin[0]); | |
| 714 | rect.y0 += f64::from(origin[1]); | |
| 715 | rect.y1 += f64::from(origin[1]); | |
| 716 | rect | |
| 717 | }; | |
| 718 | let outline = self.editor.active_outline().bounds(); | |
| 719 | for (axis, (mut start, mut end)) in [(rect.x0, rect.x1), (rect.y0, rect.y1)] | |
| 720 | .into_iter() | |
| 721 | .enumerate() | |
| 722 | { | |
| 723 | let size = f64::from(self.viewport.size[axis]); | |
| 724 | let margin = f64::from(16.0 * self.display_scale).min(size * 0.25); | |
| 725 | let (outline_start, outline_end) = | |
| 726 | [(outline.x0, outline.x1), (outline.y0, outline.y1)][axis]; | |
| 727 | let outline_start = outline_start.min(start); | |
| 728 | let outline_end = outline_end.max(end); | |
| 729 | if self.object_focus.is_none() | |
| 730 | && (outline_end - outline_start) * f64::from(self.viewport.scale) | |
| 731 | <= size - margin * 2.0 | |
| 732 | { | |
| 733 | start = outline_start; | |
| 734 | end = outline_end; | |
| 735 | } | |
| 736 | let start = | |
| 737 | start * f64::from(self.viewport.scale) + f64::from(self.viewport.origin[axis]); | |
| 738 | let end = end * f64::from(self.viewport.scale) + f64::from(self.viewport.origin[axis]); | |
| 739 | let shift = if start < margin { | |
| 740 | margin - start | |
| 741 | } else if end > size - margin { | |
| 742 | size - margin - end | |
| 743 | } else { | |
| 744 | 0.0 | |
| 745 | }; | |
| 746 | self.viewport.origin[axis] += shift as f32; | |
| 747 | } | |
| 748 | Ok(()) | |
| 749 | } | |
| 750 | ||
| 751 | fn zoom(&mut self, factor: f32) { | |
| 752 | let point = self.viewport.document_point(self.pointer); | |
| 753 | let dpr = self.window.scale_factor() as f32; | |
| 754 | self.viewport.scale = (self.viewport.scale * factor).clamp(dpr / 3.0, dpr * 16.0 / 3.0); | |
| 755 | self.viewport.origin = [ | |
| 756 | self.pointer[0] - point[0] * self.viewport.scale, | |
| 757 | self.pointer[1] - point[1] * self.viewport.scale, | |
| 758 | ]; | |
| 759 | } | |
| 760 | ||
| 761 | fn scroll(&self) -> scroll::Scroll { | |
| 762 | let editable = self | |
| 763 | .editor | |
| 764 | .visible_outlines() | |
| 765 | .chain(self.editor.caret_outline()) | |
| 766 | .map(|outline| { | |
| 767 | let rect = outline.bounds(); | |
| 768 | let offset = self | |
| 769 | .scene | |
| 770 | .as_ref() | |
| 771 | .filter(|_| self.editor.has_page_outline(outline.id)) | |
| 772 | .map(|(_, offset)| *offset) | |
| 773 | .unwrap_or([0.0; 2]); | |
| 774 | [ | |
| 775 | rect.x0 as f32 + offset[0], | |
| 776 | rect.y0 as f32 + offset[1], | |
| 777 | rect.x1 as f32 + offset[0], | |
| 778 | rect.y1 as f32 + offset[1], | |
| 779 | ] | |
| 780 | }); | |
| 781 | let fixed = self.scene.iter().flat_map(|(scene, offset)| { | |
| 782 | scene.content_bounds(&self.editor).map(|rect| { | |
| 783 | [ | |
| 784 | rect[0] + offset[0], | |
| 785 | rect[1] + offset[1], | |
| 786 | rect[2] + offset[0], | |
| 787 | rect[3] + offset[1], | |
| 788 | ] | |
| 789 | }) | |
| 790 | }); | |
| 791 | scroll::Scroll::new(self.viewport, editable.chain(fixed)) | |
| 561 | let response = self.view.focus_text()?; | |
| 562 | self.respond(response) | |
| 792 | 563 | } |
| 793 | 564 | |
| 794 | 565 | fn draw(&mut self) -> Result<(), Box<dyn Error>> { |
| 795 | trace_input(&("Draw", self.viewport.origin, self.viewport.scale)); | |
| 796 | if self.occluded || self.viewport.size.contains(&0) { | |
| 566 | let viewport = self.view.viewport; | |
| 567 | trace_input(&("Draw", viewport.origin, viewport.scale)); | |
| 568 | if self.occluded || viewport.size.contains(&0) { | |
| 797 | 569 | return Ok(()); |
| 798 | 570 | } |
| 799 | 571 | let mut reconfigure = false; |
| ... | ... | @@ -827,56 +599,12 @@ impl State { |
| 827 | 599 | return Err("Canvas surface validation failed".into()); |
| 828 | 600 | } |
| 829 | 601 | }; |
| 830 | let mut primitives = page_primitives( | |
| 831 | &self.editor, | |
| 832 | self.scene.as_ref(), | |
| 833 | match &self.drag { | |
| 834 | Some(Drag::Resize { | |
| 835 | outline: Some(outline), | |
| 836 | .. | |
| 837 | }) => Some(PointerFeedback::Resize(outline)), | |
| 838 | _ => self | |
| 839 | .preview() | |
| 840 | .map(|(id, origin)| PointerFeedback::Move(id, origin)) | |
| 841 | .or_else(|| { | |
| 842 | self.image_preview() | |
| 843 | .map(|(id, origin, size)| PointerFeedback::Image(id, origin, size)) | |
| 844 | }) | |
| 845 | .or_else(|| { | |
| 846 | if !self.pointer_inside { | |
| 847 | return None; | |
| 848 | } | |
| 849 | match page_hit_test( | |
| 850 | &self.editor, | |
| 851 | self.scene.as_ref(), | |
| 852 | self.viewport.document_point(self.pointer), | |
| 853 | self.display_scale / self.viewport.scale, | |
| 854 | ) { | |
| 855 | Some( | |
| 856 | Hit::Text { id, .. } | |
| 857 | | Hit::Handle { id, .. } | |
| 858 | | Hit::Resize { id, .. }, | |
| 859 | ) => Some(PointerFeedback::Hover(id)), | |
| 860 | _ => None, | |
| 861 | } | |
| 862 | }), | |
| 863 | }, | |
| 864 | self.object_focus, | |
| 865 | self.caret | |
| 866 | && self.focused | |
| 867 | && !matches!( | |
| 868 | self.drag, | |
| 869 | Some(Drag::Outline { .. } | Drag::Resize { .. } | Drag::Image { .. }) | |
| 870 | ), | |
| 871 | self.viewport.scale, | |
| 872 | self.display_scale, | |
| 873 | )?; | |
| 874 | self.scroll() | |
| 875 | .append(self.viewport, self.display_scale, &mut primitives); | |
| 602 | let [caret, selection] = macos::text_colors(); | |
| 603 | let primitives = self.view.primitives(TextColors { caret, selection })?; | |
| 876 | 604 | self.renderer |
| 877 | 605 | .draw( |
| 878 | 606 | &frame.texture.create_view(&Default::default()), |
| 879 | self.viewport, | |
| 607 | viewport, | |
| 880 | 608 | &primitives, |
| 881 | 609 | ) |
| 882 | 610 | .map_err(|error| format!("Canvas drawing failed: {error:?}"))?; |
| ... | ... | @@ -890,353 +618,42 @@ impl State { |
| 890 | 618 | } |
| 891 | 619 | |
| 892 | 620 | fn key(&mut self, key: &Key, text: Option<&str>) -> Result<(), Box<dyn Error>> { |
| 893 | let shift = self.modifiers.shift_key(); | |
| 894 | let command = self.modifiers.super_key(); | |
| 895 | let option = self.modifiers.alt_key(); | |
| 896 | if let Some(ObjectFocus::Image(id)) = self.object_focus | |
| 897 | && matches!(key, Key::Named(NamedKey::Backspace | NamedKey::Delete)) | |
| 898 | { | |
| 899 | self.editor.remove_image(&mut self.engine, id)?; | |
| 900 | self.set_object_focus(None); | |
| 901 | return self.changed(); | |
| 902 | } | |
| 903 | if let Some(ObjectFocus::Image(id)) = self.object_focus | |
| 904 | && !(shift || command || option || self.modifiers.control_key()) | |
| 905 | && let Key::Named(NamedKey::ArrowLeft | NamedKey::ArrowRight) = key | |
| 906 | && self.editor.step_from_image( | |
| 907 | &mut self.engine, | |
| 908 | id, | |
| 909 | key == &Key::Named(NamedKey::ArrowRight), | |
| 910 | )? | |
| 621 | let modifiers = self.view.modifiers(); | |
| 622 | if modifiers.command | |
| 623 | && modifiers.shift | |
| 624 | && self.session.is_some() | |
| 625 | && self.view.accepts_text() | |
| 626 | && let Key::Character(character) = key | |
| 911 | 627 | { |
| 912 | self.set_object_focus(None); | |
| 913 | self.reveal_focus()?; | |
| 914 | return self.changed(); | |
| 915 | } | |
| 916 | if let Some(focus) = self.object_focus { | |
| 917 | let undo = matches!(focus, ObjectFocus::Image(_)) | |
| 918 | && command | |
| 919 | && matches!(key, Key::Character(value) if value.eq_ignore_ascii_case("z")); | |
| 920 | if !undo && !read_only_shortcut(key, self.modifiers) { | |
| 921 | return Ok(()); | |
| 922 | } | |
| 923 | if key == &Key::Named(NamedKey::Escape) { | |
| 924 | self.set_object_focus(None); | |
| 925 | self.reveal_focus()?; | |
| 926 | return self.changed(); | |
| 927 | } | |
| 928 | } | |
| 929 | if command && self.modifiers.control_key() && key == &Key::Named(NamedKey::Space) { | |
| 930 | macos::show_character_palette(); | |
| 931 | return Ok(()); | |
| 932 | } | |
| 933 | if command && shift && self.session.is_some() { | |
| 934 | match key { | |
| 935 | Key::Character(character) if character.eq_ignore_ascii_case("k") => { | |
| 936 | return self.resolve_conflict(true); | |
| 937 | } | |
| 938 | Key::Character(character) if character.eq_ignore_ascii_case("t") => { | |
| 939 | return self.resolve_conflict(false); | |
| 940 | } | |
| 941 | _ => {} | |
| 942 | } | |
| 943 | } | |
| 944 | if matches!( | |
| 945 | self.drag, | |
| 946 | Some(Drag::Outline { .. } | Drag::Resize { .. } | Drag::Image { .. }) | |
| 947 | ) { | |
| 948 | if matches!( | |
| 949 | key, | |
| 950 | Key::Named( | |
| 951 | NamedKey::Alt | |
| 952 | | NamedKey::AltGraph | |
| 953 | | NamedKey::Control | |
| 954 | | NamedKey::Shift | |
| 955 | | NamedKey::Super | |
| 956 | | NamedKey::Meta | |
| 957 | ) | |
| 958 | ) { | |
| 959 | return Ok(()); | |
| 960 | } | |
| 961 | self.drag = None; | |
| 962 | if key == &Key::Named(NamedKey::Escape) { | |
| 963 | return self.changed(); | |
| 964 | } | |
| 965 | } | |
| 966 | if command && option { | |
| 967 | let delta = match key { | |
| 968 | Key::Named(NamedKey::ArrowLeft) => Some([-1.0, 0.0]), | |
| 969 | Key::Named(NamedKey::ArrowRight) => Some([1.0, 0.0]), | |
| 970 | Key::Named(NamedKey::ArrowUp) => Some([0.0, -1.0]), | |
| 971 | Key::Named(NamedKey::ArrowDown) => Some([0.0, 1.0]), | |
| 972 | _ => None, | |
| 973 | }; | |
| 974 | if let Some(delta) = delta { | |
| 975 | let outline = self.editor.active_outline(); | |
| 976 | let origin = outline.origin(); | |
| 977 | let step = if shift { 10.0 } else { 1.0 }; | |
| 978 | self.editor.move_outline( | |
| 979 | outline.id, | |
| 980 | [origin[0] + delta[0] * step, origin[1] + delta[1] * step], | |
| 981 | )?; | |
| 982 | self.reveal_focus()?; | |
| 983 | return self.changed(); | |
| 984 | } | |
| 985 | } | |
| 986 | if self.modifiers.control_key() && key == &Key::Named(NamedKey::Tab) { | |
| 987 | let outlines = self.editor.outlines(); | |
| 988 | let count = outlines.len() | |
| 989 | + self | |
| 990 | .scene | |
| 991 | .as_ref() | |
| 992 | .map_or(0, |(scene, _)| scene.read_only(Some(&self.editor)).count()); | |
| 993 | if count == 0 { | |
| 994 | return self.changed(); | |
| 995 | } | |
| 996 | let index = self | |
| 997 | .object_focus | |
| 998 | .and_then(ObjectFocus::read_only) | |
| 999 | .map_or_else( | |
| 1000 | || { | |
| 1001 | outlines | |
| 1002 | .iter() | |
| 1003 | .position(|outline| outline.id == self.editor.active_outline().id) | |
| 1004 | }, | |
| 1005 | |index| Some(outlines.len() + index), | |
| 1006 | ); | |
| 1007 | let next = index.map_or(if shift { count - 1 } else { 0 }, |index| { | |
| 1008 | if shift { | |
| 1009 | (index + count - 1) % count | |
| 1010 | } else { | |
| 1011 | (index + 1) % count | |
| 1012 | } | |
| 1013 | }); | |
| 1014 | if next < outlines.len() { | |
| 1015 | self.editor.focus_outline(outlines[next].id)?; | |
| 1016 | self.set_object_focus(None); | |
| 1017 | } else { | |
| 1018 | self.set_object_focus(Some(ObjectFocus::ReadOnly(next - outlines.len()))); | |
| 1019 | } | |
| 1020 | self.reveal_focus()?; | |
| 1021 | return self.changed(); | |
| 1022 | } | |
| 1023 | if command && let Key::Character(key) = key { | |
| 1024 | match key.to_lowercase().as_str() { | |
| 1025 | "n" if shift => { | |
| 1026 | let position = if let Some(focus) = self.object_focus { | |
| 1027 | let rect = self.object_rect(focus); | |
| 1028 | [rect[2] + 24.0, rect[1]] | |
| 1029 | } else { | |
| 1030 | let bounds = self.editor.active_outline().bounds(); | |
| 1031 | [bounds.x1 as f32 + 24.0, bounds.y0 as f32] | |
| 1032 | }; | |
| 1033 | self.editor.place_caret( | |
| 1034 | &mut self.engine, | |
| 1035 | snap_to_grid(position, self.editor.margin_origin()), | |
| 1036 | DEFAULT_OUTLINE_WIDTH, | |
| 1037 | )?; | |
| 1038 | self.set_object_focus(None); | |
| 1039 | } | |
| 1040 | "a" => self.editor.select_all()?, | |
| 1041 | "z" => { | |
| 1042 | if shift { | |
| 1043 | self.editor.redo(&mut self.engine)?; | |
| 1044 | } else { | |
| 1045 | self.editor.undo(&mut self.engine)?; | |
| 1046 | } | |
| 1047 | if let Some(ObjectFocus::Image(id)) = self.object_focus | |
| 1048 | && self.editor.image_placement(id).is_none() | |
| 1049 | { | |
| 1050 | self.set_object_focus(None); | |
| 1051 | } | |
| 1052 | } | |
| 1053 | "c" | "x" => { | |
| 1054 | let [anchor, focus] = self.editor.selection().positions; | |
| 1055 | let selected = self | |
| 1056 | .editor | |
| 1057 | .active_outline() | |
| 1058 | .document() | |
| 1059 | .slice(anchor.min(focus)..anchor.max(focus))?; | |
| 1060 | let text = selected | |
| 1061 | .iter() | |
| 1062 | .map(|paragraph| { | |
| 1063 | paragraph | |
| 1064 | .project() | |
| 1065 | .map(|projection| projection.text().text().to_owned()) | |
| 1066 | }) | |
| 1067 | .collect::<Result<Vec<_>, _>>()? | |
| 1068 | .join("\n"); | |
| 1069 | if !text.is_empty() { | |
| 1070 | self.clipboard.set_text(text)?; | |
| 1071 | if key.eq_ignore_ascii_case("x") { | |
| 1072 | self.editor.insert(&mut self.engine, "")?; | |
| 1073 | } | |
| 1074 | } | |
| 1075 | } | |
| 1076 | "v" => { | |
| 1077 | let text = self.clipboard.get_text()?; | |
| 1078 | self.editor.commit_text(&mut self.engine, text)?; | |
| 1079 | } | |
| 1080 | "+" | "=" => { | |
| 1081 | self.zoom(1.1); | |
| 1082 | return self.changed(); | |
| 1083 | } | |
| 1084 | "-" => { | |
| 1085 | self.zoom(1.0 / 1.1); | |
| 1086 | return self.changed(); | |
| 1087 | } | |
| 1088 | "0" => { | |
| 1089 | self.zoom((self.display_scale * 96.0 / 72.0) / self.viewport.scale); | |
| 1090 | return self.changed(); | |
| 1091 | } | |
| 1092 | _ => return Ok(()), | |
| 628 | if character.eq_ignore_ascii_case("k") { | |
| 629 | return self.resolve_conflict(true); | |
| 1093 | 630 | } |
| 1094 | } else { | |
| 1095 | let movement = match key { | |
| 1096 | Key::Named(NamedKey::ArrowLeft) => Some(if command { | |
| 1097 | Movement::LineStart | |
| 1098 | } else if option { | |
| 1099 | Movement::WordLeft | |
| 1100 | } else { | |
| 1101 | Movement::Left | |
| 1102 | }), | |
| 1103 | Key::Named(NamedKey::ArrowRight) => Some(if command { | |
| 1104 | Movement::LineEnd | |
| 1105 | } else if option { | |
| 1106 | Movement::WordRight | |
| 1107 | } else { | |
| 1108 | Movement::Right | |
| 1109 | }), | |
| 1110 | Key::Named(NamedKey::ArrowUp) => Some(if command { | |
| 1111 | Movement::DocumentStart | |
| 1112 | } else if option { | |
| 1113 | Movement::ParagraphStart | |
| 1114 | } else { | |
| 1115 | Movement::Up | |
| 1116 | }), | |
| 1117 | Key::Named(NamedKey::ArrowDown) => Some(if command { | |
| 1118 | Movement::DocumentEnd | |
| 1119 | } else if option { | |
| 1120 | Movement::ParagraphEnd | |
| 1121 | } else { | |
| 1122 | Movement::Down | |
| 1123 | }), | |
| 1124 | Key::Named(NamedKey::Home) if shift => Some(Movement::DocumentStart), | |
| 1125 | Key::Named(NamedKey::End) if shift => Some(Movement::DocumentEnd), | |
| 1126 | Key::Character(key) if self.modifiers.control_key() && !option => { | |
| 1127 | match key.as_str() { | |
| 1128 | "a" => Some(Movement::LineStart), | |
| 1129 | "e" => Some(Movement::LineEnd), | |
| 1130 | "b" => Some(Movement::Left), | |
| 1131 | "f" => Some(Movement::Right), | |
| 1132 | "p" => Some(Movement::Up), | |
| 1133 | "n" => Some(Movement::Down), | |
| 1134 | _ => None, | |
| 1135 | } | |
| 1136 | } | |
| 1137 | _ => None, | |
| 1138 | }; | |
| 1139 | if let Some(movement) = movement { | |
| 1140 | self.editor | |
| 1141 | .move_selection(&mut self.engine, movement, shift)?; | |
| 1142 | } else if self.editor.marked_range().is_none() { | |
| 1143 | match key { | |
| 1144 | Key::Named(NamedKey::Backspace) if command || option => { | |
| 1145 | self.editor.delete_to( | |
| 1146 | &mut self.engine, | |
| 1147 | if command { | |
| 1148 | Movement::LineStart | |
| 1149 | } else { | |
| 1150 | Movement::WordLeft | |
| 1151 | }, | |
| 1152 | )?; | |
| 1153 | } | |
| 1154 | Key::Named(NamedKey::Delete) if command || option => { | |
| 1155 | self.editor.delete_to( | |
| 1156 | &mut self.engine, | |
| 1157 | if command { | |
| 1158 | Movement::LineEnd | |
| 1159 | } else { | |
| 1160 | Movement::WordRight | |
| 1161 | }, | |
| 1162 | )?; | |
| 1163 | } | |
| 1164 | Key::Named(NamedKey::Backspace) => { | |
| 1165 | self.editor.delete(&mut self.engine, true)?; | |
| 1166 | } | |
| 1167 | Key::Named(NamedKey::Delete) => { | |
| 1168 | self.editor.delete(&mut self.engine, false)?; | |
| 1169 | } | |
| 1170 | Key::Named(NamedKey::Home | NamedKey::End) => { | |
| 1171 | let limits = self.scroll(); | |
| 1172 | self.viewport.origin[1] = -if key == &Key::Named(NamedKey::Home) { | |
| 1173 | limits.min[1] | |
| 1174 | } else { | |
| 1175 | limits.max[1] | |
| 1176 | }; | |
| 1177 | return self.changed(); | |
| 1178 | } | |
| 1179 | Key::Character(key) if self.modifiers.control_key() && !option => { | |
| 1180 | match key.as_str() { | |
| 1181 | "h" => { | |
| 1182 | self.editor.delete(&mut self.engine, true)?; | |
| 1183 | } | |
| 1184 | "d" => { | |
| 1185 | self.editor.delete(&mut self.engine, false)?; | |
| 1186 | } | |
| 1187 | "k" if !self | |
| 1188 | .editor | |
| 1189 | .delete_to(&mut self.engine, Movement::LineEnd)? => | |
| 1190 | { | |
| 1191 | self.editor.delete(&mut self.engine, false)?; | |
| 1192 | } | |
| 1193 | _ => {} | |
| 1194 | } | |
| 1195 | } | |
| 1196 | Key::Named(NamedKey::Enter) => { | |
| 1197 | self.editor.enter(&mut self.engine, shift)?; | |
| 1198 | } | |
| 1199 | Key::Named(NamedKey::Tab) => { | |
| 1200 | self.editor.tab(&mut self.engine, shift)?; | |
| 1201 | } | |
| 1202 | _ if !command && !self.modifiers.control_key() => { | |
| 1203 | if let Some(text) = text | |
| 1204 | .filter(|text| !text.is_empty() && !text.chars().any(char::is_control)) | |
| 1205 | { | |
| 1206 | self.editor.insert(&mut self.engine, text)?; | |
| 1207 | } | |
| 1208 | } | |
| 1209 | _ => {} | |
| 1210 | } | |
| 1211 | } else if key == &Key::Named(NamedKey::Escape) { | |
| 1212 | self.editor.cancel_composition(&mut self.engine)?; | |
| 631 | if character.eq_ignore_ascii_case("t") { | |
| 632 | return self.resolve_conflict(false); | |
| 1213 | 633 | } |
| 1214 | 634 | } |
| 1215 | self.reveal_focus()?; | |
| 1216 | self.changed() | |
| 635 | let response = self.view.key(&page_key(key), text)?; | |
| 636 | self.respond(response) | |
| 1217 | 637 | } |
| 1218 | 638 | } |
| 1219 | 639 | |
| 1220 | 640 | impl App { |
| 1221 | 641 | fn close(&self, event_loop: &ActiveEventLoop) { |
| 1222 | 642 | if self.state.as_ref().is_none_or(|state| { |
| 643 | let editor = &state.view.editor; | |
| 1223 | 644 | state.session.is_some() |
| 1224 | || state | |
| 1225 | .editor | |
| 1226 | .caret_outline() | |
| 1227 | .is_none_or(TextOutline::is_empty) | |
| 1228 | && state.initial_date == state.editor.date().map(|date| date.timestamp()) | |
| 645 | || editor.caret_outline().is_none_or(TextOutline::is_empty) | |
| 646 | && state.initial_date == editor.date().map(|date| date.timestamp()) | |
| 1229 | 647 | && state |
| 1230 | 648 | .initial_layouts |
| 1231 | 649 | .iter() |
| 1232 | 650 | .map(|(id, layout)| (*id, layout)) |
| 1233 | .eq(state.editor.object_layouts()) | |
| 651 | .eq(editor.object_layouts()) | |
| 1234 | 652 | && state |
| 1235 | 653 | .initial |
| 1236 | 654 | .iter() |
| 1237 | 655 | .map(|(id, document)| (id, document)) |
| 1238 | .eq(state | |
| 1239 | .editor | |
| 656 | .eq(editor | |
| 1240 | 657 | .outlines() |
| 1241 | 658 | .iter() |
| 1242 | 659 | .map(|outline| (&outline.id, outline.document()))) |
| ... | ... | @@ -1251,16 +668,11 @@ impl ApplicationHandler<UserEvent> for App { |
| 1251 | 668 | fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) { |
| 1252 | 669 | let event = match event { |
| 1253 | 670 | UserEvent::InsertText(text) => { |
| 1254 | if let Some(state) = &mut self.state | |
| 1255 | && state.object_focus.is_none() | |
| 1256 | && !text.is_empty() | |
| 1257 | && !text.chars().any(char::is_control) | |
| 1258 | { | |
| 1259 | let result = (|| -> Result<(), Box<dyn Error>> { | |
| 1260 | state.editor.commit_text(&mut state.engine, text)?; | |
| 1261 | state.reveal_focus()?; | |
| 1262 | state.changed() | |
| 1263 | })(); | |
| 671 | if let Some(state) = &mut self.state { | |
| 672 | let result = state | |
| 673 | .view | |
| 674 | .insert_text(text) | |
| 675 | .and_then(|response| state.respond(response)); | |
| 1264 | 676 | if let Err(error) = result { |
| 1265 | 677 | eprintln!("{error}"); |
| 1266 | 678 | } |
| ... | ... | @@ -1287,7 +699,7 @@ impl ApplicationHandler<UserEvent> for App { |
| 1287 | 699 | if event.window_id != state.window.id() { |
| 1288 | 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 | 703 | let result = match event.window_event { |
| 1292 | 704 | accesskit_winit::WindowEvent::InitialTreeRequested => state.update_accessibility(), |
| 1293 | 705 | accesskit_winit::WindowEvent::ActionRequested(request) => state.access_action(request), |
| ... | ... | @@ -1296,7 +708,7 @@ impl ApplicationHandler<UserEvent> for App { |
| 1296 | 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 | 712 | macos::clear_marked_text(&state.window); |
| 1301 | 713 | } |
| 1302 | 714 | if let Err(error) = result { |
| ... | ... | @@ -1345,333 +757,82 @@ impl ApplicationHandler<UserEvent> for App { |
| 1345 | 757 | return; |
| 1346 | 758 | }; |
| 1347 | 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 | 761 | let result = (|| -> Result<(), Box<dyn Error>> { |
| 1350 | match event { | |
| 1351 | WindowEvent::RedrawRequested => state.draw()?, | |
| 762 | let response = match event { | |
| 763 | WindowEvent::RedrawRequested => return state.draw(), | |
| 1352 | 764 | WindowEvent::Resized(size) => { |
| 1353 | state.viewport.size = [size.width, size.height]; | |
| 1354 | 765 | if size.width > 0 && size.height > 0 { |
| 1355 | 766 | state.config.width = size.width; |
| 1356 | 767 | state.config.height = size.height; |
| 1357 | 768 | state |
| 1358 | 769 | .surface |
| 1359 | 770 | .configure(&state.renderer.device, &state.config); |
| 1360 | state.changed()?; | |
| 1361 | // Present inside AppKit's resize transaction; a redraw on the next turn | |
| 1362 | // lets the window show the previous frame at the new size. | |
| 771 | } | |
| 772 | let response = state.view.resized([size.width, size.height])?; | |
| 773 | let changed = response.changed; | |
| 774 | state.respond(response)?; | |
| 775 | // Present inside AppKit's resize transaction; a redraw on the next turn | |
| 776 | // lets the window show the previous frame at the new size. | |
| 777 | if changed { | |
| 1363 | 778 | state.draw()?; |
| 1364 | 779 | } |
| 780 | return Ok(()); | |
| 1365 | 781 | } |
| 1366 | 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 | 783 | state.renderer.clear_glyph_cache(); |
| 1374 | state.changed()?; | |
| 1375 | } | |
| 1376 | WindowEvent::Focused(focused) => { | |
| 1377 | state.focused = focused; | |
| 1378 | if !focused { | |
| 1379 | state.drag = None; | |
| 1380 | } | |
| 1381 | state.changed()?; | |
| 784 | state.view.scale_factor_changed(scale_factor as f32)? | |
| 1382 | 785 | } |
| 786 | WindowEvent::Focused(focused) => state.view.focus_changed(focused)?, | |
| 1383 | 787 | WindowEvent::Occluded(occluded) => { |
| 1384 | 788 | trace_input(&("Window occluded", occluded)); |
| 1385 | 789 | state.occluded = occluded; |
| 1386 | if !occluded { | |
| 1387 | state.changed()?; | |
| 790 | if occluded { | |
| 791 | return Ok(()); | |
| 1388 | 792 | } |
| 1389 | } | |
| 1390 | WindowEvent::ModifiersChanged(modifiers) => { | |
| 1391 | state.modifiers = modifiers.state(); | |
| 1392 | if matches!(state.drag, Some(Drag::Outline { .. })) { | |
| 1393 | state.changed()?; | |
| 1394 | } | |
| 1395 | } | |
| 1396 | WindowEvent::CursorLeft { .. } => { | |
| 1397 | state.pointer_inside = false; | |
| 1398 | state.window.request_redraw(); | |
| 1399 | } | |
| 1400 | WindowEvent::CursorMoved { position, .. } => { | |
| 1401 | state.pointer_inside = true; | |
| 1402 | state.pointer = [position.x as f32, position.y as f32]; | |
| 1403 | let hit = state.hit_test(state.viewport.document_point(state.pointer)); | |
| 1404 | let scrollbar = state | |
| 1405 | .scroll() | |
| 1406 | .hit_test(state.viewport, state.display_scale, state.pointer) | |
| 1407 | .is_some(); | |
| 1408 | state.window.set_cursor(match (&state.drag, hit) { | |
| 1409 | (Some(Drag::Scrollbar { .. }), _) => winit::window::CursorIcon::Default, | |
| 1410 | (None, _) if scrollbar => winit::window::CursorIcon::Default, | |
| 1411 | (Some(Drag::Image { handle, .. }), _) => handle_cursor(*handle), | |
| 1412 | (None, Some(Hit::Image { handle, .. })) => handle_cursor(handle), | |
| 1413 | (Some(Drag::Resize { .. }), _) | (None, Some(Hit::Resize { .. })) => { | |
| 1414 | winit::window::CursorIcon::EwResize | |
| 1415 | } | |
| 1416 | (Some(Drag::Outline { .. }), _) | (None, Some(Hit::Handle { .. })) => { | |
| 1417 | winit::window::CursorIcon::Move | |
| 1418 | } | |
| 1419 | (None, Some(Hit::Date(_))) => winit::window::CursorIcon::Pointer, | |
| 1420 | (None, Some(Hit::ReadOnly(_))) => winit::window::CursorIcon::Default, | |
| 1421 | _ => winit::window::CursorIcon::Text, | |
| 1422 | }); | |
| 1423 | match &mut state.drag { | |
| 1424 | Some(Drag::Scrollbar { axis, grab }) => { | |
| 1425 | let (axis, grab) = (*axis, *grab); | |
| 1426 | state.scroll().drag( | |
| 1427 | &mut state.viewport, | |
| 1428 | state.display_scale, | |
| 1429 | axis, | |
| 1430 | state.pointer[axis], | |
| 1431 | grab, | |
| 1432 | ); | |
| 1433 | state.changed()?; | |
| 1434 | } | |
| 1435 | Some(Drag::Text { anchor, unit }) => { | |
| 1436 | let point = state.viewport.document_point(state.pointer); | |
| 1437 | let origin = state.editor.active_outline().origin(); | |
| 1438 | let target = state.editor.selection_at( | |
| 1439 | point[0] - origin[0], | |
| 1440 | point[1] - origin[1], | |
| 1441 | *unit, | |
| 1442 | )?; | |
| 1443 | state | |
| 1444 | .editor | |
| 1445 | .select(drag_selection(*anchor, target, *unit))?; | |
| 1446 | state.changed()?; | |
| 1447 | } | |
| 1448 | Some( | |
| 1449 | Drag::Outline { pending_press, .. } | Drag::Image { pending_press, .. }, | |
| 1450 | ) => { | |
| 1451 | if pending_press.is_some_and(|press| { | |
| 1452 | (0..2).any(|axis| { | |
| 1453 | (state.pointer[axis] - press[axis]).abs() | |
| 1454 | > 2.0 * state.display_scale | |
| 1455 | }) | |
| 1456 | }) { | |
| 1457 | *pending_press = None; | |
| 1458 | } | |
| 1459 | state.changed()?; | |
| 1460 | } | |
| 1461 | Some(Drag::Resize { outline, grab }) => { | |
| 1462 | let point = state.viewport.document_point(state.pointer); | |
| 1463 | let width = | |
| 1464 | (point[0] - state.editor.active_outline().origin()[0] - *grab) | |
| 1465 | .max(36.0); | |
| 1466 | if outline.is_some() | |
| 1467 | || (width - state.editor.active_outline().bounds().width() as f32) | |
| 1468 | .abs() | |
| 1469 | * state.viewport.scale | |
| 1470 | > 2.0 * state.display_scale | |
| 1471 | { | |
| 1472 | *outline = Some(Box::new( | |
| 1473 | state.editor.preview_resize(&mut state.engine, width)?, | |
| 1474 | )); | |
| 1475 | state.changed()?; | |
| 1476 | } | |
| 1477 | } | |
| 1478 | None => state.window.request_redraw(), | |
| 793 | Response { | |
| 794 | changed: true, | |
| 795 | redraw: true, | |
| 796 | request: None, | |
| 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 | 806 | WindowEvent::MouseInput { |
| 1482 | state: button_state, | |
| 807 | state: ElementState::Pressed, | |
| 1483 | 808 | button: MouseButton::Left, |
| 1484 | 809 | .. |
| 1485 | } => { | |
| 1486 | let point = state.viewport.document_point(state.pointer); | |
| 1487 | if button_state == ElementState::Pressed { | |
| 1488 | let now = Instant::now(); | |
| 1489 | let count = state | |
| 1490 | .last_click | |
| 1491 | .filter(|(time, point, _)| { | |
| 1492 | now.duration_since(*time) <= macos::double_click_interval() | |
| 1493 | && (0..2).all(|axis| { | |
| 1494 | (point[axis] - state.pointer[axis]).abs() | |
| 1495 | <= 4.0 * state.display_scale | |
| 1496 | }) | |
| 1497 | }) | |
| 1498 | .map_or(1, |(_, _, count)| (count % 3) + 1); | |
| 1499 | state.last_click = Some((now, state.pointer, count)); | |
| 1500 | let scrollbar = state | |
| 1501 | .scroll() | |
| 1502 | .hit_test(state.viewport, state.display_scale, state.pointer) | |
| 1503 | .map(|(axis, grab)| Drag::Scrollbar { axis, grab }); | |
| 1504 | if scrollbar.is_some() { | |
| 1505 | state.drag = scrollbar; | |
| 1506 | } else { | |
| 1507 | match state.hit_test(point) { | |
| 1508 | Some(Hit::Date(field)) => state.edit_date(field)?, | |
| 1509 | Some(Hit::ReadOnly(index)) => { | |
| 1510 | state.set_object_focus(Some(ObjectFocus::ReadOnly(index))) | |
| 1511 | } | |
| 1512 | Some(Hit::Image { id, handle }) => { | |
| 1513 | state.set_object_focus(Some(ObjectFocus::Image(id))); | |
| 1514 | state.drag = Some(Drag::Image { | |
| 1515 | id, | |
| 1516 | handle, | |
| 1517 | press: point, | |
| 1518 | pending_press: Some(state.pointer), | |
| 1519 | }); | |
| 1520 | } | |
| 1521 | Some(Hit::Handle { id, grab }) => { | |
| 1522 | state.set_object_focus(None); | |
| 1523 | state.editor.focus_outline(id)?; | |
| 1524 | state.drag = Some(Drag::Outline { | |
| 1525 | id, | |
| 1526 | grab, | |
| 1527 | pending_press: Some(state.pointer), | |
| 1528 | }); | |
| 1529 | } | |
| 1530 | Some(Hit::Resize { id, grab }) => { | |
| 1531 | state.set_object_focus(None); | |
| 1532 | state.editor.focus_outline(id)?; | |
| 1533 | state.drag = Some(Drag::Resize { | |
| 1534 | outline: None, | |
| 1535 | grab, | |
| 1536 | }); | |
| 1537 | } | |
| 1538 | Some(Hit::Text { id, point }) => { | |
| 1539 | let extend = state.modifiers.shift_key() | |
| 1540 | && state.object_focus.is_none() | |
| 1541 | && id == state.editor.active_outline().id; | |
| 1542 | state.set_object_focus(None); | |
| 1543 | state.editor.focus_outline(id)?; | |
| 1544 | let previous = state.editor.selection(); | |
| 1545 | state.editor.select_below(&mut state.engine, id, point)?; | |
| 1546 | let unit = match count { | |
| 1547 | 2 => SelectionUnit::Word, | |
| 1548 | 3 => SelectionUnit::Paragraph, | |
| 1549 | _ => SelectionUnit::Grapheme, | |
| 1550 | }; | |
| 1551 | let selection = | |
| 1552 | state.editor.selection_at(point[0], point[1], unit)?; | |
| 1553 | let selection = if extend { | |
| 1554 | Selection { | |
| 1555 | positions: [ | |
| 1556 | previous.positions[0], | |
| 1557 | selection.positions[1], | |
| 1558 | ], | |
| 1559 | affinities: [ | |
| 1560 | previous.affinities[0], | |
| 1561 | selection.affinities[1], | |
| 1562 | ], | |
| 1563 | } | |
| 1564 | } else { | |
| 1565 | selection | |
| 1566 | }; | |
| 1567 | state.editor.select(selection)?; | |
| 1568 | state.drag = Some(Drag::Text { | |
| 1569 | anchor: selection, | |
| 1570 | unit, | |
| 1571 | }); | |
| 1572 | } | |
| 1573 | None => { | |
| 1574 | let position = [ | |
| 1575 | point[0], | |
| 1576 | point[1] - 7.0 * state.display_scale / state.viewport.scale, | |
| 1577 | ]; | |
| 1578 | let position = if state.modifiers.alt_key() { | |
| 1579 | position | |
| 1580 | } else { | |
| 1581 | snap_to_grid(position, state.editor.margin_origin()) | |
| 1582 | }; | |
| 1583 | state.editor.place_caret( | |
| 1584 | &mut state.engine, | |
| 1585 | position, | |
| 1586 | DEFAULT_OUTLINE_WIDTH, | |
| 1587 | )?; | |
| 1588 | state.set_object_focus(None); | |
| 1589 | state.drag = Some(Drag::Text { | |
| 1590 | anchor: state.editor.selection(), | |
| 1591 | unit: SelectionUnit::Grapheme, | |
| 1592 | }); | |
| 1593 | } | |
| 1594 | } | |
| 1595 | } | |
| 1596 | } else { | |
| 1597 | let preview = state.preview(); | |
| 1598 | if let Some((id, origin, size)) = state.image_preview() { | |
| 1599 | state | |
| 1600 | .editor | |
| 1601 | .place_image(&mut state.engine, id, origin, size)?; | |
| 1602 | } | |
| 1603 | if let Some(Drag::Resize { | |
| 1604 | outline: Some(outline), | |
| 1605 | .. | |
| 1606 | }) = state.drag.take() | |
| 1607 | { | |
| 1608 | state | |
| 1609 | .editor | |
| 1610 | .resize(&mut state.engine, outline.bounds().width() as f32)?; | |
| 1611 | } | |
| 1612 | if let Some((id, origin)) = preview { | |
| 1613 | state.editor.move_outline(id, origin)?; | |
| 1614 | } | |
| 1615 | } | |
| 1616 | state.changed()?; | |
| 1617 | } | |
| 810 | } => state.view.pointer_pressed(Instant::now())?, | |
| 811 | WindowEvent::MouseInput { | |
| 812 | state: ElementState::Released, | |
| 813 | button: MouseButton::Left, | |
| 814 | .. | |
| 815 | } => state.view.pointer_released()?, | |
| 1618 | 816 | WindowEvent::MouseWheel { delta, .. } => { |
| 1619 | 817 | let dpr = state.window.scale_factor() as f32; |
| 1620 | let delta = match delta { | |
| 818 | state.view.wheel(match delta { | |
| 1621 | 819 | MouseScrollDelta::LineDelta(x, y) => [x * 32.0 * dpr, y * 32.0 * dpr], |
| 1622 | 820 | MouseScrollDelta::PixelDelta(p) => [p.x as f32, p.y as f32], |
| 1623 | }; | |
| 1624 | if state.modifiers.super_key() { | |
| 1625 | state.zoom((delta[1] * 0.005).exp()); | |
| 1626 | } else { | |
| 1627 | state.viewport.origin[0] += delta[0]; | |
| 1628 | state.viewport.origin[1] += delta[1]; | |
| 1629 | } | |
| 1630 | state.changed()?; | |
| 821 | })? | |
| 1631 | 822 | } |
| 1632 | 823 | WindowEvent::KeyboardInput { event, .. } |
| 1633 | 824 | if event.state == ElementState::Pressed => |
| 1634 | 825 | { |
| 1635 | state.key(&event.logical_key, event.text.as_deref())?; | |
| 1636 | } | |
| 1637 | WindowEvent::Ime(_) if state.object_focus.is_some() => {} | |
| 1638 | WindowEvent::Ime(Ime::Preedit(text, cursor)) => { | |
| 1639 | if text.is_empty() { | |
| 1640 | state.editor.cancel_composition(&mut state.engine)?; | |
| 1641 | } else { | |
| 1642 | let (start, end) = cursor.unwrap_or((text.len(), text.len())); | |
| 1643 | let start: u32 = text | |
| 1644 | .get(..start) | |
| 1645 | .ok_or("IME range splits UTF-8")? | |
| 1646 | .encode_utf16() | |
| 1647 | .count() | |
| 1648 | .try_into()?; | |
| 1649 | let end: u32 = text | |
| 1650 | .get(..end) | |
| 1651 | .ok_or("IME range splits UTF-8")? | |
| 1652 | .encode_utf16() | |
| 1653 | .count() | |
| 1654 | .try_into()?; | |
| 1655 | state.editor.compose(&mut state.engine, text, start..end)?; | |
| 1656 | } | |
| 1657 | state.reveal_focus()?; | |
| 1658 | state.changed()?; | |
| 1659 | } | |
| 1660 | WindowEvent::Ime(Ime::Commit(text)) => { | |
| 1661 | state.editor.commit_text(&mut state.engine, text)?; | |
| 1662 | state.reveal_focus()?; | |
| 1663 | state.changed()?; | |
| 1664 | } | |
| 1665 | WindowEvent::Ime(Ime::Disabled) => { | |
| 1666 | state.editor.cancel_composition(&mut state.engine)?; | |
| 1667 | state.reveal_focus()?; | |
| 1668 | state.changed()?; | |
| 826 | return state.key(&event.logical_key, event.text.as_deref()); | |
| 1669 | 827 | } |
| 1670 | _ => {} | |
| 1671 | } | |
| 1672 | Ok(()) | |
| 828 | WindowEvent::Ime(Ime::Preedit(text, cursor)) => state.view.compose(text, cursor)?, | |
| 829 | WindowEvent::Ime(Ime::Commit(text)) => state.view.commit_text(text)?, | |
| 830 | WindowEvent::Ime(Ime::Disabled) => state.view.cancel_composition()?, | |
| 831 | _ => return Ok(()), | |
| 832 | }; | |
| 833 | state.respond(response) | |
| 1673 | 834 | })(); |
| 1674 | if was_marked && state.editor.marked_range().is_none() { | |
| 835 | if was_marked && state.view.editor.marked_range().is_none() { | |
| 1675 | 836 | macos::clear_marked_text(&state.window); |
| 1676 | 837 | } |
| 1677 | 838 | if let Err(error) = result { |
| ... | ... | @@ -1683,617 +844,18 @@ impl ApplicationHandler<UserEvent> for App { |
| 1683 | 844 | let Some(state) = &mut self.state else { |
| 1684 | 845 | return; |
| 1685 | 846 | }; |
| 1686 | let [anchor, focus] = state.editor.selection().positions; | |
| 1687 | if state.focused && !state.occluded && state.object_focus.is_none() && anchor == focus { | |
| 1688 | let now = Instant::now(); | |
| 1689 | if now >= state.blink_at { | |
| 1690 | state.caret = !state.caret; | |
| 1691 | state.blink_at = now + Duration::from_millis(500); | |
| 1692 | state.window.request_redraw(); | |
| 1693 | } | |
| 1694 | event_loop.set_control_flow(ControlFlow::WaitUntil(state.blink_at)); | |
| 847 | let (repaint, next) = if state.occluded { | |
| 848 | (false, None) | |
| 1695 | 849 | } else { |
| 1696 | event_loop.set_control_flow(ControlFlow::Wait); | |
| 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(), | |
| 850 | state.view.blink(Instant::now()) | |
| 1871 | 851 | }; |
| 1872 | if (object_focus.is_none() && outline.id == editor.active_outline().id) | |
| 1873 | || matches!(preview, Some(PointerFeedback::Hover(id) | PointerFeedback::Move(id, _)) if id == outline.id) | |
| 1874 | || matches!(preview, Some(PointerFeedback::Resize(resized)) if resized.id == outline.id) | |
| 1875 | { | |
| 1876 | append_outline_chrome( | |
| 1877 | outline, | |
| 1878 | [origin[0] + offset[0], origin[1] + offset[1]], | |
| 1879 | display_scale / scale, | |
| 1880 | primitives, | |
| 1881 | ); | |
| 1882 | } | |
| 1883 | append_outline( | |
| 1884 | (object_focus.is_none() | |
| 1885 | && outline.id == editor.active_outline().id | |
| 1886 | && !matches!(preview, Some(PointerFeedback::Resize(_)))) | |
| 1887 | .then_some(editor), | |
| 1888 | outline, | |
| 1889 | [origin[0] + offset[0], origin[1] + offset[1]], | |
| 1890 | show_caret, | |
| 1891 | scale, | |
| 1892 | display_scale / scale, | |
| 1893 | primitives, | |
| 1894 | )?; | |
| 1895 | if let Some((scene, _)) = scene { | |
| 1896 | let moving = match preview { | |
| 1897 | Some(PointerFeedback::Image(id, origin, size)) => { | |
| 1898 | let [x0, y0, x1, y1] = image_rect(origin, size); | |
| 1899 | Some(( | |
| 1900 | id, | |
| 1901 | [ | |
| 1902 | x0 + offset[0], | |
| 1903 | y0 + offset[1], | |
| 1904 | x1 + offset[0], | |
| 1905 | y1 + offset[1], | |
| 1906 | ], | |
| 1907 | )) | |
| 1908 | } | |
| 1909 | _ => None, | |
| 1910 | }; | |
| 1911 | scene.append_outline_objects( | |
| 1912 | outline.shaped(), | |
| 1913 | [origin[0] + offset[0], origin[1] + offset[1]], | |
| 1914 | moving, | |
| 1915 | primitives, | |
| 1916 | ); | |
| 1917 | } | |
| 1918 | Ok::<_, Box<dyn Error>>(()) | |
| 1919 | }; | |
| 1920 | if let Some((scene, origin)) = scene { | |
| 1921 | let moving = match preview { | |
| 1922 | Some(PointerFeedback::Image(id, origin, size)) => Some((id, image_rect(origin, size))), | |
| 1923 | _ => None, | |
| 1924 | }; | |
| 1925 | scene.append_primitives_with(&mut primitives, *origin, Some(editor), moving, &paint)?; | |
| 1926 | } | |
| 1927 | for outline in editor | |
| 1928 | .visible_outlines() | |
| 1929 | .filter(|outline| scene.is_none() || !editor.has_page_outline(outline.id)) | |
| 1930 | { | |
| 1931 | paint(outline.id, [0.0; 2], &mut primitives)?; | |
| 1932 | } | |
| 1933 | if object_focus.is_none() | |
| 1934 | && let Some(outline) = editor.caret_outline() | |
| 1935 | { | |
| 1936 | append_outline( | |
| 1937 | Some(editor), | |
| 1938 | outline, | |
| 1939 | outline.origin(), | |
| 1940 | show_caret, | |
| 1941 | scale, | |
| 1942 | display_scale / scale, | |
| 1943 | &mut primitives, | |
| 1944 | )?; | |
| 1945 | } | |
| 1946 | if let Some(ObjectFocus::Image(id)) = object_focus { | |
| 1947 | let (origin, size) = match preview { | |
| 1948 | Some(PointerFeedback::Image(moving, origin, size)) if moving == id => (origin, size), | |
| 1949 | _ => editor | |
| 1950 | .image_placement(id) | |
| 1951 | .ok_or("The selected picture is missing.")?, | |
| 1952 | }; | |
| 1953 | append_image_chrome( | |
| 1954 | image_rect(origin, size), | |
| 1955 | display_scale / scale, | |
| 1956 | &mut primitives, | |
| 1957 | ); | |
| 1958 | } | |
| 1959 | if let Some(ObjectFocus::ReadOnly(index)) = object_focus { | |
| 1960 | let (scene, offset) = scene.unwrap(); | |
| 1961 | let [x0, y0, x1, y1] = scene.read_only(Some(editor)).nth(index).unwrap().rect(); | |
| 1962 | let [x0, y0, x1, y1] = [ | |
| 1963 | x0 + offset[0], | |
| 1964 | y0 + offset[1], | |
| 1965 | x1 + offset[0], | |
| 1966 | y1 + offset[1], | |
| 1967 | ]; | |
| 1968 | let border = 2.0 / scale; | |
| 1969 | for rect in [ | |
| 1970 | [x0, y0, x1, y0 + border], | |
| 1971 | [x0, y1 - border, x1, y1], | |
| 1972 | [x0, y0, x0 + border, y1], | |
| 1973 | [x1 - border, y0, x1, y1], | |
| 1974 | ] { | |
| 1975 | primitives.push(Primitive::Rect { | |
| 1976 | rect, | |
| 1977 | color: [0.25, 0.45, 0.7, 1.0], | |
| 1978 | }); | |
| 1979 | } | |
| 1980 | } | |
| 1981 | Ok(primitives) | |
| 1982 | } | |
| 1983 | ||
| 1984 | /// Native picture handles sit on a selection border drawn 5 px outside the picture, named | |
| 1985 | /// by their direction from its center. | |
| 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 | }); | |
| 852 | if repaint { | |
| 853 | state.window.request_redraw(); | |
| 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 | 859 | fn main() -> Result<(), Box<dyn Error>> { |
| 2298 | 860 | let mut args = std::env::args_os().skip(1); |
| 2299 | 861 | let mut positional = Vec::new(); |
| ... | ... | @@ -2407,1410 +969,3 @@ fn main() -> Result<(), Box<dyn Error>> { |
| 2407 | 969 | event_loop.run_app(&mut app)?; |
| 2408 | 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 | 9 | ```sh |
| 10 | 10 | python3 tools/canvas/build_macos.py |
| 11 | 11 | "target/Snowbound.app/Contents/MacOS/Snowbound" |
| 12 | cargo test -p canvas | |
| 12 | cargo test -p canvas --features interaction | |
| 13 | 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 | 32 | ↓ |
| 33 | 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 | 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 | 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 | 104 | ```sh |
| 102 | cargo test -p snowbound | |
| 105 | cargo test -p canvas --features interaction accessibility | |
| 103 | 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 | 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 | 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 | 139 | ## Unsupported page content |
| ... | ... | @@ -222,8 +225,8 @@ Full native captures, candidate font binaries/licenses and probe outputs remain |
| 222 | 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 | 227 | ```sh |
| 225 | cargo test --release -p snowbound 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 | |
| 228 | cargo test --release -p canvas --features interaction 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 | 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. |