use super::composition::normalize_canonical_pinyin_for_word;
use super::input::InputSession;
use super::learning::MAX_LEARNED_SENTENCE_SYLLABLES;
use crate::diagnostics;
use crate::lattice::decode::PinnedSpan;
use crate::pinyin::segment::split_segments;
use crate::text::{count_han_chars, is_all_han};
use crate::types::{CandidateSource, Command, KeyResult, LocalInputMode, SchemeType, WordItem};
const ROWS_PER_SPAN: usize = 64;
const MAX_SPAN_SYLLABLES: usize = 7;
#[derive(Debug, Clone)]
pub(super) struct ConversionEdit {
syllables: Vec<String>,
segments: Vec<PinnedSpan>,
pins: Vec<PinnedSpan>,
focus: usize,
rows: Vec<WordItem>,
origin: WordItem,
origin_index: usize,
}
impl ConversionEdit {
fn from_row(row: &WordItem) -> Option<Self> {
let canonical = normalize_canonical_pinyin_for_word(&row.canonical_pinyin, &row.word);
if canonical.is_empty() || !is_all_han(&row.word) {
return None;
}
let syllables = split_segments(&canonical);
let count = syllables.len();
if count < 2 || row.word.chars().count() != count {
return None;
}
let words_cover = !row.sentence_words.is_empty()
&& row.sentence_words.iter().all(|word| is_all_han(word))
&& row
.sentence_words
.iter()
.map(|word| word.chars().count())
.sum::<usize>()
== count;
let words = if words_cover {
row.sentence_words.clone()
} else {
vec![row.word.to_string()]
};
let mut segments = Vec::with_capacity(words.len());
let mut start = 0;
for word in words {
let end = start + word.chars().count();
segments.push(PinnedSpan {
start,
end,
key: syllables[start..end].join("'"),
word,
});
start = end;
}
Some(Self {
focus: count - 1,
syllables,
segments,
pins: Vec::new(),
rows: Vec::new(),
origin: row.clone(),
origin_index: 0,
})
}
pub(super) fn text(&self) -> String {
self.segments
.iter()
.map(|segment| segment.word.as_str())
.collect()
}
pub(super) fn rows(&self) -> &[WordItem] {
&self.rows
}
pub(super) fn focus_span(&self) -> (usize, usize) {
(self.focus, self.natural_end())
}
fn natural_end(&self) -> usize {
self.segments
.iter()
.find(|segment| segment.start <= self.focus && self.focus < segment.end)
.map_or(self.focus, |segment| segment.end)
}
fn text_between(&self, start: usize, end: usize) -> String {
let mut text = String::new();
for segment in &self.segments {
for (offset, character) in segment.word.chars().enumerate() {
let at = segment.start + offset;
if (start..end).contains(&at) {
text.push(character);
}
}
}
text
}
}
fn overlay_pins(segments: &[PinnedSpan], pins: &[PinnedSpan]) -> Vec<PinnedSpan> {
let mut result = Vec::with_capacity(segments.len() + pins.len());
for segment in segments {
let touched = pins
.iter()
.any(|pin| segment.start < pin.end && pin.start < segment.end);
if !touched {
result.push(segment.clone());
continue;
}
let keys: Vec<&str> = segment.key.split('\'').collect();
for (offset, character) in segment.word.chars().enumerate() {
let at = segment.start + offset;
if pins.iter().any(|pin| pin.start <= at && at < pin.end) {
continue;
}
result.push(PinnedSpan {
start: at,
end: at + 1,
word: character.to_string(),
key: keys.get(offset).copied().unwrap_or_default().to_owned(),
});
}
}
result.extend(pins.iter().cloned());
result.sort_by_key(|span| span.start);
result
}
impl InputSession {
fn conversion_applies(&self) -> bool {
matches!(self.scheme(), SchemeType::Quanpin | SchemeType::Shuangpin)
&& self.local_mode == LocalInputMode::None
&& !self.dedicated_english
&& self.temporary_original_scheme.is_none()
}
pub(super) fn move_conversion(&mut self, command: Command) -> Option<KeyResult> {
self.move_conversion_from(command, 0)
}
pub(super) fn move_conversion_from(
&mut self,
command: Command,
row_index: usize,
) -> Option<KeyResult> {
if let Some(edit) = self.conversion.as_mut() {
let count = edit.syllables.len();
edit.focus = if command == Command::ConversionLeft {
edit.focus.saturating_sub(1)
} else {
(edit.focus + 1).min(count)
};
self.refresh_conversion_rows();
return Some(KeyResult::handled());
}
if command != Command::ConversionLeft || !self.conversion_applies() {
return None;
}
let row = self.candidates().get(row_index)?;
if matches!(
row.source,
CandidateSource::AiPending | CandidateSource::CloudPending
) || !self.selection_completes_composition(row)
{
return None;
}
let mut edit = ConversionEdit::from_row(row)?;
edit.origin_index = row_index;
self.caret = None;
self.conversion = Some(edit);
self.refresh_conversion_rows();
Some(KeyResult::handled())
}
pub(super) fn handle_conversion_command(&mut self, command: Command) -> Option<KeyResult> {
match command {
Command::ConversionLeft | Command::ConversionRight => self.move_conversion(command),
Command::CommitCandidate | Command::CommitRaw | Command::CommitRawWithoutLearning => {
Some(self.commit_conversion())
}
Command::Backspace | Command::Cancel => {
self.leave_conversion();
Some(KeyResult::handled())
}
Command::MoveLeft
| Command::MoveRight
| Command::MoveHome
| Command::MoveEnd
| Command::DeleteForward => {
self.leave_conversion();
None
}
Command::CycleKanaVariant | Command::CommitReading | Command::ConvertHanja => {
Some(KeyResult::unhandled())
}
}
}
pub(super) fn leave_conversion(&mut self) {
self.conversion = None;
}
pub(super) fn select_conversion_row(&mut self, index: usize) -> KeyResult {
let Some(mut edit) = self.conversion.take() else {
return KeyResult::unhandled();
};
if edit.rows.is_empty() {
self.conversion = Some(edit);
return self.commit_conversion();
}
let Some(row) = edit.rows.get(index) else {
self.conversion = Some(edit);
return KeyResult::unhandled();
};
let start = edit.focus;
let end = start + row.word.chars().count();
let pin = PinnedSpan {
start,
end,
word: row.word.to_string(),
key: row.canonical_pinyin.to_string(),
};
edit.pins
.retain(|other| other.end <= start || other.start >= end);
edit.pins.push(pin);
edit.pins.sort_by_key(|pin| pin.start);
let converted = self
.canonical_phrase_engine()
.convert_pinned(&edit.syllables, &edit.pins);
edit.segments = if converted.is_empty() {
overlay_pins(&edit.segments, &edit.pins)
} else {
converted
};
edit.focus = end;
self.conversion = Some(edit);
self.refresh_conversion_rows();
KeyResult::handled()
}
pub(super) fn commit_conversion(&mut self) -> KeyResult {
let Some(edit) = self.conversion.take() else {
return KeyResult::unhandled();
};
if edit.pins.is_empty() {
return self.commit(edit.origin_index);
}
let text = edit.text();
let mut item = edit.origin;
item.canonical_pinyin = edit.syllables.join("'").into();
item.sentence_words = edit
.segments
.into_iter()
.map(|segment| segment.word)
.collect();
item.word = text.as_str().into();
item.weight = 0;
item.source = CandidateSource::Generated;
item.sentence_association = true;
item.fixed_position = 0;
item.fuzzy = false;
let diagnostic = self.learn_conversion_sentence(&item);
self.caret = None;
self.commit_selection(Some(item), Some(text), diagnostic)
}
fn learn_conversion_sentence(&mut self, item: &WordItem) -> Option<String> {
if !self.learning_enabled || !self.can_learn_sentence_candidate(item) {
return None;
}
let canonical = normalize_canonical_pinyin_for_word(&item.canonical_pinyin, &item.word);
if canonical.is_empty() || split_segments(&canonical).len() > MAX_LEARNED_SENTENCE_SYLLABLES
{
return None;
}
self.store_user_phrase_from_canonical_pinyin(&canonical, &item.word)
.err()
.map(|_| diagnostics::SENTENCE_NOT_PERSISTED.to_owned())
}
fn refresh_conversion_rows(&mut self) {
let Some(mut edit) = self.conversion.take() else {
return;
};
edit.rows.clear();
let count = edit.syllables.len();
let focus = edit.focus;
if focus < count {
let natural = edit.natural_end() - focus;
let longest = (count - focus).min(MAX_SPAN_SYLLABLES).max(natural);
let mut lengths = Vec::with_capacity(longest);
lengths.push(natural);
lengths.extend((1..natural).rev());
lengths.extend(natural + 1..=longest);
let scheme = self.scheme();
let push = |rows: &mut Vec<WordItem>, word: String, key: &str, weight: i64| {
if rows.iter().any(|row| row.word == word) {
return;
}
let mut item = WordItem::new(key, word, weight, CandidateSource::Database, key);
item.scheme = scheme;
rows.push(item);
};
let current = edit.text_between(focus, focus + natural);
let current_key = edit.syllables[focus..focus + natural].join("'");
push(&mut edit.rows, current, ¤t_key, 0);
let engine = self.canonical_phrase_engine();
for length in lengths {
let span = &edit.syllables[focus..focus + length];
let key = span.join("'");
for row in engine.conversion_rows(span, ROWS_PER_SPAN) {
if row.value.chars().count() == length && count_han_chars(&row.value) == length
{
push(&mut edit.rows, row.value, &key, row.weight);
}
}
}
}
self.conversion = Some(edit);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn span(start: usize, end: usize, word: &str, key: &str) -> PinnedSpan {
PinnedSpan {
start,
end,
word: word.to_owned(),
key: key.to_owned(),
}
}
#[test]
fn a_sentence_row_is_cut_at_its_words() {
let mut row = WordItem::new(
"woqubeijing",
"我去背景",
0,
CandidateSource::Generated,
"wo'qu'bei'jing",
);
row.sentence_words = vec!["我".into(), "去".into(), "背景".into()];
let edit = ConversionEdit::from_row(&row).expect("a convertible sentence");
assert_eq!(edit.text(), "我去背景");
assert_eq!(edit.segments[2], span(2, 4, "背景", "bei'jing"));
assert_eq!(edit.focus_span(), (3, 4));
assert_eq!(edit.text_between(1, 3), "去背");
let word = WordItem::new("nihao", "你好", 9, CandidateSource::Database, "ni'hao");
let edit = ConversionEdit::from_row(&word).expect("a whole word");
assert_eq!(edit.segments, [span(0, 2, "你好", "ni'hao")]);
assert_eq!(edit.focus_span(), (1, 2));
let single = WordItem::new("ni", "你", 9, CandidateSource::Database, "ni");
assert!(ConversionEdit::from_row(&single).is_none());
let abbreviated = WordItem::new("nh", "你好", 9, CandidateSource::Database, "n'h");
assert!(ConversionEdit::from_row(&abbreviated).is_none());
}
#[test]
fn rows_with_non_han_text_do_not_enter_conversion() {
let row = WordItem::new("nihao", "你a", 9, CandidateSource::Generated, "ni'hao");
assert!(ConversionEdit::from_row(&row).is_none());
}
#[test]
fn the_fallback_splits_only_what_a_pin_touches() {
let segments = [span(0, 1, "我", "wo"), span(1, 3, "背景", "bei'jing")];
let result = overlay_pins(&segments, &[span(2, 3, "京", "jing")]);
assert_eq!(
result,
[
span(0, 1, "我", "wo"),
span(1, 2, "背", "bei"),
span(2, 3, "京", "jing")
]
);
}
}