msime-engine 0.1.1

Input method engine for Chinese and other languages: quanpin, shuangpin, nine-key, wubi, zhuyin, Cantonese, Japanese, Korean and more, ranked over a word lattice with typo correction and learning from what the user picks. The engine of 水杉输入法 (Metasequoia IME).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
//! Caret editing, the editing text, segment boundaries and caret-prefix decoding (core-session.md §5.9, overlays.md §7.6).

use super::input::InputSession;
use crate::local::url;
use crate::local::GENERATED_MODE_INPUT_LIMIT;
use crate::shuangpin::query::{
    detect_active_double_helpcode_length, segment_raw_boundaries,
    trim_trailing_letters_preserve_delimiters,
};
use crate::stroke;
use crate::types::{Command, KeyResult, LocalInputMode, SchemeType};

pub(super) fn temporary_japanese_preedit(raw: &str) -> String {
    let mut preedit = String::with_capacity(1 + raw.len());
    preedit.push('R');
    preedit.push_str(raw);
    preedit
}

impl InputSession {
    /// 专用英文的预编辑;临时日文是 `"R"` 加带大小写的原文;本地模式的预编辑;越南文和藏文是显示出来的文字;粤拼是按音节加空格的字母;其余是带大小写的原文,笔画里就是键入的 `hspnzx` 字母,与 reading 画出的笔画字形一一对应。
    pub(super) fn editing_text(&self) -> String {
        if self.dedicated_english {
            return self.dedicated_english_preedit.clone();
        }
        match self.local_mode {
            LocalInputMode::TemporaryJapanese => {
                temporary_japanese_preedit(&self.engine.request().raw_input_with_cases)
            }
            // A Vietnamese word is edited as the text it shows, not as its keystrokes.
            LocalInputMode::None if self.is_vietnamese() => self.engine.preedit().to_owned(),
            // 藏文音节串同样按显示出来的藏文编辑,而不是按威利按键。
            LocalInputMode::None if self.is_tibetan() => self.engine.preedit().to_owned(),
            // Jyutping is edited as the syllables it shows (`nei hou`); an edit drops the spaces again, because the scheme keeps only letters and `'`.
            LocalInputMode::None if self.is_cantonese() => {
                self.engine.request().normalized_segmentation.clone()
            }
            LocalInputMode::None => self.raw_with_cases().to_owned(),
            _ => self.local_preedit.clone(),
        }
    }

    pub(super) fn editing_text_len(&self) -> usize {
        if self.dedicated_english {
            return self.dedicated_english_preedit.len();
        }
        match self.local_mode {
            LocalInputMode::TemporaryJapanese => {
                1 + self.engine.request().raw_input_with_cases.len()
            }
            LocalInputMode::None if self.is_vietnamese() || self.is_tibetan() => {
                self.engine.preedit().len()
            }
            LocalInputMode::None if self.is_cantonese() => {
                self.engine.request().normalized_segmentation.len()
            }
            LocalInputMode::None => self.raw_with_cases().len(),
            _ => self.local_preedit.len(),
        }
    }

    // 本地模式的入口是不可编辑的标记;普通方案和网址模式从第一个字符开始编辑。
    fn editing_begin(&self) -> usize {
        usize::from(!matches!(
            self.local_mode,
            LocalInputMode::None | LocalInputMode::Url
        ))
    }

    pub(super) fn caret_position(&self) -> usize {
        let length = self.editing_text_len();
        self.caret.unwrap_or(length).min(length)
    }

    /// input_session_editing.cpp:123-159.
    pub(super) fn edit_at_caret(&mut self, command: Command) -> KeyResult {
        let text_len = self.editing_text_len();
        let mut caret = self.caret.unwrap_or(text_len).min(text_len);
        let begin = self.editing_begin();
        match command {
            Command::MoveLeft => caret = caret.saturating_sub(1).max(begin),
            Command::MoveRight => caret = (caret + 1).min(text_len),
            Command::MoveHome => caret = begin,
            Command::MoveEnd => caret = text_len,
            Command::Backspace => {
                if caret <= begin {
                    return KeyResult::handled();
                }
                let mut text = self.editing_text();
                caret -= 1;
                let removed = text.remove(caret);
                return self.delete_editing_character(text, caret, removed);
            }
            Command::DeleteForward => {
                if caret == text_len {
                    return KeyResult::handled();
                }
                let mut text = self.editing_text();
                let removed = text.remove(caret);
                return self.delete_editing_character(text, caret, removed);
            }
            _ => return KeyResult::unhandled(),
        }
        self.caret = Some(caret);
        // Moving the caret changes which prefix is decoded.
        self.update_mixed_candidates();
        KeyResult::handled()
    }

