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