rust_widgets 2.9.2

Pure Rust cross-platform native GUI library with hardware-adaptive rendering, 180 widgets, touch/gesture support, i18n, and SVG-pipeline-accurate output
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
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
// 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::compat::Vec;
use crate::core::{Color, Font, HorizontalAlignment, Point, Rect};
use crate::event::key_codes;
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};

/// One styled run of a composition string.
///
/// # Why segments rather than one string
///
/// An input method marks the clause it is currently converting differently from the clauses
/// around it, and several platforms also carry a per-clause cursor attribute. Painting the whole
/// composition with one colour and one underline threw that information away: the user could not
/// tell which clause the candidate window would replace. A segment is the smallest unit the
/// platform reports, so it is the unit this overlay stores.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PreeditSegment {
    text: String,
    color: Option<Color>,
    underline: bool,
    cursor: bool,
}

impl PreeditSegment {
    /// Creates a plain segment: the widget's own text colour and a composition underline.
    pub fn new(text: impl Into<String>) -> Self {
        Self { text: text.into(), color: None, underline: true, cursor: false }
    }

    /// Overrides the ink for this segment.
    pub fn with_color(mut self, color: Color) -> Self {
        self.color = Some(color);
        self
    }

    /// Sets whether the composition underline is drawn beneath this segment.
    pub fn with_underline(mut self, underline: bool) -> Self {
        self.underline = underline;
        self
    }

    /// Marks this segment as the clause the input method is converting.
    ///
    /// The mark is a stronger underline; the segment's text and colour are unchanged.
    pub fn with_cursor(mut self, cursor: bool) -> Self {
        self.cursor = cursor;
        self
    }

    /// The segment's text.
    pub fn text(&self) -> &str {
        &self.text
    }

    /// The ink this segment asked for, or `None` to use the widget's text colour.
    pub fn color(&self) -> Option<Color> {
        self.color
    }

    /// Whether the composition underline is drawn for this segment.
    pub fn underline(&self) -> bool {
        self.underline
    }

    /// Whether this is the clause the input method is converting.
    pub fn is_cursor(&self) -> bool {
        self.cursor
    }
}

/// 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,
    /// Styled runs of the composition, empty when the caller supplied only plain text.
    segments: Vec<PreeditSegment>,
    /// Byte offset of the composition caret inside [`Self::text`].
    cursor: usize,
    /// The caret's x offset from the control's left edge, measured by the last paint.
    ///
    /// The input method asks the host where to anchor its candidate window *before* the next
    /// frame is drawn, so a value only produced inside `draw` would be one frame stale. The
    /// initial value is an estimate from the font size and is replaced by the true measurement
    /// as soon as the composition is painted.
    caret_offset: i32,
    /// Bumped whenever the composition, its runs, the font or the caret changes (I-P11).
    metrics_revision: u64,
    /// Memoised caret offset and per-run advances, rebuilt once per revision (I-P11).
    ///
    /// The composition is repainted every frame while a session is active, and each frame used to
    /// re-measure the caret prefix and every styled run. Both are pure functions of the string, the
    /// runs, the font and the scale, so they are measured once and read back.
    composition_metrics: core::cell::RefCell<Option<CompositionMetrics>>,
}