    /// 从编辑文字里删掉 `removed` 后剩下 `text`。网址模式删空就退出;删掉的是末尾那个、正是进入网址模式的键(`url_reverts`)时退回组字,否则与其他模式一样替换编辑文字。
    fn delete_editing_character(&mut self, text: String, caret: usize, removed: char) -> KeyResult {
        if self.local_mode == LocalInputMode::Url {
            // 网址删空后没有前缀字母可留,退出模式,否则会停在空的网址模式里吞掉后续按键。
            if text.is_empty() {
                self.reset_composition();
                return KeyResult::handled();
            }
            if caret == text.len() && self.url_reverts(&text, removed) {
                self.restore_composition_from_url(text);
                return KeyResult::handled();
            }
        }
        self.replace_editing_text(&text, caret)
    }

    /// input_session_editing.cpp:161-210.
    pub(super) fn insert_at_caret(&mut self, value: u8) -> KeyResult {
        let mut text = self.editing_text();
        let caret = self.caret_position();
        let lower = value.is_ascii_lowercase();
        let upper = value.is_ascii_uppercase();
        let mut accepted = lower || upper;
        // 真正落进输入串的字符:句中标记键在串里一律记成 `` ` ``,其余键就是自己。
        let mut inserted = value;
        if !self.dedicated_english {
            match self.local_mode {
                LocalInputMode::Unicode => {
                    accepted = value.is_ascii_hexdigit()
                        || (value == b'+' && caret == 1 && !text.contains('+'));
                }
                LocalInputMode::QuickPhrase | LocalInputMode::DateTime => accepted = lower,
                // 笔画只接受笔画键;通配符不能插在最前面;已满 `MAX_STROKES` 笔时不再插入(否则截断会丢掉末尾那一笔)。组合中的其他字母被吞掉,与在末尾键入时一样。
                LocalInputMode::None if self.stroke_rules_apply() => {
                    if !stroke::is_key(value)
                        || (value == stroke::WILDCARD && caret == 0)
                        || text.len() >= stroke::MAX_STROKES
                    {
                        return if value.is_ascii_alphabetic() {
                            KeyResult::handled()
                        } else {
                            KeyResult::unhandled()
                        };
                    }
                    accepted = true;
                }
                LocalInputMode::None => {
                    let scheme = self.scheme();
                    accepted = lower
                        || (upper && matches!(scheme, SchemeType::Quanpin | SchemeType::Shuangpin))
                        || (value == b'-' && scheme == SchemeType::JapaneseRomaji);
                    if value == b';'
                        && scheme == SchemeType::Shuangpin
                        && self.profile.uses_semicolon_key()
                    {
                        // `;`(微软的 ing,或自定义方案放在 `;` 上的韵母、零声母编码的第二个键)只能补完一个奇数长度的片段;辅助码段不算这一节的键。
                        accepted = crate::shuangpin::mid_sentence::accepts_semicolon_final_at(
                            text.as_bytes(),
                            caret,
                            self.shuangpin_helpcode_uppercase_triggers(),
                        );
                    }
                    // 句中标记:`;`/反引号在光标处能开一段时一律记成 `` ` ``;分号先让给 ing
                    // (`IsMidSentenceHelpcodeMarkerKey`/`AcceptsMarkerAt`,光标可以不在末尾)。
                    if !accepted && scheme == SchemeType::Shuangpin {
                        let modes = self.engine.request().shuangpin_helpcode_modes;
                        let mid_trigger = match value {
                            b'`' => modes.mid_sentence_backtick,
                            b';' => modes.mid_sentence_semicolon,
                            _ => false,
                        };
                        if modes.mid_sentence
                            && mid_trigger
                            && crate::shuangpin::mid_sentence::accepts_marker_at(
                                text.as_bytes(),
                                caret,
                                self.shuangpin_helpcode_uppercase_triggers(),
                            )
                        {
                            accepted = true;
                            inserted = crate::shuangpin::mid_sentence::MID_SENTENCE_MARKER;
                        }
                    }
                    if value == b'\'' && scheme.accepts_apostrophe() {
                        accepted = caret > 0;
                    }
                }
                LocalInputMode::Emoji | LocalInputMode::Kaomoji => {
                    accepted = accepted || value == b'\'';
                }
                LocalInputMode::TemporaryJapanese => {
                    accepted = accepted || matches!(value, b'\'' | b'-');
                }
                // 与 `handle_local_character` 同一组规则:符号模式只收字母。
                LocalInputMode::Symbol => accepted = value.is_ascii_alphabetic(),
                LocalInputMode::SuperJianpin | LocalInputMode::TemporaryEnglish => {}
                LocalInputMode::Expression => {
                    accepted = text.len() < GENERATED_MODE_INPUT_LIMIT
                        && (self.expression_takes_unit_letter(value, caret)
                            || (value == b'\'' && self.expression_takes_unit_separator(caret))
                            || self
                                .local_mode
                                .spelling_symbols()
                                .as_bytes()
                                .contains(&value));
                }
                LocalInputMode::Command | LocalInputMode::Mention => {
                    accepted = text.len() < GENERATED_MODE_INPUT_LIMIT
                        && (lower
                            || (value == b'\'' && self.command_takes_word_separator(caret))
                            || self.command_takes_argument_symbol(value, caret));
                }
                // 与 `handle_local_character` 同一组规则:收字母,`'` 不能插在入口字母 `C` 之后(连续的 `'` 由下面的通用检查吞掉),`;` 只在微软双拼补完奇数长度的片段时收。
                LocalInputMode::Chaizi => {
                    accepted = text.len() < GENERATED_MODE_INPUT_LIMIT
                        && (accepted
                            || (value == b'\'' && caret > 1)
                            || (value == b';' && self.chaizi_takes_symbol(value, caret)));
                }
                // 与 `handle_local_character` 同一组规则。
                LocalInputMode::Url => {
                    if !url::accepts(value) {
                        return KeyResult::unhandled();
                    }
                    // 已到长度上限时吞掉按键,与行末键入一致;只有网址不收的键才交还 runtime。
                    if text.len() >= url::INPUT_LIMIT {
                        return KeyResult::handled();
                    }
                    accepted = true;
                }
            }
        }
        if !accepted {
            // 专用英文的字符入口统一消费非字母键,光标位置不能让数字变成选词键;显式标点另走标点入口。
            if self.dedicated_english {
                return KeyResult::handled();
            }
            if !matches!(
                self.local_mode,
                LocalInputMode::None
                    | LocalInputMode::TemporaryEnglish
                    | LocalInputMode::TemporaryJapanese
                    | LocalInputMode::Url
            ) {
                // 普通快捷模式与末尾输入一致:先上屏原文,再交还拒绝键,避免被 runtime 当成选词。
                let mut result = self.commit_raw();
                result.handled = false;
                return result;
            }
            return KeyResult::unhandled();
        }
        let bytes = text.as_bytes();
        // 网址里的撇号是字面字符,可以连着出现。
        if value == b'\''
            && self.local_mode != LocalInputMode::Url
            && ((caret > 0 && bytes[caret - 1] == b'\'') || bytes.get(caret) == Some(&b'\''))
        {
            return KeyResult::handled();
        }
        text.insert(caret, inserted as char);
        self.replace_editing_text(&text, caret + 1)
    }

