Skip to main content

fret_runtime/
window_text_input_snapshot.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3
4use fret_core::{AppWindowId, Rect};
5
6/// Best-effort IME surrounding text excerpt.
7///
8/// This is intended for platform bridges that need "text around caret" semantics (e.g. winit's
9/// `ImeSurroundingText`).
10///
11/// - `text` MUST exclude any active preedit/composing text.
12/// - `cursor`/`anchor` are UTF-8 byte offsets within `text` (must be on char boundaries).
13/// - `text` SHOULD be limited to at most 4000 bytes (winit backend constraint).
14#[derive(Debug, Clone, PartialEq, Eq, Hash)]
15pub struct WindowImeSurroundingText {
16    pub text: Arc<str>,
17    pub cursor: u32,
18    pub anchor: u32,
19}
20
21impl WindowImeSurroundingText {
22    pub const MAX_TEXT_BYTES: usize = 4000;
23
24    /// Create a best-effort surrounding text excerpt from a full base-buffer string.
25    ///
26    /// The returned excerpt is bounded to [`Self::MAX_TEXT_BYTES`] and the cursor/anchor offsets
27    /// are relative to the excerpt (UTF-8 bytes).
28    pub fn best_effort_for_str(text: &str, cursor: usize, anchor: usize) -> Self {
29        fn clamp_down_to_char_boundary(text: &str, idx: usize) -> usize {
30            let mut idx = idx.min(text.len());
31            while idx > 0 && !text.is_char_boundary(idx) {
32                idx = idx.saturating_sub(1);
33            }
34            idx
35        }
36
37        let cursor = clamp_down_to_char_boundary(text, cursor);
38        let mut anchor = clamp_down_to_char_boundary(text, anchor);
39        let len = text.len();
40
41        if len <= Self::MAX_TEXT_BYTES {
42            return Self {
43                text: Arc::<str>::from(text),
44                cursor: u32::try_from(cursor).unwrap_or(u32::MAX),
45                anchor: u32::try_from(anchor).unwrap_or(u32::MAX),
46            };
47        }
48
49        let mut low = cursor.min(anchor);
50        let mut high = cursor.max(anchor);
51        if high.saturating_sub(low) > Self::MAX_TEXT_BYTES {
52            anchor = cursor;
53            low = cursor;
54            high = cursor;
55        }
56
57        let needed = high.saturating_sub(low);
58        let slack = Self::MAX_TEXT_BYTES.saturating_sub(needed);
59        let before = slack / 2;
60
61        let mut start = low
62            .saturating_sub(before)
63            .min(len.saturating_sub(Self::MAX_TEXT_BYTES));
64        let mut end = (start + Self::MAX_TEXT_BYTES).min(len);
65
66        start = clamp_down_to_char_boundary(text, start);
67        end = clamp_down_to_char_boundary(text, end);
68        if end < start {
69            end = start;
70        }
71
72        let cursor_rel = cursor.saturating_sub(start).min(end.saturating_sub(start));
73        let anchor_rel = anchor.saturating_sub(start).min(end.saturating_sub(start));
74        let excerpt = &text[start..end];
75
76        Self {
77            text: Arc::<str>::from(excerpt),
78            cursor: u32::try_from(cursor_rel).unwrap_or(u32::MAX),
79            anchor: u32::try_from(anchor_rel).unwrap_or(u32::MAX),
80        }
81    }
82}
83
84/// Window-scoped platform text-input snapshots published by the UI runtime.
85///
86/// This is a data-only integration seam for platform/runner layers that need to interop with
87/// editor-grade text input (IME, accessibility).
88///
89/// Indices are expressed in UTF-16 code units over the widget's **composed view**:
90/// base buffer text with the active IME preedit spliced at the caret.
91#[derive(Debug, Clone, PartialEq, Default)]
92pub struct WindowTextInputSnapshot {
93    pub focus_is_text_input: bool,
94    pub is_composing: bool,
95    /// Total length (UTF-16 code units) of the composed view.
96    pub text_len_utf16: u32,
97    /// Anchor/focus selection offsets in UTF-16 code units (composed view).
98    pub selection_utf16: Option<(u32, u32)>,
99    /// Marked (preedit) range in UTF-16 code units (composed view).
100    pub marked_utf16: Option<(u32, u32)>,
101    /// Best-effort IME cursor area in window logical coordinates.
102    ///
103    /// On Windows/winit this is the primary hook for positioning the candidate window.
104    pub ime_cursor_area: Option<Rect>,
105    /// Best-effort surrounding text excerpt for IME backends that support it.
106    pub surrounding_text: Option<WindowImeSurroundingText>,
107}
108
109#[derive(Debug, Default)]
110pub struct WindowTextInputSnapshotService {
111    by_window: HashMap<AppWindowId, WindowTextInputSnapshot>,
112}
113
114impl WindowTextInputSnapshotService {
115    pub fn snapshot(&self, window: AppWindowId) -> Option<&WindowTextInputSnapshot> {
116        self.by_window.get(&window)
117    }
118
119    pub fn set_snapshot(&mut self, window: AppWindowId, snapshot: WindowTextInputSnapshot) {
120        self.by_window.insert(window, snapshot);
121    }
122
123    pub fn remove_window(&mut self, window: AppWindowId) {
124        self.by_window.remove(&window);
125    }
126}