Skip to main content

oxiui_text/
ime.rs

1//! IME (Input Method Editor) composition state.
2//!
3//! Tracks the preedit string and cursor position within the composition, and
4//! provides helpers to compute underline decoration segments and the
5//! composition window rectangle.
6
7use crate::decoration::{DecorationSegment, DecorationStyle, TextDecoration};
8use crate::GlyphPosition;
9
10// ── Preedit ───────────────────────────────────────────────────────────────────
11
12/// IME composition (preedit) state.
13///
14/// Represents the intermediate text that has been entered but not yet
15/// committed by the input method.
16#[derive(Debug, Clone, Default)]
17pub struct Preedit {
18    /// The composition string currently being typed.
19    pub text: String,
20    /// Byte offset range of the cursor within the preedit text, if any.
21    /// The first element is the start, the second is the end (exclusive).
22    pub cursor_range: Option<(usize, usize)>,
23}
24
25impl Preedit {
26    /// Create a new `Preedit` with the given text and optional cursor range.
27    pub fn new(text: impl Into<String>, cursor_range: Option<(usize, usize)>) -> Self {
28        Self {
29            text: text.into(),
30            cursor_range,
31        }
32    }
33
34    /// Returns `true` when there is no active composition text.
35    pub fn is_empty(&self) -> bool {
36        self.text.is_empty()
37    }
38
39    /// Generate underline [`DecorationSegment`]s for the preedit text.
40    ///
41    /// `x_start` is the x-coordinate (in logical pixels) where the preedit
42    /// text begins.  `y_baseline` is the y-coordinate of the text baseline.
43    /// `char_width` is used as an approximate advance width per character.
44    ///
45    /// Returns an empty `Vec` when the preedit text is empty.
46    pub fn underline_segments(
47        &self,
48        x_start: f32,
49        y_baseline: f32,
50        char_width: f32,
51    ) -> Vec<DecorationSegment> {
52        if self.text.is_empty() {
53            return Vec::new();
54        }
55
56        let char_count = self.text.chars().count();
57        let total_width = char_count as f32 * char_width;
58
59        // Build a synthetic single-glyph slice spanning the whole preedit.
60        // `line_height` is approximated as ascent (char_width * 1.2) + descent
61        // (char_width * 0.2), giving a round 1.4 × char_width.
62        let line_height = char_width * 1.4;
63        let ascent = char_width * 1.2;
64        let glyph = GlyphPosition {
65            byte_offset: 0,
66            x: x_start,
67            y: y_baseline - ascent,
68            width: total_width,
69            height: line_height,
70        };
71        let glyph_slice = [glyph];
72
73        let decoration = TextDecoration {
74            underline: Some(DecorationStyle::Solid),
75            overline: None,
76            strikethrough: None,
77            color: [0, 0, 0, 255],
78            thickness: 1.0,
79        };
80
81        decoration.line_segments(&glyph_slice, y_baseline, line_height)
82    }
83
84    /// Compute the composition window rectangle relative to the insertion caret.
85    ///
86    /// Returns `(x, y, width, height)` in logical pixels.  The width is the
87    /// approximate pixel width of the preedit text; the height equals
88    /// `line_height`.
89    pub fn composition_window_rect(
90        &self,
91        caret_x: f32,
92        caret_y: f32,
93        char_width: f32,
94        line_height: f32,
95    ) -> (f32, f32, f32, f32) {
96        let width = (self.text.chars().count() as f32 * char_width).max(1.0);
97        (caret_x, caret_y, width, line_height)
98    }
99}
100
101// ── Tests ─────────────────────────────────────────────────────────────────────
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn preedit_empty_no_segments() {
109        let p = Preedit::new("", None);
110        assert!(p.is_empty());
111        let segs = p.underline_segments(0.0, 12.0, 8.0);
112        assert!(segs.is_empty(), "empty preedit must yield no segments");
113    }
114
115    #[test]
116    fn preedit_underline_segments_non_empty() {
117        let p = Preedit::new("こんにちは", None);
118        assert!(!p.is_empty());
119        let segs = p.underline_segments(0.0, 16.0, 10.0);
120        assert!(
121            !segs.is_empty(),
122            "non-empty preedit must produce at least one underline segment"
123        );
124    }
125
126    #[test]
127    fn preedit_underline_segment_covers_width() {
128        let p = Preedit::new("abc", None);
129        let char_w = 8.0_f32;
130        let segs = p.underline_segments(0.0, 16.0, char_w);
131        assert!(!segs.is_empty());
132        let seg = &segs[0];
133        // The segment must span from x_start to x_start + 3*char_w.
134        assert!(
135            (seg.x2 - seg.x1 - 3.0 * char_w).abs() < f32::EPSILON,
136            "underline segment width should equal char_count * char_width, got {}",
137            seg.x2 - seg.x1
138        );
139    }
140
141    #[test]
142    fn preedit_composition_window_rect_positive_size() {
143        let p = Preedit::new("hello", None);
144        let (x, y, w, h) = p.composition_window_rect(10.0, 20.0, 8.0, 16.0);
145        assert!((x - 10.0).abs() < f32::EPSILON);
146        assert!((y - 20.0).abs() < f32::EPSILON);
147        assert!(w > 0.0, "width must be positive");
148        assert!((h - 16.0).abs() < f32::EPSILON);
149    }
150
151    #[test]
152    fn preedit_composition_window_rect_empty_has_min_width() {
153        let p = Preedit::new("", None);
154        let (_, _, w, _) = p.composition_window_rect(0.0, 0.0, 8.0, 16.0);
155        assert!(
156            w >= 1.0,
157            "even empty preedit window must be at least 1px wide"
158        );
159    }
160
161    #[test]
162    fn preedit_cursor_range_stored() {
163        let p = Preedit::new("abc", Some((1, 2)));
164        assert_eq!(p.cursor_range, Some((1, 2)));
165    }
166}