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,
19    MouseButton, SharedString, Window,
20};
21
22use super::control_metrics;
23use crate::reactive::Signal;
24use crate::theme::{theme, Size};
25
26/// Emitted as the user edits the code.
27#[derive(Debug, Clone)]
28pub enum PinInputEvent {
29    /// The code changed. Carries the filled characters in order.
30    Change(String),
31    /// Every box is filled. Carries the full code (a `Change` fires first).
32    Complete(String),
33}
34
35/// The pure editing model: N single-character slots plus an active-slot cursor.
36#[derive(Debug, Clone, PartialEq, Eq)]
37struct PinModel {
38    slots: Vec<Option<char>>,
39    cursor: usize,
40}
41
42impl PinModel {
43    fn new(length: usize) -> Self {
44        PinModel {
45            slots: vec![None; length.max(1)],
46            cursor: 0,
47        }
48    }
49
50    fn len(&self) -> usize {
51        self.slots.len()
52    }
53
54    /// The filled characters, in slot order.
55    fn value(&self) -> String {
56        self.slots.iter().flatten().collect()
57    }
58
59    fn is_complete(&self) -> bool {
60        self.slots.iter().all(|slot| slot.is_some())
61    }
62
63    /// Type one character into the active slot and advance. Whitespace and
64    /// control characters are rejected. Returns whether the code changed —
65    /// re-typing the character a slot already holds only moves the cursor, so
66    /// a full pin never re-emits `Complete` on a no-op keystroke.
67    fn insert(&mut self, ch: char) -> bool {
68        if ch.is_whitespace() || ch.is_control() {
69            return false;
70        }
71        let changed = self.slots[self.cursor] != Some(ch);
72        self.slots[self.cursor] = Some(ch);
73        if self.cursor + 1 < self.len() {
74            self.cursor += 1;
75        }
76        changed
77    }
78
79    /// Clear the active slot; if it was already empty, retreat and clear that
80    /// one instead. Returns whether anything changed.
81    fn backspace(&mut self) -> bool {
82        if self.slots[self.cursor].is_some() {
83            self.slots[self.cursor] = None;
84            true
85        } else if self.cursor > 0 {
86            self.cursor -= 1;
87            self.slots[self.cursor] = None;
88            true
89        } else {
90            false
91        }
92    }
93
94    fn left(&mut self) {
95        self.cursor = self.cursor.saturating_sub(1);
96    }
97
98    fn right(&mut self) {
99        if self.cursor + 1 < self.len() {
100            self.cursor += 1;
101        }
102    }
103
104    /// Replace the code with pasted text: non-whitespace characters fill the
105    /// slots from the start. Returns whether the code changed — re-pasting
106    /// the identical text is a no-op and must not re-emit `Complete`.
107    fn paste(&mut self, text: &str) -> bool {
108        let len = self.len();
109        let chars: Vec<char> = text
110            .chars()
111            .filter(|c| !c.is_whitespace() && !c.is_control())
112            .take(len)
113            .collect();
114        if chars.is_empty() {
115            return false;
116        }
117        let before = self.slots.clone();
118        self.slots.fill(None);
119        for (i, ch) in chars.iter().enumerate() {
120            self.slots[i] = Some(*ch);
121        }
122        self.cursor = chars.len().min(len - 1);
123        self.slots != before
124    }
125
126    /// Programmatic replace (used by `set_text`/`bind`).
127    fn set_value(&mut self, value: &str) {
128        let len = self.len();
129        self.slots.fill(None);
130        for (i, ch) in value.chars().take(len).enumerate() {
131            self.slots[i] = Some(ch);
132        }
133        self.cursor = self
134            .slots
135            .iter()
136            .position(|slot| slot.is_none())
137            .unwrap_or(len - 1);
138    }
139
140    fn resize(&mut self, length: usize) {
141        self.slots.resize(length.max(1), None);
142        self.cursor = self.cursor.min(self.slots.len() - 1);
143    }
144}
145
146/// A one-time-code field. Create with `cx.new(|cx| PinInput::new(cx))`.
147pub struct PinInput {
148    model: PinModel,
149    focus: FocusHandle,
150    mask: bool,
151    size: Size,
152    disabled: bool,
153}
154
155impl EventEmitter<PinInputEvent> for PinInput {}
156
157impl PinInput {
158    pub fn new(cx: &mut Context<Self>) -> Self {
159        PinInput {
160            model: PinModel::new(4),
161            focus: cx.focus_handle(),
162            mask: false,
163            size: Size::Sm,
164            disabled: false,
165        }
166    }
167
168    /// Number of boxes (default 4).
169    pub fn length(mut self, length: usize) -> Self {
170        self.model.resize(length);
171        self
172    }
173
174    /// Render filled boxes as bullets instead of the typed characters.
175    pub fn mask(mut self, mask: bool) -> Self {
176        self.mask = mask;
177        self
178    }
179
180    pub fn size(mut self, size: Size) -> Self {
181        self.size = size;
182        self
183    }
184
185    pub fn disabled(mut self, disabled: bool) -> Self {
186        self.disabled = disabled;
187        self
188    }
189
190    /// Initial code (builder). Extra characters beyond `length` are dropped.
191    pub fn value(mut self, value: &str) -> Self {
192        self.model.set_value(value);
193        self
194    }
195
196    /// The field's focus handle, so a host can focus it on open.
197    pub fn focus_handle(&self) -> FocusHandle {
198        self.focus.clone()
199    }
200
201    /// The current code — the filled characters in order.
202    pub fn text(&self) -> String {
203        self.model.value()
204    }
205
206    /// Replace the code programmatically.
207    pub fn set_text(&mut self, value: &str, cx: &mut Context<Self>) {
208        self.model.set_value(value);
209        cx.notify();
210    }
211
212    /// Two-way bind this field's code to a `Signal<String>`. The signal is
213    /// the source of truth: the field adopts its value now, edits write back
214    /// through [`Signal::set_if_changed`], and signal writes replace the code.
215    /// Equality guards on both directions prevent update loops.
216    pub fn bind(entity: &Entity<PinInput>, signal: &Signal<String>, cx: &mut App) {
217        let initial = signal.get(cx);
218        entity.update(cx, |this, cx| {
219            if this.text() != initial {
220                this.set_text(&initial, cx);
221            }
222        });
223        let sink = signal.clone();
224        cx.subscribe(entity, move |_pin, event: &PinInputEvent, cx| {
225            if let PinInputEvent::Change(text) = event {
226                sink.set_if_changed(cx, text.clone());
227            }
228        })
229        .detach();
230        let pin = entity.downgrade();
231        cx.observe(signal.entity(), move |observed, cx| {
232            let value = observed.read(cx).clone();
233            pin.update(cx, |this, cx| {
234                if this.text() != value {
235                    this.set_text(&value, cx);
236                }
237            })
238            .ok();
239        })
240        .detach();
241    }
242
243    /// Emit `Change` (and `Complete` when full), repaint, and consume the key.
244    fn emit_edit(&mut self, cx: &mut Context<Self>) {
245        let value = self.model.value();
246        cx.emit(PinInputEvent::Change(value.clone()));
247        if self.model.is_complete() {
248            cx.emit(PinInputEvent::Complete(value));
249        }
250        cx.notify();
251        cx.stop_propagation();
252    }
253
254    fn on_key(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context<Self>) {
255        if self.disabled {
256            return;
257        }
258        let ks = &event.keystroke;
259        let m = &ks.modifiers;
260        match ks.key.as_str() {
261            "left" => {
262                self.model.left();
263                cx.notify();
264                cx.stop_propagation();
265            }
266            "right" => {
267                self.model.right();
268                cx.notify();
269                cx.stop_propagation();
270            }
271            "backspace" => {
272                if self.model.backspace() {
273                    self.emit_edit(cx);
274                } else {
275                    cx.stop_propagation();
276                }
277            }
278            "v" if m.platform => {
279                if let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) {
280                    if self.model.paste(&text) {
281                        self.emit_edit(cx);
282                    }
283                }
284                cx.stop_propagation();
285            }
286            _ => {
287                // Printable input: never on Cmd/Ctrl chords; Option+key is
288                // allowed so composed glyphs land (same rule as TextInput).
289                if !m.platform && !m.control {
290                    if let Some(typed) = ks.key_char.as_deref().filter(|t| !t.is_empty()) {
291                        let cursor_before = self.model.cursor;
292                        let mut changed = false;
293                        for ch in typed.chars() {
294                            changed |= self.model.insert(ch);
295                        }
296                        if changed {
297                            self.emit_edit(cx);
298                        } else if self.model.cursor != cursor_before {
299                            // Same character over a filled slot: the cursor
300                            // advanced but the code is untouched — repaint,
301                            // no Change/Complete.
302                            cx.notify();
303                            cx.stop_propagation();
304                        }
305                    }
306                }
307            }
308        }
309    }
310}
311
312impl Render for PinInput {
313    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
314        let t = theme(cx);
315        let (height, _pad_x, font) = control_metrics(self.size);
316        let radius = t.radius(t.default_radius);
317        let focused = self.focus.is_focused(window) && !self.disabled;
318
319        let border = t.border().hsla();
320        let active_border = t.primary().hsla();
321        let surface = t.surface().hsla();
322        let text_color = t.text().hsla();
323
324        let cursor = self.model.cursor;
325        let mask = self.mask;
326
327        let mut row = div()
328            .id("guise-pininput")
329            .track_focus(&self.focus)
330            .on_key_down(cx.listener(Self::on_key))
331            .flex()
332            .items_center()
333            .gap(px(8.0));
334
335        for (i, slot) in self.model.slots.iter().enumerate() {
336            let ch = slot.map(|c| if mask { '\u{2022}' } else { c });
337            let active = focused && i == cursor;
338            let mut cell = div()
339                .id(("guise-pin-box", i))
340                .flex()
341                .items_center()
342                .justify_center()
343                .w(px(height))
344                .h(px(height))
345                .rounded(px(radius))
346                .border_1()
347                .border_color(if active { active_border } else { border })
348                .bg(surface)
349                .text_size(px(font))
350                .text_color(text_color)
351                .on_mouse_down(
352                    MouseButton::Left,
353                    cx.listener(move |this, _ev, window, cx| {
354                        window.focus(&this.focus);
355                        this.model.cursor = i.min(this.model.len() - 1);
356                        cx.notify();
357                    }),
358                );
359            if let Some(ch) = ch {
360                cell = cell.child(SharedString::from(ch.to_string()));
361            }
362            row = row.child(cell);
363        }
364
365        if self.disabled {
366            row.opacity(0.6)
367        } else {
368            row
369        }
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}