pdfrum-form 0.1.0

Form interaction: events, focus, the edit control, the commit cascade
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
//! The text field's keyboard machine.
//!
//! **Typed text arrives as a character; navigation and shortcuts arrive as a
//! key.** Nothing crosses over: an accelerator-modified *character* is neither
//! a shortcut nor text, and is refused.
//!
//! The routing decision alone, as a pure function from an event to a named
//! [`TextAction`], so the shortcut table is pinned without a layout.
//!
//! **The clipboard is not ours.** The accelerator with C, V or X is refused
//! outright so an embedder's own handling sees it; the selected text is read
//! out and replacement text written in through ordinary calls.

use crate::event::{Key, Modifiers};

/// What an event asks the text field to do.
///
/// ```
/// use pdfrum_form::field::text::{route_char, Disposition, TextAction};
/// use pdfrum_form::Modifiers;
///
/// // Return means different things by field shape: a single-line field
/// // commits, a multiline one takes a paragraph break.
/// assert_eq!(
///     route_char('\r', Modifiers::NONE, Modifiers::CONTROL, false, false),
///     Disposition::Do(TextAction::Commit)
/// );
/// assert_eq!(
///     route_char('\r', Modifiers::NONE, Modifiers::CONTROL, false, true),
///     Disposition::Do(TextAction::InsertReturn)
/// );
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextAction {
    /// Insert a character at the caret, replacing any selection.
    Insert(char),
    /// Insert a paragraph break.
    InsertReturn,
    /// Delete backwards, or the selection.
    Backspace,
    /// Delete forwards, or the selection.
    Delete,
    /// Drop the selection without deleting anything.
    ClearSelection,
    /// Move the caret, optionally extending the selection.
    Move {
        /// Which way.
        motion: Motion,
        /// Whether the selection extends rather than collapsing.
        extend: bool,
    },
    /// Select the whole field.
    SelectAll,
    /// Undo one step.
    Undo,
    /// Redo one step.
    Redo,
    /// Commit the value and give up focus.
    Commit,
    /// Discard the edit and give up focus.
    Escape,
}

/// Which way a caret movement goes.
///
/// Home and End are line-wise on their own and document-wise with Control —
/// on every platform, not on the session's accelerator.
///
/// ```
/// use pdfrum_form::field::text::{route_key, Disposition, Motion, TextAction};
/// use pdfrum_form::{Key, Modifiers};
///
/// let line_wise = route_key(Key::Home, Modifiers::NONE, Modifiers::CONTROL, true, false);
/// assert_eq!(
///     line_wise,
///     Disposition::Do(TextAction::Move { motion: Motion::LineStart, extend: false })
/// );
///
/// let doc_wise = route_key(Key::Home, Modifiers::CONTROL, Modifiers::CONTROL, true, false);
/// assert_eq!(
///     doc_wise,
///     Disposition::Do(TextAction::Move { motion: Motion::DocStart, extend: false })
/// );
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Motion {
    /// One character left.
    Left,
    /// One character right.
    Right,
    /// One line up.
    Up,
    /// One line down.
    Down,
    /// To the start of the line.
    LineStart,
    /// To the end of the line.
    LineEnd,
    /// To the very start of the field.
    DocStart,
    /// To the very end of the field.
    DocEnd,
}

/// How an event was disposed of.
///
/// Consuming and ignoring are different answers, and a read-only field is the
/// case that separates them: it takes the character and does nothing with it.
///
/// ```
/// use pdfrum_form::field::text::{route_char, Disposition};
/// use pdfrum_form::Modifiers;
///
/// // Read-only: consumed, and nothing happens.
/// assert_eq!(
///     route_char('a', Modifiers::NONE, Modifiers::CONTROL, true, false),
///     Disposition::Consume
/// );
/// // A filtered character is ignored even by a read-only field, so a caller
/// // may pass it on.
/// assert_eq!(
///     route_char('\n', Modifiers::NONE, Modifiers::CONTROL, true, false),
///     Disposition::Ignore
/// );
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Disposition {
    /// The event asks for this, and was consumed.
    Do(TextAction),
    /// The event was consumed and asks for nothing — what a read-only field
    /// does with a keystroke.
    Consume,
    /// The event was not ours. The caller may pass it on.
    Ignore,
}

