denise_ui/widgets/text_input.rs
1//! A single-line editable text field.
2
3use alloc::string::String;
4
5use denise::Pen;
6use denise::{ElementState, InputEvent, KeyCode, Modifiers, Point, Radius, Rect, Role};
7use denise_text::{TextEngine, TextStyle};
8
9use crate::motion::Wake;
10use crate::widget::{
11 Animation, Event, EventCtx, Handled, MeasureCtx, Measured, Offer, PaintCtx, VisualState, Widget,
12};
13use crate::widgets::describe::{
14 Describe, DynDescribe, Group, Mismatch, Payload, Property, PropertyKind, Value,
15};
16use crate::widgets::style::{
17 Align, CARET_BLINKS_FOR_MS, DOUBLE_CLICK_MS, focus_ring, interactive_pair,
18};
19use crate::widgets::text_area::{ClipboardRequest, is_word};
20
21/// Half-period of the caret blink, in milliseconds.
22const BLINK_MS: u64 = 500;
23
24/// A single-line text field with a caret.
25///
26/// # Selecting
27///
28/// Taking focus selects everything, which is what makes Tab-and-type replace a
29/// setpoint rather than append to it. A press places the caret, a second on the
30/// same spot takes the word under it, a third takes the whole field, and
31/// dragging — with a mouse or a finger — extends from where the press landed.
32/// Shift extends with the arrows, Home and End; ⌘A or Ctrl+A takes everything.
33/// Typing, Backspace and Delete replace what is selected.
34///
35/// # The clipboard
36///
37/// ⌘C, ⌘X and ⌘V — or Ctrl — when the application has said where they go with
38/// [`with_clipboard`](Self::with_clipboard). The widget has no clipboard to
39/// reach, so it asks: copy and cut hand over the text, paste emits
40/// [`ClipboardRequest::Paste`] and the application answers with
41/// [`insert_text`](Self::insert_text). Without that wiring the three keys do
42/// nothing at all, which is what a panel with no clipboard wants.
43///
44/// A **password field refuses copy and cut**. Its whole job is that what is on
45/// the screen cannot be read, and handing the value to the system clipboard
46/// would undo that for one keystroke. Pasting into one is still allowed.
47///
48/// # Moving by word
49///
50/// Ctrl and an arrow move by word, and so does Option on a Mac — the widget
51/// cannot ask which keyboard it is in front of, so it takes both spellings.
52/// Command and an arrow go to the start or the end, which is what Command does
53/// on the machine that has it and what Home and End do everywhere. Shift
54/// extends with all of them.
55///
56/// # What it still does not do
57///
58/// No undo. A kiosk field takes a name, a PIN or a setpoint;
59/// [`TextArea`](super::TextArea) is where an editor's machinery lives.
60///
61/// # Blinking
62///
63/// The caret blinks only while the field has focus: taking focus requests
64/// animation, and losing it makes [`Widget::animate`] answer `None`, which is
65/// how a widget hands the CPU back. An unfocused panel therefore has nothing
66/// running on a timer at all — the difference between a device that idles and
67/// one that keeps a core awake for its whole service life. Typing resets the
68/// phase so the caret stays solid while it is moving.
69///
70/// A blink damages the whole field rather than the caret, because
71/// [`Widget::animate`] reports *that* something changed, not *where*. On a Pi 3
72/// that is 26 kpx twice a second — 58 µs, or 0.35% of one 60 Hz frame — against
73/// the 32 px the caret actually occupies. The 800× coarseness is real and the
74/// cost of removing it is a wider trait; the measurement is why it has not been
75/// paid.
76#[derive(Clone, Debug)]
77pub struct TextInput<M> {
78 text: String,
79 placeholder: String,
80 /// Caret position as a **character** index, not a byte offset.
81 caret: usize,
82 /// First character drawn, for fields wider than their box.
83 first_visible: usize,
84 max_chars: usize,
85 style: TextStyle,
86 radius: Radius,
87 submit: Option<M>,
88 password: bool,
89 blink_epoch: u64,
90 caret_on: bool,
91 /// Whether the field currently has focus, mirrored from the focus events.
92 /// `animate` has no context to ask the tree, and this is what lets it stop
93 /// asking for frames the moment focus moves away.
94 has_focus: bool,
95 /// The other end of the selection, as a character index, or `None` when
96 /// there is only a caret: `anchor..caret` in whichever order they fall.
97 anchor: Option<usize>,
98 /// A press is still down in the field, so moving extends the selection.
99 dragging: bool,
100 /// Where the last press landed, when, and how many have stacked up on that
101 /// spot: one places the caret, two take the word, three take everything.
102 clicks: Option<(usize, u64, u8)>,
103 /// Where copy, cut and paste go, when the application has said.
104 on_clipboard: Option<fn(ClipboardRequest) -> M>,
105}
106
107impl<M> TextInput<M> {
108 /// An empty field.
109 pub fn new() -> Self {
110 Self {
111 text: String::new(),
112 placeholder: String::new(),
113 caret: 0,
114 first_visible: 0,
115 max_chars: 256,
116 style: TextStyle::built_in(16),
117 radius: Radius::Field,
118 submit: None,
119 password: false,
120 blink_epoch: 0,
121 caret_on: true,
122 has_focus: false,
123 anchor: None,
124 dragging: false,
125 clicks: None,
126 on_clipboard: None,
127 }
128 }
129
130 /// Sets the text shown when the field is empty.
131 pub fn with_placeholder(mut self, placeholder: impl Into<String>) -> Self {
132 self.placeholder = placeholder.into();
133 self
134 }
135
136 /// Sets the message emitted when Enter is pressed.
137 pub fn with_submit(mut self, message: M) -> Self {
138 self.submit = Some(message);
139 self
140 }
141
142 /// Wires copy, cut and paste to the application, which owns the clipboard.
143 ///
144 /// Without this the three keys do nothing: a panel with no window system
145 /// has nowhere to copy to, and a widget that pretended otherwise would be
146 /// lying about where the text went.
147 pub fn with_clipboard(mut self, message: fn(ClipboardRequest) -> M) -> Self {
148 self.on_clipboard = Some(message);
149 self
150 }
151
152 /// Caps the number of characters the field will hold.
153 pub fn with_max_chars(mut self, max: usize) -> Self {
154 self.max_chars = max;
155 self
156 }
157
158 /// Sets the font and size.
159 pub fn with_style(mut self, style: TextStyle) -> Self {
160 self.style = style;
161 self
162 }
163
164 /// Sets the size, keeping the font.
165 pub fn with_size(mut self, size_px: u16) -> Self {
166 self.style.size_px = size_px;
167 self
168 }
169
170 /// The font and size this field draws in.
171 #[inline]
172 pub const fn style(&self) -> TextStyle {
173 self.style
174 }
175
176 /// Draws every character as `*`. The text is still stored in the clear —
177 /// this hides a PIN from someone standing behind the panel, and nothing more.
178 pub fn with_password(mut self, password: bool) -> Self {
179 self.password = password;
180 self
181 }
182
183 /// The current contents.
184 #[inline]
185 pub fn text(&self) -> &str {
186 &self.text
187 }
188
189 /// Replaces the contents, putting the caret at the end.
190 pub fn set_text(&mut self, text: impl Into<String>) {
191 self.text = text.into();
192 self.caret = self.len_chars();
193 self.first_visible = 0;
194 // The anchor indexed text that is no longer there.
195 self.anchor = None;
196 }
197
198 /// Inserts `text` at the caret, replacing the selection: the answer to
199 /// [`ClipboardRequest::Paste`].
200 ///
201 /// **A field is one line**, so this takes what it is given up to the first
202 /// line break and drops the rest, along with any other control characters.
203 /// Pasting three lines into a setpoint has no meaning a widget could guess
204 /// at, and joining them into one would invent a value nobody copied.
205 ///
206 /// What is left is truncated to fit `max_chars` rather than refused: a
207 /// paste one character too long is still mostly what somebody wanted.
208 pub fn insert_text(&mut self, text: &str) {
209 self.delete_selection();
210 let line = text.split(['\n', '\r']).next().unwrap_or_default();
211 let room = self.max_chars.saturating_sub(self.len_chars());
212 let mut at = self.byte_of(self.caret);
213 let mut added = 0;
214 for ch in line.chars().filter(|c| !c.is_control()) {
215 if added == room {
216 break;
217 }
218 self.text.insert(at, ch);
219 at += ch.len_utf8();
220 added += 1;
221 }
222 self.caret += added;
223 self.anchor = None;
224 }
225
226 /// Replaces the font and size.
227 pub fn set_style(&mut self, style: TextStyle) {
228 self.style = style;
229 }
230
231 /// Empties the field.
232 pub fn clear(&mut self) {
233 self.set_text(String::new());
234 }
235
236 /// Caret position, as a character index.
237 #[inline]
238 pub const fn caret(&self) -> usize {
239 self.caret
240 }
241
242 /// The selection as character indices, low end first, or `None` when there
243 /// is only a caret.
244 pub fn selection(&self) -> Option<(usize, usize)> {
245 let anchor = self.anchor.filter(|a| *a != self.caret)?;
246 Some((anchor.min(self.caret), anchor.max(self.caret)))
247 }
248
249 /// The selected text, or `None` when nothing is selected.
250 pub fn selected_text(&self) -> Option<&str> {
251 let (from, to) = self.selection()?;
252 Some(&self.text[self.byte_of(from)..self.byte_of(to)])
253 }
254
255 /// Selects everything, with the caret at the end.
256 pub fn select_all(&mut self) {
257 self.anchor = Some(0);
258 self.caret = self.len_chars();
259 }
260
261 /// Selects `from..to`, clamped to the text, with the caret at `to`.
262 ///
263 /// The pair may be given either way round: the caret lands on `to`, which is
264 /// the end a further Shift-arrow moves.
265 pub fn select_range(&mut self, from: usize, to: usize) {
266 let len = self.len_chars();
267 self.anchor = Some(from.min(len));
268 self.caret = to.min(len);
269 }
270
271 /// Drops the selection, leaving the caret where it is.
272 pub fn clear_selection(&mut self) {
273 self.anchor = None;
274 }
275
276 #[inline]
277 fn len_chars(&self) -> usize {
278 self.text.chars().count()
279 }
280
281 /// Byte offset of character `index`, or the end of the string.
282 fn byte_of(&self, index: usize) -> usize {
283 self.text
284 .char_indices()
285 .nth(index)
286 .map_or(self.text.len(), |(offset, _)| offset)
287 }
288
289 /// Horizontal padding inside the field's bounds.
290 #[inline]
291 const fn pad(&self) -> i32 {
292 self.style.size_px as i32 / 3
293 }
294
295 /// The field's inner rectangle, inside the padding.
296 fn inner(&self, bounds: Rect) -> Rect {
297 Rect::from_edges(
298 bounds.x + self.pad(),
299 bounds.y,
300 bounds.right() - self.pad(),
301 bounds.bottom(),
302 )
303 }
304
305 /// Width of characters `from..to` as they are displayed.
306 ///
307 /// Measured rather than counted. With a proportional font a caret placed by
308 /// multiplying an index by an advance is wrong everywhere except after the
309 /// first character, and wrong in a way that looks like a rendering glitch
310 /// rather than an arithmetic mistake.
311 fn run_width(&self, engine: &mut TextEngine, from: usize, to: usize) -> i32 {
312 if from >= to {
313 return 0;
314 }
315 if self.password {
316 return engine.measure_line(self.style, "*") * (to - from) as i32;
317 }
318 let (start, end) = (self.byte_of(from), self.byte_of(to));
319 engine.measure_line(self.style, &self.text[start..end])
320 }
321
322 /// First character to draw, given where the caret is and how wide the box is.
323 fn window_start(&self, engine: &mut TextEngine, bounds: Rect) -> usize {
324 let available = self.inner(bounds).width;
325 let mut first = self.first_visible.min(self.caret);
326 // Walks rather than bisects: a kiosk field holds a name or a setpoint, and
327 // the loop runs once per character that scrolled off since last frame,
328 // which is almost always one.
329 while first < self.caret && self.run_width(engine, first, self.caret) > available {
330 first += 1;
331 }
332 first
333 }
334
335 /// Horizontal offset of the caret from the field's left edge.
336 ///
337 /// Measured through the engine rather than counted as characters times a
338 /// width, which is the only thing that works with a proportional font.
339 pub fn caret_x(&self, engine: &mut TextEngine, bounds: Rect) -> i32 {
340 let first = self.window_start(engine, bounds);
341 self.pad() + self.run_width(engine, first, self.caret)
342 }
343
344 fn scroll_to_caret(&mut self, engine: &mut TextEngine, bounds: Rect) {
345 self.first_visible = self.window_start(engine, bounds);
346 }
347
348 /// Restarts the blink so the caret is solid while it is being moved, and
349 /// asks to animate again, since a caret that blinked its fill has stopped.
350 fn wake_caret(&mut self, ctx: &mut EventCtx<'_, M>) {
351 self.blink_epoch = ctx.now_ms;
352 self.caret_on = true;
353 ctx.request_animation();
354 }
355
356 fn insert(&mut self, ch: char) -> bool {
357 if self.len_chars() >= self.max_chars {
358 return false;
359 }
360 let at = self.byte_of(self.caret);
361 self.text.insert(at, ch);
362 self.caret += 1;
363 // An edit collapses the selection. Leaving the anchor where it was made
364 // the character just typed look selected, so the next keystroke
365 // replaced it — a field that kept only its last letter.
366 self.anchor = None;
367 true
368 }
369
370 fn delete_before(&mut self) -> bool {
371 if self.caret == 0 {
372 return false;
373 }
374 let at = self.byte_of(self.caret - 1);
375 self.text.remove(at);
376 self.caret -= 1;
377 self.anchor = None;
378 true
379 }
380
381 fn delete_after(&mut self) -> bool {
382 if self.caret >= self.len_chars() {
383 return false;
384 }
385 let at = self.byte_of(self.caret);
386 self.text.remove(at);
387 self.anchor = None;
388 true
389 }
390
391 /// Removes the selection, reporting whether there was one. The caret lands
392 /// where the selection started, which is where typing continues from.
393 fn delete_selection(&mut self) -> bool {
394 let Some((from, to)) = self.selection() else {
395 return false;
396 };
397 let (start, end) = (self.byte_of(from), self.byte_of(to));
398 self.text.replace_range(start..end, "");
399 self.caret = from;
400 self.anchor = None;
401 true
402 }
403
404 /// Moves the caret, extending the selection or dropping it.
405 fn move_to(&mut self, to: usize, extend: bool) {
406 if extend {
407 self.anchor.get_or_insert(self.caret);
408 } else {
409 self.anchor = None;
410 }
411 self.caret = to.min(self.len_chars());
412 }
413
414 /// The character index nearest `x`, which is where a press puts the caret.
415 ///
416 /// Nearest, not "the character containing it": a press in the right half of
417 /// a letter belongs after it. Measured through the engine one character at a
418 /// time, the same way [`caret_x`](Self::caret_x) measures, so the caret
419 /// lands exactly where the press was drawn to be.
420 fn index_at(&self, engine: &mut TextEngine, bounds: Rect, x: i32) -> usize {
421 let inner = self.inner(bounds);
422 let first = self.window_start(engine, bounds);
423 let target = x - inner.x;
424 if target <= 0 {
425 return first;
426 }
427 let len = self.len_chars();
428 let mut index = first;
429 let mut before = 0;
430 while index < len {
431 let after = self.run_width(engine, first, index + 1);
432 if target < (before + after) / 2 {
433 break;
434 }
435 before = after;
436 index += 1;
437 }
438 index
439 }
440
441 /// The run of word characters — or of non-word ones — around `index`.
442 ///
443 /// The word before wins at its end, so a double-click just past the last
444 /// letter takes the word rather than the space after it. Same rule as
445 /// [`TextArea`](super::TextArea), because it is the same gesture.
446 fn word_bounds(&self, index: usize) -> (usize, usize) {
447 let len = self.len_chars();
448 let index = index.min(len);
449 let at = |i: usize| self.text.chars().nth(i);
450 let class = match (index.checked_sub(1).and_then(&at), at(index)) {
451 (Some(b), _) if is_word(b) => true,
452 (_, Some(a)) => is_word(a),
453 (Some(b), None) => is_word(b),
454 (None, None) => return (index, index),
455 };
456 let mut start = index;
457 while start > 0 && at(start - 1).is_some_and(|c| is_word(c) == class) {
458 start -= 1;
459 }
460 let mut end = index;
461 while end < len && at(end).is_some_and(|c| is_word(c) == class) {
462 end += 1;
463 }
464 (start, end)
465 }
466
467 /// The start of the word to the left of `from`.
468 ///
469 /// Skips what is not a word and then the word itself, so the caret lands
470 /// where the word begins and pressing again walks to the one before it.
471 fn word_left(&self, from: usize) -> usize {
472 let at = |i: usize| self.text.chars().nth(i);
473 let mut index = from;
474 while index > 0 && at(index - 1).is_some_and(|c| !is_word(c)) {
475 index -= 1;
476 }
477 while index > 0 && at(index - 1).is_some_and(is_word) {
478 index -= 1;
479 }
480 index
481 }
482
483 /// The end of the word to the right of `from`, by the mirror of that rule.
484 fn word_right(&self, from: usize) -> usize {
485 let len = self.len_chars();
486 let at = |i: usize| self.text.chars().nth(i);
487 let mut index = from;
488 while index < len && at(index).is_some_and(|c| !is_word(c)) {
489 index += 1;
490 }
491 while index < len && at(index).is_some_and(is_word) {
492 index += 1;
493 }
494 index
495 }
496
497 /// What a press at `index` means, given what came before it.
498 ///
499 /// One places the caret, two takes the word, three takes everything, and a
500 /// fourth starts the count again — so holding a finger down and tapping
501 /// cycles rather than sticking on "everything". The spot has to match: a
502 /// second press a few characters away is a new first press, not a double.
503 fn click_count(&mut self, index: usize, now_ms: u64) -> u8 {
504 let count = match self.clicks {
505 Some((at, when, count))
506 if at == index && now_ms.saturating_sub(when) <= DOUBLE_CLICK_MS =>
507 {
508 count % 3 + 1
509 }
510 _ => 1,
511 };
512 self.clicks = Some((index, now_ms, count));
513 count
514 }
515
516 /// A press or a tap: places the caret, takes a word, or takes everything.
517 fn press(&mut self, position: Point, extend: bool, ctx: &mut EventCtx<'_, M>) -> Handled {
518 let bounds = ctx.bounds;
519 let index = self.index_at(ctx.text, bounds, position.x);
520 match self.click_count(index, ctx.now_ms) {
521 2 => {
522 let (start, end) = self.word_bounds(index);
523 self.anchor = Some(start);
524 self.caret = end;
525 self.dragging = false;
526 }
527 3 => {
528 self.select_all();
529 self.dragging = false;
530 }
531 _ => {
532 self.move_to(index, extend);
533 self.dragging = true;
534 }
535 }
536 self.wake_caret(ctx);
537 self.scroll_to_caret(ctx.text, bounds);
538 Handled::Yes
539 }
540
541 /// Copy or cut the selection, when there is one and the application asked
542 /// to be told.
543 fn clipboard(&mut self, ctx: &mut EventCtx<'_, M>, cut: bool) -> Handled {
544 let Some(request) = self.on_clipboard else {
545 return Handled::No;
546 };
547 // See the type's docs: a password field does not hand its value over.
548 if self.password {
549 return Handled::No;
550 }
551 let Some(text) = self.selected_text().map(String::from) else {
552 return Handled::No;
553 };
554 if cut {
555 self.delete_selection();
556 ctx.emit(request(ClipboardRequest::Cut(text)));
557 self.wake_caret(ctx);
558 let bounds = ctx.bounds;
559 self.scroll_to_caret(ctx.text, bounds);
560 return Handled::Yes;
561 }
562 ctx.emit(request(ClipboardRequest::Copy(text)));
563 Handled::Yes
564 }
565
566 /// The pointer moved with the press still down: the selection follows it.
567 fn drag(&mut self, position: Point, ctx: &mut EventCtx<'_, M>) -> Handled {
568 let bounds = ctx.bounds;
569 let index = self.index_at(ctx.text, bounds, position.x);
570 if index == self.caret {
571 return Handled::No;
572 }
573 self.move_to(index, true);
574 self.wake_caret(ctx);
575 self.scroll_to_caret(ctx.text, bounds);
576 Handled::Yes
577 }
578}
579
580impl<M> Default for TextInput<M> {
581 fn default() -> Self {
582 Self::new()
583 }
584}
585
586impl<M: Clone + 'static> Widget<M> for TextInput<M> {
587 fn describe(&self) -> Option<&dyn DynDescribe> {
588 Some(self)
589 }
590
591 fn describe_mut(&mut self) -> Option<&mut dyn DynDescribe> {
592 Some(self)
593 }
594 fn measure(&self, ctx: &mut MeasureCtx<'_>, _offered: Offer) -> Measured {
595 // A field is as wide as you make it — that is what a field is — but its
596 // height is one line of its own text in a field-sized box.
597 let line = ctx.text.line_height(self.style);
598 Measured::tall(line.max(ctx.theme.metrics.size_field).max(1))
599 }
600
601 fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut Pen<'_>) {
602 let radius = ctx.theme.radius(self.radius);
603 let disabled = ctx.state.contains(VisualState::DISABLED);
604 let focused = ctx.state.contains(VisualState::FOCUSED);
605 let (background, _) = interactive_pair(ctx.theme, Role::Base100, ctx.state);
606 canvas.fill_rounded_rect(ctx.bounds, radius, background);
607 canvas.stroke_rounded_rect(ctx.bounds, radius, 1, ctx.theme.color(Role::Base300));
608 if focused {
609 focus_ring(ctx.theme, ctx.bounds, radius, canvas);
610 }
611
612 let inner = self.inner(ctx.bounds);
613 let line_height = ctx.text.line_height(self.style);
614 let top = inner.y + Align::Center.offset(inner.height, line_height);
615 // Text is clipped to the inner box, so a value longer than the field
616 // scrolls under the border rather than over it.
617 let mut clipped = canvas.with_clip(inner);
618
619 // Under the text, and only while the field has focus: a highlight on a
620 // field nobody is typing into reads as a field that still has the
621 // keyboard. The selection itself is kept, so focus coming back shows it.
622 if focused
623 && !disabled
624 && let Some((from, to)) = self.selection()
625 {
626 let first = self.window_start(ctx.text, ctx.bounds);
627 let start = from.max(first);
628 if to > start {
629 let x0 = inner.x + self.run_width(ctx.text, first, start);
630 let x1 = inner.x + self.run_width(ctx.text, first, to);
631 clipped.fill_rect(
632 Rect::new(x0, top, x1 - x0, line_height),
633 ctx.theme.color(Role::Accent).with_alpha(60),
634 );
635 }
636 }
637
638 if self.text.is_empty() {
639 if !self.placeholder.is_empty() {
640 let hint = ctx
641 .theme
642 .color(Role::Base300)
643 .mix(ctx.theme.color(Role::BaseContent), 128);
644 ctx.text.draw(
645 &mut clipped,
646 self.style,
647 Point::new(inner.x, top),
648 &self.placeholder,
649 hint,
650 );
651 }
652 } else {
653 let content = if disabled {
654 ctx.theme.color(Role::Base300)
655 } else {
656 ctx.theme.color(Role::BaseContent)
657 };
658 let first = self.window_start(ctx.text, ctx.bounds);
659 if self.password {
660 // Drawn one at a time rather than by building a string of stars,
661 // because a paint path that allocates is a paint path that can
662 // fail on a device with no memory left.
663 let advance = ctx.text.measure_line(self.style, "*");
664 let count = self.len_chars().saturating_sub(first);
665 for i in 0..count {
666 let x = inner.x + advance * i as i32;
667 if x > inner.right() {
668 break;
669 }
670 ctx.text
671 .draw(&mut clipped, self.style, Point::new(x, top), "*", content);
672 }
673 } else {
674 let start = self.byte_of(first);
675 ctx.text.draw(
676 &mut clipped,
677 self.style,
678 Point::new(inner.x, top),
679 &self.text[start..],
680 content,
681 );
682 }
683 }
684
685 if focused && self.caret_on && !disabled {
686 let first = self.window_start(ctx.text, ctx.bounds);
687 let x = inner.x + self.run_width(ctx.text, first, self.caret);
688 let width = (i32::from(self.style.size_px) / 10).max(1);
689 clipped.fill_rect(
690 Rect::new(x, top, width, line_height),
691 ctx.theme.color(Role::Accent),
692 );
693 }
694 }
695
696 fn on_event(&mut self, event: &Event<'_>, ctx: &mut EventCtx<'_, M>) -> Handled {
697 match event {
698 Event::FocusGained => {
699 self.has_focus = true;
700 // Tabbing into a field offers its contents for replacement,
701 // which is what makes Tab-and-type work on a setpoint. A press
702 // is delivered *after* the focus it caused, so clicking into a
703 // field still collapses this to a caret where the finger landed.
704 self.select_all();
705 self.wake_caret(ctx);
706 // Not `Handled`: nothing was consumed. The tree already repaints
707 // on a focus change, so the caret appearing is covered.
708 Handled::No
709 }
710 Event::FocusLost => {
711 self.has_focus = false;
712 self.wake_caret(ctx);
713 Handled::No
714 }
715 // A finger is a pointer here: the same three counts, because a
716 // panel with an on-screen keyboard has no other way to take a word.
717 Event::Input(InputEvent::PointerButton {
718 state: ElementState::Down,
719 position,
720 modifiers,
721 ..
722 }) => self.press(*position, modifiers.contains(Modifiers::SHIFT), ctx),
723 Event::Input(InputEvent::TouchDown { position, .. }) => {
724 self.press(*position, false, ctx)
725 }
726 Event::Input(InputEvent::PointerMoved { position })
727 | Event::Input(InputEvent::TouchMoved { position, .. })
728 if self.dragging =>
729 {
730 self.drag(*position, ctx)
731 }
732 // The release ends the drag, and so does the tree taking the press
733 // away — a scene pushed over the field, or its node hidden. That
734 // arrives as `PressCancelled` and as no pointer event at all, and a
735 // drag left armed would follow the next hover across the field.
736 Event::Input(InputEvent::PointerButton {
737 state: ElementState::Up,
738 ..
739 })
740 | Event::Input(InputEvent::TouchUp { .. })
741 | Event::PressCancelled => {
742 self.dragging = false;
743 Handled::No
744 }
745 Event::Input(InputEvent::Text { ch }) if !ch.is_control() => {
746 // What is selected is what typing replaces.
747 let replaced = self.delete_selection();
748 let inserted = self.insert(*ch);
749 if replaced || inserted {
750 self.wake_caret(ctx);
751 let bounds = ctx.bounds;
752 self.scroll_to_caret(ctx.text, bounds);
753 Handled::Yes
754 } else {
755 Handled::No
756 }
757 }
758 Event::Input(InputEvent::Key {
759 code,
760 state: ElementState::Down,
761 modifiers,
762 ..
763 }) => {
764 let extend = modifiers.contains(Modifiers::SHIFT);
765 // Ctrl on the desktops that use it, Command on the one that does
766 // not; a panel with a bare keyboard has neither and needs
767 // neither. Named as `TextArea` names it.
768 let primary =
769 modifiers.contains(Modifiers::CTRL) || modifiers.contains(Modifiers::SUPER);
770 // Ctrl on Windows and Linux, Option on a Mac: the two spellings
771 // of "by word", taken together because the widget cannot ask
772 // which keyboard it is in front of. Command is the Mac's "to the
773 // end", and a field is one line, so that is Home and End.
774 let by_word =
775 modifiers.contains(Modifiers::CTRL) || modifiers.contains(Modifiers::ALT);
776 let to_end = modifiers.contains(Modifiers::SUPER);
777 match code {
778 KeyCode::A if primary => self.select_all(),
779 KeyCode::C if primary => return self.clipboard(ctx, false),
780 KeyCode::X if primary => return self.clipboard(ctx, true),
781 KeyCode::V if primary => {
782 let Some(request) = self.on_clipboard else {
783 return Handled::No;
784 };
785 ctx.emit(request(ClipboardRequest::Paste));
786 return Handled::Yes;
787 }
788 // A selection is what Backspace and Delete take first; only
789 // an empty one falls through to the character either side.
790 KeyCode::Backspace => {
791 if !self.delete_selection() {
792 self.delete_before();
793 }
794 }
795 KeyCode::Delete => {
796 if !self.delete_selection() {
797 self.delete_after();
798 }
799 }
800 // An arrow with a selection and no Shift collapses to that
801 // end rather than moving from the caret, which is what puts
802 // the caret back where a person is looking.
803 KeyCode::ArrowLeft if to_end => self.move_to(0, extend),
804 KeyCode::ArrowRight if to_end => {
805 let end = self.len_chars();
806 self.move_to(end, extend);
807 }
808 KeyCode::ArrowLeft if by_word => {
809 let to = self.word_left(self.caret);
810 self.move_to(to, extend);
811 }
812 KeyCode::ArrowRight if by_word => {
813 let to = self.word_right(self.caret);
814 self.move_to(to, extend);
815 }
816 KeyCode::ArrowLeft => match self.selection() {
817 Some((from, _)) if !extend => {
818 self.caret = from;
819 self.anchor = None;
820 }
821 _ => {
822 let to = self.caret.saturating_sub(1);
823 self.move_to(to, extend);
824 }
825 },
826 KeyCode::ArrowRight => match self.selection() {
827 Some((_, to)) if !extend => {
828 self.caret = to;
829 self.anchor = None;
830 }
831 _ => {
832 let to = self.caret.saturating_add(1);
833 self.move_to(to, extend);
834 }
835 },
836 KeyCode::Home => self.move_to(0, extend),
837 KeyCode::End => {
838 let end = self.len_chars();
839 self.move_to(end, extend);
840 }
841 KeyCode::Enter | KeyCode::NumpadEnter => {
842 if let Some(message) = self.submit.clone() {
843 ctx.emit(message);
844 }
845 // Consumed either way: Enter in a field must not fall
846 // through and activate something else.
847 return Handled::Yes;
848 }
849 _ => return Handled::No,
850 }
851 self.wake_caret(ctx);
852 let bounds = ctx.bounds;
853 self.scroll_to_caret(ctx.text, bounds);
854 // Even a caret move that changed nothing must repaint, because the
855 // caret itself is pixels.
856 Handled::Yes
857 }
858 _ => Handled::No,
859 }
860 }
861
862 fn accepts_pointer(&self) -> bool {
863 true
864 }
865
866 fn focusable(&self) -> bool {
867 true
868 }
869
870 fn animate(&mut self, now_ms: u64) -> Animation {
871 if !self.has_focus {
872 // Blinking is for the field being typed into. Answering `None` is
873 // what takes this widget out of the animating set — the caret is
874 // not drawn without focus, so there is nothing left to repaint.
875 return Animation::NONE;
876 }
877 let elapsed = now_ms.saturating_sub(self.blink_epoch);
878 if elapsed >= CARET_BLINKS_FOR_MS {
879 // Lit, and asleep until the caret is next moved.
880 let repaint = !self.caret_on;
881 self.caret_on = true;
882 return Animation {
883 repaint,
884 next: Wake::Never,
885 };
886 }
887 let on = (elapsed / BLINK_MS).is_multiple_of(2);
888 let repaint = on != self.caret_on;
889 self.caret_on = on;
890 Animation {
891 repaint,
892 // A deadline, not a frame rate: the caret flips at the end of each
893 // blink and wants exactly one wake to do it. Halving the tree's
894 // animation rate must not halve the blink, and turning motion off
895 // must not stop it — a caret gone out mid-blink is a field that
896 // looks like it has lost focus. (Resting after a while of nobody
897 // typing is different: it rests lit.)
898 //
899 // Saturating, because `now_ms` is the application's clock and this
900 // widget does not get to assume anything about it. A host that
901 // counts from the Unix epoch, or a fuzzer that passes `u64::MAX`,
902 // must not be able to panic a panel through the caret blink.
903 next: Wake::At(
904 self.blink_epoch.saturating_add(
905 (elapsed / BLINK_MS)
906 .saturating_add(1)
907 .saturating_mul(BLINK_MS),
908 ),
909 ),
910 }
911 }
912
913 /// Blinking is a schedule, so it survives [`Motion::None`](crate::Motion)
914 /// unchanged — there is nothing to land, and stopping it would be a
915 /// regression dressed up as a preference.
916 fn snap(&mut self, now_ms: u64) -> Animation {
917 Widget::<M>::animate(self, now_ms)
918 }
919}
920
921impl<M> Describe for TextInput<M> {
922 const KIND: &'static str = "text-input";
923 const DOC: &'static str = "A line of text somebody types into.";
924 const GROUP: Group = Group::Input;
925 const ICON: &'static denise::icon::Icon = &super::icons::TEXT_INPUT;
926
927 const PROPERTIES: &'static [Property] = &[
928 Property::new("text", PropertyKind::Text, "Initial contents."),
929 Property::new(
930 "placeholder",
931 PropertyKind::Text,
932 "Shown while the field is empty.",
933 ),
934 Property::new(
935 "on-submit",
936 PropertyKind::Message(Payload::None),
937 "The message emitted on Enter.",
938 ),
939 Property::new(
940 "max-chars",
941 PropertyKind::Int { min: 1, max: 4096 },
942 "How many characters the field will hold.",
943 ),
944 Property::new(
945 "password",
946 PropertyKind::Bool,
947 "Draw every character as `*`. The text is still stored in the clear.",
948 ),
949 Property::new(
950 "size",
951 PropertyKind::Int { min: 6, max: 96 },
952 "Text size in logical pixels.",
953 )
954 .in_pixels(),
955 ];
956
957 fn get(&self, name: &str) -> Option<Value> {
958 Some(match name {
959 "text" => Value::text(self.text.as_str()),
960 "placeholder" => Value::text(self.placeholder.as_str()),
961 // The message is the application's, and this crate has never seen
962 // its type. See the `describe` module docs.
963 "on-submit" => return None,
964 "max-chars" => Value::Int(i32::try_from(self.max_chars).unwrap_or(i32::MAX)),
965 "password" => Value::Bool(self.password),
966 "size" => Value::Int(i32::from(self.style.size_px)),
967 _ => return None,
968 })
969 }
970
971 fn apply(&mut self, name: &str, value: Value) -> Result<(), Mismatch> {
972 match name {
973 // Through the setter, which puts the caret at the end and resets the
974 // window: assigning the field would leave a caret pointing into text
975 // that is no longer there.
976 "text" => self.set_text(value.as_text()?),
977 "placeholder" => self.placeholder = value.as_text()?,
978 "on-submit" => return Err(Mismatch::Supplied),
979 "max-chars" => self.max_chars = value.as_index()?,
980 "password" => self.password = value.as_bool()?,
981 "size" => self.style.size_px = value.as_size()?,
982 _ => return Err(Mismatch::Unknown),
983 }
984 Ok(())
985 }
986}