#![cfg_attr(
not(target_os = "macos"),
allow(dead_code, reason = "only the macOS view follows winit's state")
)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum InKeyInsertion {
WinitCommits,
HookCommits,
Types,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct WinitIme {
source: String,
reset: bool,
marked_text: String,
marked_under: String,
}
impl WinitIme {
pub(crate) fn marked(&mut self, source: Option<&str>, text: &str) {
self.source = source.unwrap_or_default().to_owned();
self.reset = false;
text.clone_into(&mut self.marked_text);
self.source.clone_into(&mut self.marked_under);
}
pub(crate) fn left_behind(
&self,
selected: Option<&str>,
holds_marked_text: bool,
same_input_method: impl FnOnce(&str, &str) -> bool,
) -> Option<(&str, &str)> {
let selected = selected.filter(|selected| !selected.is_empty())?;
(holds_marked_text
&& !self.marked_text.is_empty()
&& !self.marked_under.is_empty()
&& selected != self.marked_under
&& !same_input_method(&self.marked_under, selected))
.then_some((self.marked_text.as_str(), self.marked_under.as_str()))
}
pub(crate) fn key_down(&mut self, source: Option<&str>, holds_marked_text: bool) -> bool {
let source = source.unwrap_or_default();
if holds_marked_text && !self.reset && self.source != source {
self.source = source.to_owned();
self.reset = true;
true
} else {
false
}
}
pub(crate) fn insertion(&self, holds_marked_text: bool, text: &str) -> InKeyInsertion {
if !holds_marked_text || text.chars().next().is_some_and(char::is_control) {
InKeyInsertion::Types
} else if self.reset {
InKeyInsertion::HookCommits
} else {
InKeyInsertion::WinitCommits
}
}
}
pub(crate) fn same_input_method(
a: &str,
a_method: Option<&str>,
b: &str,
b_method: Option<&str>,
) -> bool {
if a == b {
return true;
}
match (a_method, b_method) {
(Some(a_method), Some(b_method)) => a_method == b_method,
_ => {
let extends = |mode: &str, of: &str| {
mode.strip_prefix(of)
.is_some_and(|rest| rest.len() > 1 && rest.starts_with('.'))
};
extends(a, b) || extends(b, a)
}
}
}
#[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 KATAKANA: &str = "com.apple.inputmethod.Kotoeri.RomajiTyping.Japanese.Katakana";
const KOTOERI_ROMAN: &str = "com.apple.inputmethod.Kotoeri.RomajiTyping.Roman";
const PINYIN: &str = "com.apple.inputmethod.SCIM.ITABC";
const SHUANGPIN: &str = "com.apple.inputmethod.SCIM.Shuangpin";
const ABC: &str = "com.apple.keylayout.ABC";
const KANA_TYPING: &str = "com.apple.inputmethod.Kotoeri.KanaTyping.Japanese";
const TRADITIONAL_PINYIN: &str = "com.apple.inputmethod.TCIM.Pinyin";
fn method_of(source: &str) -> Option<&'static str> {
[
"com.apple.inputmethod.Kotoeri.RomajiTyping",
"com.apple.inputmethod.Kotoeri.KanaTyping",
"com.apple.inputmethod.SCIM",
"com.apple.inputmethod.TCIM",
"com.apple.inputmethod.Korean",
]
.into_iter()
.find(|method| {
source
.strip_prefix(method)
.is_some_and(|mode| mode.starts_with('.'))
})
}
fn described(a: &str, b: &str) -> bool {
same_input_method(a, method_of(a), b, method_of(b))
}
fn undescribed(a: &str, b: &str) -> bool {
same_input_method(a, None, b, None)
}
#[test]
fn a_mode_switch_within_one_input_method_leaves_the_composition_alone() {
let mut ime = WinitIme::default();
ime.marked(Some(KOTOERI), "にほn");
assert_eq!(ime.left_behind(Some(KATAKANA), true, described), None);
assert_eq!(
ime.left_behind(Some(KATAKANA), true, undescribed),
None,
"with no description, from the identifiers"
);
assert_eq!(ime.left_behind(Some(KOTOERI), true, described), None);
assert_eq!(
ime.left_behind(Some(FRENCH), true, described),
Some(("にほn", KOTOERI)),
"the real switch"
);
ime.marked(Some(KATAKANA), "ニホn");
assert_eq!(ime.left_behind(Some(KOTOERI), true, undescribed), None);
assert_eq!(
ime.left_behind(Some(FRENCH), true, undescribed),
Some(("ニホn", KATAKANA))
);
}
#[test]
fn modes_of_one_input_method_are_told_by_their_description() {
assert!(described(KOTOERI, KATAKANA));
assert!(described(KATAKANA, KOTOERI_ROMAN), "sibling modes");
assert!(described(PINYIN, SHUANGPIN));
assert!(!described(KOTOERI, KANA_TYPING), "Romaji and Kana typing");
assert!(!described(PINYIN, TRADITIONAL_PINYIN));
assert!(!described(KOTOERI, PINYIN));
assert!(!described(PINYIN, FRENCH));
assert!(!described(KOREAN, KOTOERI));
assert!(described(FRENCH, FRENCH));
assert!(
!described(FRENCH, ABC),
"two keyboard layouts are two sources: a dead key is not carried"
);
assert!(!same_input_method(
KOTOERI,
Some("com.apple.inputmethod.Kotoeri.RomajiTyping"),
KATAKANA,
Some("com.example.inputmethod.Other")
));
}
#[test]
fn undescribed_sources_are_one_input_method_only_by_a_longer_name() {
assert!(undescribed(KOTOERI, KATAKANA));
assert!(undescribed(KATAKANA, KOTOERI));
assert!(
!undescribed(KATAKANA, KOTOERI_ROMAN),
"siblings are not told apart without a description"
);
assert!(!undescribed(PINYIN, SHUANGPIN));
assert!(!undescribed(KOTOERI, FRENCH));
assert!(!undescribed(
"com.apple.keylayout.French",
"com.apple.keylayout.French-PC"
));
assert!(
!undescribed(KOTOERI, &format!("{KOTOERI}.")),
"an empty mode"
);
assert!(same_input_method(
KOTOERI,
method_of(KOTOERI),
KATAKANA,
None
));
assert!(!same_input_method(
KOTOERI,
method_of(KOTOERI),
FRENCH,
None
));
}
#[test]
fn a_composition_typed_under_one_source_is_committed_by_winit() {
let mut ime = WinitIme::default();
assert!(!ime.key_down(Some(KOREAN), false));
ime.marked(Some(KOREAN), "ㅎ");
for syllable in ["하", "한", "ㄱ"] {
assert!(!ime.key_down(Some(KOREAN), true));
ime.marked(Some(KOREAN), syllable);
}
assert!(!ime.key_down(Some(KOREAN), true));
assert_eq!(ime.insertion(true, "한"), InKeyInsertion::WinitCommits);
}
#[test]
fn a_composition_left_marked_across_a_switch_is_committed_by_the_hook() {
let mut ime = WinitIme::default();
ime.marked(Some(KOREAN), "하");
assert!(!ime.key_down(Some(KOREAN), true));
ime.marked(Some(KOREAN), "한");
assert!(
ime.key_down(Some(KOTOERI), true),
"winit disables its IME for the switch"
);
assert_eq!(
ime.insertion(true, "\u{3000}"),
InKeyInsertion::HookCommits,
"winit would drop it"
);
}
#[test]
fn the_reset_lasts_until_the_next_mark() {
let mut ime = WinitIme::default();
ime.marked(Some(KOREAN), "한");
assert!(ime.key_down(Some(FRENCH), true));
assert!(!ime.key_down(Some(FRENCH), true));
assert_eq!(ime.insertion(true, "x"), InKeyInsertion::HookCommits);
ime.marked(Some(KOTOERI), "にほん");
assert!(!ime.key_down(Some(KOTOERI), true));
assert_eq!(ime.insertion(true, "日本"), InKeyInsertion::WinitCommits);
}
#[test]
fn a_switch_with_nothing_marked_changes_nothing_that_commits() {
let mut ime = WinitIme::default();
ime.marked(Some(KOREAN), "한");
assert!(!ime.key_down(Some(KOTOERI), false));
assert_eq!(ime.insertion(false, "\u{3000}"), InKeyInsertion::Types);
ime.marked(Some(KOTOERI), "か");
assert!(!ime.key_down(Some(KOTOERI), true));
assert_eq!(ime.insertion(true, "か"), InKeyInsertion::WinitCommits);
}
#[test]
fn a_control_string_is_never_committed() {
let mut ime = WinitIme::default();
ime.marked(Some(KOREAN), "한");
assert_eq!(ime.insertion(true, "\r"), InKeyInsertion::Types);
assert!(ime.key_down(Some(KOTOERI), true));
assert_eq!(ime.insertion(true, "\u{1b}"), InKeyInsertion::Types);
}
#[test]
fn no_source_counts_as_winits_empty_source() {
let mut ime = WinitIme::default();
ime.marked(None, "한");
assert!(!ime.key_down(Some(""), true));
assert!(ime.key_down(Some(KOREAN), true));
}
#[test]
fn a_composition_marked_under_a_source_no_longer_selected_is_left_behind() {
let mut ime = WinitIme::default();
ime.marked(Some(KOREAN), "그");
ime.marked(Some(KOREAN), "글");
assert_eq!(
ime.left_behind(Some(FRENCH), true, described),
Some(("글", KOREAN))
);
ime.marked(Some(KOTOERI), "ニホン");
assert_eq!(
ime.left_behind(Some(FRENCH), true, described),
Some(("ニホン", KOTOERI))
);
}
#[test]
fn a_composition_its_input_method_committed_is_not_left_behind() {
let mut ime = WinitIme::default();
ime.marked(Some(KOREAN), "글");
assert_eq!(ime.left_behind(Some(FRENCH), false, described), None);
}
#[test]
fn a_composition_goes_on_while_its_source_is_selected() {
let mut ime = WinitIme::default();
ime.marked(Some(KOTOERI), "にほn");
assert_eq!(
ime.left_behind(Some(KOTOERI), true, described),
None,
"a change announced for the source composing"
);
assert_eq!(
ime.left_behind(None, true, described),
None,
"no source to compare"
);
assert_eq!(
ime.left_behind(Some(""), true, described),
None,
"no source either"
);
ime.marked(None, "にほn");
assert_eq!(
ime.left_behind(Some(FRENCH), true, described),
None,
"the source it was composed under is not known"
);
}
#[test]
fn the_input_method_that_composed_is_the_one_at_the_mark_not_at_the_reset() {
let mut ime = WinitIme::default();
ime.marked(Some(KOREAN), "한");
assert!(ime.key_down(Some(FRENCH), true));
assert_eq!(
ime.left_behind(Some(FRENCH), true, described),
Some(("한", KOREAN))
);
assert_eq!(ime.left_behind(Some(KOREAN), true, described), None);
}
#[test]
fn an_emptied_mark_leaves_nothing_behind() {
let mut ime = WinitIme::default();
ime.marked(Some(KOREAN), "한");
ime.marked(Some(KOREAN), "");
assert_eq!(
ime.left_behind(Some(FRENCH), true, described),
None,
"a stale answer from winit claiming marked text"
);
}
}