1//! Text editing shared by the page and interface text: keys, each platform's editing chords
2//! and caret, click counting and caret movement within one parley layout.
3
4use parley::{
5 Affinity, Brush, Layout,
6 editing::{Cursor, Selection},
7};
8use std::{collections::BTreeSet, time::Duration};
9use web_time::Instant;
10
11#[derive(Clone, Debug, PartialEq)]
12pub enum Key {
13 Named(NamedKey),
14 Character(String),
15}
16
17#[derive(Clone, Copy, Debug, PartialEq)]
18pub enum NamedKey {
19 Escape,
20 Tab,
21 Space,
22 Enter,
23 Backspace,
24 Delete,
25 ArrowLeft,
26 ArrowRight,
27 ArrowUp,
28 ArrowDown,
29 Home,
30 End,
31 PageUp,
32 PageDown,
33 F7,
34 F11,
35 /// Shift, Control, Option or Command pressed alone.
36 Modifier,
37 Other,
38}
39
40#[derive(Clone, Copy, Debug, Default, PartialEq)]
41pub struct Modifiers {
42 pub shift: bool,
43 pub control: bool,
44 /// Option, or Alt.
45 pub option: bool,
46 /// The shortcut modifier: Command on macOS, Control elsewhere, where it comes with
47 /// `control`.
48 pub command: bool,
49}
50
51#[derive(Clone, Copy, Debug, PartialEq)]
52pub enum Movement {
53 Left,
54 Right,
55 Up,
56 Down,
57 WordLeft,
58 WordRight,
59 LineStart,
60 LineEnd,
61 ParagraphStart,
62 ParagraphEnd,
63 DocumentStart,
64 DocumentEnd,
65}
66
67#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
68pub enum SelectionUnit {
69 #[default]
70 Grapheme,
71 Word,
72 Paragraph,
73}
74
75/// What a key does to text under the platform's conventions.
76#[derive(Clone, Copy, Debug, PartialEq)]
77pub enum Command {
78 /// Moves the focus, extending the selection with Shift.
79 Move(Movement),
80 /// Deletes the selection, or the grapheme before or after the caret.
81 Delete { backward: bool },
82 /// Deletes the selection, or from the caret to where the movement would take it.
83 DeleteTo(Movement),
84 /// Control-K: deletes to the line's end, or the break after it when already there.
85 Kill,
86 /// Scrolls the view a page, leaving the caret, as Page Up and Page Down do on macOS.
87 ScrollPage { up: bool },
88 /// Moves the caret a page and the view with it, extending the selection with Shift.
89 MovePage { up: bool },
90 /// Moves the selected paragraphs past their sibling above or below: OneNote's Alt+Shift+Up
91 /// and Down, Option-Command on macOS as OneNote for Mac binds them, and VS Code's Alt or
92 /// Option with Up and Down.
93 MoveParagraphs { up: bool },
94}
95
96impl Command {
97 pub fn from_key(key: &Key, modifiers: Modifiers) -> Option<Self> {
98 Platform::CURRENT.command(key, modifiers)
99 }
100}
101
102/// A desktop's text editing conventions: its chords, and how its caret looks and blinks.
103#[derive(Clone, Copy, Debug, PartialEq, Eq)]
104pub enum Platform {
105 MacOs,
106 /// GTK, as GNOME and most Linux desktops edit text.
107 Gtk,
108 Windows,
109}
110
111/// A caret's blink, in microseconds: solid for `delay` after it moves, then repeating
112/// `phases` of fading out, off, fading in and on, each fade in `steps` equal steps, until
113/// `timeout` after the move leaves it solid.
114struct Blink {
115 delay: u64,
116 phases: [u64; 4],
117 steps: u64,
118 timeout: Option<u64>,
119}
120
121impl Platform {
122 pub const CURRENT: Platform = if cfg!(target_vendor = "apple") {
123 Platform::MacOs
124 } else if cfg!(windows) {
125 Platform::Windows
126 } else {
127 Platform::Gtk
128 };
129
130 pub fn command(self, key: &Key, modifiers: Modifiers) -> Option<Command> {
131 match self {
132 Platform::MacOs => mac_command(key, modifiers),
133 Platform::Gtk | Platform::Windows => pc_command(key, modifiers),
134 }
135 }
136
137 /// The caret's width in logical pixels: AppKit's insertion indicator, GTK's stem at
138 /// its 0.04 aspect ratio for body text, and Windows' default caret.
139 pub const fn caret_width(self) -> f32 {
140 match self {
141 Platform::MacOs => 2.0,
142 Platform::Gtk | Platform::Windows => 1.0,
143 }
144 }
145
146 fn blink(self) -> Blink {
147 match self {
148 // AppKit's insertion indicator: 350 ms off and 650 ms on, each opening with a
149 // fade in four 37.5 ms steps.
150 Platform::MacOs => Blink {
151 delay: 650_000,
152 phases: [150_000, 200_000, 150_000, 500_000],
153 steps: 4,
154 timeout: None,
155 },
156 // GtkText at the default 1200 ms blink time: half a cycle's pause after a
157 // move, then quarters of on, fading out, off and fading in, stopping after the
158 // default 10 s; its per-frame fade is taken in 60 Hz steps.
159 Platform::Gtk => Blink {
160 delay: 900_000,
161 phases: [300_000; 4],
162 steps: 18,
163 timeout: Some(10_000_000),
164 },
165 // GetCaretBlinkTime's default 530 ms, without fades, until CaretTimeout's 5 s.
166 Platform::Windows => Blink {
167 delay: 530_000,
168 phases: [0, 530_000, 0, 530_000],
169 steps: 1,
170 timeout: Some(5_000_000),
171 },
172 }
173 }
174
175 /// The caret's opacity `since` it last moved, and how long that opacity holds; None
176 /// once it has stopped blinking.
177 pub fn caret_blink(self, since: Duration) -> (f32, Option<Duration>) {
178 let Blink {
179 delay,
180 phases,
181 steps,
182 timeout,
183 } = self.blink();
184 let period: u64 = phases.iter().sum();
185 // The opacity at `time` and when it next may change.
186 let at = |time: u64| -> (f32, u64) {
187 if time < delay {
188 return (1.0, delay);
189 }
190 let mut within = (time - delay) % period;
191 let mut start = time - within;
192 for (phase, length) in phases.into_iter().enumerate() {
193 if within < length {
194 return match phase {
195 1 => (0.0, start + length),
196 3 => (1.0, start + length),
197 _ => {
198 let step = within * steps / length;
199 let shown = step as f32 / steps as f32;
200 (
201 if phase == 0 { 1.0 - shown } else { shown },
202 start + ((step + 1) * length).div_ceil(steps),
203 )
204 }
205 };
206 }
207 within -= length;
208 start += length;
209 }
210 unreachable!("a time within the period falls in a phase")
211 };
212 let now = u64::try_from(since.as_micros()).unwrap_or(u64::MAX);
213 if timeout.is_some_and(|timeout| now >= timeout) {
214 return (1.0, None);
215 }
216 let (opacity, mut until) = at(now);
217 // A settled phase holds into the next one's first step, which shows the same.
218 for _ in 0..2 * (steps + 2) {
219 let (next, end) = at(until);
220 if next != opacity {
221 break;
222 }
223 until = end;
224 }
225 match timeout {
226 Some(timeout) if until >= timeout && opacity == 1.0 => (opacity, None),
227 Some(timeout) if until >= timeout => {
228 (opacity, Some(Duration::from_micros(timeout - now)))
229 }
230 _ => (opacity, Some(Duration::from_micros(until - now))),
231 }
232 }
233
234 /// The caret's colour and selected text's fill with and without keyboard focus as sRGB
235 /// and alpha, for a light or `dark` appearance: AppKit's system colours, libadwaita's
236 /// text colour and translucent accent, and Windows' Highlight made translucent, as
237 /// selected text keeps its colour here.
238 pub fn text_colors(self, dark: bool) -> [([u8; 3], f32); 3] {
239 match (self, dark) {
240 (Platform::MacOs, false) => [
241 ([0x00, 0x7a, 0xff], 1.0),
242 ([0xb3, 0xd7, 0xff], 1.0),
243 ([0xdc, 0xdc, 0xdc], 1.0),
244 ],
245 (Platform::MacOs, true) => [
246 ([0x00, 0x7a, 0xff], 1.0),
247 ([0x3f, 0x63, 0x8b], 1.0),
248 ([0x46, 0x46, 0x46], 1.0),
249 ],
250 (Platform::Gtk, false) => [
251 ([0x00, 0x00, 0x06], 0.8),
252 ([0x35, 0x84, 0xe4], 0.3),
253 ([0x00, 0x00, 0x06], 0.08),
254 ],
255 (Platform::Gtk, true) => [
256 ([0xff, 0xff, 0xff], 1.0),
257 ([0x35, 0x84, 0xe4], 0.3),
258 ([0xff, 0xff, 0xff], 0.1),
259 ],
260 (Platform::Windows, false) => [
261 ([0x00, 0x00, 0x00], 1.0),
262 ([0x00, 0x78, 0xd7], 0.4),
263 ([0x00, 0x00, 0x00], 0.1),
264 ],
265 (Platform::Windows, true) => [
266 ([0xff, 0xff, 0xff], 1.0),
267 ([0x00, 0x78, 0xd7], 0.5),
268 ([0xff, 0xff, 0xff], 0.15),
269 ],
270 }
271 }
272}
273
274/// AppKit's key bindings, including its Emacs chords on Control.
275fn mac_command(key: &Key, modifiers: Modifiers) -> Option<Command> {
276 let Modifiers {
277 shift,
278 control,
279 option,
280 command,
281 } = modifiers;
282 let pick = |plain, with_option, with_command| {
283 Some(Command::Move(if command {
284 with_command
285 } else if option {
286 with_option
287 } else {
288 plain
289 }))
290 };
291 match key {
292 Key::Named(arrow @ (NamedKey::ArrowUp | NamedKey::ArrowDown))
293 if option && !shift && !control =>
294 {
295 Some(Command::MoveParagraphs {
296 up: *arrow == NamedKey::ArrowUp,
297 })
298 }
299 Key::Named(NamedKey::ArrowLeft) => {
300 pick(Movement::Left, Movement::WordLeft, Movement::LineStart)
301 }
302 Key::Named(NamedKey::ArrowRight) => {
303 pick(Movement::Right, Movement::WordRight, Movement::LineEnd)
304 }
305 Key::Named(NamedKey::ArrowUp) => pick(
306 Movement::Up,
307 Movement::ParagraphStart,
308 Movement::DocumentStart,
309 ),
310 Key::Named(NamedKey::ArrowDown) => pick(
311 Movement::Down,
312 Movement::ParagraphEnd,
313 Movement::DocumentEnd,
314 ),
315 Key::Named(NamedKey::Home) if shift => Some(Command::Move(Movement::DocumentStart)),
316 Key::Named(NamedKey::End) if shift => Some(Command::Move(Movement::DocumentEnd)),
317 Key::Named(NamedKey::PageUp) if option => Some(Command::MovePage { up: true }),
318 Key::Named(NamedKey::PageDown) if option => Some(Command::MovePage { up: false }),
319 Key::Named(NamedKey::PageUp) => Some(Command::ScrollPage { up: true }),
320 Key::Named(NamedKey::PageDown) => Some(Command::ScrollPage { up: false }),
321 Key::Named(NamedKey::Backspace) if command || option => {
322 Some(Command::DeleteTo(if command {
323 Movement::LineStart
324 } else {
325 Movement::WordLeft
326 }))
327 }
328 Key::Named(NamedKey::Delete) if command || option => Some(Command::DeleteTo(if command {
329 Movement::LineEnd
330 } else {
331 Movement::WordRight
332 })),
333 Key::Named(NamedKey::Backspace) => Some(Command::Delete { backward: true }),
334 Key::Named(NamedKey::Delete) => Some(Command::Delete { backward: false }),
335 Key::Character(key) if control && !option => match key.as_str() {
336 "a" => Some(Command::Move(Movement::LineStart)),
337 "e" => Some(Command::Move(Movement::LineEnd)),
338 "b" => Some(Command::Move(Movement::Left)),
339 "f" => Some(Command::Move(Movement::Right)),
340 "p" => Some(Command::Move(Movement::Up)),
341 "n" => Some(Command::Move(Movement::Down)),
342 "h" => Some(Command::Delete { backward: true }),
343 "d" => Some(Command::Delete { backward: false }),
344 "k" => Some(Command::Kill),
345 _ => None,
346 },
347 _ => None,
348 }
349}
350
351/// GTK's and Windows' shared bindings: Control moves and deletes by words, and with Home
352/// and End reaches the document's ends; Control with Up and Down moves by paragraphs.
353fn pc_command(key: &Key, modifiers: Modifiers) -> Option<Command> {
354 let Modifiers {
355 control, option, ..
356 } = modifiers;
357 if option {
358 return match key {
359 _ if control => None,
360 Key::Named(NamedKey::ArrowUp) => Some(Command::MoveParagraphs { up: true }),
361 Key::Named(NamedKey::ArrowDown) => Some(Command::MoveParagraphs { up: false }),
362 _ => None,
363 };
364 }
365 let pick =
366 |plain, with_control| Some(Command::Move(if control { with_control } else { plain }));
367 match key {
368 Key::Named(NamedKey::ArrowLeft) => pick(Movement::Left, Movement::WordLeft),
369 Key::Named(NamedKey::ArrowRight) => pick(Movement::Right, Movement::WordRight),
370 Key::Named(NamedKey::ArrowUp) => pick(Movement::Up, Movement::ParagraphStart),
371 Key::Named(NamedKey::ArrowDown) => pick(Movement::Down, Movement::ParagraphEnd),
372 Key::Named(NamedKey::Home) => pick(Movement::LineStart, Movement::DocumentStart),
373 Key::Named(NamedKey::End) => pick(Movement::LineEnd, Movement::DocumentEnd),
374 Key::Named(NamedKey::PageUp) if !control => Some(Command::MovePage { up: true }),
375 Key::Named(NamedKey::PageDown) if !control => Some(Command::MovePage { up: false }),
376 Key::Named(NamedKey::Backspace) if control => Some(Command::DeleteTo(Movement::WordLeft)),
377 Key::Named(NamedKey::Delete) if control => Some(Command::DeleteTo(Movement::WordRight)),
378 Key::Named(NamedKey::Backspace) => Some(Command::Delete { backward: true }),
379 Key::Named(NamedKey::Delete) => Some(Command::Delete { backward: false }),
380 _ => None,
381 }
382}
383
384/// Counts presses into single, double and triple clicks.
385#[derive(Debug)]
386pub struct Clicks {
387 interval: Duration,
388 last: Option<(Instant, [f32; 2], SelectionUnit)>,
389}
390
391impl Clicks {
392 /// `interval` is the platform's double-click interval.
393 pub fn new(interval: Duration) -> Self {
394 Self {
395 interval,
396 last: None,
397 }
398 }
399
400 /// Records a press at `point` and returns the unit it selects by. A press within the
401 /// interval and `slop` of the previous one on each axis continues its count, cycling
402 /// after three.
403 pub fn press(&mut self, at: Instant, point: [f32; 2], slop: f32) -> SelectionUnit {
404 let unit = self
405 .last
406 .filter(|(time, last, _)| {
407 at.saturating_duration_since(*time) <= self.interval
408 && (0..2).all(|axis| (last[axis] - point[axis]).abs() <= slop)
409 })
410 .map_or(SelectionUnit::Grapheme, |(_, _, unit)| match unit {
411 SelectionUnit::Grapheme => SelectionUnit::Word,
412 SelectionUnit::Word => SelectionUnit::Paragraph,
413 SelectionUnit::Paragraph => SelectionUnit::Grapheme,
414 });
415 self.last = Some((at, point, unit));
416 unit
417 }
418}
419
420/// The caret's width in logical pixels on this platform.
421pub const CARET_WIDTH: f32 = Platform::CURRENT.caret_width();
422
423/// The caret's opacity `since` it last moved on this platform; see [`Platform::caret_blink`].
424pub fn caret_blink(since: Duration) -> (f32, Option<Duration>) {
425 Platform::CURRENT.caret_blink(since)
426}
427
428/// `caret`, linear RGBA, with the alpha that shows it at `opacity` over `backdrop` as bright
429/// as AppKit's layers do, which blend in sRGB rather than linear light.
430pub fn caret_color(caret: [f32; 4], backdrop: [f32; 4], opacity: f32) -> [f32; 4] {
431 let luminance = |[red, green, blue]: [f32; 3]| 0.2126 * red + 0.7152 * green + 0.0722 * blue;
432 let [under, over] = [backdrop, caret].map(|color| [color[0], color[1], color[2]]);
433 let shown: [f32; 3] = std::array::from_fn(|channel| {
434 let [under, over] = [under[channel], over[channel]].map(crate::encode);
435 crate::linear(under + opacity * (over - under))
436 });
437 let span = luminance(over) - luminance(under);
438 let alpha = if span.abs() < 1e-4 {
439 opacity
440 } else {
441 ((luminance(shown) - luminance(under)) / span).clamp(0.0, 1.0)
442 };
443 [caret[0], caret[1], caret[2], caret[3] * alpha]
444}
445
446/// The selection a drag makes from the selection its press made to the one under the
447/// pointer, both by `unit`, ordered by `position`. Words and paragraphs stay whole, so
448/// the press's selection stays covered when the drag reverses past it.
449pub fn drag<T: Copy, P: PartialOrd>(
450 anchor: [T; 2],
451 target: [T; 2],
452 unit: SelectionUnit,
453 position: impl Fn(T) -> P,
454) -> [T; 2] {
455 if unit == SelectionUnit::Grapheme {
456 return [anchor[0], target[1]];
457 }
458 let [anchor_start, anchor_end] = anchor.map(&position);
459 let [target_start, target_end] = target.map(&position);
460 let backwards = target_start < anchor_start && target_start < anchor_end;
461 let start = usize::from((anchor_start > anchor_end) != backwards);
462 let end = usize::from((target_start > target_end) == backwards);
463 [anchor[start], target[end]]
464}
465
466/// The selection a press makes by `unit`, where `hit` is the cursor at the press's `x`.
467pub fn selection_at<B: Brush>(
468 layout: &Layout<B>,
469 hit: Cursor,
470 x: f32,
471 unit: SelectionUnit,
472) -> Selection {
473 match unit {
474 SelectionUnit::Grapheme => hit.into(),
475 SelectionUnit::Word => {
476 let rect = hit.geometry(layout, 1.0);
477 Selection::word_from_point(layout, x, ((rect.y0 + rect.y1) * 0.5) as f32)
478 }
479 SelectionUnit::Paragraph => Selection::new(
480 Cursor::from_byte_index(layout, 0, Affinity::Downstream),
481 Cursor::from_byte_index(layout, usize::MAX, Affinity::Upstream),
482 ),
483 }
484}
485
486/// Where `movement` takes `selection` within `layout`, keeping its anchor when `extend`.
487/// Up, Down and the paragraph and document movements stop at the layout's ends.
488pub fn step<B: Brush>(
489 layout: &Layout<B>,
490 selection: Selection,
491 movement: Movement,
492 extend: bool,
493) -> Selection {
494 let to = |focus: Cursor| {
495 if extend {
496 selection.extend(focus)
497 } else {
498 focus.into()
499 }
500 };
501 match movement {
502 Movement::Left => selection.previous_visual(layout, extend),
503 Movement::Right => selection.next_visual(layout, extend),
504 Movement::WordLeft => to(word_cursor(layout, selection.focus(), true)),
505 Movement::WordRight => to(word_cursor(layout, selection.focus(), false)),
506 Movement::LineStart => selection.line_start(layout, extend),
507 Movement::LineEnd => selection.line_end(layout, extend),
508 Movement::Up | Movement::ParagraphStart | Movement::DocumentStart => {
509 to(Cursor::from_byte_index(layout, 0, Affinity::Downstream))
510 }
511 Movement::Down | Movement::ParagraphEnd | Movement::DocumentEnd => to(
512 Cursor::from_byte_index(layout, usize::MAX, Affinity::Upstream),
513 ),
514 }
515}
516
517/// The next word boundary in visual order, stopping before a word going left and after
518/// one going right, as macOS does.
519pub fn word_cursor<B: Brush>(layout: &Layout<B>, cursor: Cursor, backward: bool) -> Cursor {
520 let mut current = cursor;
521 let mut visited = BTreeSet::new();
522 loop {
523 if !visited.insert((current.index(), current.affinity() == Affinity::Upstream)) {
524 // Parley visual cursors can cycle at soft-wrapped bidi boundaries.
525 let [left, right] = cursor.visual_clusters(layout);
526 let rtl = if backward {
527 left.or(right)
528 } else {
529 right.or(left)
530 }
531 .is_some_and(|cluster| cluster.is_rtl());
532 return if backward != rtl {
533 cursor.previous_logical_word(layout)
534 } else {
535 cursor.next_logical_word(layout)
536 };
537 }
538 let next = if backward {
539 current.previous_visual(layout)
540 } else {
541 current.next_visual(layout)
542 };
543 if next == current {
544 return current;
545 }
546 current = next;
547 let [Some(left), Some(right)] = current.visual_clusters(layout) else {
548 return current;
549 };
550 let boundary = if left.is_rtl() {
551 left.is_word_boundary()
552 && if backward {
553 left.is_space_or_nbsp()
554 || (right.is_word_boundary() && !right.is_space_or_nbsp())
555 } else {
556 !left.is_space_or_nbsp()
557 }
558 } else {
559 right.is_word_boundary()
560 && if backward {
561 !right.is_space_or_nbsp()
562 } else {
563 !left.is_space_or_nbsp()
564 }
565 };
566 if boundary {
567 return current;
568 }
569 }
570}
571
572#[cfg(test)]
573mod tests {
574 use super::*;
575 use parley::{FontContext, LayoutContext};
576
577 fn layout(text: &str) -> Layout<()> {
578 let mut fonts = FontContext::new();
579 let mut context = LayoutContext::new();
580 let mut layout = context
581 .ranged_builder(&mut fonts, text, 1.0, false)
582 .build(text);
583 layout.break_all_lines(None);
584 layout
585 }
586
587 #[test]
588 fn keys_follow_macos_editing_chords() {
589 let with = |modifiers: fn(&mut Modifiers)| {
590 let mut value = Modifiers::default();
591 modifiers(&mut value);
592 value
593 };
594 let named = |named| Key::Named(named);
595 let cases = [
596 (
597 named(NamedKey::ArrowLeft),
598 with(|m| m.option = true),
599 Some(Command::Move(Movement::WordLeft)),
600 ),
601 (
602 named(NamedKey::ArrowRight),
603 with(|m| (m.command, m.option) = (true, true)),
604 Some(Command::Move(Movement::LineEnd)),
605 ),
606 (named(NamedKey::Home), Modifiers::default(), None),
607 (
608 named(NamedKey::End),
609 with(|m| m.shift = true),
610 Some(Command::Move(Movement::DocumentEnd)),
611 ),
612 (
613 named(NamedKey::Backspace),
614 with(|m| m.option = true),
615 Some(Command::DeleteTo(Movement::WordLeft)),
616 ),
617 (
618 named(NamedKey::Delete),
619 with(|m| m.command = true),
620 Some(Command::DeleteTo(Movement::LineEnd)),
621 ),
622 (
623 named(NamedKey::Backspace),
624 with(|m| m.control = true),
625 Some(Command::Delete { backward: true }),
626 ),
627 (
628 Key::Character("e".into()),
629 with(|m| m.control = true),
630 Some(Command::Move(Movement::LineEnd)),
631 ),
632 (
633 Key::Character("k".into()),
634 with(|m| m.control = true),
635 Some(Command::Kill),
636 ),
637 (
638 Key::Character("k".into()),
639 with(|m| (m.control, m.option) = (true, true)),
640 None,
641 ),
642 (Key::Character("a".into()), Modifiers::default(), None),
643 (
644 named(NamedKey::ArrowDown),
645 with(|m| (m.command, m.option) = (true, true)),
646 Some(Command::MoveParagraphs { up: false }),
647 ),
648 (
649 named(NamedKey::ArrowUp),
650 with(|m| (m.command, m.option) = (true, true)),
651 Some(Command::MoveParagraphs { up: true }),
652 ),
653 (
654 named(NamedKey::ArrowUp),
655 with(|m| m.option = true),
656 Some(Command::MoveParagraphs { up: true }),
657 ),
658 (
659 named(NamedKey::ArrowDown),
660 with(|m| m.option = true),
661 Some(Command::MoveParagraphs { up: false }),
662 ),
663 (
664 named(NamedKey::ArrowUp),
665 with(|m| (m.shift, m.option) = (true, true)),
666 Some(Command::Move(Movement::ParagraphStart)),
667 ),
668 (
669 named(NamedKey::ArrowDown),
670 with(|m| (m.shift, m.option) = (true, true)),
671 Some(Command::Move(Movement::ParagraphEnd)),
672 ),
673 (
674 named(NamedKey::ArrowUp),
675 with(|m| (m.control, m.option) = (true, true)),
676 Some(Command::Move(Movement::ParagraphStart)),
677 ),
678 ];
679 for (key, modifiers, command) in cases {
680 assert_eq!(
681 Platform::MacOs.command(&key, modifiers),
682 command,
683 "{key:?} {modifiers:?}"
684 );
685 }
686 }
687
688 #[test]
689 fn keys_follow_gtk_and_windows_editing_chords() {
690 // Control arrives as the shortcut modifier too.
691 let control = Modifiers {
692 control: true,
693 command: true,
694 ..Modifiers::default()
695 };
696 let plain = Modifiers::default();
697 let alt = Modifiers {
698 option: true,
699 ..Modifiers::default()
700 };
701 let alt_shift = Modifiers { shift: true, ..alt };
702 let named = |named| Key::Named(named);
703 let cases = [
704 (
705 named(NamedKey::ArrowUp),
706 alt_shift,
707 Some(Command::MoveParagraphs { up: true }),
708 ),
709 (
710 named(NamedKey::ArrowDown),
711 alt_shift,
712 Some(Command::MoveParagraphs { up: false }),
713 ),
714 (
715 named(NamedKey::ArrowUp),
716 alt,
717 Some(Command::MoveParagraphs { up: true }),
718 ),
719 (
720 named(NamedKey::ArrowDown),
721 alt,
722 Some(Command::MoveParagraphs { up: false }),
723 ),
724 (
725 named(NamedKey::ArrowUp),
726 Modifiers {
727 control: true,
728 command: true,
729 ..alt
730 },
731 None,
732 ),
733 (
734 named(NamedKey::ArrowLeft),
735 control,
736 Some(Command::Move(Movement::WordLeft)),
737 ),
738 (
739 named(NamedKey::ArrowRight),
740 control,
741 Some(Command::Move(Movement::WordRight)),
742 ),
743 (
744 named(NamedKey::ArrowUp),
745 control,
746 Some(Command::Move(Movement::ParagraphStart)),
747 ),
748 (
749 named(NamedKey::ArrowDown),
750 plain,
751 Some(Command::Move(Movement::Down)),
752 ),
753 (
754 named(NamedKey::Home),
755 plain,
756 Some(Command::Move(Movement::LineStart)),
757 ),
758 (
759 named(NamedKey::End),
760 plain,
761 Some(Command::Move(Movement::LineEnd)),
762 ),
763 (
764 named(NamedKey::Home),
765 control,
766 Some(Command::Move(Movement::DocumentStart)),
767 ),
768 (
769 named(NamedKey::End),
770 control,
771 Some(Command::Move(Movement::DocumentEnd)),
772 ),
773 (
774 named(NamedKey::PageUp),
775 plain,
776 Some(Command::MovePage { up: true }),
777 ),
778 (
779 named(NamedKey::PageDown),
780 plain,
781 Some(Command::MovePage { up: false }),
782 ),
783 (
784 named(NamedKey::Backspace),
785 control,
786 Some(Command::DeleteTo(Movement::WordLeft)),
787 ),
788 (
789 named(NamedKey::Delete),
790 control,
791 Some(Command::DeleteTo(Movement::WordRight)),
792 ),
793 (
794 named(NamedKey::Backspace),
795 plain,
796 Some(Command::Delete { backward: true }),
797 ),
798 (named(NamedKey::ArrowLeft), alt, None),
799 // No Emacs chords: Control-A and Control-E are shortcuts.
800 (Key::Character("a".into()), control, None),
801 (Key::Character("e".into()), control, None),
802 ];
803 for platform in [Platform::Gtk, Platform::Windows] {
804 for (key, modifiers, command) in &cases {
805 assert_eq!(
806 platform.command(key, *modifiers),
807 *command,
808 "{platform:?} {key:?} {modifiers:?}"
809 );
810 }
811 }
812 }
813
814 #[test]
815 fn presses_count_within_the_interval_and_slop_and_cycle() {
816 let mut clicks = Clicks::new(Duration::from_millis(500));
817 let start = Instant::now();
818 let at = |millis| start + Duration::from_millis(millis);
819 let units: Vec<_> = [(0, 0.0), (100, 3.0), (200, 3.0), (300, 0.0), (1000, 0.0)]
820 .map(|(millis, x)| clicks.press(at(millis), [x, 0.0], 4.0))
821 .into();
822 use SelectionUnit::*;
823 assert_eq!(units, [Grapheme, Word, Paragraph, Grapheme, Grapheme]);
824 assert_eq!(clicks.press(at(1100), [10.0, 0.0], 4.0), Grapheme);
825 }
826
827 #[test]
828 fn grouped_drag_keeps_the_initial_word_when_reversing_direction() {
829 let drag = |anchor, target, unit| drag(anchor, target, unit, |offset: usize| offset);
830 let word = [6, 10];
831 assert_eq!(drag(word, [11, 16], SelectionUnit::Word), [6, 16]);
832 assert_eq!(drag(word, [0, 5], SelectionUnit::Word), [10, 0]);
833 assert_eq!(drag(word, word, SelectionUnit::Word), word);
834 assert_eq!(drag([10, 3], [7, 7], SelectionUnit::Grapheme), [10, 7]);
835 }
836
837 #[test]
838 fn words_stop_at_their_edges_in_both_directions() {
839 let layout = layout("one two three");
840 let at = |index| Cursor::from_byte_index(&layout, index, Affinity::Downstream);
841 let words = |start, backward| {
842 std::iter::successors(Some(at(start)), |cursor| {
843 let next = word_cursor(&layout, *cursor, backward);
844 (next != *cursor).then_some(next)
845 })
846 .skip(1)
847 .map(|cursor| cursor.index())
848 .collect::<Vec<_>>()
849 };
850 assert_eq!(words(0, false), [3, 7, 14]);
851 assert_eq!(words(14, true), [9, 4, 0]);
852 }
853
854 #[test]
855 fn steps_collapse_extend_and_reach_the_ends() {
856 let layout = layout("one two");
857 let all = selection_at(&layout, Cursor::default(), 0.0, SelectionUnit::Paragraph);
858 assert_eq!(all.text_range(), 0..7);
859 let left = step(&layout, all, Movement::Left, false);
860 assert_eq!(left.text_range(), 0..0);
861 let word = step(&layout, left, Movement::WordRight, true);
862 assert_eq!(word.text_range(), 0..3);
863 assert_eq!(
864 step(&layout, word, Movement::Down, false).focus().index(),
865 7
866 );
867 assert_eq!(
868 step(&layout, word, Movement::Up, true).text_range(),
869 0..0,
870 "extending keeps the anchor"
871 );
872 }
873
874 #[test]
875 fn caret_fades_as_bright_as_appkit_s_srgb_blend() {
876 let linear = |value: f32| ((value / 255.0 + 0.055) / 1.055).powf(2.4);
877 let pink = [248.0, 79.0, 158.0].map(linear);
878 let pink = [pink[0], pink[1], pink[2], 1.0];
879 let white = [1.0; 4];
880 assert_eq!(caret_color(pink, white, 1.0)[3], 1.0);
881 assert_eq!(caret_color(pink, white, 0.0)[3], 0.0);
882 // Half the caret over white blends to sRGB green 167; TextEdit measures 170.
883 let alpha = caret_color(pink, white, 0.5)[3];
884 let green = 1.0 + alpha * (pink[1] - 1.0);
885 assert!((green - linear(167.5)).abs() < 0.02, "{alpha}");
886 }
887
888 /// The opacity and hold in milliseconds `millis` after the caret moved.
889 fn blink_at(platform: Platform, millis: f64) -> (f32, Option<f64>) {
890 let (opacity, hold) = platform.caret_blink(Duration::from_secs_f64(millis / 1000.0));
891 (opacity, hold.map(|hold| hold.as_secs_f64() * 1000.0))
892 }
893
894 fn assert_blink(platform: Platform, millis: f64, expected: (f32, Option<f64>)) {
895 let (opacity, hold) = blink_at(platform, millis);
896 assert_eq!(opacity, expected.0, "{platform:?} at {millis} ms");
897 match (hold, expected.1) {
898 (Some(hold), Some(expected)) => {
899 assert!(
900 (hold - expected).abs() < 0.01,
901 "{platform:?} at {millis} ms holds {hold} ms, not {expected}"
902 )
903 }
904 (hold, expected) => assert_eq!(hold, expected, "{platform:?} at {millis} ms"),
905 }
906 }
907
908 /// How often the opacity changes over `span` milliseconds from `from`.
909 fn blink_changes(platform: Platform, from: u32, span: u32) -> usize {
910 let mut previous = blink_at(platform, f64::from(from)).0;
911 (from + 1..=from + span)
912 .filter(|&millis| {
913 let opacity = blink_at(platform, f64::from(millis)).0;
914 std::mem::replace(&mut previous, opacity) != opacity
915 })
916 .count()
917 }
918
919 #[test]
920 fn caret_blinks_on_appkit_s_timing() {
921 let mac = Platform::MacOs;
922 assert_blink(mac, 0.0, (1.0, Some(687.5)));
923 assert_blink(mac, 687.5, (0.75, Some(37.5)));
924 assert_blink(mac, 760.0, (0.5, Some(2.5)));
925 assert_blink(mac, 800.0, (0.0, Some(237.5)));
926 assert_blink(mac, 1037.5, (0.25, Some(37.5)));
927 assert_blink(mac, 1150.0, (1.0, Some(537.5)));
928 assert_blink(mac, 1687.5, (0.75, Some(37.5)));
929 assert_blink(mac, 3_600_200.0, (1.0, Some(487.5)));
930 assert_eq!(
931 blink_changes(mac, 1000, 1000),
932 8,
933 "four fade steps each way per second"
934 );
935 }
936
937 #[test]
938 fn caret_blinks_on_gtk_s_timing() {
939 let gtk = Platform::Gtk;
940 // Solid for half the 1200 ms cycle and its visible quarter, then fades out over 300 ms.
941 assert_blink(gtk, 0.0, (1.0, Some(900.0 + 1000.0 / 60.0)));
942 assert_blink(gtk, 1200.0, (0.0, Some(300.0 + 1000.0 / 60.0)));
943 assert_blink(gtk, 1800.0, (1.0, Some(300.0 + 1000.0 / 60.0)));
944 let (fading, _) = blink_at(gtk, 1050.0);
945 assert!((fading - 0.5).abs() <= 1.0 / 18.0, "{fading}");
946 // A fade each way per 1.2 s cycle, in 60 Hz steps.
947 assert_eq!(blink_changes(gtk, 900, 1200), 2 * 18);
948 // Ten seconds without moving leave it solid, cutting short the fade in.
949 assert_blink(gtk, 9_700.0, (0.0, Some(216.667)));
950 assert_blink(gtk, 9_990.0, (5.0 / 18.0, Some(10.0)));
951 assert_blink(gtk, 10_000.0, (1.0, None));
952 }
953
954 #[test]
955 fn caret_blinks_on_windows_timing() {
956 let windows = Platform::Windows;
957 assert_blink(windows, 0.0, (1.0, Some(530.0)));
958 assert_blink(windows, 530.0, (0.0, Some(530.0)));
959 assert_blink(windows, 1100.0, (1.0, Some(490.0)));
960 assert_eq!(blink_changes(windows, 530, 1060), 2, "no fades");
961 assert_blink(windows, 4800.0, (0.0, Some(200.0)));
962 assert_blink(windows, 5000.0, (1.0, None));
963 }
964
965 #[test]
966 fn carets_and_selections_match_each_platform() {
967 assert_eq!(
968 [Platform::MacOs, Platform::Gtk, Platform::Windows].map(Platform::caret_width),
969 [2.0, 1.0, 1.0]
970 );
971 for platform in [Platform::MacOs, Platform::Gtk, Platform::Windows] {
972 for dark in [false, true] {
973 let [caret, focused, unfocused] = platform.text_colors(dark);
974 assert!(caret.1 >= 0.8, "{platform:?} carets stand out");
975 assert!(focused.1 > unfocused.1 || focused.0 != unfocused.0);
976 }
977 }
978 // libadwaita tints selections with the accent at 30% and follows the text colour.
979 let [caret, selection, _] = Platform::Gtk.text_colors(false);
980 assert_eq!(selection, ([0x35, 0x84, 0xe4], 0.3));
981 assert_eq!(caret, ([0x00, 0x00, 0x06], 0.8));
982 }
983}