use frus_widgets::{Edit, Key};
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum ImeEvent {
Commit(String),
Composing(String),
ComposingRegion { start: u32, end: u32 },
FinishComposing,
Delete { before: u32, after: u32 },
Action,
Key { code: i32, unicode: u32 },
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum Step {
Place {
cursor: usize,
anchor: Option<usize>,
},
Key(Key),
Compose(Option<(usize, usize)>),
ComposeTo(usize),
}
pub(crate) fn char_index(text: &str, utf16: usize) -> usize {
let mut units = 0;
for (index, c) in text.chars().enumerate() {
if units >= utf16 {
return index;
}
units += c.len_utf16();
}
text.chars().count()
}
pub(crate) fn utf16_index(text: &str, chars: usize) -> usize {
text.chars().take(chars).map(char::len_utf16).sum()
}
fn type_over(edit: &Edit, len: usize, with: &str) -> (Vec<Step>, usize) {
let composing = edit
.composing
.map(|(s, e)| (s.min(len), e.min(len)))
.filter(|(s, e)| s < e);
let mut steps = Vec::new();
let start = match composing {
Some((s, e)) => {
steps.push(Step::Place {
cursor: e,
anchor: Some(s),
});
s
}
None => edit
.selection_range()
.map(|(s, _)| s)
.unwrap_or(edit.cursor.min(len)),
};
if !with.is_empty() {
steps.push(Step::Key(Key::Text(with.to_string())));
} else if composing.is_some() || edit.selection_range().is_some() {
steps.push(Step::Key(Key::Backspace));
}
(steps, start)
}
pub(crate) fn plan(event: &ImeEvent, edit: &Edit, text: &str) -> Vec<Step> {
let len = text.chars().count();
match event {
ImeEvent::Commit(with) if with == "\n" || with == "\r" => {
let (mut steps, _) = type_over(edit, len, "");
steps.push(Step::Compose(None));
steps.push(Step::Key(Key::Enter));
steps
}
ImeEvent::Commit(with) => {
let (mut steps, _) = type_over(edit, len, with);
steps.push(Step::Compose(None));
steps
}
ImeEvent::Composing(with) => {
let (mut steps, start) = type_over(edit, len, with);
steps.push(if with.is_empty() {
Step::Compose(None)
} else {
Step::ComposeTo(start)
});
steps
}
ImeEvent::ComposingRegion { start, end } => {
let a = char_index(text, *start as usize);
let b = char_index(text, *end as usize);
let (s, e) = (a.min(b), a.max(b));
vec![Step::Compose((s < e).then_some((s, e)))]
}
ImeEvent::FinishComposing => vec![Step::Compose(None)],
ImeEvent::Delete { before, after } => {
let mut steps = vec![Step::Key(Key::Backspace); *before as usize];
steps.extend(vec![Step::Key(Key::Delete); *after as usize]);
steps
}
ImeEvent::Action => vec![Step::Key(Key::Enter)],
ImeEvent::Key { code, unicode } => match code {
66 => vec![Step::Key(Key::Enter)],
67 => vec![Step::Key(Key::Backspace)],
_ => char::from_u32(*unicode)
.filter(|c| !c.is_control())
.map(|c| vec![Step::Key(Key::Text(c.to_string()))])
.unwrap_or_default(),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Default)]
struct Field {
chars: Vec<char>,
edit: Edit,
submitted: usize,
no_digits: bool,
}
impl Field {
fn text(&self) -> String {
self.chars.iter().collect()
}
fn run(&mut self, event: ImeEvent) {
let text = self.text();
for step in plan(&event, &self.edit, &text) {
match step {
Step::Place { cursor, anchor } => {
self.edit.cursor = cursor;
self.edit.anchor = anchor;
}
Step::Key(key) => self.key(key),
Step::Compose(region) => self.edit.composing = region,
Step::ComposeTo(start) => {
let end = self.edit.cursor;
self.edit.composing = (end > start).then_some((start, end));
}
}
}
}
fn key(&mut self, key: Key) {
let len = self.chars.len();
let mut cursor = self.edit.cursor.min(len);
let selection = self.edit.selection_range();
match key {
Key::Text(text) => {
if let Some((s, e)) = selection {
self.chars.drain(s..e);
cursor = s;
}
let typed: Vec<char> = text
.chars()
.filter(|c| !(self.no_digits && c.is_ascii_digit()))
.collect();
for c in typed {
self.chars.insert(cursor, c);
cursor += 1;
}
}
Key::Backspace => {
if let Some((s, e)) = selection {
self.chars.drain(s..e);
cursor = s;
} else if cursor > 0 {
self.chars.remove(cursor - 1);
cursor -= 1;
}
}
Key::Delete => {
if let Some((s, e)) = selection {
self.chars.drain(s..e);
cursor = s;
} else if cursor < len {
self.chars.remove(cursor);
}
}
Key::Enter => self.submitted += 1,
_ => {}
}
self.edit.cursor = cursor;
self.edit.anchor = None;
}
}
fn composing(text: &str) -> ImeEvent {
ImeEvent::Composing(text.into())
}
#[test]
fn each_composition_replaces_the_one_before() {
let mut field = Field::default();
field.run(composing("F"));
field.run(composing("Fr"));
assert_eq!(field.text(), "Fr");
assert_eq!(
field.edit.composing,
Some((0, 2)),
"and it is still being composed"
);
}
#[test]
fn a_composition_starts_at_the_caret_after_what_is_written() {
let mut field = Field::default();
field.run(ImeEvent::Commit("Hi ".into()));
field.run(composing("t"));
field.run(composing("th"));
assert_eq!(field.text(), "Hi th");
assert_eq!(field.edit.composing, Some((3, 5)));
}
#[test]
fn a_commit_replaces_the_composition() {
let mut field = Field::default();
field.run(composing("helo"));
field.run(ImeEvent::Commit("hello".into()));
assert_eq!(field.text(), "hello");
assert_eq!(field.edit.composing, None);
assert_eq!(field.edit.cursor, 5);
}
#[test]
fn a_word_the_keyboard_reclaims_is_typed_over() {
let mut field = Field::default();
for event in [
composing("F"),
composing("Fr"),
ImeEvent::FinishComposing,
ImeEvent::ComposingRegion { start: 0, end: 2 },
composing("Fru"),
composing("Frus"),
composing("Frusc"),
composing("Fruscl"),
composing("Frusclip"),
] {
field.run(event);
}
assert_eq!(field.text(), "Frusclip");
}
#[test]
fn finishing_keeps_the_text_and_ends_the_composition() {
let mut field = Field::default();
field.run(composing("word"));
field.run(ImeEvent::FinishComposing);
assert_eq!(field.text(), "word");
assert_eq!(field.edit.composing, None);
field.run(composing("s"));
assert_eq!(field.text(), "words");
}
#[test]
fn an_empty_composition_takes_the_composed_text_away() {
let mut field = Field::default();
field.run(composing("abc"));
field.run(composing(""));
assert_eq!(field.text(), "");
assert_eq!(field.edit.composing, None);
}
#[test]
fn a_newline_commit_submits_rather_than_types() {
let mut field = Field::default();
field.run(composing("go"));
field.run(ImeEvent::FinishComposing);
field.run(ImeEvent::Commit("\n".into()));
assert_eq!(field.text(), "go");
assert_eq!(field.submitted, 1);
}
#[test]
fn a_refused_character_is_not_counted_as_composed() {
let mut field = Field {
no_digits: true,
..Field::default()
};
field.run(ImeEvent::Commit("x".into()));
field.run(composing("a1"));
assert_eq!(field.text(), "xa");
assert_eq!(field.edit.composing, Some((1, 2)));
field.run(composing("a1b"));
assert_eq!(field.text(), "xab", "the x before it survives");
}
#[test]
fn keys_and_actions_are_the_fields_own_keys() {
let mut field = Field::default();
field.run(ImeEvent::Commit("abcd".into()));
field.run(ImeEvent::Key {
code: 67,
unicode: 0,
});
assert_eq!(field.text(), "abc", "67 is Backspace");
field.run(ImeEvent::Key {
code: 29,
unicode: 'x' as u32,
});
assert_eq!(field.text(), "abcx", "a character is typed");
field.run(ImeEvent::Key {
code: 61,
unicode: '\t' as u32,
});
assert_eq!(field.text(), "abcx", "a control character is not");
field.edit.cursor = 2;
field.run(ImeEvent::Delete {
before: 1,
after: 1,
});
assert_eq!(field.text(), "ax", "one before the caret, one after");
field.run(ImeEvent::Action);
field.run(ImeEvent::Key {
code: 66,
unicode: 0,
});
assert_eq!(field.submitted, 2, "the action and 66 are Enter");
}
#[test]
fn positions_are_converted_from_the_units_java_counts_in() {
let text = "a\u{1F600}b";
assert_eq!(char_index(text, 3), 2);
assert_eq!(utf16_index(text, 2), 3);
let mut field = Field::default();
field.run(ImeEvent::Commit(text.into()));
field.run(ImeEvent::ComposingRegion { start: 3, end: 4 });
assert_eq!(
field.edit.composing,
Some((2, 3)),
"the b, not half the emoji"
);
field.run(ImeEvent::ComposingRegion { start: 4, end: 3 });
assert_eq!(field.edit.composing, Some((2, 3)));
}
}