    /// input_session_editing.cpp:212-245.
    pub(super) fn replace_editing_text(&mut self, text: &str, caret: usize) -> KeyResult {
        let mut diagnostic = None;
        if self.dedicated_english {
            self.dedicated_english_preedit = text.to_owned();
            self.update_dedicated_english_candidates();
        } else if self.local_mode != LocalInputMode::None
            && self.local_mode != LocalInputMode::TemporaryJapanese
        {
            self.local_preedit = text.to_owned();
            diagnostic = self.update_local_candidates();
        } else {
            let payload = if self.local_mode == LocalInputMode::TemporaryJapanese {
                &text[1..]
            } else {
                text
            };
            self.pending_sequence = Some(payload.to_ascii_lowercase());
            self.pending_sequence_with_cases = Some(payload.to_owned());
            self.apply_pending_sequence();
            if self.local_mode == LocalInputMode::TemporaryJapanese {
                self.refresh_temporary_japanese();
            }
        }
        self.caret = Some(caret);
        // The edit may have moved the caret into or out of a complete prefix.
        self.update_mixed_candidates();
        self.invalidate_online_requests();
        self.discard_abandoned_phrase_progress();
        KeyResult::handled().with_diagnostic(diagnostic)
    }

    /// input_session_editing.cpp:80-121.
    pub(super) fn segment_raw_boundaries(&self) -> Vec<usize> {
        // Local modes and dedicated English spell words, not syllables: the host edits them one character at a time.
        if self.dedicated_english || self.local_mode != LocalInputMode::None {
            return Vec::new();
        }
        let raw_with_cases = self.raw_with_cases();
        if raw_with_cases.is_empty() {
            return Vec::new();
        }
        if self.has_literal_input() {
            return self.literal_raw_boundaries();
        }
        match self.engine.current_scheme_type() {
            SchemeType::Shuangpin => {
                let profile = self.shuangpin_profile();
                let request = self.engine.request();
                // 带辅助码段时音节边界在去段串上算,再按 source_index 映回原串(编辑文本含段标记)。
                let (clean, index_map) = if request.raw_input_with_helpcode_marks.is_empty() {
                    (raw_with_cases, None)
                } else {
                    (
                        request.raw_input_with_cases.as_str(),
                        Some(&request.helpcode_source_index),
                    )
                };
                let helpcode_length =
                    detect_active_double_helpcode_length(&request.raw_input, clean, profile);
                let base = if helpcode_length > 0 {
                    trim_trailing_letters_preserve_delimiters(clean, helpcode_length)
                } else {
                    clean.to_owned()
                };
                let mut boundaries = segment_raw_boundaries(&base, profile);
                if helpcode_length > 0 && !boundaries.is_empty() {
                    // The active double helpcode is one editable unit of its own, the same boundary raw_segmentation draws before it.
                    boundaries.push(base.len());
                    boundaries.push(clean.len());
                    boundaries.sort_unstable();
                    boundaries.dedup();
                }
                if let Some(index_map) = index_map {
                    for boundary in &mut boundaries {
                        *boundary = index_map[*boundary];
                    }
                    boundaries.sort_unstable();
                    boundaries.dedup();
                }
                boundaries
            }
            SchemeType::Quanpin => quanpin_raw_boundaries(
                raw_with_cases,
                self.pinyin_segmentation_with_cases().as_ref(),
            ),
            // The Cantonese editing text is spaced, so raw offsets would not land on its syllables; the host edits it one character at a time. A stroke is one character, so Stroke has no units either.
            SchemeType::Wubi
            | SchemeType::JapaneseRomaji
            | SchemeType::Korean
            | SchemeType::Cantonese
            | SchemeType::Zhuyin
            | SchemeType::Vietnamese
            | SchemeType::Tibetan
            | SchemeType::Stroke => Vec::new(),
        }
    }

