use crate::types::{QueryRequest, SchemeKey, SchemeType};
pub const MAX_CODE_LENGTH: usize = 4;
pub const MAX_MIXED_CODE_LENGTH: usize = 32;
#[derive(Debug, Clone, Default)]
pub struct WubiScheme {
raw: String,
mixed_pinyin_allowed: bool,
extended_length_allowed: bool,
pinyin_lookup_allowed: bool,
z_wildcard: bool,
grid: bool,
}
pub const PINYIN_LOOKUP_PREFIX: u8 = b'z';
fn is_wubi_letter(lower: u8, mixed_pinyin_allowed: bool, z_wildcard: bool) -> bool {
(b'a'..=b'y').contains(&lower) || (lower == b'z' && (mixed_pinyin_allowed || z_wildcard))
}
impl WubiScheme {
pub fn new() -> Self {
Self::default()
}
pub fn reset(&mut self) {
self.raw.clear();
self.extended_length_allowed = false;
self.grid = false;
}
pub fn handle_grid_key(&mut self, representative: u8) -> bool {
if super::grid::letters(representative).is_empty()
|| (!self.raw.is_empty() && !self.grid)
|| self.raw.len() >= MAX_CODE_LENGTH
{
return false;
}
self.grid = true;
self.extended_length_allowed = false;
self.raw.push(char::from(representative));
true
}
pub fn grid_composing(&self) -> bool {
self.grid && !self.raw.is_empty()
}
pub fn handle_key(&mut self, key: SchemeKey) -> bool {
match key {
SchemeKey::Backspace => {
let removed = self.raw.pop().is_some();
if self.raw.is_empty() {
self.grid = false;
}
removed
}
SchemeKey::Letter(_) if self.grid_composing() => false,
SchemeKey::Letter(letter) if letter.is_ascii_alphabetic() => {
let lower = letter.to_ascii_lowercase();
let opens_lookup = self.pinyin_lookup_allowed
&& self.raw.is_empty()
&& lower == PINYIN_LOOKUP_PREFIX;
let accepted = opens_lookup
|| self.in_pinyin_lookup()
|| is_wubi_letter(lower, self.mixed_pinyin_allowed, self.z_wildcard);
if accepted && self.raw.len() < self.max_code_length() {
self.raw.push(char::from(lower));
true
} else {
false
}
}
SchemeKey::Letter(_)
| SchemeKey::Apostrophe
| SchemeKey::Semicolon
| SchemeKey::Minus
| SchemeKey::Symbol(_)
| SchemeKey::Requery => false,
}
}
pub fn build_request(&self) -> QueryRequest {
let mut request = QueryRequest::default();
self.build_request_into(&mut request);
request
}
pub fn build_request_into(&self, request: &mut QueryRequest) {
request.scheme = SchemeType::Wubi;
request.raw_input.clone_from(&self.raw);
request.raw_input_with_cases.clone_from(&self.raw);
request.normalized_input.clone_from(&self.raw);
request.raw_segmentation.clone_from(&self.raw);
request.normalized_segmentation.clone_from(&self.raw);
request.segmentation.clone_from(&self.raw);
request.wubi_z_wildcard = self.z_wildcard && self.raw.contains('z');
request.wubi_grid = self.grid_composing();
request.valid = !self.raw.is_empty();
}
pub fn preedit(&self) -> String {
self.raw.clone()
}
pub fn set_raw_input(&mut self, raw: &str) {
self.grid = false;
let lookup = self.pinyin_lookup_allowed
&& raw
.bytes()
.find(u8::is_ascii_alphabetic)
.is_some_and(|first| first.to_ascii_lowercase() == PINYIN_LOOKUP_PREFIX);
let limit = if lookup || self.extended_length_allowed {
MAX_MIXED_CODE_LENGTH
} else {
MAX_CODE_LENGTH
};
let mixed_pinyin_allowed = self.mixed_pinyin_allowed;
let z_wildcard = self.z_wildcard;
self.raw.clear();
self.raw.reserve(limit.min(raw.len()));
self.raw.extend(
raw.bytes()
.map(|byte| byte.to_ascii_lowercase())
.filter(|&lower| {
if lookup {
lower.is_ascii_lowercase()
} else {
is_wubi_letter(lower, mixed_pinyin_allowed, z_wildcard)
}
})
.take(limit)
.map(char::from),
);
}
pub fn has_complete_code(&self) -> bool {
self.raw.len() == MAX_CODE_LENGTH && !self.in_pinyin_lookup()
}
pub fn set_mixed_pinyin_allowed(&mut self, allowed: bool) {
self.mixed_pinyin_allowed = allowed;
}
pub fn set_z_wildcard(&mut self, enabled: bool) {
self.z_wildcard = enabled;
}
pub fn set_pinyin_lookup_allowed(&mut self, allowed: bool) {
self.pinyin_lookup_allowed = allowed;
}
pub fn in_pinyin_lookup(&self) -> bool {
self.pinyin_lookup_allowed && self.raw.as_bytes().first() == Some(&PINYIN_LOOKUP_PREFIX)
}
pub fn set_extended_length_allowed(&mut self, allowed: bool) {
self.extended_length_allowed = allowed;
}
pub fn extended_length_allowed(&self) -> bool {
self.extended_length_allowed
}
fn max_code_length(&self) -> usize {
if self.extended_length_allowed || self.in_pinyin_lookup() {
MAX_MIXED_CODE_LENGTH
} else {
MAX_CODE_LENGTH
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn typed(scheme: &mut WubiScheme, text: &str) {
for byte in text.bytes() {
let key = match byte {
b'\'' => SchemeKey::Apostrophe,
_ => SchemeKey::Letter(byte),
};
scheme.handle_key(key);
}
}
#[test]
fn refuses_z_a_fifth_letter_and_apostrophes() {
let mut scheme = WubiScheme::new();
typed(&mut scheme, "z");
assert_eq!(scheme.preedit(), "");
typed(&mut scheme, "abcd");
typed(&mut scheme, "e");
assert_eq!(scheme.preedit(), "abcd");
typed(&mut scheme, "'");
assert_eq!(scheme.preedit(), "abcd");
assert!(scheme.has_complete_code());
}
#[test]
fn symbol_keys_are_ignored() {
let mut scheme = WubiScheme::new();
typed(&mut scheme, "ab");
scheme.handle_key(SchemeKey::Symbol(b';'));
scheme.handle_key(SchemeKey::Symbol(b' '));
assert_eq!(scheme.preedit(), "ab");
}
#[test]
fn uppercase_letters_are_lowercased() {
let mut scheme = WubiScheme::new();
typed(&mut scheme, "WQ");
assert_eq!(scheme.preedit(), "wq");
}
#[test]
fn mixed_pinyin_accepts_z_and_extended_length_lifts_the_limit() {
let mut scheme = WubiScheme::new();
scheme.set_mixed_pinyin_allowed(true);
typed(&mut scheme, "zhongguo");
assert_eq!(scheme.preedit(), "zhon");
assert!(scheme.has_complete_code());
scheme.set_extended_length_allowed(true);
typed(&mut scheme, "gguo");
assert_eq!(scheme.preedit(), "zhongguo");
assert!(!scheme.has_complete_code());
scheme.reset();
assert_eq!(scheme.preedit(), "");
typed(&mut scheme, "zhongguo");
assert_eq!(scheme.preedit(), "zhon");
}
#[test]
fn a_leading_z_opens_the_pinyin_lookup() {
let mut scheme = WubiScheme::new();
scheme.set_pinyin_lookup_allowed(true);
typed(&mut scheme, "zzhongguo");
assert_eq!(scheme.preedit(), "zzhongguo");
assert!(scheme.in_pinyin_lookup());
assert!(!scheme.has_complete_code());
scheme.reset();
typed(&mut scheme, &"z".repeat(40));
assert_eq!(scheme.preedit().len(), MAX_MIXED_CODE_LENGTH);
scheme.reset();
typed(&mut scheme, "zhao");
assert!(!scheme.has_complete_code());
scheme.reset();
typed(&mut scheme, "wqz");
assert_eq!(scheme.preedit(), "wq");
assert!(!scheme.in_pinyin_lookup());
scheme.reset();
typed(&mut scheme, "zh");
scheme.handle_key(SchemeKey::Backspace);
assert!(scheme.in_pinyin_lookup());
scheme.handle_key(SchemeKey::Backspace);
assert_eq!(scheme.preedit(), "");
assert!(!scheme.in_pinyin_lookup());
typed(&mut scheme, "abcde");
assert_eq!(scheme.preedit(), "abcd");
}
#[test]
fn the_pinyin_lookup_stays_closed_unless_allowed() {
let mut scheme = WubiScheme::new();
typed(&mut scheme, "zhao");
assert_eq!(scheme.preedit(), "hao");
assert!(!scheme.in_pinyin_lookup());
let mut mixed = WubiScheme::new();
mixed.set_mixed_pinyin_allowed(true);
typed(&mut mixed, "zhao");
assert_eq!(mixed.preedit(), "zhao");
assert!(!mixed.in_pinyin_lookup());
assert!(mixed.has_complete_code());
}
#[test]
fn set_raw_input_keeps_a_lookup_spelling_whole() {
let mut scheme = WubiScheme::new();
scheme.set_pinyin_lookup_allowed(true);
scheme.set_raw_input("Z'zhong-guo");
assert_eq!(scheme.preedit(), "zzhongguo");
assert!(scheme.in_pinyin_lookup());
scheme.set_raw_input("nihaoz");
assert_eq!(scheme.preedit(), "niha");
assert!(!scheme.in_pinyin_lookup());
}
#[test]
fn backspace_and_return() {
let mut scheme = WubiScheme::new();
typed(&mut scheme, "wq");
scheme.handle_key(SchemeKey::Backspace);
assert_eq!(scheme.preedit(), "w");
scheme.handle_key(SchemeKey::Backspace);
scheme.handle_key(SchemeKey::Backspace);
assert_eq!(scheme.preedit(), "");
typed(&mut scheme, "ab");
scheme.reset();
assert_eq!(scheme.preedit(), "");
}
#[test]
fn set_raw_input_normalises_and_clips() {
let mut scheme = WubiScheme::new();
scheme.set_raw_input("A'b-Z9cdef");
assert_eq!(scheme.preedit(), "abcd");
scheme.set_mixed_pinyin_allowed(true);
scheme.set_extended_length_allowed(true);
scheme.set_raw_input("Ni'Hao");
assert_eq!(scheme.preedit(), "nihao");
}
#[test]
fn set_raw_input_reuses_existing_storage() {
let mut scheme = WubiScheme::new();
scheme.set_mixed_pinyin_allowed(true);
scheme.set_extended_length_allowed(true);
scheme.set_raw_input("abcdefghijklmnopqrstuvwxyzabcdef");
let capacity = scheme.raw.capacity();
scheme.set_raw_input("ab");
assert_eq!(scheme.preedit(), "ab");
assert!(scheme.raw.capacity() >= capacity);
}
#[test]
fn grid_keys_compose_group_codes_up_to_four() {
let mut scheme = WubiScheme::new();
scheme.set_mixed_pinyin_allowed(true);
scheme.set_z_wildcard(true);
scheme.set_pinyin_lookup_allowed(true);
for group in *b"qqut" {
assert!(scheme.handle_grid_key(group));
}
assert!(scheme.grid_composing());
assert!(!scheme.handle_grid_key(b'q'));
assert_eq!(scheme.preedit(), "qqut");
assert!(scheme.has_complete_code());
assert!(!scheme.in_pinyin_lookup());
let request = scheme.build_request();
assert!(request.wubi_grid);
assert!(!request.wubi_z_wildcard);
assert_eq!(request.normalized_input, "qqut");
}
#[test]
fn grid_codes_and_letters_never_share_a_composition() {
let mut scheme = WubiScheme::new();
assert!(scheme.handle_grid_key(b'q'));
assert!(!scheme.handle_key(SchemeKey::Letter(b'a')));
assert_eq!(scheme.preedit(), "q");
assert!(scheme.handle_key(SchemeKey::Backspace));
assert!(!scheme.grid_composing());
assert!(!scheme.build_request().wubi_grid);
typed(&mut scheme, "w");
assert!(!scheme.handle_grid_key(b'q'));
assert_eq!(scheme.preedit(), "w");
assert!(!scheme.build_request().wubi_grid);
scheme.reset();
assert!(!scheme.handle_grid_key(b'w'));
assert!(!scheme.handle_grid_key(b'z'));
assert!(scheme.handle_grid_key(b'x'));
scheme.set_raw_input("wq");
assert!(!scheme.grid_composing());
scheme.reset();
assert!(scheme.handle_grid_key(b'm'));
scheme.reset();
assert!(!scheme.grid_composing());
assert!(scheme.preedit().is_empty());
}
#[test]
fn request_carries_the_code_in_every_field() {
let mut scheme = WubiScheme::new();
assert!(!scheme.build_request().valid);
typed(&mut scheme, "wq");
let request = scheme.build_request();
assert_eq!(request.scheme, SchemeType::Wubi);
assert!(request.valid);
for field in [
&request.raw_input,
&request.raw_input_with_cases,
&request.normalized_input,
&request.raw_segmentation,
&request.normalized_segmentation,
&request.segmentation,
] {
assert_eq!(field, "wq");
}
}
}