Skip to main content

pdfrum_form/field/
text.rs

1//! The text field's keyboard machine.
2//!
3//! **Typed text arrives as a character; navigation and shortcuts arrive as a
4//! key.** Nothing crosses over: an accelerator-modified *character* is neither
5//! a shortcut nor text, and is refused.
6//!
7//! The routing decision alone, as a pure function from an event to a named
8//! [`TextAction`], so the shortcut table is pinned without a layout.
9//!
10//! **The clipboard is not ours.** The accelerator with C, V or X is refused
11//! outright so an embedder's own handling sees it; the selected text is read
12//! out and replacement text written in through ordinary calls.
13
14use crate::event::{Key, Modifiers};
15
16/// What an event asks the text field to do.
17///
18/// ```
19/// use pdfrum_form::field::text::{route_char, Disposition, TextAction};
20/// use pdfrum_form::Modifiers;
21///
22/// // Return means different things by field shape: a single-line field
23/// // commits, a multiline one takes a paragraph break.
24/// assert_eq!(
25///     route_char('\r', Modifiers::NONE, Modifiers::CONTROL, false, false),
26///     Disposition::Do(TextAction::Commit)
27/// );
28/// assert_eq!(
29///     route_char('\r', Modifiers::NONE, Modifiers::CONTROL, false, true),
30///     Disposition::Do(TextAction::InsertReturn)
31/// );
32/// ```
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum TextAction {
35    /// Insert a character at the caret, replacing any selection.
36    Insert(char),
37    /// Insert a paragraph break.
38    InsertReturn,
39    /// Delete backwards, or the selection.
40    Backspace,
41    /// Delete forwards, or the selection.
42    Delete,
43    /// Drop the selection without deleting anything.
44    ClearSelection,
45    /// Move the caret, optionally extending the selection.
46    Move {
47        /// Which way.
48        motion: Motion,
49        /// Whether the selection extends rather than collapsing.
50        extend: bool,
51    },
52    /// Select the whole field.
53    SelectAll,
54    /// Undo one step.
55    Undo,
56    /// Redo one step.
57    Redo,
58    /// Commit the value and give up focus.
59    Commit,
60    /// Discard the edit and give up focus.
61    Escape,
62}
63
64/// Which way a caret movement goes.
65///
66/// Home and End are line-wise on their own and document-wise with Control —
67/// on every platform, not on the session's accelerator.
68///
69/// ```
70/// use pdfrum_form::field::text::{route_key, Disposition, Motion, TextAction};
71/// use pdfrum_form::{Key, Modifiers};
72///
73/// let line_wise = route_key(Key::Home, Modifiers::NONE, Modifiers::CONTROL, true, false);
74/// assert_eq!(
75///     line_wise,
76///     Disposition::Do(TextAction::Move { motion: Motion::LineStart, extend: false })
77/// );
78///
79/// let doc_wise = route_key(Key::Home, Modifiers::CONTROL, Modifiers::CONTROL, true, false);
80/// assert_eq!(
81///     doc_wise,
82///     Disposition::Do(TextAction::Move { motion: Motion::DocStart, extend: false })
83/// );
84/// ```
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum Motion {
87    /// One character left.
88    Left,
89    /// One character right.
90    Right,
91    /// One line up.
92    Up,
93    /// One line down.
94    Down,
95    /// To the start of the line.
96    LineStart,
97    /// To the end of the line.
98    LineEnd,
99    /// To the very start of the field.
100    DocStart,
101    /// To the very end of the field.
102    DocEnd,
103}
104
105/// How an event was disposed of.
106///
107/// Consuming and ignoring are different answers, and a read-only field is the
108/// case that separates them: it takes the character and does nothing with it.
109///
110/// ```
111/// use pdfrum_form::field::text::{route_char, Disposition};
112/// use pdfrum_form::Modifiers;
113///
114/// // Read-only: consumed, and nothing happens.
115/// assert_eq!(
116///     route_char('a', Modifiers::NONE, Modifiers::CONTROL, true, false),
117///     Disposition::Consume
118/// );
119/// // A filtered character is ignored even by a read-only field, so a caller
120/// // may pass it on.
121/// assert_eq!(
122///     route_char('\n', Modifiers::NONE, Modifiers::CONTROL, true, false),
123///     Disposition::Ignore
124/// );
125/// ```
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub enum Disposition {
128    /// The event asks for this, and was consumed.
129    Do(TextAction),
130    /// The event was consumed and asks for nothing — what a read-only field
131    /// does with a keystroke.
132    Consume,
133    /// The event was not ours. The caller may pass it on.
134    Ignore,
135}
136
137/// Whether these modifiers make a gesture a shortcut.
138///
139/// A **subset** test, as the oracle's is — the accelerator bit is set and alt
140/// is not — rather than exact equality. The difference shows on a chord
141/// carrying an extra modifier: equality would call it plain text, and the
142/// oracle calls it a shortcut. Shift is deliberately not consulted here,
143/// because each call site reads it differently: select-all and redo-with-Y
144/// are disqualified by it, while undo-with-Z takes it as meaning redo.
145///
146/// ```
147/// use pdfrum_form::Modifiers;
148/// use pdfrum_form::field::text::is_shortcut;
149///
150/// assert!(is_shortcut(Modifiers::CONTROL, Modifiers::CONTROL));
151/// // A chord carrying an extra modifier is still a shortcut.
152/// assert!(is_shortcut(Modifiers::CONTROL | Modifiers::META, Modifiers::CONTROL));
153/// // Alt takes it back out of shortcut territory.
154/// assert!(!is_shortcut(Modifiers::CONTROL | Modifiers::ALT, Modifiers::CONTROL));
155/// // And the other platform's modifier is not this platform's.
156/// assert!(!is_shortcut(Modifiers::META, Modifiers::CONTROL));
157/// ```
158#[must_use]
159pub fn is_shortcut(modifiers: Modifiers, accelerator: Modifiers) -> bool {
160    modifiers.contains(accelerator) && !modifiers.contains(Modifiers::ALT)
161}
162
163/// Routes a key-down.
164///
165/// `accelerator` is the modifier that means "shortcut" — the platform's own,
166/// which is configuration rather than a compile-time choice — and
167/// `redo_on_y` says whether the accelerator with `Y` redoes, which is true
168/// off Apple keyboards and false on them.
169///
170/// A shortcut with the *wrong* modifier is [`Disposition::Ignore`], not a
171/// silent no-op: the assertions check the rejection as specifically as the
172/// acceptance.
173///
174/// ```
175/// use pdfrum_form::field::text::{route_key, Disposition, TextAction};
176/// use pdfrum_form::{Key, Modifiers};
177///
178/// // A general-keyboard configuration: Control is the accelerator and Y redoes.
179/// let general = |key, modifiers| route_key(key, modifiers, Modifiers::CONTROL, true, false);
180///
181/// // Select-all is the accelerator with A and only that; shift disqualifies it.
182/// assert_eq!(general(Key::A, Modifiers::CONTROL), Disposition::Do(TextAction::SelectAll));
183/// assert_eq!(
184///     general(Key::A, Modifiers::CONTROL | Modifiers::SHIFT),
185///     Disposition::Ignore
186/// );
187///
188/// // Z is the one shortcut that reads shift as a meaning rather than a
189/// // disqualifier.
190/// assert_eq!(general(Key::Z, Modifiers::CONTROL), Disposition::Do(TextAction::Undo));
191/// assert_eq!(
192///     general(Key::Z, Modifiers::CONTROL | Modifiers::SHIFT),
193///     Disposition::Do(TextAction::Redo)
194/// );
195///
196/// // The wrong platform's modifier is rejected outright.
197/// assert_eq!(general(Key::A, Modifiers::META), Disposition::Ignore);
198/// ```
199#[must_use]
200pub fn route_key(
201    key: Key,
202    modifiers: Modifiers,
203    accelerator: Modifiers,
204    redo_on_y: bool,
205    has_selection: bool,
206) -> Disposition {
207    // The oracle's test is a plain subset — the accelerator bit is set —
208    // with shift and alt handled per call site rather than folded in here.
209    // An exact-equality test would answer "not a shortcut" for a chord that
210    // carries an extra modifier, where the oracle answers "shortcut".
211    let shortcut = is_shortcut(modifiers, accelerator);
212    let shift = modifiers.contains(Modifiers::SHIFT);
213    let alt = modifiers.contains(Modifiers::ALT);
214    // Document-wise Home and End are gated on Control on **every** platform,
215    // not on the session's accelerator: the oracle reads the control bit
216    // directly here and carries an open bug (crbug 448699368) saying the meta
217    // key ought to work on Apple keyboards too. Reproduced rather than
218    // improved, so that a caret-position assertion agrees with the oracle.
219    let document_wise = modifiers.contains(Modifiers::CONTROL);
220
221    // A forward delete with a selection is rewritten to a plain
222    // clear-selection before the table is consulted, which is why deleting a
223    // selection and pressing an unrecognized key take the same branch.
224    if key == Key::Delete && has_selection {
225        return Disposition::Do(TextAction::ClearSelection);
226    }
227
228    match key {
229        Key::Delete => Disposition::Do(TextAction::Delete),
230        Key::Up => Disposition::Do(TextAction::Move {
231            motion: Motion::Up,
232            extend: shift,
233        }),
234        Key::Down => Disposition::Do(TextAction::Move {
235            motion: Motion::Down,
236            extend: shift,
237        }),
238        Key::Left => Disposition::Do(TextAction::Move {
239            motion: Motion::Left,
240            extend: shift,
241        }),
242        Key::Right => Disposition::Do(TextAction::Move {
243            motion: Motion::Right,
244            extend: shift,
245        }),
246        Key::Home => Disposition::Do(TextAction::Move {
247            motion: if document_wise {
248                Motion::DocStart
249            } else {
250                Motion::LineStart
251            },
252            extend: shift,
253        }),
254        Key::End => Disposition::Do(TextAction::Move {
255            motion: if document_wise {
256                Motion::DocEnd
257            } else {
258                Motion::LineEnd
259            },
260            extend: shift,
261        }),
262        // Select-all takes the accelerator alone: adding shift is explicitly
263        // not select-all.
264        Key::A if shortcut && !shift && !alt => Disposition::Do(TextAction::SelectAll),
265        Key::Y if redo_on_y && shortcut && !shift && !alt => Disposition::Do(TextAction::Redo),
266        // Z is the one shortcut that takes shift as a meaning rather than a
267        // disqualifier.
268        Key::Z if shortcut && !alt => Disposition::Do(if shift {
269            TextAction::Redo
270        } else {
271            TextAction::Undo
272        }),
273        // An unrecognized key clears the selection rather than being ignored,
274        // which is the branch a rewritten delete arrives at.
275        Key::Unknown => Disposition::Do(TextAction::ClearSelection),
276        _ => Disposition::Ignore,
277    }
278}
279
280/// Routes a typed character.
281///
282/// Three characters are refused outright — line feed, escape and the delete
283/// control code — and so is any character carrying the accelerator without
284/// alt. The delete refusal is deliberate and asserted: an embedder may send a
285/// character alongside a delete key, and taking both would delete twice.
286///
287/// A **read-only** field consumes the character and does nothing, which is
288/// not the same as ignoring it.
289///
290/// ```
291/// use pdfrum_form::field::text::{route_char, Disposition, TextAction};
292/// use pdfrum_form::Modifiers;
293///
294/// let typed = |ch| route_char(ch, Modifiers::NONE, Modifiers::CONTROL, false, false);
295///
296/// assert_eq!(typed('a'), Disposition::Do(TextAction::Insert('a')));
297/// // Escape discards the in-progress edit rather than being filtered away.
298/// assert_eq!(typed('\u{1B}'), Disposition::Do(TextAction::Escape));
299/// // Line feed and the delete control code are refused, so an embedder that
300/// // also sends a delete key does not delete twice.
301/// assert_eq!(typed('\n'), Disposition::Ignore);
302/// assert_eq!(typed('\u{7F}'), Disposition::Ignore);
303/// ```
304#[must_use]
305pub fn route_char(
306    ch: char,
307    modifiers: Modifiers,
308    accelerator: Modifiers,
309    read_only: bool,
310    multi_line: bool,
311) -> Disposition {
312    const LINE_FEED: char = '\u{0A}';
313    const ESCAPE: char = '\u{1B}';
314    const DELETE: char = '\u{7F}';
315    const BACKSPACE: char = '\u{08}';
316    const RETURN: char = '\u{0D}';
317
318    // Escape never reaches the edit control's own filter: the field handler
319    // above it intercepts the key, discards the in-progress edit and reports
320    // the event consumed. Line feed and the delete control code really are
321    // the filter's, and really do fall through unhandled.
322    if ch == ESCAPE {
323        return Disposition::Do(TextAction::Escape);
324    }
325    if matches!(ch, LINE_FEED | DELETE) {
326        return Disposition::Ignore;
327    }
328
329    // The accelerator makes a character a shortcut's business, not text — and
330    // shortcuts are decided on the key path, so this is simply a refusal.
331    // Adding alt takes it back out of shortcut territory.
332    if is_shortcut(modifiers, accelerator) {
333        return Disposition::Ignore;
334    }
335
336    if read_only {
337        return Disposition::Consume;
338    }
339
340    match ch {
341        BACKSPACE => Disposition::Do(TextAction::Backspace),
342        // A single-line field commits on Return; a multiline one takes it as
343        // a paragraph break.
344        RETURN => Disposition::Do(if multi_line {
345            TextAction::InsertReturn
346        } else {
347            TextAction::Commit
348        }),
349        _ => Disposition::Do(TextAction::Insert(ch)),
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    /// The two platform configurations, so every shortcut row runs both ways
358    /// on one machine — which the oracle's own tests cannot do.
359    const GENERAL: (Modifiers, bool) = (Modifiers::CONTROL, true);
360    const APPLE: (Modifiers, bool) = (Modifiers::META, false);
361
362    fn key(k: Key, m: Modifiers, (accel, redo_y): (Modifiers, bool)) -> Disposition {
363        route_key(k, m, accel, redo_y, false)
364    }
365
366    fn ch(c: char, m: Modifiers, (accel, _): (Modifiers, bool)) -> Disposition {
367        route_char(c, m, accel, false, false)
368    }
369
370    /// Select-all is the accelerator with A, and only that.
371    #[test]
372    fn select_all_takes_the_accelerator_alone() {
373        for platform in [GENERAL, APPLE] {
374            let (accel, _) = platform;
375            assert_eq!(
376                key(Key::A, accel, platform),
377                Disposition::Do(TextAction::SelectAll)
378            );
379            // Shift disqualifies it explicitly.
380            assert_eq!(
381                key(Key::A, accel | Modifiers::SHIFT, platform),
382                Disposition::Ignore
383            );
384            // And so does no modifier at all.
385            assert_eq!(key(Key::A, Modifiers::NONE, platform), Disposition::Ignore);
386        }
387    }
388
389    /// The other platform's modifier is rejected, which is asserted as hard
390    /// as the right one being accepted.
391    #[test]
392    fn the_wrong_platforms_modifier_is_rejected() {
393        assert_eq!(key(Key::A, Modifiers::META, GENERAL), Disposition::Ignore);
394        assert_eq!(key(Key::A, Modifiers::CONTROL, APPLE), Disposition::Ignore);
395        assert_eq!(key(Key::Z, Modifiers::META, GENERAL), Disposition::Ignore);
396        assert_eq!(key(Key::Z, Modifiers::CONTROL, APPLE), Disposition::Ignore);
397    }
398
399    /// Z takes shift as a meaning rather than a disqualifier — the only
400    /// shortcut that does.
401    #[test]
402    fn the_accelerator_with_z_undoes_and_with_shift_redoes() {
403        for platform in [GENERAL, APPLE] {
404            let (accel, _) = platform;
405            assert_eq!(
406                key(Key::Z, accel, platform),
407                Disposition::Do(TextAction::Undo)
408            );
409            assert_eq!(
410                key(Key::Z, accel | Modifiers::SHIFT, platform),
411                Disposition::Do(TextAction::Redo)
412            );
413        }
414    }
415
416    /// Y redoes off Apple and does nothing on it, while shift-Z redoes on
417    /// both. That asymmetry is real and tested rather than smoothed away.
418    #[test]
419    fn the_accelerator_with_y_redoes_only_off_apple() {
420        assert_eq!(
421            key(Key::Y, Modifiers::CONTROL, GENERAL),
422            Disposition::Do(TextAction::Redo)
423        );
424        assert_eq!(key(Key::Y, Modifiers::META, APPLE), Disposition::Ignore);
425
426        // Shift disqualifies it on the platform that has it at all.
427        assert_eq!(
428            key(Key::Y, Modifiers::CONTROL | Modifiers::SHIFT, GENERAL),
429            Disposition::Ignore
430        );
431    }
432
433    /// The clipboard is the embedder's, so these three fall straight through.
434    #[test]
435    fn the_clipboard_shortcuts_are_not_ours() {
436        for platform in [GENERAL, APPLE] {
437            let (accel, _) = platform;
438            for letter in [
439                Key::from_virtual(0x43),
440                Key::from_virtual(0x56),
441                Key::from_virtual(0x58),
442            ] {
443                assert_eq!(
444                    key(letter, accel, platform),
445                    Disposition::Ignore,
446                    "cut, copy and paste belong to the embedder"
447                );
448            }
449        }
450    }
451
452    /// Navigation is handled, and the accelerator turns line-wise motion into
453    /// document-wise motion.
454    #[test]
455    fn navigation_keys_are_handled() {
456        for platform in [GENERAL, APPLE] {
457            assert_eq!(
458                key(Key::Left, Modifiers::NONE, platform),
459                Disposition::Do(TextAction::Move {
460                    motion: Motion::Left,
461                    extend: false
462                })
463            );
464            assert_eq!(
465                key(Key::Home, Modifiers::NONE, platform),
466                Disposition::Do(TextAction::Move {
467                    motion: Motion::LineStart,
468                    extend: false
469                })
470            );
471            assert_eq!(
472                key(Key::Home, Modifiers::CONTROL, platform),
473                Disposition::Do(TextAction::Move {
474                    motion: Motion::DocStart,
475                    extend: false
476                })
477            );
478            assert_eq!(
479                key(Key::Up, Modifiers::NONE, platform),
480                Disposition::Do(TextAction::Move {
481                    motion: Motion::Up,
482                    extend: false
483                })
484            );
485        }
486    }
487
488    /// Shift extends rather than collapsing, on every motion.
489    #[test]
490    fn shift_extends_every_motion() {
491        for k in [
492            Key::Left,
493            Key::Right,
494            Key::Up,
495            Key::Down,
496            Key::Home,
497            Key::End,
498        ] {
499            let Disposition::Do(TextAction::Move { extend, .. }) =
500                key(k, Modifiers::SHIFT, GENERAL)
501            else {
502                panic!("{k:?} should move");
503            };
504            assert!(extend, "{k:?} with shift should extend");
505        }
506    }
507
508    /// A forward delete with a selection removes the selection, by way of a
509    /// rewrite that lands in the same branch an unrecognized key does.
510    #[test]
511    fn delete_with_a_selection_clears_it() {
512        assert_eq!(
513            route_key(Key::Delete, Modifiers::NONE, Modifiers::CONTROL, true, true),
514            Disposition::Do(TextAction::ClearSelection)
515        );
516        assert_eq!(
517            route_key(
518                Key::Delete,
519                Modifiers::NONE,
520                Modifiers::CONTROL,
521                true,
522                false
523            ),
524            Disposition::Do(TextAction::Delete)
525        );
526        assert_eq!(
527            key(Key::Unknown, Modifiers::NONE, GENERAL),
528            Disposition::Do(TextAction::ClearSelection)
529        );
530    }
531
532    /// Keys the field does not decide on fall through, so a caller can act on
533    /// them — which is how Tab reaches focus traversal.
534    #[test]
535    fn undecided_keys_fall_through() {
536        for k in [
537            Key::Tab,
538            Key::Space,
539            Key::PageUp,
540            Key::PageDown,
541            Key::Insert,
542            Key::from_virtual(0x70), // F1
543            Key::from_virtual(0x30), // '0'
544        ] {
545            assert_eq!(
546                key(k, Modifiers::NONE, GENERAL),
547                Disposition::Ignore,
548                "{k:?} is not the text field's"
549            );
550        }
551    }
552
553    /// The negative half of the split: an accelerator-modified character is
554    /// not a shortcut here, and it is not text either.
555    #[test]
556    fn an_accelerator_modified_character_is_refused() {
557        // The control code the accelerator with A produces.
558        assert_eq!(
559            ch('\u{01}', Modifiers::CONTROL, GENERAL),
560            Disposition::Ignore
561        );
562        assert_eq!(ch('a', Modifiers::CONTROL, GENERAL), Disposition::Ignore);
563        assert_eq!(ch('a', Modifiers::META, APPLE), Disposition::Ignore);
564
565        // …but alt takes it back out of shortcut territory.
566        assert_eq!(
567            ch('a', Modifiers::CONTROL | Modifiers::ALT, GENERAL),
568            Disposition::Do(TextAction::Insert('a'))
569        );
570    }
571
572    /// Two characters are filtered before anything else looks at them. The
573    /// delete refusal matters: an embedder may send a character alongside a
574    /// delete key, and taking both would delete twice.
575    #[test]
576    fn line_feed_and_delete_are_filtered() {
577        for c in ['\u{0A}', '\u{7F}'] {
578            assert_eq!(
579                ch(c, Modifiers::NONE, GENERAL),
580                Disposition::Ignore,
581                "{c:?} must not reach the text"
582            );
583        }
584    }
585
586    /// Escape is **not** one of them: it is intercepted a layer above the
587    /// edit control's filter, discards the in-progress edit, and reports the
588    /// event consumed.
589    #[test]
590    fn escape_discards_the_edit_and_is_consumed() {
591        assert_eq!(
592            ch('\u{1B}', Modifiers::NONE, GENERAL),
593            Disposition::Do(TextAction::Escape)
594        );
595        // A read-only field has nothing to discard, but the key is still the
596        // field handler's rather than the filter's.
597        assert_eq!(
598            route_char('\u{1B}', Modifiers::NONE, Modifiers::CONTROL, true, false),
599            Disposition::Do(TextAction::Escape)
600        );
601    }
602
603    /// Home and End take document-wise motion from **Control** on every
604    /// platform, not from the session's accelerator: the oracle reads the
605    /// control bit directly and carries an open bug saying the meta key
606    /// ought to work on Apple keyboards too.
607    #[test]
608    fn document_wise_motion_is_control_on_every_platform() {
609        for platform in [GENERAL, APPLE] {
610            assert_eq!(
611                key(Key::Home, Modifiers::CONTROL, platform),
612                Disposition::Do(TextAction::Move {
613                    motion: Motion::DocStart,
614                    extend: false
615                }),
616                "Control+Home is document-wise everywhere"
617            );
618        }
619
620        // On an Apple configuration the accelerator is Meta, and it does
621        // *not* widen the motion — reproduced, not improved.
622        assert_eq!(
623            key(Key::Home, Modifiers::META, APPLE),
624            Disposition::Do(TextAction::Move {
625                motion: Motion::LineStart,
626                extend: false
627            })
628        );
629    }
630
631    /// The shortcut test is a subset test, so a chord carrying an extra
632    /// modifier is still a shortcut — which is what the oracle answers and
633    /// what an exact-equality test would get wrong.
634    #[test]
635    fn an_extra_modifier_does_not_stop_a_chord_being_a_shortcut() {
636        assert!(is_shortcut(Modifiers::CONTROL, Modifiers::CONTROL));
637        assert!(is_shortcut(
638            Modifiers::CONTROL | Modifiers::META,
639            Modifiers::CONTROL
640        ));
641        // Alt takes it back out of shortcut territory.
642        assert!(!is_shortcut(
643            Modifiers::CONTROL | Modifiers::ALT,
644            Modifiers::CONTROL
645        ));
646        assert!(!is_shortcut(Modifiers::META, Modifiers::CONTROL));
647    }
648
649    /// Return passes through and means different things by field shape;
650    /// backspace passes through and always deletes.
651    #[test]
652    fn return_and_backspace_pass_the_filter() {
653        assert_eq!(
654            route_char('\u{0D}', Modifiers::NONE, Modifiers::CONTROL, false, false),
655            Disposition::Do(TextAction::Commit),
656            "a single-line field commits on Return"
657        );
658        assert_eq!(
659            route_char('\u{0D}', Modifiers::NONE, Modifiers::CONTROL, false, true),
660            Disposition::Do(TextAction::InsertReturn),
661            "a multiline one takes a paragraph break"
662        );
663        assert_eq!(
664            ch('\u{08}', Modifiers::NONE, GENERAL),
665            Disposition::Do(TextAction::Backspace)
666        );
667    }
668
669    /// A read-only field consumes the character and does nothing, which is a
670    /// third answer distinct from both acting and ignoring.
671    #[test]
672    fn a_read_only_field_consumes_without_acting() {
673        assert_eq!(
674            route_char('a', Modifiers::NONE, Modifiers::CONTROL, true, false),
675            Disposition::Consume
676        );
677        // The filter still runs first: a filtered character is ignored, not
678        // consumed, even by a read-only field.
679        assert_eq!(
680            route_char('\u{0A}', Modifiers::NONE, Modifiers::CONTROL, true, false),
681            Disposition::Ignore
682        );
683    }
684
685    /// Ordinary text is inserted, including text well outside ASCII — the
686    /// right-to-left fixtures type Hebrew directly.
687    #[test]
688    fn ordinary_characters_are_inserted() {
689        assert_eq!(
690            ch('a', Modifiers::NONE, GENERAL),
691            Disposition::Do(TextAction::Insert('a'))
692        );
693        assert_eq!(
694            ch(' ', Modifiers::NONE, GENERAL),
695            Disposition::Do(TextAction::Insert(' '))
696        );
697        // Hebrew bet, from the right-to-left live-edit fixture.
698        assert_eq!(
699            ch('\u{05D1}', Modifiers::NONE, GENERAL),
700            Disposition::Do(TextAction::Insert('\u{05D1}'))
701        );
702    }
703
704    /// Shift is a text modifier, not a disqualifier: shift-A is a capital A.
705    #[test]
706    fn shift_does_not_stop_a_character_being_text() {
707        assert_eq!(
708            ch('A', Modifiers::SHIFT, GENERAL),
709            Disposition::Do(TextAction::Insert('A'))
710        );
711    }
712}