use crate::event::{Key, Modifiers};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextAction {
Insert(char),
InsertReturn,
Backspace,
Delete,
ClearSelection,
Move {
motion: Motion,
extend: bool,
},
SelectAll,
Undo,
Redo,
Commit,
Escape,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Motion {
Left,
Right,
Up,
Down,
LineStart,
LineEnd,
DocStart,
DocEnd,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Disposition {
Do(TextAction),
Consume,
Ignore,
}
#[must_use]
pub fn is_shortcut(modifiers: Modifiers, accelerator: Modifiers) -> bool {
modifiers.contains(accelerator) && !modifiers.contains(Modifiers::ALT)
}
#[must_use]
pub fn route_key(
key: Key,
modifiers: Modifiers,
accelerator: Modifiers,
redo_on_y: bool,
has_selection: bool,
) -> Disposition {
let shortcut = is_shortcut(modifiers, accelerator);
let shift = modifiers.contains(Modifiers::SHIFT);
let alt = modifiers.contains(Modifiers::ALT);
let document_wise = modifiers.contains(Modifiers::CONTROL);
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,
}),
Key::A if shortcut && !shift && !alt => Disposition::Do(TextAction::SelectAll),
Key::Y if redo_on_y && shortcut && !shift && !alt => Disposition::Do(TextAction::Redo),
Key::Z if shortcut && !alt => Disposition::Do(if shift {
TextAction::Redo
} else {
TextAction::Undo
}),
Key::Unknown => Disposition::Do(TextAction::ClearSelection),
_ => 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}';
if ch == ESCAPE {
return Disposition::Do(TextAction::Escape);
}
if matches!(ch, LINE_FEED | DELETE) {
return Disposition::Ignore;
}
if is_shortcut(modifiers, accelerator) {
return Disposition::Ignore;
}
if read_only {
return Disposition::Consume;
}
match ch {
BACKSPACE => Disposition::Do(TextAction::Backspace),
RETURN => Disposition::Do(if multi_line {
TextAction::InsertReturn
} else {
TextAction::Commit
}),
_ => Disposition::Do(TextAction::Insert(ch)),
}
}
#[cfg(test)]
mod tests {
use super::*;
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)
}
#[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)
);
assert_eq!(
key(Key::A, accel | Modifiers::SHIFT, platform),
Disposition::Ignore
);
assert_eq!(key(Key::A, Modifiers::NONE, platform), Disposition::Ignore);
}
}
#[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);
}
#[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)
);
}
}
#[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);
assert_eq!(
key(Key::Y, Modifiers::CONTROL | Modifiers::SHIFT, GENERAL),
Disposition::Ignore
);
}
#[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"
);
}
}
}
#[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
})
);
}
}
#[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");
}
}
#[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)
);
}
#[test]
fn undecided_keys_fall_through() {
for k in [
Key::Tab,
Key::Space,
Key::PageUp,
Key::PageDown,
Key::Insert,
Key::from_virtual(0x70), Key::from_virtual(0x30), ] {
assert_eq!(
key(k, Modifiers::NONE, GENERAL),
Disposition::Ignore,
"{k:?} is not the text field's"
);
}
}
#[test]
fn an_accelerator_modified_character_is_refused() {
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);
assert_eq!(
ch('a', Modifiers::CONTROL | Modifiers::ALT, GENERAL),
Disposition::Do(TextAction::Insert('a'))
);
}
#[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"
);
}
}
#[test]
fn escape_discards_the_edit_and_is_consumed() {
assert_eq!(
ch('\u{1B}', Modifiers::NONE, GENERAL),
Disposition::Do(TextAction::Escape)
);
assert_eq!(
route_char('\u{1B}', Modifiers::NONE, Modifiers::CONTROL, true, false),
Disposition::Do(TextAction::Escape)
);
}
#[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"
);
}
assert_eq!(
key(Key::Home, Modifiers::META, APPLE),
Disposition::Do(TextAction::Move {
motion: Motion::LineStart,
extend: false
})
);
}
#[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
));
assert!(!is_shortcut(
Modifiers::CONTROL | Modifiers::ALT,
Modifiers::CONTROL
));
assert!(!is_shortcut(Modifiers::META, Modifiers::CONTROL));
}
#[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)
);
}
#[test]
fn a_read_only_field_consumes_without_acting() {
assert_eq!(
route_char('a', Modifiers::NONE, Modifiers::CONTROL, true, false),
Disposition::Consume
);
assert_eq!(
route_char('\u{0A}', Modifiers::NONE, Modifiers::CONTROL, true, false),
Disposition::Ignore
);
}
#[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(' '))
);
assert_eq!(
ch('\u{05D1}', Modifiers::NONE, GENERAL),
Disposition::Do(TextAction::Insert('\u{05D1}'))
);
}
#[test]
fn shift_does_not_stop_a_character_being_text() {
assert_eq!(
ch('A', Modifiers::SHIFT, GENERAL),
Disposition::Do(TextAction::Insert('A'))
);
}
}