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}