/// The measured layout of one composition (I-P11).
struct CompositionMetrics {
    revision: u64,
    font: Font,
    dpi_bits: u32,
    caret_offset: i32,
    segment_advances: Vec<i32>,
}

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,
            segments: Vec::new(),
            cursor: 0,
            caret_offset: 0,
            metrics_revision: 0,
            composition_metrics: core::cell::RefCell::new(None),
        }
    }

    /// Invalidates the memoised composition layout (I-P11).
    fn invalidate_metrics(&mut self) {
        self.metrics_revision = self.metrics_revision.wrapping_add(1);
    }

    /// The composition caret's byte offset, snapped to a character boundary.
    fn caret_byte(&self) -> usize {
        let caret_byte = self.cursor.min(self.text.len());
        self.text
            .char_indices()
            .take_while(|(index, _)| *index < caret_byte)
            .last()
            .map(|(index, ch)| index + ch.len_utf8())
            .unwrap_or(0)
    }

    /// Ensures the composition's measurements are current, measuring only on a stale revision.
    fn ensure_composition_metrics(&self, context: &RenderContext) {
        let dpi_bits = context.dpi_scale().to_bits();
        if let Ok(cache) = self.composition_metrics.try_borrow() {
            if let Some(metrics) = cache.as_ref() {
                if metrics.revision == self.metrics_revision
                    && metrics.font == self.font
                    && metrics.dpi_bits == dpi_bits
                {
                    return;
                }
            }
        }
        let caret_offset =
            context.measure_text(&self.text[..self.caret_byte()], &self.font).width as i32;
        let segment_advances = self
            .segments
            .iter()
            .map(|segment| context.measure_text(segment.text(), &self.font).width as i32)
            .collect();
        if let Ok(mut cache) = self.composition_metrics.try_borrow_mut() {
            *cache = Some(CompositionMetrics {
                revision: self.metrics_revision,
                font: self.font.clone(),
                dpi_bits,
                caret_offset,
                segment_advances,
            });
        }
    }

    /// Runs `f` with the cached caret offset and per-run advances (I-P11).
    ///
    /// Falls back to short-lived defaults when the cache is already borrowed, rather than
    /// panicking inside a paint.
    fn with_composition_metrics<R>(&self, f: impl FnOnce(i32, &[i32]) -> R) -> R {
        if let Ok(cache) = self.composition_metrics.try_borrow() {
            if let Some(metrics) = cache.as_ref() {
                return f(metrics.caret_offset, &metrics.segment_advances);
            }
        }
        f(self.caret_offset, &[])
    }

    /// Sets the preedit composition text to display.
    ///
    /// A same-value write is a no-op: an IME can re-announce the composition it already has, and
    /// repainting the same glyphs queues a frame nobody can see (WDGT-12).
    pub fn set_text(&mut self, text: impl Into<String>) {
        let text = text.into();
        if self.text == text && self.segments.is_empty() {
            return;
        }
        self.text = text;
        // A plain-text write replaces the styled runs: keeping segments from a previous
        // composition would paint glyphs that are no longer in the value.
        self.segments.clear();
        self.cursor = self.text.len();
        self.invalidate_metrics();
        self.base.request_redraw();
    }

    /// Replaces the composition with styled segments.
    ///
    /// The segments' text is concatenated into [`ImePreedit::text`], so the plain accessor stays
    /// total and a host that reads back the composition does not have to walk the runs. An empty
    /// vector clears the overlay.
    pub fn set_segments(&mut self, segments: Vec<PreeditSegment>) {
        let mut text = String::new();
        let mut cursor_at = None;
        for segment in &segments {
            if segment.is_cursor() && cursor_at.is_none() {
                cursor_at = Some(text.len());
            }
            text.push_str(segment.text());
        }
        if self.text == text && self.segments == segments {
            return;
        }
        self.text = text;
        self.cursor = cursor_at.unwrap_or(self.text.len());
        self.segments = segments;
        self.invalidate_metrics();
        self.base.request_redraw();
    }

    /// The styled runs of the composition, empty when it was set as plain text.
    pub fn segments(&self) -> &[PreeditSegment] {
        &self.segments
    }

    /// The caret's rectangle in the window, for anchoring a candidate window.
    ///
    /// The x is the offset measured by the most recent paint, or a font-size estimate before the
    /// first paint. The y and height are the control's own text band, so a platform that places
    /// its candidate list below the caret gets a sensible anchor even mid-composition.
    pub fn caret_rect(&self) -> Rect {
        let rect = self.geometry();
        Rect::new(rect.x + self.caret_offset, rect.y, 1, rect.height)
    }

    /// The caret's top-left point in the window.
    pub fn caret_screen_position(&self) -> Point {
        let caret = self.caret_rect();
        Point::new(caret.x, caret.y)
    }

    /// 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) {
        if self.font == font {
            return;
        }
        self.font = font;
        self.invalidate_metrics();
        self.base.request_redraw();
    }

    /// Sets the text color.
    pub fn set_text_color(&mut self, color: Color) {
        // The `_set` flag is part of the comparison: the first write must go through even when it
        // repeats the default colour, because that is what starts overriding the theme (WDGT-15).
        if self.text_color_set && self.text_color == color {
            return;
        }
        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) {
        if self.underline_color_set && self.underline_color == color {
            return;
        }
        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) {
        if self.underline_thickness == thickness {
            return;
        }
        self.underline_thickness = thickness;
        self.base.request_redraw();
    }

    /// Returns the composition caret position, in characters from the start of
    /// the preedit text.
    ///
    /// The composition caret is stored as a byte offset (the unit every text API in the crate
    /// uses) and reported in characters, because that is the unit the property contract and the
    /// input-method event carry.
    pub fn cursor_position(&self) -> usize {
        self.text[..self.cursor.min(self.text.len())].chars().count()
    }

    /// Moves the composition caret.
    ///
    /// Positions past the end of the composition are clamped to it; the value is snapped to a
    /// character boundary so slicing the composition at the caret stays total.
    pub fn set_cursor_position(&mut self, position: usize) {
        let byte = self
            .text
            .char_indices()
            .nth(position)
            .map(|(index, _)| index)
            .unwrap_or(self.text.len());
        if byte != self.cursor {
            self.cursor = byte;
            self.invalidate_metrics();
            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 set_state_theme_hook(&mut self) {
        crate::style::reapply_active_theme_state(self);
    }
    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();
        let theme = crate::style::resolved_theme_style_shared("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.face_with_gradient(
            rect,
            surface,
            self.style().background_gradient.as_ref(),
            self.style().surface.unwrap_or_default(),
            self.style().border_radius.unwrap_or(0),
            Color::BLACK,
        );

        // 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);
        let font_size = self.font.size() as i32;
        let underline_y = rect.y + font_size + 2;
        let underline_color = self.effective_underline_color(
            crate::style::semantic_color(crate::style::SemanticColor::Info).unwrap_or(ink),
        );

        // Caret placement is needed by both the paint and the candidate-window query, so the
        // composition is measured **once per revision** and the paint reads the cache (I-P11).
        // Previously every frame re-measured the caret prefix and every styled run — and the plain
        // path measured its own fitted string a second time to place the underline.
        self.ensure_composition_metrics(context);
        let surface_rect = rect;
        let underline_thickness = self.underline_thickness;
        let segments = &self.segments;
        let plain_text = &self.text;
        let caret_offset = self.with_composition_metrics(|caret, advances| {
            if segments.is_empty() {
                // Plain composition: one run, fitted to the control's own width so a long
                // candidate string cannot paint past the right edge.
                let fitted = context.draw_text_fitted_cow(
                    surface_rect,
                    plain_text,
                    &self.font,
                    text_color,
                    HorizontalAlignment::Left,
                );
                let text_width = context.measure_text(&fitted, &self.font).width as i32;
                let underline_x_end =
                    (surface_rect.x + text_width).min(surface_rect.x + surface_rect.width as i32);
                context.draw_line_stroke(
                    Point::new(surface_rect.x, underline_y),
                    Point::new(underline_x_end, underline_y),
                    underline_color,
                    underline_thickness,
                );
            } else {
                // Styled composition: each run keeps its own ink and underline. The runs are
                // fitted independently against the space that is left, so the last one cannot
                // leave the control even when the input method reports an over-long clause.
                let mut x = surface_rect.x;
                for (index, segment) in segments.iter().enumerate() {
                    if x >= surface_rect.x + surface_rect.width as i32 {
                        break;
                    }
                    let remaining = (surface_rect.x + surface_rect.width as i32 - x).max(1) as u32;
                    let box_rect = Rect::new(x, surface_rect.y, remaining, surface_rect.height);
                    // The cached advance is what the run takes when it fits; a run that has to
                    // be elided is measured on its fitted form, because the ellipsis is part of
                    // what is painted.
                    let fitted = context.draw_text_fitted_cow(
                        box_rect,
                        segment.text(),
                        &self.font,
                        segment.color().unwrap_or(text_color),
                        HorizontalAlignment::Left,
                    );
                    let advance = match advances.get(index) {
                        // A run that was painted verbatim takes its cached advance; an elided one
                        // is measured on the fitted form, because the ellipsis is part of what is
                        // painted.
                        Some(advance) if matches!(fitted, crate::compat::Cow::Borrowed(_)) => {
                            *advance
                        }
                        _ => context.measure_text(&fitted, &self.font).width as i32,
                    };
                    if segment.underline() {
                        let thickness = if segment.is_cursor() {
                            underline_thickness.max(2)
                        } else {
                            underline_thickness
                        };
                        context.draw_line_stroke(
                            Point::new(x, underline_y),
                            Point::new(
                                (x + advance).min(surface_rect.x + surface_rect.width as i32),
                                underline_y,
                            ),
                            underline_color,
                            thickness,
                        );
                    }
                    x += advance;
                }
            }
            caret
        });
        self.caret_offset = caret_offset;

        // The composition caret is drawn at the stored offset, which is also what
        // `caret_rect` reports to a platform that anchors its candidate window there.
        if self.base.focus_reason().is_some() {
            let caret_x = (rect.x + caret_offset).min(rect.x + rect.width as i32 - 1);
            context.draw_line(
                Point::new(caret_x, rect.y + 2),
                Point::new(caret_x, rect.y + rect.height as i32 - 2),
                text_color,
            );
        }
    }
}

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;
        }
        match event {
            // The composition string and its caret as the platform reports them. This is the
            // authoritative path: a host that forwards input-method events used to get no
            // update at all, because only synthetic `KeyPress` characters were consumed.
            Event::ImePreedit { text, cursor } => {
                let byte = (*cursor).min(text.len());
                let byte = text
                    .char_indices()
                    .take_while(|(index, _)| *index < byte)
                    .last()
                    .map(|(index, ch)| index + ch.len_utf8())
                    .unwrap_or(0);
                let next = text.clone();
                if self.text != next {
                    self.text = next;
                }
                // A platform preedit replaces any previously supplied styled runs: the runs
                // described the old composition.
                self.segments.clear();
                self.cursor = byte;
                self.invalidate_metrics();
                self.base.request_redraw();
            }
            // A commit ends the composition. The committed text belongs to the edit host, not to
            // this overlay, so the overlay empties rather than duplicating it.
            Event::ImeCommit { .. } => {
                if !self.text.is_empty() || !self.segments.is_empty() {
                    self.text.clear();
                    self.segments.clear();
                    self.cursor = 0;
                    self.invalidate_metrics();
                    self.base.request_redraw();
                }
            }
            Event::TextInput { text } => {
                if !text.is_empty() {
                    let mut next = self.text.clone();
                    next.push_str(text);
                    self.text = next;
                    self.segments.clear();
                    self.cursor = self.text.len();
                    self.invalidate_metrics();
                    self.base.request_redraw();
                }
            }
            Event::KeyPress { key, modifiers } => {
                if *key == key_codes::BACKSPACE {
                    // Backspace removes the character before the caret, not the last character:
                    // once the caret can sit inside the composition, those are different edits.
                    if self.cursor > 0 {
                        let mut boundary = self.cursor.saturating_sub(1);
                        while boundary > 0 && !self.text.is_char_boundary(boundary) {
                            boundary -= 1;
                        }
                        self.text.replace_range(boundary..self.cursor, "");
                        self.cursor = boundary;
                        self.segments.clear();
                        self.invalidate_metrics();
                        self.base.request_redraw();
                    }
                } else if *key >= 32 && *key <= 126 {
                    let c = char::from_u32(*key).unwrap_or(' ');
                    let c = if *modifiers & 0x02 != 0 { c.to_ascii_uppercase() } else { c };
                    let mut buffer = [0u8; 4];
                    let encoded = c.encode_utf8(&mut buffer);
                    self.text.insert_str(self.cursor, encoded);
                    self.cursor += encoded.len();
                    self.segments.clear();
                    self.invalidate_metrics();
                    self.base.request_redraw();
                }
            }
            _ => {}
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::compat::vec;

    #[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.
    /// The platform's own composition events drive the overlay.
    ///
    /// Only synthetic key presses were consumed before, so a host that forwarded the real
    /// input-method events saw a stale composition string and an unanchored candidate window.
    #[test]
    fn platform_composition_events_drive_the_overlay() {
        let mut preedit = ImePreedit::new(Rect::new(0, 0, 200, 30));
        preedit.handle_event(&Event::ime_preedit("にほん", 3));
        assert_eq!(preedit.text(), "にほん");
        assert_eq!(preedit.cursor_position(), 1, "three bytes is one character");

        preedit.handle_event(&Event::ime_commit("日本"));
        assert_eq!(preedit.text(), "", "a commit ends the composition overlay");
        assert_eq!(preedit.cursor_position(), 0);
    }

    /// Styled runs keep their own ink and are concatenated into the plain text accessor.
    #[test]
    fn styled_segments_are_painted_and_readable_as_text() {
        let mut preedit = ImePreedit::new(Rect::new(0, 0, 200, 30));
        preedit.set_segments(vec![
            PreeditSegment::new("abc"),
            PreeditSegment::new("def").with_color(Color::RED).with_cursor(true),
        ]);
        assert_eq!(preedit.text(), "abcdef");
        assert_eq!(preedit.segments().len(), 2);
        assert!(preedit.segments()[1].is_cursor());
        assert_eq!(preedit.cursor_position(), 3, "the caret sits at the converting clause");
        assert_eq!(preedit.segments()[0].text(), "abc");
        assert_eq!(preedit.segments()[1].text(), "def");
    }

    /// The caret's screen position follows the composition caret.
    #[test]
    fn the_caret_position_is_reported_for_candidate_window_anchoring() {
        let mut preedit = ImePreedit::new(Rect::new(10, 20, 200, 30));
        preedit.set_text("hello");
        let at_end = preedit.caret_screen_position();
        assert_eq!(at_end.x, 10, "before the first paint the offset is the text origin");

        preedit.set_cursor_position(0);
        let _ = crate::widget::svg::render_to_svg(&mut preedit);
        assert_eq!(preedit.caret_rect().x, 10);

        preedit.set_cursor_position(5);
        let _ = crate::widget::svg::render_to_svg(&mut preedit);
        assert!(preedit.caret_rect().x > 10, "the caret moved with the composition cursor");
    }

    #[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");
    }
}