Skip to main content

incular_runtime/
text_shortcuts.rs

1//! Flutter 3.41 `DefaultTextEditingShortcuts`: the keys that edit and move
2//! the caret in a text field, per target platform.
3//!
4//! Flutter chooses one table per `defaultTargetPlatform`. The Windows, Linux
5//! and Android tables share `_commonShortcuts` and `_clipboardShortcuts`; the
6//! macOS table (reused for iOS) uses Option for words, Command for lines and
7//! documents, and adds Emacs-style Control bindings. Activators match their
8//! modifiers exactly, so a chord not listed here edits nothing.
9
10use incular_core::{Code, Modifiers};
11
12/// The platform whose `DefaultTextEditingShortcuts` table applies to text
13/// fields (Flutter's `defaultTargetPlatform`).
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub enum TextShortcutPlatform {
16    Android,
17    Ios,
18    Linux,
19    MacOs,
20    Windows,
21}
22
23impl TextShortcutPlatform {
24    /// The platform this build targets. In a browser it is the operating
25    /// system the browser runs on, so a Mac uses Command shortcuts.
26    #[must_use]
27    pub fn current() -> Self {
28        #[cfg(target_arch = "wasm32")]
29        if let Some(platform) = Self::browser() {
30            return platform;
31        }
32        if cfg!(target_os = "macos") {
33            Self::MacOs
34        } else if cfg!(target_os = "ios") {
35            Self::Ios
36        } else if cfg!(target_os = "android") {
37            Self::Android
38        } else if cfg!(target_os = "linux") {
39            Self::Linux
40        } else {
41            Self::Windows
42        }
43    }
44
45    /// Reads `navigator.userAgentData.platform` where available, then the
46    /// legacy `navigator.platform` and user agent strings.
47    #[cfg(target_arch = "wasm32")]
48    fn browser() -> Option<Self> {
49        use wasm_bindgen::JsValue;
50
51        let get = |target: &JsValue, key: &str| {
52            js_sys::Reflect::get(target, &JsValue::from_str(key))
53                .ok()
54                .filter(|value| !value.is_undefined() && !value.is_null())
55        };
56        let navigator = get(&js_sys::global(), "navigator")?;
57        let described = [
58            get(&navigator, "userAgentData").and_then(|data| get(&data, "platform")),
59            get(&navigator, "platform"),
60            get(&navigator, "userAgent"),
61        ]
62        .into_iter()
63        .flatten()
64        .filter_map(|value| value.as_string())
65        .collect::<Vec<_>>()
66        .join(" ")
67        .to_ascii_lowercase();
68        // Android user agents also mention Linux. iPadOS reports itself as a
69        // Mac, whose table it shares.
70        if described.contains("android") {
71            Some(Self::Android)
72        } else if ["iphone", "ipad", "ipod", "ios"]
73            .iter()
74            .any(|name| described.contains(name))
75        {
76            Some(Self::Ios)
77        } else if described.contains("mac") {
78            Some(Self::MacOs)
79        } else if described.contains("win") {
80            Some(Self::Windows)
81        } else if described.contains("linux") || described.contains("cros") {
82            Some(Self::Linux)
83        } else {
84            None
85        }
86    }
87
88    const fn is_apple(self) -> bool {
89        matches!(self, Self::MacOs | Self::Ios)
90    }
91}
92
93/// What a text-editing chord asks the focused field to do. Each variant is the
94/// Flutter intent of the same name.
95#[derive(Clone, Copy, Debug, PartialEq, Eq)]
96pub(crate) enum TextIntent {
97    DeleteCharacter {
98        forward: bool,
99    },
100    DeleteToNextWordBoundary {
101        forward: bool,
102    },
103    DeleteToLineBreak {
104        forward: bool,
105    },
106    ExtendByCharacter {
107        forward: bool,
108        collapse: bool,
109    },
110    ExtendToNextWordBoundary {
111        forward: bool,
112        collapse: bool,
113    },
114    ExtendToNextWordBoundaryOrCaretLocation {
115        forward: bool,
116    },
117    ExtendToLineBreak {
118        forward: bool,
119        collapse: bool,
120        continues_at_wrap: bool,
121    },
122    ExtendToNextParagraphBoundary {
123        forward: bool,
124        collapse: bool,
125    },
126    ExtendToNextParagraphBoundaryOrCaretLocation {
127        forward: bool,
128    },
129    ExtendToDocumentBoundary {
130        forward: bool,
131        collapse: bool,
132    },
133    ExpandToLineBreak {
134        forward: bool,
135    },
136    ExpandToDocumentBoundary {
137        forward: bool,
138    },
139    ExtendVerticallyToAdjacentLine {
140        forward: bool,
141        collapse: bool,
142    },
143    ExtendVerticallyToAdjacentPage {
144        forward: bool,
145        collapse: bool,
146    },
147    ScrollToDocumentBoundary {
148        forward: bool,
149    },
150    ScrollPage {
151        forward: bool,
152    },
153    Copy,
154    Cut,
155    Paste,
156    SelectAll,
157    Undo,
158    Redo,
159    TransposeCharacters,
160    /// `DoNothingAndStopPropagationTextIntent`: the key belongs to the IME.
161    DoNothing,
162}
163
164/// Resolves a key chord to its text-editing intent on `platform`.
165pub(crate) fn resolve(
166    platform: TextShortcutPlatform,
167    code: Code,
168    modifiers: Modifiers,
169) -> Option<TextIntent> {
170    let chord = Chord {
171        ctrl: modifiers.ctrl(),
172        alt: modifiers.alt(),
173        shift: modifiers.shift(),
174        meta: modifiers.meta(),
175        num_lock: modifiers.contains(Modifiers::NUM_LOCK),
176    };
177    if platform.is_apple() {
178        return mac(code, chord);
179    }
180    let table = match platform {
181        TextShortcutPlatform::Linux => linux(code, chord),
182        TextShortcutPlatform::Windows => windows(code, chord),
183        _ => android(code, chord),
184    };
185    table
186        .or_else(|| clipboard(code, chord))
187        .or_else(|| common(code, chord))
188}
189
190#[derive(Clone, Copy)]
191struct Chord {
192    ctrl: bool,
193    alt: bool,
194    shift: bool,
195    meta: bool,
196    num_lock: bool,
197}
198
199impl Chord {
200    /// Whether exactly the given modifiers are down (shift is matched by the
201    /// callers that accept both states).
202    fn only(self, ctrl: bool, alt: bool, shift: bool, meta: bool) -> bool {
203        self.ctrl == ctrl && self.alt == alt && self.shift == shift && self.meta == meta
204    }
205
206    fn plain(self) -> bool {
207        self.only(false, false, false, false)
208    }
209}
210
211/// `_commonShortcuts`.
212fn common(code: Code, c: Chord) -> Option<TextIntent> {
213    use TextIntent::*;
214    if c.meta {
215        return None;
216    }
217    let collapse = !c.shift;
218    let forward_of = |code| matches!(code, Code::ArrowRight | Code::ArrowDown | Code::PageDown);
219    match code {
220        // Delete shortcuts, with shift in either state.
221        Code::Backspace => match (c.ctrl, c.alt) {
222            (false, false) => Some(DeleteCharacter { forward: false }),
223            (true, false) => Some(DeleteToNextWordBoundary { forward: false }),
224            (false, true) => Some(DeleteToLineBreak { forward: false }),
225            (true, true) => None,
226        },
227        Code::Delete => match (c.ctrl, c.alt) {
228            (true, false) => Some(DeleteToNextWordBoundary { forward: true }),
229            (false, true) => Some(DeleteToLineBreak { forward: true }),
230            (false, false) if !c.shift => Some(DeleteCharacter { forward: true }),
231            _ => None,
232        },
233        Code::ArrowLeft | Code::ArrowRight => {
234            let forward = forward_of(code);
235            match (c.ctrl, c.alt) {
236                (false, false) => Some(ExtendByCharacter { forward, collapse }),
237                (false, true) => Some(ExtendToLineBreak {
238                    forward,
239                    collapse,
240                    continues_at_wrap: false,
241                }),
242                (true, false) => Some(ExtendToNextWordBoundary { forward, collapse }),
243                (true, true) => None,
244            }
245        }
246        Code::ArrowUp | Code::ArrowDown => {
247            let forward = forward_of(code);
248            match (c.ctrl, c.alt) {
249                (false, false) => Some(ExtendVerticallyToAdjacentLine { forward, collapse }),
250                (false, true) => Some(ExtendToDocumentBoundary { forward, collapse }),
251                (true, false) if c.shift => Some(ExtendToNextParagraphBoundary {
252                    forward,
253                    collapse: false,
254                }),
255                _ => None,
256            }
257        }
258        Code::PageUp | Code::PageDown if !c.ctrl && !c.alt => {
259            Some(ExtendVerticallyToAdjacentPage {
260                forward: forward_of(code),
261                collapse,
262            })
263        }
264        _ => None,
265    }
266}
267
268/// `_clipboardShortcuts`.
269fn clipboard(code: Code, c: Chord) -> Option<TextIntent> {
270    use TextIntent::*;
271    let ctrl_only = c.only(true, false, false, false);
272    match code {
273        Code::KeyX if ctrl_only => Some(Cut),
274        Code::KeyC if ctrl_only => Some(Copy),
275        Code::KeyV if ctrl_only => Some(Paste),
276        Code::Delete if c.only(false, false, true, false) => Some(Cut),
277        Code::Insert if ctrl_only => Some(Copy),
278        Code::Insert if c.only(false, false, true, false) => Some(Paste),
279        Code::KeyA if ctrl_only => Some(SelectAll),
280        Code::KeyZ if ctrl_only => Some(Undo),
281        Code::KeyZ if c.only(true, false, true, false) => Some(Redo),
282        // These keys go to the IME when a field is focused.
283        Code::Space | Code::Enter if c.plain() => Some(DoNothing),
284        _ => None,
285    }
286}
287
288/// Home/End on Windows and Android continue across soft wraps.
289fn home_end(code: Code, c: Chord, continues_at_wrap: bool) -> Option<TextIntent> {
290    use TextIntent::*;
291    if c.alt || c.meta {
292        return None;
293    }
294    let forward = match code {
295        Code::Home => false,
296        Code::End => true,
297        _ => return None,
298    };
299    let collapse = !c.shift;
300    Some(if c.ctrl {
301        ExtendToDocumentBoundary { forward, collapse }
302    } else {
303        ExtendToLineBreak {
304            forward,
305            collapse,
306            continues_at_wrap,
307        }
308    })
309}
310
311fn windows(code: Code, c: Chord) -> Option<TextIntent> {
312    home_end(code, c, true)
313}
314
315fn android(code: Code, c: Chord) -> Option<TextIntent> {
316    home_end(code, c, true)
317}
318
319/// `_linuxShortcuts` plus `_linuxNumpadShortcuts`.
320fn linux(code: Code, c: Chord) -> Option<TextIntent> {
321    use TextIntent::*;
322    if let Some(intent) = home_end(code, c, false) {
323        return Some(intent);
324    }
325    if c.alt || c.meta {
326        return None;
327    }
328    // With NumLock on the numpad needs Shift to navigate, with it off Shift
329    // must be released; Control selects the next word or paragraph.
330    if c.shift != c.num_lock {
331        return None;
332    }
333    let collapse = !c.num_lock;
334    let word_or_paragraph = c.ctrl;
335    match code {
336        Code::Numpad6 | Code::Numpad4 => {
337            let forward = code == Code::Numpad6;
338            Some(if word_or_paragraph {
339                ExtendToNextWordBoundary { forward, collapse }
340            } else {
341                ExtendByCharacter { forward, collapse }
342            })
343        }
344        Code::Numpad8 | Code::Numpad2 => {
345            let forward = code == Code::Numpad2;
346            Some(if word_or_paragraph {
347                ExtendToNextParagraphBoundary { forward, collapse }
348            } else {
349                ExtendVerticallyToAdjacentLine { forward, collapse }
350            })
351        }
352        Code::Numpad9 | Code::Numpad3 if !word_or_paragraph => {
353            Some(ExtendVerticallyToAdjacentPage {
354                forward: code == Code::Numpad3,
355                collapse,
356            })
357        }
358        Code::Numpad7 | Code::Numpad1 if !word_or_paragraph => {
359            Some(ExtendVerticallyToAdjacentLine {
360                forward: code == Code::Numpad1,
361                collapse,
362            })
363        }
364        Code::NumpadDecimal => Some(if word_or_paragraph {
365            DeleteToNextWordBoundary { forward: true }
366        } else {
367            DeleteCharacter { forward: true }
368        }),
369        _ => None,
370    }
371}
372
373/// `_macShortcuts` (also `_iOSShortcuts`).
374fn mac(code: Code, c: Chord) -> Option<TextIntent> {
375    use TextIntent::*;
376    let collapse = !c.shift;
377    let forward_of = |code| matches!(code, Code::ArrowRight | Code::ArrowDown | Code::PageDown);
378    let mods = (c.ctrl, c.alt, c.meta);
379    match code {
380        // Delete shortcuts, with shift in either state.
381        Code::Backspace | Code::Delete => {
382            let forward = code == Code::Delete;
383            match mods {
384                (false, false, false) => Some(DeleteCharacter { forward }),
385                (false, true, false) => Some(DeleteToNextWordBoundary { forward }),
386                (false, false, true) => Some(DeleteToLineBreak { forward }),
387                _ => None,
388            }
389        }
390        Code::ArrowLeft | Code::ArrowRight => {
391            let forward = forward_of(code);
392            match (mods, c.shift) {
393                ((false, false, false), _) => Some(ExtendByCharacter { forward, collapse }),
394                ((false, true, false), false) => Some(ExtendToNextWordBoundary {
395                    forward,
396                    collapse: true,
397                }),
398                ((false, true, false), true) => {
399                    Some(ExtendToNextWordBoundaryOrCaretLocation { forward })
400                }
401                ((false, false, true), false) => Some(ExtendToLineBreak {
402                    forward,
403                    collapse: true,
404                    continues_at_wrap: false,
405                }),
406                ((false, false, true), true) => Some(ExpandToLineBreak { forward }),
407                _ => None,
408            }
409        }
410        Code::ArrowUp | Code::ArrowDown => {
411            let forward = forward_of(code);
412            match (mods, c.shift) {
413                ((false, false, false), _) => {
414                    Some(ExtendVerticallyToAdjacentLine { forward, collapse })
415                }
416                ((false, true, false), false) => Some(ExtendToLineBreak {
417                    forward,
418                    collapse: true,
419                    continues_at_wrap: false,
420                }),
421                ((false, true, false), true) => {
422                    Some(ExtendToNextParagraphBoundaryOrCaretLocation { forward })
423                }
424                ((false, false, true), false) => Some(ExtendToDocumentBoundary {
425                    forward,
426                    collapse: true,
427                }),
428                ((false, false, true), true) => Some(ExpandToDocumentBoundary { forward }),
429                _ => None,
430            }
431        }
432        Code::Home | Code::End if mods == (false, false, false) => {
433            let forward = code == Code::End;
434            Some(if c.shift {
435                ExpandToDocumentBoundary { forward }
436            } else {
437                ScrollToDocumentBoundary { forward }
438            })
439        }
440        Code::PageUp | Code::PageDown if mods == (false, false, false) => {
441            let forward = code == Code::PageDown;
442            Some(if c.shift {
443                ExtendVerticallyToAdjacentPage {
444                    forward,
445                    collapse: false,
446                }
447            } else {
448                ScrollPage { forward }
449            })
450        }
451        Code::KeyX if c.only(false, false, false, true) => Some(Cut),
452        Code::KeyC if c.only(false, false, false, true) => Some(Copy),
453        Code::KeyV if c.only(false, false, false, true) => Some(Paste),
454        Code::KeyA if c.only(false, false, false, true) => Some(SelectAll),
455        Code::KeyZ if c.only(false, false, false, true) => Some(Undo),
456        Code::KeyZ if c.only(false, false, true, true) => Some(Redo),
457        // Emacs-style bindings.
458        Code::KeyE if c.only(true, false, false, false) => Some(ExtendToLineBreak {
459            forward: true,
460            collapse: true,
461            continues_at_wrap: false,
462        }),
463        Code::KeyA if c.only(true, false, false, false) => Some(ExtendToLineBreak {
464            forward: false,
465            collapse: true,
466            continues_at_wrap: false,
467        }),
468        Code::KeyF if c.only(true, false, false, false) => Some(ExtendByCharacter {
469            forward: true,
470            collapse: true,
471        }),
472        Code::KeyB if c.only(true, false, false, false) => Some(ExtendByCharacter {
473            forward: false,
474            collapse: true,
475        }),
476        Code::KeyN if c.only(true, false, false, false) => Some(ExtendVerticallyToAdjacentLine {
477            forward: true,
478            collapse: true,
479        }),
480        Code::KeyP if c.only(true, false, false, false) => Some(ExtendVerticallyToAdjacentLine {
481            forward: false,
482            collapse: true,
483        }),
484        Code::KeyT if c.only(true, false, false, false) => Some(TransposeCharacters),
485        Code::Space | Code::Enter if c.plain() => Some(DoNothing),
486        _ => None,
487    }
488}