    /// 将光标限制在可编辑范围内并重算前缀候选;禁止内部光标的方案仍留在末尾。
    pub(super) fn set_caret(&mut self, caret: Option<usize>) {
        self.exit_date_time_menu();
        self.helpcode_entry = None;
        // 宿主直接放字母光标,就是回到拼音编辑。
        self.leave_conversion();
        if self.engine.current_scheme_type().locks_caret()
            && !self.dedicated_english
            && self.local_mode == LocalInputMode::None
        {
            self.caret = None;
            return;
        }
        let length = self.editing_text_len();
        let begin = self.editing_begin().min(length);
        self.caret = caret.map(|position| position.clamp(begin, length));
        // Only the scheme composition decodes by caret; local and English lists do not depend on it.
        if !self.dedicated_english && self.local_mode == LocalInputMode::None {
            self.update_mixed_candidates();
        }
    }

    /// The last segment boundary at or before the caret, or the raw length.
    pub(super) fn prefix_end(&self) -> usize {
        let raw_length = self.raw_with_cases().len();
        if self.caret.is_none() {
            return raw_length;
        }
        let boundaries = self.segment_raw_boundaries();
        let caret = self.caret_position();
        boundaries
            .iter()
            .rev()
            .find(|boundary| **boundary <= caret)
            .copied()
            .unwrap_or(raw_length)
    }

    pub(super) fn pending_suffix(&self) -> String {
        let raw = self.raw_with_cases();
        let end = self.prefix_end();
        raw.get(end..).unwrap_or_default().to_owned()
    }