/// Whether these modifiers make a gesture a shortcut.
///
/// A **subset** test, as the oracle's is — the accelerator bit is set and alt
/// is not — rather than exact equality. The difference shows on a chord
/// carrying an extra modifier: equality would call it plain text, and the
/// oracle calls it a shortcut. Shift is deliberately not consulted here,
/// because each call site reads it differently: select-all and redo-with-Y
/// are disqualified by it, while undo-with-Z takes it as meaning redo.
///
/// ```
/// use pdfrum_form::Modifiers;
/// use pdfrum_form::field::text::is_shortcut;
///
/// assert!(is_shortcut(Modifiers::CONTROL, Modifiers::CONTROL));
/// // A chord carrying an extra modifier is still a shortcut.
/// assert!(is_shortcut(Modifiers::CONTROL | Modifiers::META, Modifiers::CONTROL));
/// // Alt takes it back out of shortcut territory.
/// assert!(!is_shortcut(Modifiers::CONTROL | Modifiers::ALT, Modifiers::CONTROL));
/// // And the other platform's modifier is not this platform's.
/// assert!(!is_shortcut(Modifiers::META, Modifiers::CONTROL));
/// ```
#[must_use]
pub fn is_shortcut(modifiers: Modifiers, accelerator: Modifiers) -> bool {
    modifiers.contains(accelerator) && !modifiers.contains(Modifiers::ALT)
}

/// Routes a key-down.
///
/// `accelerator` is the modifier that means "shortcut" — the platform's own,
/// which is configuration rather than a compile-time choice — and
/// `redo_on_y` says whether the accelerator with `Y` redoes, which is true
/// off Apple keyboards and false on them.
///
/// A shortcut with the *wrong* modifier is [`Disposition::Ignore`], not a
/// silent no-op: the assertions check the rejection as specifically as the
/// acceptance.
///
/// ```
/// use pdfrum_form::field::text::{route_key, Disposition, TextAction};
/// use pdfrum_form::{Key, Modifiers};
///
/// // A general-keyboard configuration: Control is the accelerator and Y redoes.
/// let general = |key, modifiers| route_key(key, modifiers, Modifiers::CONTROL, true, false);
///
/// // Select-all is the accelerator with A and only that; shift disqualifies it.
/// assert_eq!(general(Key::A, Modifiers::CONTROL), Disposition::Do(TextAction::SelectAll));
/// assert_eq!(
///     general(Key::A, Modifiers::CONTROL | Modifiers::SHIFT),
///     Disposition::Ignore
/// );
///
/// // Z is the one shortcut that reads shift as a meaning rather than a
/// // disqualifier.
/// assert_eq!(general(Key::Z, Modifiers::CONTROL), Disposition::Do(TextAction::Undo));
/// assert_eq!(
///     general(Key::Z, Modifiers::CONTROL | Modifiers::SHIFT),
///     Disposition::Do(TextAction::Redo)
/// );
///
/// // The wrong platform's modifier is rejected outright.
/// assert_eq!(general(Key::A, Modifiers::META), Disposition::Ignore);
/// ```
#[must_use]
pub fn route_key(
    key: Key,
    modifiers: Modifiers,
    accelerator: Modifiers,
    redo_on_y: bool,
    has_selection: bool,
) -> Disposition {
    // The oracle's test is a plain subset — the accelerator bit is set —
    // with shift and alt handled per call site rather than folded in here.
    // An exact-equality test would answer "not a shortcut" for a chord that
    // carries an extra modifier, where the oracle answers "shortcut".
    let shortcut = is_shortcut(modifiers, accelerator);
    let shift = modifiers.contains(Modifiers::SHIFT);
    let alt = modifiers.contains(Modifiers::ALT);
    // Document-wise Home and End are gated on Control on **every** platform,
    // not on the session's accelerator: the oracle reads the control bit
    // directly here and carries an open bug (crbug 448699368) saying the meta
    // key ought to work on Apple keyboards too. Reproduced rather than
    // improved, so that a caret-position assertion agrees with the oracle.
    let document_wise = modifiers.contains(Modifiers::CONTROL);

    // A forward delete with a selection is rewritten to a plain
    // clear-selection before the table is consulted, which is why deleting a
    // selection and pressing an unrecognized key take the same branch.
    if key == Key::Delete && has_selection {
        return Disposition::Do(TextAction::ClearSelection);
    }

    match key {
        Key::Delete => Disposition::Do(TextAction::Delete),
        Key::Up => Disposition::Do(TextAction::Move {
            motion: Motion::Up,
            extend: shift,
        }),
        Key::Down => Disposition::Do(TextAction::Move {
            motion: Motion::Down,
            extend: shift,
        }),
        Key::Left => Disposition::Do(TextAction::Move {
            motion: Motion::Left,
            extend: shift,
        }),
        Key::Right => Disposition::Do(TextAction::Move {
            motion: Motion::Right,
            extend: shift,
        }),
        Key::Home => Disposition::Do(TextAction::Move {
            motion: if document_wise {
                Motion::DocStart
            } else {
                Motion::LineStart
            },
            extend: shift,
        }),
        Key::End => Disposition::Do(TextAction::Move {
            motion: if document_wise {
                Motion::DocEnd
            } else {
                Motion::LineEnd
            },
            extend: shift,
        }),
        // Select-all takes the accelerator alone: adding shift is explicitly
        // not select-all.
        Key::A if shortcut && !shift && !alt => Disposition::Do(TextAction::SelectAll),
        Key::Y if redo_on_y && shortcut && !shift && !alt => Disposition::Do(TextAction::Redo),
        // Z is the one shortcut that takes shift as a meaning rather than a
        // disqualifier.
        Key::Z if shortcut && !alt => Disposition::Do(if shift {
            TextAction::Redo
        } else {
            TextAction::Undo
        }),
        // An unrecognized key clears the selection rather than being ignored,
        // which is the branch a rewritten delete arrives at.
        Key::Unknown => Disposition::Do(TextAction::ClearSelection),
        _ => Disposition::Ignore,
    }
}

