Skip to main content

crabshell_ui/
text_input.rs

1//! [`TextInput`], a one-line text field.
2
3use std::rc::Rc;
4
5use cosmic_text::Cursor;
6use crabshell_reactive::Signal;
7
8use crate::cx::Cx;
9use crate::edit::{Edit, grapheme_start, graphemes_before};
10use crate::geometry::{Point, Rect};
11use crate::input::Handler;
12use crate::prop::{IntoProp, Prop};
13use crate::style::Style;
14use crate::text::TextNode;
15use crate::tree::{NodeKind, borrow};
16use crate::widgets::{Element, ElementKind, Widget};
17use crate::window::Window;
18
19/// What a password shows for each character.
20const DOT: char = '•';
21
22/// The width a field takes when nothing else sets one, in logical pixels.
23pub(crate) const DEFAULT_WIDTH: f32 = 200.0;
24
25pub(crate) type AcceptHandler = Rc<Handler<dyn Fn(&Cx, &str)>>;
26
27pub(crate) struct InputProps {
28  pub(crate) text: Signal<String>,
29  pub(crate) placeholder: Option<Prop<String>>,
30  pub(crate) password: Option<Prop<bool>>,
31  pub(crate) focus: Option<Prop<bool>>,
32  pub(crate) on_accept: Option<AcceptHandler>,
33}
34
35/// A one-line text field. It takes the keyboard focus when clicked, when
36/// Tab reaches it, or when [`focus`](Self::focus) says so, and edits the
37/// text in a shared signal: typing writes it, and setting it changes what
38/// the field shows. Arrows, Home and End move the caret, Shift selects,
39/// Ctrl moves and deletes by word, and Ctrl+A selects everything. Keys it
40/// doesn't use, such as Up, Down and Escape, go on to the window's
41/// `on_key`.
42///
43/// ```
44/// use crabshell_ui::{Cx, TextInput};
45///
46/// fn search(cx: &Cx) -> TextInput {
47///     let query = cx.signal(String::new());
48///     TextInput::new(query)
49///         .placeholder("Search")
50///         .on_accept(|_cx, text| println!("run {text}"))
51/// }
52/// ```
53pub struct TextInput {
54  input: InputProps,
55  style: Option<Prop<Style>>,
56}
57
58impl TextInput {
59  /// A field editing `text`.
60  pub fn new(text: Signal<String>) -> TextInput {
61    TextInput {
62      input: InputProps {
63        text,
64        placeholder: None,
65        password: None,
66        focus: None,
67        on_accept: None,
68      },
69      style: None,
70    }
71  }
72
73  /// Sets the text shown, dimmed, while the field is empty.
74  pub fn placeholder(mut self, text: impl IntoProp<String>) -> TextInput {
75    self.input.placeholder = Some(text.into_prop());
76    self
77  }
78
79  /// Sets whether the field shows a dot for each character instead of
80  /// the text.
81  pub fn password(mut self, password: impl IntoProp<bool>) -> TextInput {
82    self.input.password = Some(password.into_prop());
83    self
84  }
85
86  /// Takes the keyboard focus whenever this turns true, as when a
87  /// launcher opens.
88  pub fn focus(mut self, focus: impl IntoProp<bool>) -> TextInput {
89    self.input.focus = Some(focus.into_prop());
90    self
91  }
92
93  /// Runs `handler` with the text when Enter is pressed in the field.
94  pub fn on_accept(mut self, handler: impl Fn(&Cx, &str) + 'static) -> TextInput {
95    self.input.on_accept = Some(Rc::new(Handler::new(handler)));
96    self
97  }
98
99  /// Sets the style. A field cuts its text to the inside of its border.
100  pub fn style(mut self, style: impl IntoProp<Style>) -> TextInput {
101    self.style = Some(style.into_prop());
102    self
103  }
104}
105
106impl Widget for TextInput {
107  fn into_element(self: Box<Self>) -> Element {
108    Element {
109      name: "TextInput",
110      style: self.style,
111      kind: ElementKind::Input(Box::new(self.input)),
112      children: Vec::new(),
113    }
114  }
115}
116
117/// Text an input method is composing, shown at the caret, underlined,
118/// until it commits or drops it.
119#[derive(Clone, Debug, PartialEq, Eq)]
120pub struct Preedit {
121  /// The text.
122  pub text: String,
123  /// Where in `text`, in bytes, its own caret or selection is, if shown.
124  pub cursor: Option<(usize, usize)>,
125}
126
127/// The focused text field, as an input method needs it.
128#[derive(Clone, Debug, PartialEq)]
129pub struct FieldState {
130  /// Which field it is: another number when the focus moves to another.
131  pub field: u64,
132  /// The field's text.
133  pub text: String,
134  /// The caret, in bytes.
135  pub cursor: usize,
136  /// The other end of the selection, or the caret without one.
137  pub anchor: usize,
138  /// Where the caret is drawn, in window coordinates.
139  pub caret: Rect,
140}
141
142impl Window {
143  /// The focused text field, while the window has the keyboard, for an
144  /// input method. Never a password: input methods learn what is typed.
145  pub fn field(&self) -> Option<FieldState> {
146    let tree = self.tree.borrow();
147    if !tree.keyboard.entered {
148      return None;
149    }
150    let id = tree.keyboard.focused?;
151    let NodeKind::Input(input) = &tree.node(id)?.kind else {
152      return None;
153    };
154    (!input.password).then(|| FieldState {
155      field: u64::from(id),
156      text: input.edit.text.clone(),
157      cursor: input.edit.cursor,
158      anchor: input.edit.anchor.unwrap_or(input.edit.cursor),
159      caret: tree.window_rect(id, input.caret),
160    })
161  }
162
163  /// Inserts `text` from an input method at the focused field's caret,
164  /// replacing its selection. A password field takes nothing.
165  pub fn commit_text(&self, text: &str) {
166    let text = text.replace(['\n', '\r'], " ");
167    self.edit_field(|edit| edit.insert(&text));
168  }
169
170  /// Pastes `text` from the clipboard into the focused field, replacing
171  /// its selection; line breaks become spaces. Unlike an input method's
172  /// text, a paste goes into a password field too.
173  pub fn paste(&self, text: &str) {
174    // An empty clipboard pastes nothing, not even over the selection.
175    if text.is_empty() {
176      return;
177    }
178    let text = text.replace(['\n', '\r'], " ");
179    self.change_field(true, |edit| edit.insert(&text));
180  }
181
182  /// Deletes `before` bytes before the focused field's selection, or its
183  /// caret, and `after` bytes after it, for an input method; the
184  /// selection itself stays. Lengths that would split a character are
185  /// ignored.
186  pub fn delete_around(&self, before: usize, after: usize) {
187    self.edit_field(|edit| {
188      let selected = edit.selection().unwrap_or(edit.cursor..edit.cursor);
189      let start = selected.start.saturating_sub(before);
190      let end = selected.end.saturating_add(after).min(edit.text.len());
191      if !(edit.text.is_char_boundary(start) && edit.text.is_char_boundary(end)) {
192        return;
193      }
194      edit.text.replace_range(selected.end..end, "");
195      edit.text.replace_range(start..selected.start, "");
196      let removed = selected.start - start;
197      edit.cursor -= removed;
198      edit.anchor = edit.anchor.map(|anchor| anchor - removed);
199    });
200  }
201
202  /// Shows `preedit` at the focused field's caret, replacing its
203  /// selection, or clears it. A password field shows none.
204  pub fn set_preedit(&self, preedit: Option<Preedit>) {
205    let preedit = preedit.filter(|preedit| !preedit.text.is_empty());
206    if preedit.is_some() {
207      self.edit_field(|edit| {
208        if edit.selection().is_some() {
209          edit.insert("");
210        }
211      });
212    }
213    let mut tree = borrow(&self.tree);
214    let Some(id) = tree.keyboard.focused else {
215      return;
216    };
217    if matches!(tree.node(id).map(|node| &node.kind), Some(NodeKind::Input(input)) if input.password)
218    {
219      return;
220    }
221    tree.update_input(id, |input| {
222      input.preedit = preedit;
223      input.refresh();
224    });
225  }
226
227  /// Changes the focused field's text with `change`, then writes it to
228  /// the field's signal once the tree is released. A password field is
229  /// left alone: input methods don't type in one.
230  fn edit_field(&self, change: impl FnOnce(&mut Edit)) {
231    self.change_field(false, change);
232  }
233
234  /// As [`edit_field`](Self::edit_field), into a password field too when
235  /// `passwords`.
236  fn change_field(&self, passwords: bool, change: impl FnOnce(&mut Edit)) {
237    let write = {
238      let mut tree = borrow(&self.tree);
239      let Some(id) = tree.keyboard.focused else {
240        return;
241      };
242      let before = match tree.node(id).map(|node| &node.kind) {
243        Some(NodeKind::Input(input)) if passwords || !input.password => input.edit.text.clone(),
244        _ => return,
245      };
246      let mut write = None;
247      tree.update_input(id, |input| {
248        change(&mut input.edit);
249        input.refresh();
250        if input.edit.text != before {
251          write = Some((input.signal, input.edit.text.clone()));
252        }
253      });
254      write
255    };
256    if let Some((signal, text)) = write {
257      // A signal whose owner is gone just isn't written.
258      signal.try_set(text);
259    }
260  }
261}
262
263/// A field in the tree.
264pub(crate) struct InputNode {
265  /// What is shown: the text, its dots, or the placeholder.
266  pub(crate) text: TextNode,
267  pub(crate) edit: Edit,
268  /// Written as the text is edited.
269  pub(crate) signal: Signal<String>,
270  pub(crate) placeholder: String,
271  pub(crate) password: bool,
272  /// The field has the keyboard, so it shows its caret.
273  pub(crate) focused: bool,
274  /// How far the text is scrolled left to keep the caret in view.
275  pub(crate) scroll: f32,
276  /// Where the caret and the selection are, from the last layout, in
277  /// window coordinates.
278  pub(crate) caret: Rect,
279  /// More than one span when right-to-left and left-to-right text mix.
280  pub(crate) selection: Vec<Rect>,
281  /// The content box the text is cut to, from the last layout.
282  pub(crate) area: Rect,
283  /// What an input method is composing, shown at the caret.
284  pub(crate) preedit: Option<Preedit>,
285  /// The line under the preedit, from the last layout.
286  pub(crate) underline: Vec<Rect>,
287  /// Kept in the reactive runtime, which drops the user's closure when the
288  /// field's owner goes.
289  pub(crate) on_accept: Signal<Option<AcceptHandler>>,
290}
291
292impl InputNode {
293  pub(crate) fn new(signal: Signal<String>, on_accept: Signal<Option<AcceptHandler>>) -> InputNode {
294    InputNode {
295      text: TextNode::new(),
296      edit: Edit::default(),
297      signal,
298      placeholder: String::new(),
299      password: false,
300      focused: false,
301      scroll: 0.0,
302      caret: Rect::default(),
303      selection: Vec::new(),
304      area: Rect::default(),
305      preedit: None,
306      underline: Vec::new(),
307      on_accept,
308    }
309  }
310
311  /// Whether the placeholder is what shows.
312  pub(crate) fn showing_placeholder(&self) -> bool {
313    self.edit.text.is_empty() && self.preedit.is_none()
314  }
315
316  /// The preedit's length in bytes, 0 without one.
317  fn preedit_len(&self) -> usize {
318    self
319      .preedit
320      .as_ref()
321      .map_or(0, |preedit| preedit.text.len())
322  }
323
324  /// Puts what should show into the text node. Returns whether that
325  /// changed, which needs a layout.
326  pub(crate) fn refresh(&mut self) -> bool {
327    let shown = if self.showing_placeholder() {
328      self.placeholder.clone()
329    } else if self.password {
330      DOT
331        .to_string()
332        .repeat(graphemes_before(&self.edit.text, self.edit.text.len()))
333    } else if let Some(preedit) = &self.preedit {
334      let (before, after) = self.edit.text.split_at(self.edit.cursor);
335      format!("{before}{}{after}", preedit.text)
336    } else {
337      self.edit.text.clone()
338    };
339    self.text.set_content(shown)
340  }
341
342  /// The byte in what is shown that matches byte `at` of the text.
343  fn shown_index(&self, at: usize) -> usize {
344    if self.showing_placeholder() {
345      0
346    } else if self.password {
347      graphemes_before(&self.edit.text, at) * DOT.len_utf8()
348    } else if at > self.edit.cursor {
349      at + self.preedit_len()
350    } else {
351      at
352    }
353  }
354
355  /// The byte of the text that matches byte `at` of what is shown.
356  fn text_index(&self, at: usize) -> usize {
357    if self.showing_placeholder() {
358      0
359    } else if self.password {
360      grapheme_start(&self.edit.text, at / DOT.len_utf8())
361    } else if at <= self.edit.cursor {
362      at
363    } else {
364      // Inside the preedit is at the caret; past it, shifted back.
365      let after = at.saturating_sub(self.preedit_len()).max(self.edit.cursor);
366      after.min(self.edit.text.len())
367    }
368  }
369
370  /// How far from the line's left edge the caret before byte `at` of
371  /// what is shown sits. In right-to-left text, the start is on the right.
372  fn x_at(&self, at: usize) -> f32 {
373    self
374      .text
375      .runs()
376      .next()
377      .and_then(|run| run.cursor_position(&Cursor::new(0, at)))
378      .unwrap_or(0.0)
379  }
380
381  /// Scrolls the text to keep the caret inside `content`, then places the
382  /// caret and the selection. Called after the text node's own layout.
383  pub(crate) fn place(&mut self, content: Rect) {
384    let at = self.shown_index(self.edit.cursor);
385    let caret = match &self.preedit {
386      Some(preedit) => {
387        at + preedit
388          .cursor
389          .map_or(preedit.text.len(), |(start, _)| start)
390      }
391      None => at,
392    };
393    let caret = self.x_at(caret);
394    let width = self.text.runs().next().map_or(0.0, |run| run.line_w);
395    // The caret's own pixel counts as text, so it shows at the end.
396    let room = (content.width - 1.0).max(0.0);
397    let mut scroll = self.scroll.min((width - room).max(0.0));
398    if caret - scroll > room {
399      scroll = caret - room;
400    }
401    if caret < scroll {
402      scroll = caret;
403    }
404    self.scroll = scroll.max(0.0);
405    self.text.origin.x = content.x - self.scroll;
406    let origin = self.text.origin;
407    let height = self
408      .text
409      .runs()
410      .next()
411      .map_or(content.height, |run| run.line_height);
412    self.caret = Rect::new(origin.x + caret, origin.y, 1.0, height);
413    self.area = content;
414    self.selection = match (self.edit.selection(), self.text.runs().next()) {
415      (Some(range), Some(run)) => {
416        let start = Cursor::new(0, self.shown_index(range.start));
417        let end = Cursor::new(0, self.shown_index(range.end));
418        run
419          .highlight(start, end)
420          .map(|(x, width)| Rect::new(origin.x + x, origin.y, width, height))
421          .collect()
422      }
423      _ => Vec::new(),
424    };
425    self.underline = match (&self.preedit, self.text.runs().next()) {
426      (Some(preedit), Some(run)) => run
427        .highlight(Cursor::new(0, at), Cursor::new(0, at + preedit.text.len()))
428        .map(|(x, width)| Rect::new(origin.x + x, origin.y + height - 1.0, width, 1.0))
429        .collect(),
430      _ => Vec::new(),
431    };
432  }
433
434  /// The text position nearest `point`, in window coordinates.
435  pub(crate) fn hit(&self, point: Point) -> usize {
436    let origin = self.text.origin;
437    let at = self
438      .text
439      .buffer
440      .hit(point.x - origin.x, point.y - origin.y)
441      .map_or(0, |cursor| cursor.index);
442    self.text_index(at)
443  }
444}