    /// Decode only the complete units before the caret; cached by prefix.
    pub(super) fn refresh_prefix_candidates(&mut self) {
        self.prefix_active = false;
        // 光标后编辑开着且生效时,候选是光标后那段的,不做光标前缀解码。
        if self.refresh_caret_edit() {
            return;
        }
        if self.caret.is_none() || self.dedicated_english || self.local_mode != LocalInputMode::None
        {
            self.prefix_candidates.clear();
            self.prefix_query_input.clear();
            return;
        }
        let end = self.prefix_end();
        let raw_with_cases = self.raw_with_cases();
        // A caret inside the first unit has no whole unit before it: the whole-input list stays rather than an empty prefix query.
        if end == 0 || end >= raw_with_cases.len() {
            self.prefix_candidates.clear();
            self.prefix_query_input.clear();
            return;
        }
        let prefix_with_cases = &raw_with_cases[..end];
        let lowercase = prefix_with_cases
            .bytes()
            .all(|byte| byte.is_ascii_lowercase() || byte == b'\'');
        // Caret moves inside one unit keep the same prefix; only a new prefix costs a query.
        if lowercase {
            if self.prefix_query_input != prefix_with_cases {
                let prefix = prefix_with_cases.to_owned();
                self.prefix_candidates = self.engine.query_raw_candidates(&prefix, &prefix);
                self.prefix_query_input = prefix;
            }
        } else {
            let prefix = prefix_with_cases.to_ascii_lowercase();
            if self.prefix_query_input != prefix {
                let raw_with_cases = prefix_with_cases.to_owned();
                self.prefix_candidates = self.engine.query_raw_candidates(&prefix, &raw_with_cases);
                self.prefix_query_input = prefix;
            }
        }
        self.prefix_active = true;
    }

    /// The typed raw input with its case, as the scheme holds it. 带辅助码段时是用户敲的原串
    /// (含段标记),与 `get_pinyin_sequence_with_cases` 一致。
    pub(super) fn raw_with_cases(&self) -> &str {
        let request = self.engine.request();
        if !request.raw_input_with_helpcode_marks.is_empty() {
            &request.raw_input_with_helpcode_marks
        } else if request.raw_input_with_cases.is_empty() {
            &request.raw_input
        } else {
            &request.raw_input_with_cases
        }
    }
}

/// Unit boundaries of a quanpin spelling in raw coordinates (input_session_editing.cpp:35-77). The visible preedit is always rebuilt from the raw letters, the autocorrect cut and the alias layer only moving or adding separators, so a separator in the display marks where the next raw unit starts. A display that cannot explain the raw letters yields no boundaries, and the host falls back to editing single characters rather than deleting an arbitrary span.
pub(super) fn quanpin_raw_boundaries(raw: &str, display: &str) -> Vec<usize> {
    let raw_bytes = raw.as_bytes();
    let raw_letter_count = raw_bytes.iter().filter(|byte| **byte != b'\'').count();
    if raw_letter_count == 0 {
        return Vec::new();
    }
    let mut boundaries = Vec::with_capacity(raw_letter_count + 1);
    boundaries.push(0);
    let mut raw_offset = 0;
    let mut letters_seen = 0;
    for byte in display.bytes() {
        if byte != b'\'' {
            letters_seen += 1;
            while raw_offset < raw_bytes.len() && raw_bytes[raw_offset] == b'\'' {
                raw_offset += 1;
            }
            raw_offset = raw_offset.saturating_add(1);
            continue;
        }
        // A trailing separator has no next letter and starts no unit.
        let mut next = raw_offset;
        while next < raw_bytes.len() && raw_bytes[next] == b'\'' {
            next += 1;
        }
        if next < raw_bytes.len() {
            boundaries.push(next);
        }
    }
    if letters_seen != raw_letter_count {
        return Vec::new();
    }
    boundaries.push(raw.len());
    boundaries.dedup();
    boundaries
}

#[cfg(test)]
mod tests {
    use super::quanpin_raw_boundaries;

    /// Why caret 1 keeps the whole-input list on `nihaoma` but decodes 虐 on `nhaoma` (golden qp_caret_editing, MoveRight vs DeleteForward): the prefix floors to a segmentation boundary, and a lone initial is its own unit, so only the second spelling has a boundary at 1.
    #[test]
    fn a_lone_initial_is_a_complete_unit_but_a_split_syllable_is_not() {
        assert_eq!(quanpin_raw_boundaries("nihaoma", "ni'hao'ma"), [0, 2, 5, 7]);
        assert_eq!(quanpin_raw_boundaries("nhaoma", "n'hao'ma"), [0, 1, 4, 6]);
    }

    #[test]
    fn quanpin_boundaries_do_not_allocate_temporary_raw_offsets() {
        let (boundaries, allocations) = crate::ime::personal_rerank::allocations::count(|| {
            quanpin_raw_boundaries("nihaoma", "ni'hao'ma")
        });

        assert_eq!(boundaries, [0, 2, 5, 7]);
        assert_eq!(allocations, 1, "boundary allocations: {allocations}");
    }
}