use std::sync::Arc;
const MAX_COMPOSITION_LENGTH: usize = 10_000;
const MAX_CANDIDATES: usize = 1_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CompositionState {
#[default]
Idle,
Composing,
Selecting,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CompositionEvent {
Start,
Update {
text: String,
cursor: usize,
},
End {
text: Option<String>,
},
CandidatesChanged {
candidates: Vec<String>,
selected: usize,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Candidate {
pub text: String,
pub label: Option<String>,
pub annotation: Option<String>,
}
impl Candidate {
pub fn new(text: impl Into<String>) -> Self {
Self {
text: text.into(),
label: None,
annotation: None,
}
}
pub fn with_label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
pub fn with_annotation(mut self, annotation: impl Into<String>) -> Self {
self.annotation = Some(annotation.into());
self
}
}
impl From<&str> for Candidate {
fn from(s: &str) -> Self {
Self::new(s)
}
}
impl From<String> for Candidate {
fn from(s: String) -> Self {
Self::new(s)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CompositionStyle {
#[default]
Underline,
Highlight,
Colored,
UnderlineHighlight,
}
#[derive(Debug, Clone)]
pub struct ImeConfig {
pub composition_style: CompositionStyle,
pub show_candidates: bool,
pub max_candidates: usize,
pub candidate_offset: (i16, i16),
pub inline_composition: bool,
}
impl Default for ImeConfig {
fn default() -> Self {
Self {
composition_style: CompositionStyle::Underline,
show_candidates: true,
max_candidates: 9,
candidate_offset: (0, 1),
inline_composition: true,
}
}
}
type CompositionCallback = Arc<dyn Fn(&CompositionEvent) + Send + Sync>;
pub struct ImeState {
state: CompositionState,
composing_text: String,
cursor: usize,
candidates: Vec<Candidate>,
selected_candidate: usize,
config: ImeConfig,
callbacks: Vec<CompositionCallback>,
enabled: bool,
}
impl ImeState {
pub fn new() -> Self {
Self {
state: CompositionState::Idle,
composing_text: String::new(),
cursor: 0,
candidates: Vec::new(),
selected_candidate: 0,
config: ImeConfig::default(),
callbacks: Vec::new(),
enabled: true,
}
}
pub fn with_config(config: ImeConfig) -> Self {
Self {
config,
..Self::new()
}
}
pub fn config(&self) -> &ImeConfig {
&self.config
}
pub fn set_config(&mut self, config: ImeConfig) {
self.config = config;
}
pub fn enable(&mut self) {
self.enabled = true;
}
pub fn disable(&mut self) {
self.enabled = false;
if self.state != CompositionState::Idle {
self.cancel();
}
}
pub fn is_enabled(&self) -> bool {
self.enabled
}
pub fn state(&self) -> CompositionState {
self.state
}
pub fn is_composing(&self) -> bool {
self.state != CompositionState::Idle
}
pub fn composing_text(&self) -> &str {
&self.composing_text
}
pub fn cursor(&self) -> usize {
self.cursor
}
pub fn candidates(&self) -> &[Candidate] {
&self.candidates
}
pub fn selected_candidate(&self) -> usize {
self.selected_candidate
}
pub fn selected_text(&self) -> Option<&str> {
self.candidates
.get(self.selected_candidate)
.map(|c| c.text.as_str())
}
fn emit_candidates_changed(&mut self) {
let candidates: Vec<String> = self.candidates.iter().map(|c| c.text.clone()).collect();
self.emit(CompositionEvent::CandidatesChanged {
candidates,
selected: self.selected_candidate,
});
}
pub fn start_composition(&mut self) {
if !self.enabled {
return;
}
self.state = CompositionState::Composing;
self.composing_text.clear();
self.cursor = 0;
self.candidates.clear();
self.selected_candidate = 0;
self.emit(CompositionEvent::Start);
}
pub fn update_composition(&mut self, text: &str, cursor: usize) {
if self.state == CompositionState::Idle {
self.start_composition();
}
let byte_len = text.len();
let char_count = text.chars().count();
if byte_len > MAX_COMPOSITION_LENGTH || char_count > MAX_COMPOSITION_LENGTH {
return;
}
self.composing_text = text.to_string();
self.cursor = cursor.min(char_count);
self.emit(CompositionEvent::Update {
text: self.composing_text.clone(),
cursor: self.cursor,
});
}
pub fn set_candidates(&mut self, candidates: Vec<Candidate>) {
if candidates.len() > MAX_CANDIDATES {
return;
}
self.candidates = candidates;
self.selected_candidate = 0;
if !self.candidates.is_empty() {
self.state = CompositionState::Selecting;
}
self.emit_candidates_changed();
}
pub fn next_candidate(&mut self) {
if self.candidates.is_empty() {
return;
}
self.selected_candidate = (self.selected_candidate + 1) % self.candidates.len();
self.emit_candidates_changed();
}
pub fn prev_candidate(&mut self) {
if self.candidates.is_empty() {
return;
}
self.selected_candidate = if self.selected_candidate == 0 {
self.candidates.len() - 1
} else {
self.selected_candidate - 1
};
self.emit_candidates_changed();
}
pub fn select_candidate(&mut self, index: usize) {
if index < self.candidates.len() {
self.selected_candidate = index;
self.emit_candidates_changed();
}
}
pub fn commit(&mut self, text: &str) -> Option<String> {
if self.state == CompositionState::Idle {
return None;
}
let committed = text.to_string();
self.reset_state();
self.emit(CompositionEvent::End {
text: Some(committed.clone()),
});
Some(committed)
}
pub fn commit_selected(&mut self) -> Option<String> {
if let Some(candidate) = self.candidates.get(self.selected_candidate) {
let text = candidate.text.clone();
self.commit(&text)
} else if !self.composing_text.is_empty() {
let text = self.composing_text.clone();
self.commit(&text)
} else {
None
}
}
pub fn cancel(&mut self) {
if self.state == CompositionState::Idle {
return;
}
self.reset_state();
self.emit(CompositionEvent::End { text: None });
}
pub fn backspace(&mut self) -> bool {
if self.state == CompositionState::Idle || self.composing_text.is_empty() {
return false;
}
let mut chars: Vec<char> = self.composing_text.chars().collect();
if self.cursor > 0 && self.cursor <= chars.len() {
chars.remove(self.cursor - 1);
self.composing_text = chars.into_iter().collect();
self.cursor = self.cursor.saturating_sub(1);
if self.composing_text.is_empty() {
self.cancel();
} else {
self.emit(CompositionEvent::Update {
text: self.composing_text.clone(),
cursor: self.cursor,
});
}
true
} else {
false
}
}
pub fn move_cursor_left(&mut self) {
if self.cursor > 0 {
self.cursor -= 1;
self.emit(CompositionEvent::Update {
text: self.composing_text.clone(),
cursor: self.cursor,
});
}
}
pub fn move_cursor_right(&mut self) {
let len = self.composing_text.chars().count();
if self.cursor < len {
self.cursor += 1;
self.emit(CompositionEvent::Update {
text: self.composing_text.clone(),
cursor: self.cursor,
});
}
}
pub fn on_composition<F>(&mut self, callback: F)
where
F: Fn(&CompositionEvent) + Send + Sync + 'static,
{
self.callbacks.push(Arc::new(callback));
}
pub fn clear_callbacks(&mut self) {
self.callbacks.clear();
}
fn reset_state(&mut self) {
self.state = CompositionState::Idle;
self.composing_text.clear();
self.cursor = 0;
self.candidates.clear();
self.selected_candidate = 0;
}
fn emit(&self, event: CompositionEvent) {
for callback in &self.callbacks {
callback(&event);
}
}
}
impl Default for ImeState {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct PreeditString {
segments: Vec<PreeditSegment>,
}
#[derive(Debug, Clone)]
pub struct PreeditSegment {
pub text: String,
pub highlighted: bool,
pub has_cursor: bool,
pub cursor_pos: usize,
}
impl PreeditString {
pub fn new() -> Self {
Self {
segments: Vec::new(),
}
}
pub fn from_ime(ime: &ImeState) -> Self {
let text = ime.composing_text();
let cursor = ime.cursor();
if text.is_empty() {
return Self::new();
}
let chars: Vec<char> = text.chars().collect();
let (before, after) = chars.split_at(cursor.min(chars.len()));
let mut preedit = Self::new();
if !before.is_empty() {
preedit.segments.push(PreeditSegment {
text: before.iter().collect(),
highlighted: false,
has_cursor: false,
cursor_pos: 0,
});
}
preedit.segments.push(PreeditSegment {
text: String::new(),
highlighted: false,
has_cursor: true,
cursor_pos: 0,
});
if !after.is_empty() {
preedit.segments.push(PreeditSegment {
text: after.iter().collect(),
highlighted: false,
has_cursor: false,
cursor_pos: 0,
});
}
preedit
}
pub fn add_segment(&mut self, segment: PreeditSegment) {
self.segments.push(segment);
}
pub fn segments(&self) -> &[PreeditSegment] {
&self.segments
}
pub fn text(&self) -> String {
self.segments.iter().map(|s| s.text.as_str()).collect()
}
pub fn is_empty(&self) -> bool {
self.segments.is_empty() || self.text().is_empty()
}
}
impl Default for PreeditString {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod validation_tests {
use super::*;
#[test]
fn test_update_composition_rejects_excessive_byte_length() {
let mut ime = ImeState::new();
ime.start_composition();
let long_text = "あ".repeat(MAX_COMPOSITION_LENGTH + 1);
ime.update_composition(&long_text, 0);
assert_eq!(ime.composing_text(), "");
assert!(!ime.is_composing() || ime.composing_text().is_empty());
}
#[test]
fn test_update_composition_rejects_excessive_char_count() {
let mut ime = ImeState::new();
ime.start_composition();
let long_text = "a".repeat(MAX_COMPOSITION_LENGTH + 1);
ime.update_composition(&long_text, 0);
assert_eq!(ime.composing_text(), "");
}
#[test]
fn test_update_composition_accepts_at_max_length() {
let mut ime = ImeState::new();
ime.start_composition();
let text = "a".repeat(MAX_COMPOSITION_LENGTH);
ime.update_composition(&text, MAX_COMPOSITION_LENGTH);
assert_eq!(ime.composing_text().len(), MAX_COMPOSITION_LENGTH);
assert_eq!(ime.cursor(), MAX_COMPOSITION_LENGTH);
}
#[test]
fn test_update_composition_validates_before_allocation() {
let mut ime = ImeState::new();
ime.start_composition();
let original_text = "test";
ime.composing_text = original_text.to_string();
let long_text = "x".repeat(MAX_COMPOSITION_LENGTH * 2);
ime.update_composition(&long_text, 0);
assert_eq!(ime.composing_text(), original_text);
}
#[test]
fn test_update_composition_unicode_char_count() {
let mut ime = ImeState::new();
ime.start_composition();
let emoji_text = "😀".repeat(MAX_COMPOSITION_LENGTH + 1);
ime.update_composition(&emoji_text, 0);
assert_eq!(ime.composing_text(), "");
}
#[test]
fn test_update_composition_mixed_width_unicode() {
let mut ime = ImeState::new();
ime.start_composition();
let mixed_text = "aあ".repeat(MAX_COMPOSITION_LENGTH / 2 + 1);
ime.update_composition(&mixed_text, 0);
assert_eq!(ime.composing_text(), "");
}
#[test]
fn test_update_composition_empty_text_always_accepted() {
let mut ime = ImeState::new();
ime.start_composition();
ime.update_composition("", 0);
assert_eq!(ime.composing_text(), "");
assert_eq!(ime.cursor(), 0);
}
#[test]
fn test_update_composition_short_text_accepted() {
let mut ime = ImeState::new();
ime.start_composition();
ime.update_composition("こんにちは", 5);
assert_eq!(ime.composing_text(), "こんにちは");
assert_eq!(ime.cursor(), 5);
}
#[test]
fn test_update_composition_cursor_clamped_to_char_count() {
let mut ime = ImeState::new();
ime.start_composition();
ime.update_composition("Hello", 100);
assert_eq!(ime.composing_text(), "Hello");
assert_eq!(ime.cursor(), 5); }
}