From ae223338e49dc43fbddd144b13cd6fcd9b62e91e Mon Sep 17 00:00:00 2001 From: dcz Date: Fri, 6 Feb 2026 12:24:52 +0000 Subject: [PATCH] text_input: Add IME surrounding text --- src/action.rs | 14 ++- src/message.rs | 4 +- src/views/editor/mod.rs | 4 +- src/views/editor/view.rs | 6 +- src/views/text_input.rs | 213 +++++++++++++++++++++++++++++++++++++-- src/window/handle.rs | 41 ++++++-- 6 files changed, 257 insertions(+), 25 deletions(-) diff --git a/src/action.rs b/src/action.rs index 2f8dc9271..341e55855 100644 --- a/src/action.rs +++ b/src/action.rs @@ -10,7 +10,7 @@ use std::sync::atomic::AtomicU64; use floem_reactive::{SignalWith, UpdaterEffect}; use peniko::kurbo::{Point, Size, Vec2}; -use winit::window::{ResizeDirection, Theme}; +use winit::window::{ImeSurroundingText, ResizeDirection, Theme}; use crate::platform::{Duration, Instant}; @@ -255,8 +255,11 @@ pub fn clear_app_focus() { } /// Set whether ime input is shown. -pub fn set_ime_allowed(allowed: bool) { - add_update_message(UpdateMessage::SetImeAllowed { allowed }); +pub fn set_ime_allowed(allowed: bool, surrounding_text: Option) { + add_update_message(UpdateMessage::SetImeAllowed { + allowed, + surrounding_text, + }); } /// Set the ime cursor area. @@ -264,6 +267,11 @@ pub fn set_ime_cursor_area(position: Point, size: Size) { add_update_message(UpdateMessage::SetImeCursorArea { position, size }); } +/// Set the ime surrounding text. +pub fn set_ime_surrounding_text(surrounding: ImeSurroundingText) { + add_update_message(UpdateMessage::SetImeSurroundingText(surrounding)); +} + /// Creates a new overlay on the current window. pub fn add_overlay(view: V) -> ViewId { let id = view.id(); diff --git a/src/message.rs b/src/message.rs index e76be5328..ffb7be35b 100644 --- a/src/message.rs +++ b/src/message.rs @@ -3,7 +3,7 @@ use std::{any::Any, cell::RefCell, collections::HashMap}; use floem_reactive::Scope; use peniko::kurbo::{Point, Rect, Size, Vec2}; use ui_events::pointer::PointerId; -use winit::window::{ResizeDirection, Theme}; +use winit::window::{ImeSurroundingText, ResizeDirection, Theme}; use crate::{ platform::menu::Menu, @@ -87,11 +87,13 @@ pub enum UpdateMessage { FocusWindow, SetImeAllowed { allowed: bool, + surrounding_text: Option, }, SetImeCursorArea { position: Point, size: Size, }, + SetImeSurroundingText(ImeSurroundingText), WindowVisible(bool), ViewTransitionAnimComplete(ViewId), SetTheme(Option), diff --git a/src/views/editor/mod.rs b/src/views/editor/mod.rs index 559fd682e..630d42484 100644 --- a/src/views/editor/mod.rs +++ b/src/views/editor/mod.rs @@ -506,8 +506,8 @@ impl Editor { self.ime_cursor_area.set(None); if self.editor_view_focused_value.get_untracked() { - set_ime_allowed(false); - set_ime_allowed(true); + set_ime_allowed(false, None); + set_ime_allowed(true, None); } }); } diff --git a/src/views/editor/view.rs b/src/views/editor/view.rs index d3b43015c..446a9b42b 100644 --- a/src/views/editor/view.rs +++ b/src/views/editor/view.rs @@ -996,7 +996,7 @@ pub fn editor_view( allows_ime.set(allowing_ime); if focused { - set_ime_allowed(allowing_ime); + set_ime_allowed(allowing_ime, None); } } @@ -1045,13 +1045,13 @@ pub fn editor_view( prev_ime_area.set(None); if allows_ime.get_untracked() { - set_ime_allowed(true); + set_ime_allowed(true, None); } }) .on_event_cont(EventListener::FocusLost, move |_| { focused.set(false); editor.with_untracked(|ed| ed.commit_preedit()); - set_ime_allowed(false); + set_ime_allowed(false, None); }) .on_event(EventListener::ImePreedit, move |event| { if !is_active.get_untracked() || !focused.get_untracked() { diff --git a/src/views/text_input.rs b/src/views/text_input.rs index 8bec889c5..96c74e654 100644 --- a/src/views/text_input.rs +++ b/src/views/text_input.rs @@ -1,5 +1,5 @@ #![deny(missing_docs)] -use crate::action::{exec_after, set_ime_allowed, set_ime_cursor_area}; +use crate::action::{exec_after, set_ime_allowed, set_ime_cursor_area, set_ime_surrounding_text}; use crate::event::{EventListener, EventPropagation}; use crate::reactive::{Effect, RwSignal}; use crate::style::{FontFamily, FontProps, PaddingProp, SelectionStyle, StyleClass, TextAlignProp}; @@ -15,10 +15,11 @@ use floem_renderer::Renderer; use ui_events::keyboard::{Key, KeyState, KeyboardEvent, Modifiers, NamedKey}; use ui_events::pointer::{PointerButton, PointerButtonEvent, PointerEvent}; use unicode_segmentation::UnicodeSegmentation; +use winit::window::ImeSurroundingText; use crate::{peniko::color::palette, style::Style, view::View}; -use std::{any::Any, ops::Range}; +use std::{any::Any, cmp, ops::Range}; use crate::platform::{Duration, Instant}; use crate::text::{Attrs, AttrsList, FamilyOwned, TextLayout}; @@ -235,11 +236,10 @@ pub fn text_input(buffer: RwSignal) -> TextInput { } .on_event_stop(EventListener::FocusGained, move |_| { is_focused.set(true); - set_ime_allowed(true); }) .on_event_stop(EventListener::FocusLost, move |_| { is_focused.set(false); - set_ime_allowed(false); + set_ime_allowed(false, None); }) .class(TextInputClass) } @@ -293,6 +293,45 @@ fn get_word_based_motion(event: &KeyboardEvent) -> Option { .then_some(Movement::Line)); } +fn new_surrounding_text(text: &str, cursor: usize, anchor: usize) -> Option { + // Maximum message size enforced by winit is 3999 + let maxlen = cmp::min(3999, text.len()); + let (start, end) = (cmp::min(cursor, anchor), cmp::max(cursor, anchor)); + let (start, end) = if end - start > maxlen { + // Arbitrary number. A buffer around cursor (not anchor) if the whole selection doesn't fit. + const MINIMUM_SURROUNDING_BYTES: usize = 10; + + if cursor > anchor { + let cursor_end = cmp::min(cursor + MINIMUM_SURROUNDING_BYTES, text.len()); + (cursor_end.saturating_sub(maxlen), cursor_end) + } else { + let cursor_end = cursor.saturating_sub(MINIMUM_SURROUNDING_BYTES); + (cursor_end, cmp::min(cursor_end + maxlen, text.len())) + } + } else { + // Arbitrary number, based on a guess about how long an autocompletion context should be. + const IDEAL_SURROUNDING_BYTES: usize = 100; + let start = start.saturating_sub(IDEAL_SURROUNDING_BYTES); + let end = cmp::min(end + IDEAL_SURROUNDING_BYTES, text.len()); + (start, end) + }; + + let start = text.ceil_char_boundary(start); + let end = text.floor_char_boundary(end); + + let text = &text[start..end]; + let cursor = cursor - start; + let anchor = cmp::min(anchor.saturating_sub(start), 3999); + + match ImeSurroundingText::new(text.into(), cursor, anchor) { + Ok(request) => Some(request), + Err(e) => { + eprintln!("Failed to create surrounding text: {e:?}"); + None + } + } +} + const DEFAULT_FONT_SIZE: f32 = 14.0; const CURSOR_BLINK_INTERVAL_MS: u64 = 500; @@ -549,6 +588,28 @@ impl TextInput { }; } + fn calculate_surrounding_text(&self, text: &str) -> Option { + let anchor = if let Some(Range { start, end }) = self.selection { + if self.cursor_glyph_idx == start { + end + } else { + start + } + } else { + self.cursor_glyph_idx + }; + new_surrounding_text(text, self.cursor_glyph_idx, anchor) + } + + fn update_surrounding_text(&self, buf: &str) { + if !self.is_focused { + return; + } + if let Some(surrounding) = self.calculate_surrounding_text(buf) { + set_ime_surrounding_text(surrounding); + } + } + fn update_ime_cursor_area(&mut self) { if !self.is_focused { return; @@ -595,8 +656,8 @@ impl TextInput { if self.is_focused { // toggle IME to flush external preedit state - set_ime_allowed(false); - set_ime_allowed(true); + set_ime_allowed(false, None); + set_ime_allowed(true, None); // ime area will be set in compute_layout } @@ -1135,6 +1196,14 @@ impl View for TextInput { let is_focused = *state; if self.is_focused != is_focused { + if is_focused { + self.buffer.buffer.with_untracked(|buf| { + let surrounding = self.calculate_surrounding_text(buf); + set_ime_allowed(true, surrounding); + }); + } else { + set_ime_allowed(false, None); + } self.is_focused = is_focused; self.last_ime_cursor_area = None; @@ -1154,12 +1223,14 @@ impl View for TextInput { if updated { self.buffer.last_buffer.clone_from(buf); } - updated }); if text_updated { self.update_text_layout(); + self.buffer.buffer.with_untracked(|buf| { + self.update_surrounding_text(buf); + }); self.id.request_layout(); } } else { @@ -1484,7 +1555,7 @@ impl View for TextInput { #[cfg(test)] mod tests { - use crate::views::text_input::get_dbl_click_selection; + use crate::views::text_input::{get_dbl_click_selection, new_surrounding_text}; use super::replace_range; @@ -1658,4 +1729,130 @@ mod tests { assert_eq!(range, 0..s.len()); } + + /// Surrounding text equality. Fields are not public, so it can't be deconstructed. + macro_rules! sureq { + ($surrounding:expr, $expected:expr $(,)?) => { + let surrounding = $surrounding; + match $expected { + None => assert_eq!(surrounding, None), + Some((text, cursor, anchor)) => { + if let Some(surrounding) = surrounding { + assert_eq!( + (text, cursor, anchor), + ( + surrounding.text(), + surrounding.cursor(), + surrounding.anchor() + ), + ) + } else { + panic!("assertion 'surrounding is Some' failed"); + } + } + } + }; + } + + #[test] + fn surrounding_text() { + sureq!(new_surrounding_text("test", 1, 1), Some(("test", 1, 1)),); + sureq!(new_surrounding_text("test", 1, 2), Some(("test", 1, 2)),); + sureq!(new_surrounding_text("test", 2, 1), Some(("test", 2, 1))); + sureq!(new_surrounding_text("test", 2, 5), None); + sureq!(new_surrounding_text("test", 5, 5), None); + } + + #[test] + fn surrounding_text_multibyte() { + // 4 bytes, 2 code points. Valid indices: 0, 2, 4. + sureq!(new_surrounding_text("łł", 2, 2), Some(("łł", 2, 2))); + sureq!(new_surrounding_text("łł", 1, 1), None); + sureq!(new_surrounding_text("łł", 1, 2), None); + sureq!(new_surrounding_text("łł", 2, 1), None); + } + + /// create a pattern to make manual inspection easier when problems arise. + fn generate_text_pattern() -> String { + let mut pattern = [b'A'; 5000]; + let max = b'Z' - b'A'; + pattern + .iter_mut() + .enumerate() + .for_each(|(i, b)| *b += (i % max as usize) as u8); + str::from_utf8(pattern.as_slice()).unwrap().into() + } + + #[test] + fn surrounding_text_large_text() { + let text = generate_text_pattern(); + sureq!( + new_surrounding_text(&text, 1, 1), + Some((&text[0..101], 1, 1)) + ); + sureq!( + new_surrounding_text(&text, 1, 100), + Some((&text[0..200], 1, 100)) + ); + sureq!( + new_surrounding_text(&text, 200, 200), + Some((&text[100..300], 100, 100)) + ); + sureq!( + new_surrounding_text(&text, 200, 500), + Some((&text[100..600], 100, 400)) + ); + sureq!( + new_surrounding_text(&text, 4999, 4999), + Some((&text[4899..5000], 100, 100)) + ); + sureq!( + new_surrounding_text(&text, 4800, 4800), + Some((&text[4700..4900], 100, 100)) + ); + sureq!( + new_surrounding_text(&text, 2000, 2000), + Some((&text[1900..2100], 100, 100)) + ); + } + + #[test] + fn surrounding_text_large_selection() { + let text = generate_text_pattern(); + sureq!( + new_surrounding_text(&text, 0, 5000), + Some((&text[0..3999], 0, 3999)) + ); + sureq!( + new_surrounding_text(&text, 5000, 0), + Some((&text[1001..5000], 3999, 0)) + ); + + sureq!( + new_surrounding_text(&text, 0, 4000), + Some((&text[0..3999], 0, 3999)) + ); + sureq!( + new_surrounding_text(&text, 5000, 1000), + Some((&text[1001..5000], 3999, 0)) + ); + + sureq!( + new_surrounding_text(&text, 4000, 0), + Some((&text[11..4010], 3989, 0)) + ); + sureq!( + new_surrounding_text(&text, 1000, 5000), + Some((&text[990..4989], 10, 3999)) + ); + + sureq!( + new_surrounding_text(&text, 500, 4500), + Some((&text[490..4489], 10, 3999)) + ); + sureq!( + new_surrounding_text(&text, 4500, 500), + Some((&text[511..4510], 3989, 0)) + ); + } } diff --git a/src/window/handle.rs b/src/window/handle.rs index 95a7e7a7b..c104b22a8 100644 --- a/src/window/handle.rs +++ b/src/window/handle.rs @@ -980,7 +980,10 @@ impl WindowHandle { UpdateMessage::SetWindowTitle { title } => { self.window.set_title(&title); } - UpdateMessage::SetImeAllowed { allowed } => { + UpdateMessage::SetImeAllowed { + allowed, + surrounding_text, + } => { if self.window.ime_capabilities().is_some() != allowed { let ime = if allowed { let position = LogicalPosition::new(0, 0); @@ -989,14 +992,22 @@ impl WindowHandle { .with_cursor_area(position.into(), size.into()) .with_hint_and_purpose(ImeHint::NONE, ImePurpose::Normal); + let caps = ImeCapabilities::new() + .with_hint_and_purpose() + .with_cursor_area(); + + let (caps, request_data) = + if let Some(surrounding_text) = surrounding_text { + ( + caps.with_surrounding_text(), + request_data.with_surrounding_text(surrounding_text), + ) + } else { + (caps, request_data) + }; + ImeRequest::Enable( - ImeEnableRequest::new( - ImeCapabilities::new() - .with_hint_and_purpose() - .with_cursor_area(), - request_data, - ) - .unwrap(), + ImeEnableRequest::new(caps, request_data).unwrap(), ) } else { ImeRequest::Disable @@ -1028,6 +1039,20 @@ impl WindowHandle { .unwrap(); } } + UpdateMessage::SetImeSurroundingText(surrounding) => { + if self + .window + .ime_capabilities() + .map(|caps| caps.surrounding_text()) + .unwrap_or(false) + { + self.window + .request_ime_update(ImeRequest::Update( + ImeRequestData::default().with_surrounding_text(surrounding), + )) + .unwrap(); + } + } UpdateMessage::Inspect => { inspector::capture(self.window_id); }