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
// SPDX-FileCopyrightText: Copyright (c) 2026 Mike Li/Mikewolfli/Wei Li(mikewolfli@163.com)
// SPDX-License-Identifier: MIT
//! IME preedit text overlay widget — shows composition text with an underline.
//!
//! This widget displays the current IME composition string at a given position,
//! rendered with an underline style to indicate the preedit (uncommitted) state,
//! similar to how operating systems render inline IME composition text.
use crate::core::{Color, Font, HorizontalAlignment, Point, Rect};
use crate::event::{Event, EventHandler};
use crate::render::RenderContext;
use crate::widget::capability::coercion::{expect_string, expect_usize};
use crate::widget::capability::properties_trait::{base_property_get, base_property_set};
use crate::widget::capability::types::{CapabilityAccessError, CapabilityValue};
use crate::widget::capability::WidgetProperties;
use crate::widget::{BaseWidget, Draw, Widget, WidgetKind};
use crate::{impl_widget_property_hooks, property_names_of};
/// IME preedit text overlay widget that renders composition text with an underline.
///
/// This widget is visually similar to a Label but draws a composition underline
/// beneath the text to indicate that it is part of an active IME composition session.
pub struct ImePreedit {
base: BaseWidget,
text: String,
font: Font,
text_color: Color,
underline_color: Color,
underline_thickness: u32,
/// Whether the caller ever chose a text colour through [`ImePreedit::set_text_color`].
///
/// The field is only ever *written* by that setter, so its constructed value cannot be told
/// from a caller's write by looking at the field alone. The flag records the difference, which
/// is what lets `draw` fall back to the theme's foreground for a colour nobody chose instead of
/// keeping the black the constructor happened to install.
text_color_set: bool,
/// Whether the caller ever chose an underline colour through
/// [`ImePreedit::set_underline_color`]. Same reason as [`Self::text_color_set`].
underline_color_set: bool,
}
impl ImePreedit {
/// Creates a new ImePreedit widget at the given geometry.
pub fn new(geometry: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::ImePreedit, geometry, "ImePreedit"),
text: String::new(),
font: Font::default(),
text_color: Color::BLACK,
underline_color: Color::BLACK,
underline_thickness: 1,
text_color_set: false,
underline_color_set: false,
}
}
/// Sets the preedit composition text to display.
pub fn set_text(&mut self, text: &str) {
self.text = text.to_string();
self.base.request_redraw();
}
/// Returns the current preedit text.
pub fn text(&self) -> &str {
&self.text
}
/// Sets the font used for the preedit text.
pub fn set_font(&mut self, font: Font) {
self.font = font;
self.base.request_redraw();
}
/// Sets the text color.
pub fn set_text_color(&mut self, color: Color) {
self.text_color = color;
self.text_color_set = true;
self.base.request_redraw();
}
/// Sets the underline color.
pub fn set_underline_color(&mut self, color: Color) {
self.underline_color = color;
self.underline_color_set = true;
self.base.request_redraw();
}
/// Sets the underline thickness in pixels.
pub fn set_underline_thickness(&mut self, thickness: u32) {
self.underline_thickness = thickness;
self.base.request_redraw();
}
/// Returns the composition caret position, in characters from the start of
/// the preedit text.
///
/// The preedit has no independent caret field: text is only ever appended or
/// truncated from the end, so the caret always sits at the end of the
/// composition and this reports the text's character count. The
/// `cursor_position` property reads this rather than publishing a constant.
pub fn cursor_position(&self) -> usize {
self.text.chars().count()
}
/// Moves the composition caret.
///
/// Positions past the end of the composition are clamped to it; a position
/// inside the text is not representable because the widget has no carets
/// between characters, so any value below the end is clamped to the end as
/// well. This keeps `cursor_position` a faithful read of what the widget can
/// actually honour instead of accepting a write it would silently ignore.
pub fn set_cursor_position(&mut self, position: usize) {
if position != self.cursor_position() {
self.base.request_redraw();
}
}
/// The text colour `draw` should actually use.
///
/// Precedence is the crate-wide one: a colour the caller set wins, otherwise the caller's
/// style colour (theme-applied or explicit), otherwise the theme's foreground passed in as
/// `fallback`. The field's constructed `Color::BLACK` is deliberately not consulted, because
/// it is a constructor default rather than a choice, and treating it as one would keep the
/// control black on a dark theme.
fn effective_text_color(&self, fallback: Color) -> Color {
if self.text_color_set {
return self.text_color;
}
self.base.style().text_color.unwrap_or(fallback)
}
/// The underline colour `draw` should actually use. Same precedence as
/// [`Self::effective_text_color`].
fn effective_underline_color(&self, fallback: Color) -> Color {
if self.underline_color_set {
return self.underline_color;
}
self.base.style().border_color.unwrap_or(fallback)
}
}
impl Widget for ImePreedit {
fn base(&self) -> &BaseWidget {
&self.base
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn size_hint(&self) -> crate::core::Size {
crate::core::Size::new(200, 24)
}
impl_draw_bridge!();
impl_widget_property_hooks!();
}
/// `ImePreedit`'s property contract.
///
/// Read/write semantics are carried over unchanged from the centralised
/// `access_read_input.in.rs` / `access_write_input.in.rs` dispatch, so callers see
/// the same coercions and the same errors as before.
impl WidgetProperties for ImePreedit {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"text" => Ok(CapabilityValue::String(self.text().to_string())),
"cursor_position" => Ok(CapabilityValue::UInt(self.cursor_position() as u64)),
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"text" => {
self.set_text(&expect_string(value)?);
Ok(())
}
"cursor_position" => {
self.set_cursor_position(expect_usize(value)?);
Ok(())
}
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of!["text", "cursor_position", BASE_PROPERTY_NAMES]
}
/// Runs one of the commands `ime_preedit` publishes.
///
/// Both assign state — the preedit string and the caret inside it — and each needs
/// a payload, so both are answered through the property route. The preedit string is
/// supplied by the input method, so a nameless command could not invent one.
fn command(&mut self, name: &str) -> Result<(), CapabilityAccessError> {
match name {
"set_text" | "set_cursor_position" => Err(CapabilityAccessError::OutOfRange),
_ => Err(CapabilityAccessError::UnknownCommand),
}
}
}
impl Draw for ImePreedit {
fn draw(&mut self, context: &mut RenderContext) {
if self.text.is_empty() {
return;
}
let rect = self.geometry();
// Chrome colours resolve the explicit style first, then the theme's resolved style for
// this control, and only then fall back to a literal. The widget's own field defaults were
// both `Color::BLACK`, so a light/dark switch left the composition text and its underline
// unchanged — the rendering census reported the control as theme-blind.
//
// `ime_preedit` classifies as `WidgetRole::Text`, whose resolved background is `None`;
// the theme's own `background` is read instead and tinted, which is the surface the text
// would sit on in a real edit host. The theme read is a separate manager lock, taken and
// released inside the accessor, so it is not held across the draw — the global manager's
// mutex is not re-entrant.
let style = self.base.style().clone();
let theme = crate::style::resolved_theme_style("ime_preedit");
// Read as its own lock acquisition and copied out as values, so the guard is dropped
// before anything else touches the theme.
let (window_fill, foreground) = {
let manager = crate::style::theme_manager();
match manager.current_theme() {
Some(active) => (active.colors.background, active.colors.foreground),
None => (Color::rgb(240, 240, 240), Color::BLACK),
}
};
// The surface the composition sits on: one step from the window fill toward the text
// colour, so the preedit is a distinct element on a light theme and on a dark one rather
// than a bare black on black. A caller's own colour still wins.
let ink = style
.text_color
.or_else(|| theme.as_ref().and_then(|t| t.text_color))
.unwrap_or(foreground);
let surface = window_fill.blend(&ink, 0.08);
context.fill_rect(rect, surface);
// The preedit text: a caller's own colour wins, then the theme's foreground, and the
// widget's constructed default is dropped in favour of that foreground rather than a
// fixed black the theme cannot override.
let text_color = self.effective_text_color(ink);
// Draw the preedit text, fitted to the control's own width.
//
// The composition string is authored by the input method, so the control neither
// chooses nor bounds its length: a long candidate run advanced `font.size()` pixels
// per character straight past the control's right edge. Drawing it at the origin
// clipped it on the raster backends but left it in the SVG snapshot as text leaving
// the picture, and the underline below was sized from the *unfitted* string, so the
// rule that marks the composition pointed somewhere the text no longer reached.
// `draw_text_fitted` returns what it drew, which is what keeps the underline under
// the glyphs that are actually on screen.
let fitted = context.draw_text_fitted(
rect,
&self.text,
&self.font,
text_color,
HorizontalAlignment::Left,
);
// Draw composition underline below the text. The advance is measured from the drawn
// string rather than estimated as `len() × font.size()`: the two differ once the text
// is truncated, and the estimate also overstated every non-ASCII cluster.
let font_size = self.font.size() as i32;
let text_width = context.measure_text(&fitted, &self.font).width as i32;
let underline_y = rect.y + font_size + 2;
let underline_x_end = (rect.x + text_width).min(rect.x + rect.width as i32);
// The underline marks the composition as uncommitted, so it carries the theme's primary
// when the caller has not chosen one of its own.
let underline_color = self.effective_underline_color(
crate::style::semantic_color(crate::style::SemanticColor::Info).unwrap_or(ink),
);
context.draw_line_stroke(
Point::new(rect.x, underline_y),
Point::new(underline_x_end, underline_y),
underline_color,
self.underline_thickness,
);
}
}
impl EventHandler for ImePreedit {
/// Typing edits the preedit buffer only while the control is enabled.
///
/// The disabled state was not consulted, so a host that disables the control — the usual way
/// to take a text entry out of play — still had characters appended to it by keystrokes meant
/// for whatever the user was actually looking at.
fn handle_event(&mut self, event: &Event) {
self.base.handle_event(event);
if !self.base.is_enabled() {
return;
}
if let Event::KeyPress { key, modifiers } = event {
if *key == 8 {
// Backspace — remove last character
if !self.text.is_empty() {
self.text.pop();
self.base.request_redraw();
}
} else if *key >= 32 && *key <= 126 {
// Printable ASCII — append to preedit text
let c = char::from_u32(*key).unwrap_or(' ');
// Respect shift for uppercase letters
let c = if *modifiers & 0x02 != 0 { c.to_ascii_uppercase() } else { c };
self.text.push(c);
self.base.request_redraw();
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ime_preedit_new_defaults() {
let ip = ImePreedit::new(Rect::new(0, 0, 200, 30));
assert_eq!(ip.text(), "");
assert_eq!(ip.kind(), WidgetKind::ImePreedit);
}
#[test]
fn ime_preedit_set_text() {
let mut ip = ImePreedit::new(Rect::new(0, 0, 200, 30));
ip.set_text("composition");
assert_eq!(ip.text(), "composition");
}
#[test]
fn ime_preedit_set_text_empty() {
let mut ip = ImePreedit::new(Rect::new(0, 0, 200, 30));
ip.set_text("hello");
ip.set_text("");
assert_eq!(ip.text(), "");
}
#[test]
fn ime_preedit_set_font() {
let mut ip = ImePreedit::new(Rect::new(0, 0, 200, 30));
let font = Font::default();
ip.set_font(font);
// No panic expected
}
#[test]
fn ime_preedit_set_colors() {
let mut ip = ImePreedit::new(Rect::new(0, 0, 200, 30));
ip.set_text_color(Color::RED);
ip.set_underline_color(Color::BLUE);
}
#[test]
fn ime_preedit_underline_thickness() {
let mut ip = ImePreedit::new(Rect::new(0, 0, 200, 30));
ip.set_underline_thickness(3);
}
#[test]
fn ime_preedit_draw_empty_text() {
let mut ip = ImePreedit::new(Rect::new(0, 0, 200, 30));
// Should not crash when drawing empty text
let svg = crate::widget::svg::render_to_svg(&mut ip);
assert!(svg.starts_with("<svg"));
}
/// A disabled preedit control ignores keystrokes.
///
/// The handler never consulted `enabled`, so a host that disabled the control — the usual way
/// to take a text entry out of play — still had characters appended to it by keystrokes meant
/// for whatever the user was actually looking at.
#[test]
fn ime_preedit_ignores_keys_when_disabled() {
use crate::event::Event;
use crate::widget::Widget;
let mut preedit = ImePreedit::new(Rect::new(0, 0, 200, 30));
preedit.handle_event(&Event::KeyPress { key: 65, modifiers: 0 }); // 'A'
assert_eq!(preedit.text(), "A", "an enabled control accepts typed text");
preedit.set_enabled(false);
preedit.handle_event(&Event::KeyPress { key: 66, modifiers: 0 }); // 'B'
assert_eq!(preedit.text(), "A", "a disabled control must not accept text");
// Backspace is refused too, so the buffer is frozen rather than editable one way.
preedit.set_enabled(true);
preedit.handle_event(&Event::KeyPress { key: 67, modifiers: 0 }); // 'C'
assert_eq!(preedit.text(), "AC");
preedit.set_enabled(false);
preedit.handle_event(&Event::KeyPress { key: 8, modifiers: 0 }); // backspace
assert_eq!(preedit.text(), "AC", "a disabled control must not accept backspace either");
}
}