Skip to main content

dear_imgui_rs/context/
ime.rs

1use super::binding::CTX_MUTEX;
2use crate::{Context, Id, sys};
3
4/// Transient IME text owned by the active text widget, separate from its buffer.
5#[derive(Clone, Debug, Eq, PartialEq)]
6pub struct ImePreedit {
7    text: String,
8    cursor_range: Option<(usize, usize)>,
9    owner: ImeOwner,
10}
11
12impl ImePreedit {
13    pub fn text(&self) -> &str {
14        &self.text
15    }
16
17    /// UTF-8 byte offsets for the cursor or selected range; `None` hides the cursor.
18    /// A reversed range is accepted and retains the cursor direction.
19    pub fn cursor_range(&self) -> Option<(usize, usize)> {
20        self.cursor_range
21    }
22}
23
24#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
25pub enum ImePreeditError {
26    #[error("IME cursor offsets must be UTF-8 boundaries within the preedit text")]
27    InvalidCursorRange,
28    #[error("IME preedit text must not contain NUL")]
29    InteriorNul,
30}
31
32#[derive(Debug, Default)]
33pub(crate) struct ImeState {
34    pub(crate) preedit: Option<ImePreedit>,
35    presentation: Option<InputPresentation>,
36    session: ImeSession,
37}
38
39#[derive(Clone, Copy, Debug, Eq, PartialEq)]
40struct ImeOwner {
41    widget: sys::ImGuiID,
42    window: sys::ImGuiID,
43    viewport: sys::ImGuiID,
44}
45
46impl ImeOwner {
47    fn current(raw: &sys::ImGuiContext) -> Option<Self> {
48        if raw.ActiveId == 0
49            || raw.InputTextState.ID != raw.ActiveId
50            || raw.ActiveIdWindow.is_null()
51            || raw.InputTextState.Flags & sys::ImGuiInputTextFlags_ReadOnly != 0
52        {
53            return None;
54        }
55        let window = unsafe { &*raw.ActiveIdWindow };
56        if window.Viewport.is_null() {
57            return None;
58        }
59        Some(Self {
60            widget: raw.ActiveId,
61            window: window.ID,
62            viewport: unsafe { (*window.Viewport)._ImGuiViewport.ID },
63        })
64    }
65}
66
67#[derive(Clone, Copy, Debug, Default)]
68enum ImeSession {
69    #[default]
70    Idle,
71    Composing(ImeOwner),
72    Cancelled {
73        owner: ImeOwner,
74        reset_requested: bool,
75    },
76    Disabled,
77}
78
79impl ImeState {
80    pub(crate) fn cancel(&mut self) {
81        self.preedit = None;
82        if let ImeSession::Composing(owner) = self.session {
83            self.session = ImeSession::Cancelled {
84                owner,
85                reset_requested: true,
86            };
87        }
88    }
89
90    fn reconcile_owner(&mut self, current: Option<ImeOwner>) {
91        if let ImeSession::Composing(owner) = self.session
92            && current != Some(owner)
93        {
94            self.cancel();
95        }
96    }
97
98    pub(crate) fn session_viewport(&self) -> Option<Id> {
99        match self.session {
100            ImeSession::Composing(owner) | ImeSession::Cancelled { owner, .. } => {
101                Some(Id::from(owner.viewport))
102            }
103            ImeSession::Idle | ImeSession::Disabled => None,
104        }
105    }
106
107    pub(crate) fn take_reset_request(&mut self) -> Option<Id> {
108        if let ImeSession::Cancelled {
109            owner,
110            reset_requested,
111        } = &mut self.session
112            && std::mem::take(reset_requested)
113        {
114            Some(Id::from(owner.viewport))
115        } else {
116            None
117        }
118    }
119
120    pub(crate) fn reset_session(&mut self) {
121        self.preedit = None;
122        self.session = ImeSession::Idle;
123    }
124}
125
126#[derive(Clone, Copy, Debug)]
127struct InputPresentation {
128    frame: i32,
129    owner: sys::ImGuiID,
130    font: *mut sys::ImFont,
131    size: f32,
132    density: f32,
133}
134
135impl crate::Ui {
136    // Capture inside the widget's font scope, never from the frame's final font.
137    pub(crate) fn run_input_widget<R>(
138        &self,
139        label: *const std::ffi::c_char,
140        f: impl FnOnce() -> R,
141    ) -> R {
142        self.run_with_bound_context(|| {
143            // A clipped inactive widget may leave LastItemData unchanged.
144            let owner = unsafe { sys::igGetID_Str(label) };
145            let (font, size, density) = unsafe {
146                let font = (*self.ctx).Font;
147                (font, (*self.ctx).FontSize, (*font).CurrentRasterizerDensity)
148            };
149            let result = f();
150            unsafe {
151                let raw = &*self.ctx;
152                if raw.ActiveId == owner
153                    && raw.InputTextState.ID == owner
154                    && raw.LastItemData.ID == owner
155                {
156                    self.ime.borrow_mut().presentation = Some(InputPresentation {
157                        frame: raw.FrameCount,
158                        owner: raw.ActiveId,
159                        font,
160                        size,
161                        density,
162                    });
163                }
164                self.ime
165                    .borrow_mut()
166                    .reconcile_owner(ImeOwner::current(raw));
167            }
168            result
169        })
170    }
171}
172
173impl Context {
174    /// Replace the active text widget's composition without editing its buffer or undo history.
175    ///
176    /// Cursor offsets use UTF-8 bytes. Empty text hides the panel while retaining
177    /// its owner until commit; `None` hides its cursor. Updates without an active
178    /// editable text widget are discarded.
179    /// The composition is drawn in a non-interactive foreground panel at the
180    /// widget's caret, on its owning viewport. Password input masks its text.
181    /// Core single-line, hint, and multiline text inputs capture their presentation;
182    /// unsupported custom/native widgets discard composition.
183    /// After cancellation or an owner change, a platform must report a new
184    /// enabled session through [`Self::notify_ime_enabled`] before more updates.
185    pub fn set_ime_preedit(
186        &mut self,
187        text: &str,
188        cursor_range: Option<(usize, usize)>,
189    ) -> Result<(), ImePreeditError> {
190        let _guard = CTX_MUTEX.lock();
191        self.assert_current_context("Context::set_ime_preedit()");
192        if let Some((start, end)) = cursor_range
193            && (!text.is_char_boundary(start) || !text.is_char_boundary(end))
194        {
195            return Err(ImePreeditError::InvalidCursorRange);
196        }
197        if text.contains('\0') {
198            return Err(ImePreeditError::InteriorNul);
199        }
200        let raw = unsafe { &*self.as_raw() };
201        let owner = ImeOwner::current(raw);
202        let mut ime = self.state.ime.borrow_mut();
203        ime.reconcile_owner(owner);
204        if matches!(
205            ime.session,
206            ImeSession::Cancelled { .. } | ImeSession::Disabled
207        ) {
208            return Ok(());
209        }
210        if text.is_empty() {
211            ime.preedit = None;
212            return Ok(());
213        }
214        let Some(owner) = owner.filter(|owner| {
215            ime.presentation
216                .is_some_and(|p| p.owner == owner.widget && p.frame == raw.FrameCount)
217        }) else {
218            return Ok(());
219        };
220        ime.session = ImeSession::Composing(owner);
221        ime.preedit = Some(ImePreedit {
222            text: text.to_owned(),
223            cursor_range,
224            owner,
225        });
226        Ok(())
227    }
228
229    /// Return an owned snapshot, without borrowing state needed by platform teardown.
230    pub fn ime_preedit(&self) -> Option<ImePreedit> {
231        self.state.ime.borrow().preedit.clone()
232    }
233
234    /// Whether transient composition is active.
235    pub fn has_ime_preedit(&self) -> bool {
236        self.state.ime.borrow().preedit.is_some()
237    }
238
239    /// Whether a composition owns an input, including an empty pre-commit panel.
240    pub fn has_ime_composition(&self) -> bool {
241        matches!(self.state.ime.borrow().session, ImeSession::Composing(_))
242    }
243
244    /// Notify the Context that the platform enabled or disabled its IME session.
245    ///
246    /// This does not enable the native IME. Enabling starts a fresh session;
247    /// disabling rejects preedit and commit events until the next enabled notice.
248    pub fn notify_ime_enabled(&mut self, enabled: bool) {
249        let mut ime = self.state.ime.borrow_mut();
250        if enabled {
251            ime.reset_session();
252        } else {
253            ime.cancel();
254            if let ImeSession::Cancelled {
255                reset_requested, ..
256            } = &mut ime.session
257            {
258                *reset_requested = false;
259            } else {
260                ime.session = ImeSession::Disabled;
261            }
262        }
263    }
264
265    /// Commit IME text to its original input, returning whether characters were queued.
266    ///
267    /// Control characters are ignored. A cancelled session or a changed owner
268    /// rejects the commit. Without preedit, native/custom editors requesting text
269    /// input can still receive direct commits through the regular IO queue.
270    pub fn commit_ime_text(&mut self, text: &str) -> bool {
271        let _guard = CTX_MUTEX.lock();
272        self.assert_current_context("Context::commit_ime_text()");
273        let raw = unsafe { &*self.as_raw() };
274        {
275            let mut ime = self.state.ime.borrow_mut();
276            ime.reconcile_owner(ImeOwner::current(raw));
277            if matches!(
278                ime.session,
279                ImeSession::Cancelled { .. } | ImeSession::Disabled
280            ) || !(raw.PlatformImeData.WantVisible || raw.PlatformImeData.WantTextInput)
281            {
282                return false;
283            }
284            ime.reset_session();
285        }
286        let mut forwarded = false;
287        for ch in text.chars().filter(|ch| !ch.is_control()) {
288            self.io_mut().add_input_character(ch);
289            forwarded = true;
290        }
291        forwarded
292    }
293
294    /// Viewport whose composition is active or awaiting a native reset.
295    pub fn ime_session_viewport(&self) -> Option<Id> {
296        self.state.ime.borrow().session_viewport()
297    }
298
299    /// Cancel transient composition without submitting text.
300    ///
301    /// The platform must consume its reset request and report a fresh enabled
302    /// session before new IME events are accepted.
303    pub fn clear_ime_preedit(&mut self) {
304        self.state.ime.borrow_mut().cancel();
305    }
306
307    /// Viewport currently requesting text input, for native-window event routing.
308    pub fn text_input_viewport(&self) -> Option<Id> {
309        let _guard = CTX_MUTEX.lock();
310        self.assert_current_context("Context::text_input_viewport()");
311        let raw = unsafe { &*self.as_raw() };
312        (raw.PlatformImeData.WantVisible || raw.PlatformImeData.WantTextInput)
313            .then(|| Id::from(raw.PlatformImeData.ViewportId))
314    }
315
316    pub(crate) fn render_ime_preedit(&mut self) {
317        let raw = unsafe { &*self.as_raw() };
318        {
319            let mut state = self.state.ime.borrow_mut();
320            // Widget submission may start with inactive inputs. Only the frame
321            // boundary can require the owner to have submitted its IME data.
322            let current = ImeOwner::current(raw).filter(|owner| {
323                raw.PlatformImeData.WantVisible
324                    && state
325                        .presentation
326                        .is_some_and(|p| p.owner == owner.widget && p.frame == raw.FrameCount)
327            });
328            state.reconcile_owner(current);
329        }
330        // Do not hold RefCell borrows across font loading or native callbacks.
331        let (preedit, presentation) = {
332            let state = self.state.ime.borrow();
333            let Some(preedit) = state.preedit.clone() else {
334                return;
335            };
336            (preedit, state.presentation)
337        };
338        // Rendering runs under the Context lock, before native EndFrame/Render.
339        let ime = raw.PlatformImeData;
340        if raw.IO.AppFocusLost
341            || ImeOwner::current(raw) != Some(preedit.owner)
342            || !ime.WantVisible
343            || !presentation
344                .is_some_and(|p| p.owner == preedit.owner.widget && p.frame == raw.FrameCount)
345        {
346            self.clear_ime_preedit();
347            return;
348        }
349        let viewport = unsafe { sys::igFindViewportByID(ime.ViewportId) };
350        let presentation = presentation.expect("validated current-frame input presentation");
351        let size = presentation.size;
352        if viewport.is_null()
353            || presentation.font.is_null()
354            || !presentation.density.is_finite()
355            || presentation.density <= 0.0
356            || !size.is_finite()
357            || size <= 0.0
358            || !ime.InputPos.x.is_finite()
359            || !ime.InputPos.y.is_finite()
360        {
361            return;
362        }
363        let password = raw.InputTextState.Flags & sys::ImGuiInputTextFlags_Password != 0;
364        let masked;
365        let (text, cursor) = if password {
366            masked = "*".repeat(preedit.text.chars().count());
367            (
368                masked.as_str(),
369                preedit.cursor_range.map(|(a, b)| {
370                    (
371                        preedit.text[..a].chars().count(),
372                        preedit.text[..b].chars().count(),
373                    )
374                }),
375            )
376        } else {
377            (preedit.text.as_str(), preedit.cursor_range)
378        };
379        // Keep geometry transient: no InputText callbacks, text events, or undo edits.
380        unsafe {
381            let font = presentation.font;
382            let previous_density = (*font).CurrentRasterizerDensity;
383            (*font).CurrentRasterizerDensity = presentation.density;
384            draw_preedit(font, size, viewport, ime.InputPos, text, cursor);
385            (*font).CurrentRasterizerDensity = previous_density;
386        }
387    }
388}
389
390unsafe fn draw_preedit(
391    font: *mut sys::ImFont,
392    size: f32,
393    viewport: *mut sys::ImGuiViewport,
394    anchor: sys::ImVec2_c,
395    text: &str,
396    cursor: Option<(usize, usize)>,
397) {
398    // All pointers belong to the currently bound, live Context and frame.
399    unsafe {
400        let measure = |text: &str| {
401            sys::ImFont_CalcTextSizeA(
402                font,
403                size,
404                f32::MAX,
405                0.0,
406                text.as_ptr().cast(),
407                text.as_ptr().add(text.len()).cast(),
408                std::ptr::null_mut(),
409            )
410        };
411        let offset = |byte: usize| {
412            let prefix = &text[..byte];
413            let line = prefix.rsplit('\n').next().unwrap_or("");
414            sys::ImVec2_c {
415                x: measure(line).x,
416                y: size * prefix.bytes().filter(|&b| b == b'\n').count() as f32,
417            }
418        };
419        let extent = measure(text);
420        let vp = &*viewport;
421        if !extent.x.is_finite() || !extent.y.is_finite() || vp.Size.x <= 0.0 || vp.Size.y <= 0.0 {
422            return;
423        }
424        let padding = 3.0;
425        let width = (extent.x + padding * 2.0).min(vp.Size.x);
426        let height = (extent.y.max(size) + padding * 2.0).min(vp.Size.y);
427        let left = anchor.x.max(vp.Pos.x).min(vp.Pos.x + vp.Size.x - width);
428        let top = anchor.y.max(vp.Pos.y).min(vp.Pos.y + vp.Size.y - height);
429        let caret = cursor.map(|(_, end)| offset(end));
430        let scroll_x = caret.map_or(0.0, |caret| (caret.x + padding * 2.0 - width).max(0.0));
431        let scroll_y = caret.map_or(0.0, |caret| {
432            (caret.y + size + padding * 2.0 - height).max(0.0)
433        });
434        let origin = sys::ImVec2_c {
435            x: left + padding - scroll_x,
436            y: top + padding - scroll_y,
437        };
438        let min = sys::ImVec2_c { x: left, y: top };
439        let max = sys::ImVec2_c {
440            x: left + width,
441            y: top + height,
442        };
443        let draw = sys::igGetForegroundDrawList_ViewportPtr(viewport);
444        sys::ImDrawList_PushClipRect(draw, min, max, true);
445        sys::ImDrawList_AddRectFilled(
446            draw,
447            min,
448            max,
449            sys::igGetColorU32_Col(sys::ImGuiCol_PopupBg, 1.0),
450            0.0,
451            0,
452        );
453        if let Some((a, b)) = cursor {
454            let start = a.min(b);
455            let end = a.max(b);
456            let mut byte = 0;
457            for line in text.split_inclusive('\n') {
458                let line_end = byte + line.len();
459                if start != end && start < line_end && end > byte {
460                    let from = offset(start.max(byte));
461                    let to = offset(end.min(line_end - usize::from(line.ends_with('\n'))));
462                    sys::ImDrawList_AddRectFilled(
463                        draw,
464                        sys::ImVec2_c {
465                            x: origin.x + from.x,
466                            y: origin.y + from.y,
467                        },
468                        sys::ImVec2_c {
469                            x: origin.x + to.x.max(from.x + 1.0),
470                            y: origin.y + from.y + size,
471                        },
472                        sys::igGetColorU32_Col(sys::ImGuiCol_TextSelectedBg, 1.0),
473                        0.0,
474                        0,
475                    );
476                }
477                byte = line_end;
478            }
479        }
480        let color = sys::igGetColorU32_Col(sys::ImGuiCol_Text, 1.0);
481        sys::ImDrawList_AddText_FontPtr(
482            draw,
483            font,
484            size,
485            origin,
486            color,
487            text.as_ptr().cast(),
488            text.as_ptr().add(text.len()).cast(),
489            0.0,
490            std::ptr::null(),
491        );
492        sys::ImDrawList_AddLine(
493            draw,
494            sys::ImVec2_c {
495                x: origin.x,
496                y: origin.y + extent.y.max(size),
497            },
498            sys::ImVec2_c {
499                x: origin.x + extent.x,
500                y: origin.y + extent.y.max(size),
501            },
502            color,
503            1.0,
504            0,
505        );
506        if let Some(caret) = caret {
507            let position = sys::ImVec2_c {
508                x: origin.x + caret.x,
509                y: origin.y + caret.y,
510            };
511            sys::ImDrawList_AddLine(
512                draw,
513                position,
514                sys::ImVec2_c {
515                    x: position.x,
516                    y: position.y + size,
517                },
518                color,
519                1.0,
520                0,
521            );
522            (*sys::igGetCurrentContext()).PlatformImeData.InputPos = position;
523        }
524        sys::ImDrawList_PopClipRect(draw);
525    }
526}