Skip to main content

guise/input/
pin.rs

1//! `PinInput` — segmented one-character code boxes (gpui entity).
2//!
3//! Owns its slots, cursor, and focus; renders N single-character boxes and
4//! emits [`PinInputEvent`] as the code changes or completes. Typing advances,
5//! backspace clears and retreats, arrows move, and Cmd+V fills the boxes from
6//! the clipboard.
7//!
8//! ```ignore
9//! let pin = cx.new(|cx| PinInput::new(cx).length(6).mask(true));
10//! cx.subscribe(&pin, |_this, _pin, event: &PinInputEvent, _cx| {
11//!     if let PinInputEvent::Complete(code) = event { /* verify */ }
12//! })
13//! .detach();
14//! ```
15
16use gpui::prelude::*;
17use gpui::{
18  div, px, App, Context, Entity, EventEmitter, FocusHandle, IntoElement, KeyDownEvent, MouseButton,
19  SharedString, Window,
20};
21
22use super::control_metrics;
23use crate::devtools::Probed;
24use crate::reactive::Signal;
25use crate::theme::{theme, Size};
26
27/// Emitted as the user edits the code.
28#[derive(Debug, Clone)]
29pub enum PinInputEvent {
30  /// The code changed. Carries the filled characters in order.
31  Change(String),
32  /// Every box is filled. Carries the full code (a `Change` fires first).
33  Complete(String),
34}
35
36/// The pure editing model: N single-character slots plus an active-slot cursor.
37#[derive(Debug, Clone, PartialEq, Eq)]
38struct PinModel {
39  slots: Vec<Option<char>>,
40  cursor: usize,
41}
42
43impl PinModel {
44  fn new(length: usize) -> Self {
45    PinModel {
46      slots: vec![None; length.max(1)],
47      cursor: 0,
48    }
49  }
50
51  fn len(&self) -> usize {
52    self.slots.len()
53  }
54
55  /// The filled characters, in slot order.
56  fn value(&self) -> String {
57    self.slots.iter().flatten().collect()
58  }
59
60  fn is_complete(&self) -> bool {
61    self.slots.iter().all(|slot| slot.is_some())
62  }
63
64  /// Type one character into the active slot and advance. Whitespace and
65  /// control characters are rejected. Returns whether the code changed —
66  /// re-typing the character a slot already holds only moves the cursor, so
67  /// a full pin never re-emits `Complete` on a no-op keystroke.
68  fn insert(&mut self, ch: char) -> bool {
69    if ch.is_whitespace() || ch.is_control() {
70      return false;
71    }
72    let changed = self.slots[self.cursor] != Some(ch);
73    self.slots[self.cursor] = Some(ch);
74    if self.cursor + 1 < self.len() {
75      self.cursor += 1;
76    }
77    changed
78  }
79
80  /// Clear the active slot; if it was already empty, retreat and clear that
81  /// one instead. Returns whether anything changed.
82  fn backspace(&mut self) -> bool {
83    if self.slots[self.cursor].is_some() {
84      self.slots[self.cursor] = None;
85      true
86    } else if self.cursor > 0 {
87      self.cursor -= 1;
88      self.slots[self.cursor] = None;
89      true
90    } else {
91      false
92    }
93  }
94
95  fn left(&mut self) {
96    self.cursor = self.cursor.saturating_sub(1);
97  }
98
99  fn right(&mut self) {
100    if self.cursor + 1 < self.len() {
101      self.cursor += 1;
102    }
103  }
104
105  /// Replace the code with pasted text: non-whitespace characters fill the
106  /// slots from the start. Returns whether the code changed — re-pasting
107  /// the identical text is a no-op and must not re-emit `Complete`.
108  fn paste(&mut self, text: &str) -> bool {
109    let len = self.len();
110    let chars: Vec<char> = text
111      .chars()
112      .filter(|c| !c.is_whitespace() && !c.is_control())
113      .take(len)
114      .collect();
115    if chars.is_empty() {
116      return false;
117    }
118    let before = self.slots.clone();
119    self.slots.fill(None);
120    for (i, ch) in chars.iter().enumerate() {
121      self.slots[i] = Some(*ch);
122    }
123    self.cursor = chars.len().min(len - 1);
124    self.slots != before
125  }
126
127  /// Programmatic replace (used by `set_text`/`bind`).
128  fn set_value(&mut self, value: &str) {
129    let len = self.len();
130    self.slots.fill(None);
131    for (i, ch) in value.chars().take(len).enumerate() {
132      self.slots[i] = Some(ch);
133    }
134    self.cursor = self
135      .slots
136      .iter()
137      .position(|slot| slot.is_none())
138      .unwrap_or(len - 1);
139  }
140
141  fn resize(&mut self, length: usize) {
142    self.slots.resize(length.max(1), None);
143    self.cursor = self.cursor.min(self.slots.len() - 1);
144  }
145}
146
147/// A one-time-code field. Create with `cx.new(|cx| PinInput::new(cx))`.
148pub struct PinInput {
149  model: PinModel,
150  focus: FocusHandle,
151  mask: bool,
152  size: Size,
153  disabled: bool,
154}
155
156impl EventEmitter<PinInputEvent> for PinInput {}
157
158impl PinInput {
159  pub fn new(cx: &mut Context<Self>) -> Self {
160    PinInput {
161      model: PinModel::new(4),
162      focus: cx.focus_handle(),
163      mask: false,
164      size: Size::Sm,
165      disabled: false,
166    }
167  }
168
169  /// Number of boxes (default 4).
170  pub fn length(mut self, length: usize) -> Self {
171    self.model.resize(length);
172    self
173  }
174
175  /// Render filled boxes as bullets instead of the typed characters.
176  pub fn mask(mut self, mask: bool) -> Self {
177    self.mask = mask;
178    self
179  }
180
181  pub fn size(mut self, size: Size) -> Self {
182    self.size = size;
183    self
184  }
185
186  pub fn disabled(mut self, disabled: bool) -> Self {
187    self.disabled = disabled;
188    self
189  }
190
191  /// Initial code (builder). Extra characters beyond `length` are dropped.
192  pub fn value(mut self, value: &str) -> Self {
193    self.model.set_value(value);
194    self
195  }
196
197  /// The field's focus handle, so a host can focus it on open.
198  pub fn focus_handle(&self) -> FocusHandle {
199    self.focus.clone()
200  }
201
202  /// The current code — the filled characters in order.
203  pub fn text(&self) -> String {
204    self.model.value()
205  }
206
207  /// Replace the code programmatically.
208  pub fn set_text(&mut self, value: &str, cx: &mut Context<Self>) {
209    self.model.set_value(value);
210    cx.notify();
211  }
212
213  /// Two-way bind this field's code to a `Signal<String>`. The signal is
214  /// the source of truth: the field adopts its value now, edits write back
215  /// through [`Signal::set_if_changed`], and signal writes replace the code.
216  /// Equality guards on both directions prevent update loops.
217  pub fn bind(entity: &Entity<PinInput>, signal: &Signal<String>, cx: &mut App) {
218    let initial = signal.get(cx);
219    entity.update(cx, |this, cx| {
220      if this.text() != initial {
221        this.set_text(&initial, cx);
222      }
223    });
224    let sink = signal.clone();
225    cx.subscribe(entity, move |_pin, event: &PinInputEvent, cx| {
226      if let PinInputEvent::Change(text) = event {
227        sink.set_if_changed(cx, text.clone());
228      }
229    })
230    .detach();
231    let pin = entity.downgrade();
232    cx.observe(signal.entity(), move |observed, cx| {
233      let value = observed.read(cx).clone();
234      pin
235        .update(cx, |this, cx| {
236          if this.text() != value {
237            this.set_text(&value, cx);
238          }
239        })
240        .ok();
241    })
242    .detach();
243  }
244
245  /// Emit `Change` (and `Complete` when full), repaint, and consume the key.
246  fn emit_edit(&mut self, cx: &mut Context<Self>) {
247    let value = self.model.value();
248    cx.emit(PinInputEvent::Change(value.clone()));
249    if self.model.is_complete() {
250      cx.emit(PinInputEvent::Complete(value));
251    }
252    cx.notify();
253    cx.stop_propagation();
254  }
255
256  fn on_key(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context<Self>) {
257    if self.disabled {
258      return;
259    }
260    let ks = &event.keystroke;
261    let m = &ks.modifiers;
262    match ks.key.as_str() {
263      "left" => {
264        self.model.left();
265        cx.notify();
266        cx.stop_propagation();
267      }
268      "right" => {
269        self.model.right();
270        cx.notify();
271        cx.stop_propagation();
272      }
273      "backspace" => {
274        if self.model.backspace() {
275          self.emit_edit(cx);
276        } else {
277          cx.stop_propagation();
278        }
279      }
280      "v" if m.platform => {
281        if let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) {
282          if self.model.paste(&text) {
283            self.emit_edit(cx);
284          }
285        }
286        cx.stop_propagation();
287      }
288      _ => {
289        // Printable input: never on Cmd/Ctrl chords; Option+key is
290        // allowed so composed glyphs land (same rule as TextInput).
291        if !m.platform && !m.control {
292          if let Some(typed) = ks.key_char.as_deref().filter(|t| !t.is_empty()) {
293            let cursor_before = self.model.cursor;
294            let mut changed = false;
295            for ch in typed.chars() {
296              changed |= self.model.insert(ch);
297            }
298            if changed {
299              self.emit_edit(cx);
300            } else if self.model.cursor != cursor_before {
301              // Same character over a filled slot: the cursor
302              // advanced but the code is untouched — repaint,
303              // no Change/Complete.
304              cx.notify();
305              cx.stop_propagation();
306            }
307          }
308        }
309      }
310    }
311  }
312}
313
314impl Render for PinInput {
315  fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
316    let t = theme(cx);
317    let (height, _pad_x, font) = control_metrics(self.size);
318    let radius = t.radius(t.default_radius);
319    let focused = self.focus.is_focused(window) && !self.disabled;
320
321    let border = t.border().hsla();
322    let active_border = t.primary().hsla();
323    let surface = t.surface().hsla();
324    let text_color = t.text().hsla();
325
326    let cursor = self.model.cursor;
327    let mask = self.mask;
328
329    let mut row = div()
330      .id("guise-pininput")
331      .track_focus(&self.focus)
332      .on_key_down(cx.listener(Self::on_key))
333      .flex()
334      .items_center()
335      .gap(px(8.0));
336
337    for (i, slot) in self.model.slots.iter().enumerate() {
338      let ch = slot.map(|c| if mask { '\u{2022}' } else { c });
339      let active = focused && i == cursor;
340      let mut cell = div()
341        .id(("guise-pin-box", i))
342        .flex()
343        .items_center()
344        .justify_center()
345        .w(px(height))
346        .h(px(height))
347        .rounded(px(radius))
348        .border_1()
349        .border_color(if active { active_border } else { border })
350        .bg(surface)
351        .text_size(px(font))
352        .text_color(text_color)
353        .on_mouse_down(
354          MouseButton::Left,
355          cx.listener(move |this, _ev, window, cx| {
356            window.focus(&this.focus);
357            this.model.cursor = i.min(this.model.len() - 1);
358            cx.notify();
359          }),
360        );
361      if let Some(ch) = ch {
362        cell = cell.child(SharedString::from(ch.to_string()));
363      }
364      row = row.child(cell);
365    }
366
367    let element = if self.disabled { row.opacity(0.6) } else { row };
368
369    element.probe("PinInput")
370  }
371}
372
373#[cfg(test)]
374mod tests {
375  use super::PinModel;
376
377  #[test]
378  fn typing_advances_and_stops_at_the_last_box() {
379    let mut pin = PinModel::new(4);
380    assert!(pin.insert('1'));
381    assert!(pin.insert('2'));
382    assert_eq!(pin.value(), "12");
383    assert_eq!(pin.cursor, 2);
384
385    pin.insert('3');
386    pin.insert('4');
387    assert_eq!(pin.cursor, 3, "cursor parks on the last box");
388    assert!(pin.is_complete());
389
390    // Typing again overwrites the last box in place.
391    assert!(pin.insert('9'));
392    assert_eq!(pin.value(), "1239");
393    assert_eq!(pin.cursor, 3);
394  }
395
396  #[test]
397  fn retyping_the_same_char_reports_no_change() {
398    let mut pin = PinModel::new(4);
399    pin.paste("1234");
400    // Double-pressing the final digit: complete pin, parked cursor.
401    assert!(!pin.insert('4'));
402    assert_eq!(pin.value(), "1234");
403    // Mid-pin, the cursor still advances but the code is unchanged.
404    pin.left();
405    assert!(!pin.insert('3'));
406    assert_eq!(pin.cursor, 3);
407    assert_eq!(pin.value(), "1234");
408  }
409
410  #[test]
411  fn identical_paste_reports_no_change() {
412    let mut pin = PinModel::new(4);
413    assert!(pin.paste("1234"));
414    assert!(!pin.paste("1234"));
415    assert!(pin.paste("129"), "different content still reports a change");
416    assert_eq!(pin.value(), "129");
417  }
418
419  #[test]
420  fn whitespace_and_control_chars_are_rejected() {
421    let mut pin = PinModel::new(4);
422    assert!(!pin.insert(' '));
423    assert!(!pin.insert('\t'));
424    assert!(!pin.insert('\u{7}'));
425    assert_eq!(pin.value(), "");
426    assert_eq!(pin.cursor, 0);
427  }
428
429  #[test]
430  fn backspace_clears_current_then_retreats() {
431    let mut pin = PinModel::new(4);
432    pin.insert('1');
433    pin.insert('2');
434    // cursor sits on the empty third box: retreat and clear box 2.
435    assert!(pin.backspace());
436    assert_eq!(pin.value(), "1");
437    assert_eq!(pin.cursor, 1);
438    // Now box 1 (empty after the clear)... clear box 0 next.
439    assert!(pin.backspace());
440    assert_eq!(pin.value(), "");
441    assert_eq!(pin.cursor, 0);
442    // Empty at the first box: nothing to do.
443    assert!(!pin.backspace());
444  }
445
446  #[test]
447  fn backspace_clears_a_filled_box_in_place() {
448    let mut pin = PinModel::new(4);
449    pin.paste("1234");
450    assert_eq!(pin.cursor, 3);
451    assert!(pin.backspace());
452    assert_eq!(pin.value(), "123");
453    assert_eq!(pin.cursor, 3, "clears the filled box without retreating");
454  }
455
456  #[test]
457  fn arrows_clamp_to_the_boxes() {
458    let mut pin = PinModel::new(3);
459    pin.left();
460    assert_eq!(pin.cursor, 0);
461    pin.right();
462    pin.right();
463    pin.right();
464    assert_eq!(pin.cursor, 2);
465    pin.left();
466    assert_eq!(pin.cursor, 1);
467  }
468
469  #[test]
470  fn paste_fills_from_the_start_and_skips_whitespace() {
471    let mut pin = PinModel::new(4);
472    pin.insert('9');
473    assert!(pin.paste(" 12 34 56 "));
474    assert_eq!(pin.value(), "1234", "replaces old content, truncates");
475    assert!(pin.is_complete());
476    assert_eq!(pin.cursor, 3);
477  }
478
479  #[test]
480  fn short_paste_leaves_the_cursor_on_the_next_empty_box() {
481    let mut pin = PinModel::new(6);
482    assert!(pin.paste("12"));
483    assert_eq!(pin.value(), "12");
484    assert_eq!(pin.cursor, 2);
485    assert!(!pin.paste("   "), "whitespace-only paste is a no-op");
486  }
487
488  #[test]
489  fn set_value_places_the_cursor_at_the_first_empty_box() {
490    let mut pin = PinModel::new(4);
491    pin.set_value("12");
492    assert_eq!(pin.cursor, 2);
493    pin.set_value("123456");
494    assert_eq!(pin.value(), "1234", "extra characters are dropped");
495    assert_eq!(pin.cursor, 3);
496    pin.set_value("");
497    assert_eq!(pin.value(), "");
498    assert_eq!(pin.cursor, 0);
499  }
500
501  #[test]
502  fn resize_preserves_slots_and_clamps_the_cursor() {
503    let mut pin = PinModel::new(6);
504    pin.paste("123456");
505    pin.resize(3);
506    assert_eq!(pin.value(), "123");
507    assert_eq!(pin.cursor, 2);
508    pin.resize(5);
509    assert_eq!(pin.value(), "123");
510    assert_eq!(pin.len(), 5);
511    // Zero-length requests are clamped to one box.
512    pin.resize(0);
513    assert_eq!(pin.len(), 1);
514    assert_eq!(pin.cursor, 0);
515  }
516}