/// Routes a typed character.
///
/// Three characters are refused outright — line feed, escape and the delete
/// control code — and so is any character carrying the accelerator without
/// alt. The delete refusal is deliberate and asserted: an embedder may send a
/// character alongside a delete key, and taking both would delete twice.
///
/// A **read-only** field consumes the character and does nothing, which is
/// not the same as ignoring it.
///
/// ```
/// use pdfrum_form::field::text::{route_char, Disposition, TextAction};
/// use pdfrum_form::Modifiers;
///
/// let typed = |ch| route_char(ch, Modifiers::NONE, Modifiers::CONTROL, false, false);
///
/// assert_eq!(typed('a'), Disposition::Do(TextAction::Insert('a')));
/// // Escape discards the in-progress edit rather than being filtered away.
/// assert_eq!(typed('\u{1B}'), Disposition::Do(TextAction::Escape));
/// // Line feed and the delete control code are refused, so an embedder that
/// // also sends a delete key does not delete twice.
/// assert_eq!(typed('\n'), Disposition::Ignore);
/// assert_eq!(typed('\u{7F}'), Disposition::Ignore);
/// ```
#[must_use]
pub fn route_char(
    ch: char,
    modifiers: Modifiers,
    accelerator: Modifiers,
    read_only: bool,
    multi_line: bool,
) -> Disposition {
    const LINE_FEED: char = '\u{0A}';
    const ESCAPE: char = '\u{1B}';
    const DELETE: char = '\u{7F}';
    const BACKSPACE: char = '\u{08}';
    const RETURN: char = '\u{0D}';

    // Escape never reaches the edit control's own filter: the field handler
    // above it intercepts the key, discards the in-progress edit and reports
    // the event consumed. Line feed and the delete control code really are
    // the filter's, and really do fall through unhandled.
    if ch == ESCAPE {
        return Disposition::Do(TextAction::Escape);
    }
    if matches!(ch, LINE_FEED | DELETE) {
        return Disposition::Ignore;
    }

    // The accelerator makes a character a shortcut's business, not text — and
    // shortcuts are decided on the key path, so this is simply a refusal.
    // Adding alt takes it back out of shortcut territory.
    if is_shortcut(modifiers, accelerator) {
        return Disposition::Ignore;
    }

    if read_only {
        return Disposition::Consume;
    }

    match ch {
        BACKSPACE => Disposition::Do(TextAction::Backspace),
        // A single-line field commits on Return; a multiline one takes it as
        // a paragraph break.
        RETURN => Disposition::Do(if multi_line {
            TextAction::InsertReturn
        } else {
            TextAction::Commit
        }),
        _ => Disposition::Do(TextAction::Insert(ch)),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The two platform configurations, so every shortcut row runs both ways
    /// on one machine — which the oracle's own tests cannot do.
    const GENERAL: (Modifiers, bool) = (Modifiers::CONTROL, true);
    const APPLE: (Modifiers, bool) = (Modifiers::META, false);

    fn key(k: Key, m: Modifiers, (accel, redo_y): (Modifiers, bool)) -> Disposition {
        route_key(k, m, accel, redo_y, false)
    }

    fn ch(c: char, m: Modifiers, (accel, _): (Modifiers, bool)) -> Disposition {
        route_char(c, m, accel, false, false)
    }

    /// Select-all is the accelerator with A, and only that.
    #[test]
    fn select_all_takes_the_accelerator_alone() {
        for platform in [GENERAL, APPLE] {
            let (accel, _) = platform;
            assert_eq!(
                key(Key::A, accel, platform),
                Disposition::Do(TextAction::SelectAll)
            );
            // Shift disqualifies it explicitly.
            assert_eq!(
                key(Key::A, accel | Modifiers::SHIFT, platform),
                Disposition::Ignore
            );
            // And so does no modifier at all.
            assert_eq!(key(Key::A, Modifiers::NONE, platform), Disposition::Ignore);
        }
    }

    /// The other platform's modifier is rejected, which is asserted as hard
    /// as the right one being accepted.
    #[test]
    fn the_wrong_platforms_modifier_is_rejected() {
        assert_eq!(key(Key::A, Modifiers::META, GENERAL), Disposition::Ignore);
        assert_eq!(key(Key::A, Modifiers::CONTROL, APPLE), Disposition::Ignore);
        assert_eq!(key(Key::Z, Modifiers::META, GENERAL), Disposition::Ignore);
        assert_eq!(key(Key::Z, Modifiers::CONTROL, APPLE), Disposition::Ignore);
    }

    /// Z takes shift as a meaning rather than a disqualifier — the only
    /// shortcut that does.
    #[test]
    fn the_accelerator_with_z_undoes_and_with_shift_redoes() {
        for platform in [GENERAL, APPLE] {
            let (accel, _) = platform;
            assert_eq!(
                key(Key::Z, accel, platform),
                Disposition::Do(TextAction::Undo)
            );
            assert_eq!(
                key(Key::Z, accel | Modifiers::SHIFT, platform),
                Disposition::Do(TextAction::Redo)
            );
        }
    }

    /// Y redoes off Apple and does nothing on it, while shift-Z redoes on
    /// both. That asymmetry is real and tested rather than smoothed away.
    #[test]
    fn the_accelerator_with_y_redoes_only_off_apple() {
        assert_eq!(
            key(Key::Y, Modifiers::CONTROL, GENERAL),
            Disposition::Do(TextAction::Redo)
        );
        assert_eq!(key(Key::Y, Modifiers::META, APPLE), Disposition::Ignore);

        // Shift disqualifies it on the platform that has it at all.
        assert_eq!(
            key(Key::Y, Modifiers::CONTROL | Modifiers::SHIFT, GENERAL),
            Disposition::Ignore
        );
    }

    /// The clipboard is the embedder's, so these three fall straight through.
    #[test]
    fn the_clipboard_shortcuts_are_not_ours() {
        for platform in [GENERAL, APPLE] {
            let (accel, _) = platform;
            for letter in [
                Key::from_virtual(0x43),
                Key::from_virtual(0x56),
                Key::from_virtual(0x58),
            ] {
                assert_eq!(
                    key(letter, accel, platform),
                    Disposition::Ignore,
                    "cut, copy and paste belong to the embedder"
                );
            }
        }
    }

    /// Navigation is handled, and the accelerator turns line-wise motion into
    /// document-wise motion.
    #[test]
    fn navigation_keys_are_handled() {
        for platform in [GENERAL, APPLE] {
            assert_eq!(
                key(Key::Left, Modifiers::NONE, platform),
                Disposition::Do(TextAction::Move {
                    motion: Motion::Left,
                    extend: false
                })
            );
            assert_eq!(
                key(Key::Home, Modifiers::NONE, platform),
                Disposition::Do(TextAction::Move {
                    motion: Motion::LineStart,
                    extend: false
                })
            );
            assert_eq!(
                key(Key::Home, Modifiers::CONTROL, platform),
                Disposition::Do(TextAction::Move {
                    motion: Motion::DocStart,
                    extend: false
                })
            );
            assert_eq!(
                key(Key::Up, Modifiers::NONE, platform),
                Disposition::Do(TextAction::Move {
                    motion: Motion::Up,
                    extend: false
                })
            );
        }
    }

    /// Shift extends rather than collapsing, on every motion.
    #[test]
    fn shift_extends_every_motion() {
        for k in [
            Key::Left,
            Key::Right,
            Key::Up,
            Key::Down,
            Key::Home,
            Key::End,
        ] {
            let Disposition::Do(TextAction::Move { extend, .. }) =
                key(k, Modifiers::SHIFT, GENERAL)
            else {
                panic!("{k:?} should move");
            };
            assert!(extend, "{k:?} with shift should extend");
        }
    }

    /// A forward delete with a selection removes the selection, by way of a
    /// rewrite that lands in the same branch an unrecognized key does.
    #[test]
    fn delete_with_a_selection_clears_it() {
        assert_eq!(
            route_key(Key::Delete, Modifiers::NONE, Modifiers::CONTROL, true, true),
            Disposition::Do(TextAction::ClearSelection)
        );
        assert_eq!(
            route_key(
                Key::Delete,
                Modifiers::NONE,
                Modifiers::CONTROL,
                true,
                false
            ),
            Disposition::Do(TextAction::Delete)
        );
        assert_eq!(
            key(Key::Unknown, Modifiers::NONE, GENERAL),
            Disposition::Do(TextAction::ClearSelection)
        );
    }

    /// Keys the field does not decide on fall through, so a caller can act on
    /// them — which is how Tab reaches focus traversal.
    #[test]
    fn undecided_keys_fall_through() {
        for k in [
            Key::Tab,
            Key::Space,
            Key::PageUp,
            Key::PageDown,
            Key::Insert,
            Key::from_virtual(0x70), // F1
            Key::from_virtual(0x30), // '0'
        ] {
            assert_eq!(
                key(k, Modifiers::NONE, GENERAL),
                Disposition::Ignore,
                "{k:?} is not the text field's"
            );
        }
    }

    /// The negative half of the split: an accelerator-modified character is
    /// not a shortcut here, and it is not text either.
    #[test]
    fn an_accelerator_modified_character_is_refused() {
        // The control code the accelerator with A produces.
        assert_eq!(
            ch('\u{01}', Modifiers::CONTROL, GENERAL),
            Disposition::Ignore
        );
        assert_eq!(ch('a', Modifiers::CONTROL, GENERAL), Disposition::Ignore);
        assert_eq!(ch('a', Modifiers::META, APPLE), Disposition::Ignore);

        // …but alt takes it back out of shortcut territory.
        assert_eq!(
            ch('a', Modifiers::CONTROL | Modifiers::ALT, GENERAL),
            Disposition::Do(TextAction::Insert('a'))
        );
    }

    /// Two characters are filtered before anything else looks at them. The
    /// delete refusal matters: an embedder may send a character alongside a
    /// delete key, and taking both would delete twice.
    #[test]
    fn line_feed_and_delete_are_filtered() {
        for c in ['\u{0A}', '\u{7F}'] {
            assert_eq!(
                ch(c, Modifiers::NONE, GENERAL),
                Disposition::Ignore,
                "{c:?} must not reach the text"
            );
        }
    }

    /// Escape is **not** one of them: it is intercepted a layer above the
    /// edit control's filter, discards the in-progress edit, and reports the
    /// event consumed.
    #[test]
    fn escape_discards_the_edit_and_is_consumed() {
        assert_eq!(
            ch('\u{1B}', Modifiers::NONE, GENERAL),
            Disposition::Do(TextAction::Escape)
        );
        // A read-only field has nothing to discard, but the key is still the
        // field handler's rather than the filter's.
        assert_eq!(
            route_char('\u{1B}', Modifiers::NONE, Modifiers::CONTROL, true, false),
            Disposition::Do(TextAction::Escape)
        );
    }

    /// Home and End take document-wise motion from **Control** on every
    /// platform, not from the session's accelerator: the oracle reads the
    /// control bit directly and carries an open bug saying the meta key
    /// ought to work on Apple keyboards too.
    #[test]
    fn document_wise_motion_is_control_on_every_platform() {
        for platform in [GENERAL, APPLE] {
            assert_eq!(
                key(Key::Home, Modifiers::CONTROL, platform),
                Disposition::Do(TextAction::Move {
                    motion: Motion::DocStart,
                    extend: false
                }),
                "Control+Home is document-wise everywhere"
            );
        }

        // On an Apple configuration the accelerator is Meta, and it does
        // *not* widen the motion — reproduced, not improved.
        assert_eq!(
            key(Key::Home, Modifiers::META, APPLE),
            Disposition::Do(TextAction::Move {
                motion: Motion::LineStart,
                extend: false
            })
        );
    }

    /// The shortcut test is a subset test, so a chord carrying an extra
    /// modifier is still a shortcut — which is what the oracle answers and
    /// what an exact-equality test would get wrong.
    #[test]
    fn an_extra_modifier_does_not_stop_a_chord_being_a_shortcut() {
        assert!(is_shortcut(Modifiers::CONTROL, Modifiers::CONTROL));
        assert!(is_shortcut(
            Modifiers::CONTROL | Modifiers::META,
            Modifiers::CONTROL
        ));
        // Alt takes it back out of shortcut territory.
        assert!(!is_shortcut(
            Modifiers::CONTROL | Modifiers::ALT,
            Modifiers::CONTROL
        ));
        assert!(!is_shortcut(Modifiers::META, Modifiers::CONTROL));
    }

    /// Return passes through and means different things by field shape;
    /// backspace passes through and always deletes.
    #[test]
    fn return_and_backspace_pass_the_filter() {
        assert_eq!(
            route_char('\u{0D}', Modifiers::NONE, Modifiers::CONTROL, false, false),
            Disposition::Do(TextAction::Commit),
            "a single-line field commits on Return"
        );
        assert_eq!(
            route_char('\u{0D}', Modifiers::NONE, Modifiers::CONTROL, false, true),
            Disposition::Do(TextAction::InsertReturn),
            "a multiline one takes a paragraph break"
        );
        assert_eq!(
            ch('\u{08}', Modifiers::NONE, GENERAL),
            Disposition::Do(TextAction::Backspace)
        );
    }

    /// A read-only field consumes the character and does nothing, which is a
    /// third answer distinct from both acting and ignoring.
    #[test]
    fn a_read_only_field_consumes_without_acting() {
        assert_eq!(
            route_char('a', Modifiers::NONE, Modifiers::CONTROL, true, false),
            Disposition::Consume
        );
        // The filter still runs first: a filtered character is ignored, not
        // consumed, even by a read-only field.
        assert_eq!(
            route_char('\u{0A}', Modifiers::NONE, Modifiers::CONTROL, true, false),
            Disposition::Ignore
        );
    }

    /// Ordinary text is inserted, including text well outside ASCII — the
    /// right-to-left fixtures type Hebrew directly.
    #[test]
    fn ordinary_characters_are_inserted() {
        assert_eq!(
            ch('a', Modifiers::NONE, GENERAL),
            Disposition::Do(TextAction::Insert('a'))
        );
        assert_eq!(
            ch(' ', Modifiers::NONE, GENERAL),
            Disposition::Do(TextAction::Insert(' '))
        );
        // Hebrew bet, from the right-to-left live-edit fixture.
        assert_eq!(
            ch('\u{05D1}', Modifiers::NONE, GENERAL),
            Disposition::Do(TextAction::Insert('\u{05D1}'))
        );
    }

    /// Shift is a text modifier, not a disqualifier: shift-A is a capital A.
    #[test]
    fn shift_does_not_stop_a_character_being_text() {
        assert_eq!(
            ch('A', Modifiers::SHIFT, GENERAL),
            Disposition::Do(TextAction::Insert('A'))
        );
    }
}