#![cfg_attr(
not(target_os = "macos"),
allow(
dead_code,
reason = "only the macOS view keeps a composition at a switch"
)
)]
pub(crate) const MAX_KEPT: usize = 4;
pub(crate) const DISPLAY_SEPARATORS: [char; 2] = [' ', '\''];
fn without_separators(text: &str) -> Vec<char> {
text.chars()
.filter(|c| !DISPLAY_SEPARATORS.contains(c))
.collect()
}
pub(crate) fn delivers(kept: &str, delivered: &str) -> bool {
let kept = without_separators(kept);
let delivered = without_separators(delivered);
if kept.is_empty() {
return false;
}
if kept == delivered {
return true;
}
let romaji = kept
.iter()
.rev()
.take_while(|c| c.is_ascii_alphabetic())
.count();
let before = &kept[..kept.len() - romaji];
if romaji == 0 || before.is_empty() {
return false;
}
let Some(converted) = delivered.strip_prefix(before) else {
return false;
};
let is_kana = |c: &char| ('\u{3041}'..='\u{30FF}').contains(c);
converted.len() <= romaji && converted.iter().all(is_kana)
}
pub(crate) fn as_committed(marked: &str, dictating: bool) -> String {
if dictating {
return marked.to_owned();
}
let chars: Vec<char> = marked.chars().collect();
let mut committed = String::with_capacity(marked.len());
let mut index = 0;
while index < chars.len() {
if chars[index] != ' ' {
committed.push(chars[index]);
index += 1;
continue;
}
let run_end = chars[index..]
.iter()
.position(|&c| c != ' ')
.map_or(chars.len(), |offset| index + offset);
let between_letters = index > 0
&& chars[index - 1].is_ascii_alphabetic()
&& chars.get(run_end).is_some_and(char::is_ascii_alphabetic);
if !between_letters {
committed.extend(&chars[index..run_end]);
}
index = run_end;
}
committed
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Delivery {
New,
Again,
MarkedAgain,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Kept {
source: String,
text: String,
marked_again: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct LeftBehind {
kept: Vec<Kept>,
}
impl LeftBehind {
pub(crate) fn kept(&mut self, source: &str, text: &str) {
if without_separators(text).is_empty() {
return;
}
self.kept.retain(|kept| kept.source != source);
self.kept.push(Kept {
source: source.to_owned(),
text: text.to_owned(),
marked_again: false,
});
if self.kept.len() > MAX_KEPT {
self.kept.remove(0);
}
}
pub(crate) fn marked_outside_key(&mut self, text: &str, held_before: bool) -> bool {
let mut again = false;
for kept in &mut self.kept {
kept.marked_again = !held_before && delivers(&kept.text, text);
again |= kept.marked_again;
}
again
}
pub(crate) fn inserted_outside_key(&mut self, text: &str, holds_marked_text: bool) -> Delivery {
let found = self.kept.iter().position(|kept| {
delivers(&kept.text, text) && (kept.marked_again || !holds_marked_text)
});
self.forget_marks();
match found {
Some(index) => {
self.kept.remove(index);
if holds_marked_text {
Delivery::MarkedAgain
} else {
Delivery::Again
}
}
None => Delivery::New,
}
}
pub(crate) fn input_method_acted(&mut self, source: Option<&str>) {
if let Some(source) = source {
self.kept.retain(|kept| kept.source != source);
}
self.forget_marks();
}
pub(crate) fn is_empty(&self) -> bool {
self.kept.is_empty()
}
fn forget_marks(&mut self) {
for kept in &mut self.kept {
kept.marked_again = false;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const KOREAN: &str = "com.apple.inputmethod.Korean.2SetKorean";
const FRENCH: &str = "com.apple.keylayout.French-PC";
const KOTOERI: &str = "com.apple.inputmethod.Kotoeri.RomajiTyping.Japanese";
const PINYIN: &str = "com.apple.inputmethod.SCIM.ITABC";
#[test]
fn pinyin_is_kept_as_its_letters_and_their_later_delivery_dropped() {
let kept = as_committed("ni hao", false);
assert_eq!(kept, "nihao", "what Pinyin commits in TextEdit");
let mut left = LeftBehind::default();
left.kept(PINYIN, &kept);
assert_eq!(left.inserted_outside_key("nihao", false), Delivery::Again);
assert!(left.is_empty());
}
#[test]
fn a_delivery_is_told_whatever_display_separators_either_holds() {
let mut left = LeftBehind::default();
left.kept(PINYIN, "ni hao");
assert_eq!(left.inserted_outside_key("nihao", false), Delivery::Again);
left.kept(PINYIN, "ni'hao");
assert_eq!(left.inserted_outside_key("nihao", false), Delivery::Again);
left.kept(PINYIN, "nihao");
assert_eq!(left.inserted_outside_key("ni'hao", false), Delivery::Again);
left.kept(PINYIN, "nihao");
assert!(left.marked_outside_key("ni hao", false));
assert_eq!(
left.inserted_outside_key("nihao", true),
Delivery::MarkedAgain
);
}
#[test]
fn separators_are_all_that_may_differ() {
let mut left = LeftBehind::default();
left.kept(PINYIN, "ni hao");
assert_eq!(left.inserted_outside_key("nihaoma", false), Delivery::New);
assert_eq!(left.inserted_outside_key("niha", false), Delivery::New);
assert_eq!(left.inserted_outside_key("你好", false), Delivery::New);
assert_eq!(left.inserted_outside_key(" ", false), Delivery::New);
assert_eq!(left.inserted_outside_key("'", false), Delivery::New);
assert!(!left.is_empty());
let mut left = LeftBehind::default();
left.kept(PINYIN, " ' ");
assert!(left.is_empty());
}
#[test]
fn kotoeri_committing_its_romaji_as_kana_is_told_from_what_it_showed() {
let mut left = LeftBehind::default();
left.kept(KOTOERI, &as_committed("にほn", false));
assert_eq!(left.inserted_outside_key("にほん", false), Delivery::Again);
assert!(left.is_empty());
assert!(delivers("ニホn", "ニホン"));
assert!(!delivers("にほky", "にほきy"), "a letter is not kana");
assert!(delivers("にほky", "にほき"));
assert!(delivers("にほk", "にほ"));
left.kept(KOTOERI, "にほn");
assert!(left.marked_outside_key("にほn", false));
assert_eq!(
left.inserted_outside_key("にほん", true),
Delivery::MarkedAgain
);
}
#[test]
fn a_romaji_commit_must_keep_what_came_before_it() {
assert!(!delivers("にほn", "にん"), "the text before the romaji");
assert!(!delivers("にほn", "にほんご"), "more kana than letters");
assert!(!delivers("にほn", "にほ😀"));
assert!(!delivers("にほn", "にほN"));
assert!(!delivers("ニホン", "ニホンご"), "no romaji was pending");
assert!(
!delivers("n", "ん"),
"romaji alone: a kana insertion from a palette is not taken for it"
);
assert!(
!delivers("nihao", "に"),
"Pinyin's letters are not romaji before kana"
);
assert!(!delivers("", ""), "nothing kept");
}
#[test]
fn only_the_spaces_between_typed_letters_go_from_the_kept_text() {
assert_eq!(as_committed("ni hao ma", false), "nihaoma");
assert_eq!(as_committed("ni h", false), "nih");
assert_eq!(as_committed("ni hao", false), "nihao", "a run of them");
assert_eq!(as_committed("xi'an", false), "xi'an", "a typed apostrophe");
assert_eq!(as_committed("你 hao", false), "你 hao");
assert_eq!(as_committed(" ni ", false), " ni ", "at either end");
assert_eq!(as_committed("ni3 hao", false), "ni3 hao");
assert_eq!(as_committed("ニホン", false), "ニホン");
assert_eq!(as_committed("글", false), "글");
assert_eq!(as_committed("", false), "");
assert_eq!(
as_committed("hello world", true),
"hello world",
"a dictation's spaces are between its words"
);
}
#[test]
fn kotoeri_delivering_its_composition_again_is_dropped_once() {
let mut left = LeftBehind::default();
left.kept(KOTOERI, "ニホン");
assert_eq!(left.inserted_outside_key("ニホン", false), Delivery::Again);
assert_eq!(
left.inserted_outside_key("ニホン", false),
Delivery::New,
"the record went with the delivery"
);
assert!(left.is_empty());
}
#[test]
fn korean_marking_and_delivering_its_composition_again_is_dropped() {
let mut left = LeftBehind::default();
left.kept(KOREAN, "글");
assert!(left.marked_outside_key("글", false));
assert_eq!(left.inserted_outside_key("글", true), Delivery::MarkedAgain);
assert!(left.is_empty());
}
#[test]
fn a_commit_of_a_composition_marked_otherwise_is_not_a_delivery() {
let mut left = LeftBehind::default();
left.kept(KOREAN, "글");
left.input_method_acted(Some(KOREAN));
assert_eq!(left.inserted_outside_key("글", true), Delivery::New);
let mut left = LeftBehind::default();
left.kept(KOREAN, "글");
assert!(!left.marked_outside_key("글", true));
assert_eq!(left.inserted_outside_key("글", true), Delivery::New);
left.marked_outside_key("ニホン", false);
assert_eq!(left.inserted_outside_key("글", true), Delivery::New);
assert!(!left.is_empty(), "the record is still waiting");
}
#[test]
fn a_mark_counts_only_until_the_next_call() {
let mut left = LeftBehind::default();
left.kept(KOREAN, "글");
left.marked_outside_key("글", false);
left.marked_outside_key("", true);
assert_eq!(left.inserted_outside_key("글", true), Delivery::New);
left.marked_outside_key("글", false);
left.inserted_outside_key("😀", false);
assert_eq!(left.inserted_outside_key("글", true), Delivery::New);
left.marked_outside_key("글", false);
left.input_method_acted(None);
assert_eq!(left.inserted_outside_key("글", true), Delivery::New);
assert_eq!(
left.inserted_outside_key("글", false),
Delivery::Again,
"with nothing marked it is still the delivery"
);
}
#[test]
fn typing_with_another_source_keeps_the_record() {
let mut left = LeftBehind::default();
left.kept(KOTOERI, "ニホン");
left.input_method_acted(Some(FRENCH));
assert_eq!(left.inserted_outside_key("ニホン", false), Delivery::Again);
}
#[test]
fn other_text_inserted_outside_a_key_press_goes_on() {
let mut left = LeftBehind::default();
left.kept(KOTOERI, "ニホン");
assert_eq!(left.inserted_outside_key("😀", false), Delivery::New);
assert_eq!(left.inserted_outside_key("ニホ", false), Delivery::New);
assert_eq!(left.inserted_outside_key("", false), Delivery::New);
assert!(!left.is_empty());
}
#[test]
fn one_record_per_input_method_and_a_few_at_most() {
let mut left = LeftBehind::default();
left.kept(KOREAN, "한");
left.kept(KOREAN, "글");
assert_eq!(
left.inserted_outside_key("한", false),
Delivery::New,
"Korean holds one composition, the latest"
);
left.kept(KOTOERI, "ニホン");
assert_eq!(left.inserted_outside_key("글", false), Delivery::Again);
assert_eq!(left.inserted_outside_key("ニホン", false), Delivery::Again);
let mut left = LeftBehind::default();
for n in 0..=MAX_KEPT {
left.kept(&format!("source{n}"), &format!("text{n}"));
}
assert_eq!(
left.inserted_outside_key("text0", false),
Delivery::New,
"the oldest is forgotten"
);
assert_eq!(
left.inserted_outside_key(&format!("text{MAX_KEPT}"), false),
Delivery::Again
);
left.kept(KOREAN, "");
assert_eq!(left.kept.len(), MAX_KEPT - 1, "nothing to record");
}
}