1use incular_core::{Code, Modifiers};
11
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub enum TextShortcutPlatform {
16 Android,
17 Ios,
18 Linux,
19 MacOs,
20 Windows,
21}
22
23impl TextShortcutPlatform {
24 #[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 #[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 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#[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 DoNothing,
162}
163
164pub(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 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
211fn 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 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
268fn 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 Code::Space | Code::Enter if c.plain() => Some(DoNothing),
284 _ => None,
285 }
286}
287
288fn 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
319fn 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 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
373fn 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 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 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}