git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

src/ime.rs (8.3K)

  1 //! Input-method composition, between the text widget the user types into and
  2 //! the shell whose window system runs the input method (an IME: Japanese,
  3 //! Chinese, Korean, an accent picker, an emoji panel).
  4 //!
  5 //! Three things cross, and only one of them is new to the toolkit:
  6 //!
  7 //! - **The commit** — text the input method has finished composing — is
  8 //!   delivered as TYPED text (`Driver::commit_text`: a key press carrying it,
  9 //!   then its release), so every widget that inserts what a key types takes
 10 //!   it with no change: `TextBox`, `LineEdit`, the `DocEditor`, an app's own
 11 //!   field.
 12 //! - **The composition** (the preedit: text still being composed, with the
 13 //!   input method's cursor in it) is shared here: a shell sets it
 14 //!   ([`set_preedit`], through `Driver::preedit`), and the widget that is
 15 //!   editing shows it at its caret — `TextBox` splices it into its buffer as
 16 //!   a provisional run, underlined, which is never committed, recorded in
 17 //!   its history or reported as a change.
 18 //! - **The caret** goes the other way: a widget that is editing reports its
 19 //!   caret's rect each frame it paints ([`report_caret`]), so the shell can
 20 //!   place the input method's candidate window under it, and knows text
 21 //!   input is wanted at all ([`caret`] is `None` when nothing is editing;
 22 //!   the macOS shell then keeps keys away from the input method, so an IME
 23 //!   left on does not swallow an app's single-key shortcuts).
 24 //!
 25 //! A widget that drops a composition it was showing — it stopped editing
 26 //! mid-composition — asks the shell to cancel it in the input method too
 27 //! ([`request_reset`], drained by the shell with [`take_reset`]).
 28 //!
 29 //! The shells that feed it: the Wayland shell through `text-input-v3`
 30 //! (`backend::text_input`), the browser's through a hidden textarea, the
 31 //! AppKit shell as an `NSTextInputClient`.
 32 //!
 33 //! Per thread, as the context menu is: a window's widgets, its shell and its
 34 //! frame are one thread's.
 35 
 36 use std::cell::RefCell;
 37 
 38 /// Text the input method is still composing.
 39 #[derive(Debug, Clone, PartialEq, Eq, Default)]
 40 pub struct Preedit {
 41     pub text: String,
 42     /// The input method's cursor (or selected clause) in `text`, as a byte
 43     /// range; `None` to show no caret in it.
 44     pub cursor: Option<(usize, usize)>,
 45 }
 46 
 47 impl Preedit {
 48     pub fn new(text: impl Into<String>, cursor: Option<(usize, usize)>) -> Self {
 49         Self { text: text.into(), cursor }
 50     }
 51 
 52     /// The caret's position in `text`, in chars: the end of the input
 53     /// method's cursor range, or the end of the text when it has none. A
 54     /// byte offset off a char boundary or past the end is clamped back.
 55     pub fn caret_chars(&self) -> usize {
 56         let end = self.cursor.map_or(self.text.len(), |(_, e)| e).min(self.text.len());
 57         let mut end = end;
 58         while !self.text.is_char_boundary(end) {
 59             end -= 1;
 60         }
 61         self.text[..end].chars().count()
 62     }
 63 }
 64 
 65 #[derive(Default)]
 66 pub(crate) struct State {
 67     preedit: Option<Preedit>,
 68     /// Moves on every change of `preedit`, so a widget can tell it has
 69     /// applied the current one.
 70     generation: u64,
 71     /// The caret the last built frame reported, and the one this frame has
 72     /// so far.
 73     caret: Option<[f32; 4]>,
 74     reported: Option<[f32; 4]>,
 75     reset: bool,
 76     /// A press landed since the shell last asked ([`note_press`]).
 77     pressed: bool,
 78 }
 79 
 80 /// The current window's composition and caret (`crate::window_state`).
 81 fn state<R>(f: impl FnOnce(&RefCell<State>) -> R) -> R {
 82     crate::window_state::with(|w| f(&w.ime))
 83 }
 84 
 85 /// The composition changed: `None` (or empty text) when there is none. A
 86 /// shell's, through `Driver::preedit`.
 87 pub fn set_preedit(preedit: Option<Preedit>) {
 88     let preedit = preedit.filter(|p| !p.text.is_empty());
 89     state(|s| {
 90         let mut s = s.borrow_mut();
 91         if s.preedit != preedit {
 92             s.preedit = preedit;
 93             s.generation += 1;
 94         }
 95     });
 96 }
 97 
 98 /// The composition, if the input method is composing.
 99 pub fn preedit() -> Option<Preedit> {
100     state(|s| s.borrow().preedit.clone())
101 }
102 
103 /// Moves whenever the composition does.
104 pub fn generation() -> u64 {
105     state(|s| s.borrow().generation)
106 }
107 
108 /// A frame is being built: carets are reported afresh.
109 pub fn begin_frame() {
110     state(|s| s.borrow_mut().reported = None);
111 }
112 
113 /// The frame is built: what was reported is the caret.
114 pub fn end_frame() {
115     state(|s| {
116         let mut s = s.borrow_mut();
117         s.caret = s.reported;
118     });
119 }
120 
121 /// A widget editing text has its caret at `x, y` (`w` x `h`), in the
122 /// window's logical px. Called as it paints.
123 pub fn report_caret(x: f32, y: f32, w: f32, h: f32) {
124     state(|s| s.borrow_mut().reported = Some([x, y, w, h]));
125 }
126 
127 /// Where the editing widget's caret was in the last built frame, or `None`
128 /// when no widget is editing text.
129 pub fn caret() -> Option<[f32; 4]> {
130     state(|s| s.borrow().caret)
131 }
132 
133 /// A widget dropped a composition it was showing: the input method should
134 /// cancel it too. Clears the composition.
135 pub fn request_reset() {
136     set_preedit(None);
137     state(|s| s.borrow_mut().reset = true);
138 }
139 
140 /// What this frame has reported so far, replaced by `caret`. For
141 /// `text_input::capture`, which reads what one painting reported.
142 pub(crate) fn swap_reported(caret: Option<[f32; 4]>) -> Option<[f32; 4]> {
143     state(|s| std::mem::replace(&mut s.borrow_mut().reported, caret))
144 }
145 
146 /// A pointer or touch press reached the window. A field that is still
147 /// editing in the next frame is announced to the shell again
148 /// ([`take_press`]): the compositor raises its on-screen keyboard on an
149 /// announcement that follows a touch, and a field that was already open —
150 /// a focused terminal, a text box still editing — would otherwise say
151 /// nothing new when tapped. True when a field is editing, so the caller
152 /// builds that frame.
153 pub fn note_press() -> bool {
154     state(|s| {
155         let mut s = s.borrow_mut();
156         s.pressed = true;
157         s.caret.is_some()
158     })
159 }
160 
161 /// Whether a press landed since the last call. A shell's.
162 pub fn take_press() -> bool {
163     state(|s| std::mem::replace(&mut s.borrow_mut().pressed, false))
164 }
165 
166 /// Whether a reset was asked for since the last call. A shell's.
167 pub fn take_reset() -> bool {
168     state(|s| std::mem::replace(&mut s.borrow_mut().reset, false))
169 }
170 
171 #[cfg(test)]
172 mod tests {
173     use super::*;
174 
175     #[test]
176     fn a_composition_moves_the_generation_only_when_it_changes() {
177         let g0 = generation();
178         set_preedit(Some(Preedit::new("に", None)));
179         let g1 = generation();
180         assert!(g1 > g0);
181         set_preedit(Some(Preedit::new("に", None)));
182         assert_eq!(generation(), g1);
183         // Empty text is no composition.
184         set_preedit(Some(Preedit::new("", None)));
185         assert_eq!(preedit(), None);
186         assert!(generation() > g1);
187     }
188 
189     #[test]
190     fn the_caret_is_the_end_of_the_cursor_range_in_chars() {
191         assert_eq!(Preedit::new("にほん", None).caret_chars(), 3);
192         // "に" is three bytes.
193         assert_eq!(Preedit::new("にほん", Some((0, 3))).caret_chars(), 1);
194         // Off a boundary, clamped back.
195         assert_eq!(Preedit::new("にほん", Some((0, 4))).caret_chars(), 1);
196         assert_eq!(Preedit::new("ab", Some((0, 9))).caret_chars(), 2);
197     }
198 
199     #[test]
200     fn the_caret_is_what_the_last_built_frame_reported() {
201         begin_frame();
202         report_caret(10.0, 20.0, 1.5, 16.0);
203         assert_eq!(caret(), None, "not until the frame is built");
204         end_frame();
205         assert_eq!(caret(), Some([10.0, 20.0, 1.5, 16.0]));
206         begin_frame();
207         end_frame();
208         assert_eq!(caret(), None, "a frame with nothing editing");
209     }
210 
211     #[test]
212     fn a_press_wants_a_frame_only_while_a_field_is_editing() {
213         begin_frame();
214         end_frame();
215         assert!(!note_press(), "nothing editing: no frame owed");
216         assert!(take_press(), "but the press is still noted");
217         assert!(!take_press(), "once");
218         begin_frame();
219         report_caret(1.0, 2.0, 3.0, 4.0);
220         end_frame();
221         assert!(note_press());
222         assert!(take_press());
223     }
224 
225     #[test]
226     fn a_reset_clears_the_composition_and_is_taken_once() {
227         set_preedit(Some(Preedit::new("ka", None)));
228         request_reset();
229         assert_eq!(preedit(), None);
230         assert!(take_reset());
231         assert!(!take_reset());
232     }
233 }