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::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.update(cx, |this, cx| {
235                if this.text() != value {
236                    this.set_text(&value, cx);
237                }
238            })
239            .ok();
240        })
241        .detach();
242    }
243
244    /// Emit `Change` (and `Complete` when full), repaint, and consume the key.
245    fn emit_edit(&mut self, cx: &mut Context<Self>) {
246        let value = self.model.value();
247        cx.emit(PinInputEvent::Change(value.clone()));
248        if self.model.is_complete() {
249            cx.emit(PinInputEvent::Complete(value));
250        }
251        cx.notify();
252        cx.stop_propagation();
253    }
254
255    fn on_key(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context<Self>) {
256        if self.disabled {
257            return;
258        }
259        let ks = &event.keystroke;
260        let m = &ks.modifiers;
261        match ks.key.as_str() {
262            "left" => {
263                self.model.left();
264                cx.notify();
265                cx.stop_propagation();
266            }
267            "right" => {
268                self.model.right();
269                cx.notify();
270                cx.stop_propagation();
271            }
272            "backspace" => {
273                if self.model.backspace() {
274                    self.emit_edit(cx);
275                } else {
276                    cx.stop_propagation();
277                }
278            }
279            "v" if m.platform => {
280                if let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) {
281                    if self.model.paste(&text) {
282                        self.emit_edit(cx);
283                    }
284                }
285                cx.stop_propagation();
286            }
287            _ => {
288                // Printable input: never on Cmd/Ctrl chords; Option+key is
289                // allowed so composed glyphs land (same rule as TextInput).
290                if !m.platform && !m.control {
291                    if let Some(typed) = ks.key_char.as_deref().filter(|t| !t.is_empty()) {
292                        let cursor_before = self.model.cursor;
293                        let mut changed = false;
294                        for ch in typed.chars() {
295                            changed |= self.model.insert(ch);
296                        }
297                        if changed {
298                            self.emit_edit(cx);
299                        } else if self.model.cursor != cursor_before {
300                            // Same character over a filled slot: the cursor
301                            // advanced but the code is untouched — repaint,
302                            // no Change/Complete.
303                            cx.notify();
304                            cx.stop_propagation();
305                        }
306                    }
307                }
308            }
309        }
310    }
311}
312
313impl Render for PinInput {
314    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
315        let t = theme(cx);
316        let (height, _pad_x, font) = control_metrics(self.size);
317        let radius = t.radius(t.default_radius);
318        let focused = self.focus.is_focused(window) && !self.disabled;
319
320        let border = t.border().hsla();
321        let active_border = t.primary().hsla();
322        let surface = t.surface().hsla();
323        let text_color = t.text().hsla();
324
325        let cursor = self.model.cursor;
326        let mask = self.mask;
327
328        let mut row = div()
329            .id("guise-pininput")
330            .track_focus(&self.focus)
331            .on_key_down(cx.listener(Self::on_key))
332            .flex()
333            .items_center()
334            .gap(px(8.0));
335
336        for (i, slot) in self.model.slots.iter().enumerate() {
337            let ch = slot.map(|c| if mask { '\u{2022}' } else { c });
338            let active = focused && i == cursor;
339            let mut cell = div()
340                .id(("guise-pin-box", i))
341                .flex()
342                .items_center()
343                .justify_center()
344                .w(px(height))
345                .h(px(height))
346                .rounded(px(radius))
347                .border_1()
348                .border_color(if active { active_border } else { border })
349                .bg(surface)
350                .text_size(px(font))
351                .text_color(text_color)
352                .on_mouse_down(
353                    MouseButton::Left,
354                    cx.listener(move |this, _ev, window, cx| {
355                        window.focus(&this.focus);
356                        this.model.cursor = i.min(this.model.len() - 1);
357                        cx.notify();
358                    }),
359                );
360            if let Some(ch) = ch {
361                cell = cell.child(SharedString::from(ch.to_string()));
362            }
363            row = row.child(cell);
364        }
365
366        let element = if self.disabled { row.opacity(0.6) } else { row };
367
368        element.probe("PinInput")
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use super::PinModel;
375
376    #[test]
377    fn typing_advances_and_stops_at_the_last_box() {
378        let mut pin = PinModel::new(4);
379        assert!(pin.insert('1'));
380        assert!(pin.insert('2'));
381        assert_eq!(pin.value(), "12");
382        assert_eq!(pin.cursor, 2);
383
384        pin.insert('3');
385        pin.insert('4');
386        assert_eq!(pin.cursor, 3, "cursor parks on the last box");
387        assert!(pin.is_complete());
388
389        // Typing again overwrites the last box in place.
390        assert!(pin.insert('9'));
391        assert_eq!(pin.value(), "1239");
392        assert_eq!(pin.cursor, 3);
393    }
394
395    #[test]
396    fn retyping_the_same_char_reports_no_change() {
397        let mut pin = PinModel::new(4);
398        pin.paste("1234");
399        // Double-pressing the final digit: complete pin, parked cursor.
400        assert!(!pin.insert('4'));
401        assert_eq!(pin.value(), "1234");
402        // Mid-pin, the cursor still advances but the code is unchanged.
403        pin.left();
404        assert!(!pin.insert('3'));
405        assert_eq!(pin.cursor, 3);
406        assert_eq!(pin.value(), "1234");
407    }
408
409    #[test]
410    fn identical_paste_reports_no_change() {
411        let mut pin = PinModel::new(4);
412        assert!(pin.paste("1234"));
413        assert!(!pin.paste("1234"));
414        assert!(pin.paste("129"), "different content still reports a change");
415        assert_eq!(pin.value(), "129");
416    }
417
418    #[test]
419    fn whitespace_and_control_chars_are_rejected() {
420        let mut pin = PinModel::new(4);
421        assert!(!pin.insert(' '));
422        assert!(!pin.insert('\t'));
423        assert!(!pin.insert('\u{7}'));
424        assert_eq!(pin.value(), "");
425        assert_eq!(pin.cursor, 0);
426    }
427
428    #[test]
429    fn backspace_clears_current_then_retreats() {
430        let mut pin = PinModel::new(4);
431        pin.insert('1');
432        pin.insert('2');
433        // cursor sits on the empty third box: retreat and clear box 2.
434        assert!(pin.backspace());
435        assert_eq!(pin.value(), "1");
436        assert_eq!(pin.cursor, 1);
437        // Now box 1 (empty after the clear)... clear box 0 next.
438        assert!(pin.backspace());
439        assert_eq!(pin.value(), "");
440        assert_eq!(pin.cursor, 0);
441        // Empty at the first box: nothing to do.
442        assert!(!pin.backspace());
443    }
444
445    #[test]
446    fn backspace_clears_a_filled_box_in_place() {
447        let mut pin = PinModel::new(4);
448        pin.paste("1234");
449        assert_eq!(pin.cursor, 3);
450        assert!(pin.backspace());
451        assert_eq!(pin.value(), "123");
452        assert_eq!(pin.cursor, 3, "clears the filled box without retreating");
453    }
454
455    #[test]
456    fn arrows_clamp_to_the_boxes() {
457        let mut pin = PinModel::new(3);
458        pin.left();
459        assert_eq!(pin.cursor, 0);
460        pin.right();
461        pin.right();
462        pin.right();
463        assert_eq!(pin.cursor, 2);
464        pin.left();
465        assert_eq!(pin.cursor, 1);
466    }
467
468    #[test]
469    fn paste_fills_from_the_start_and_skips_whitespace() {
470        let mut pin = PinModel::new(4);
471        pin.insert('9');
472        assert!(pin.paste(" 12 34 56 "));
473        assert_eq!(pin.value(), "1234", "replaces old content, truncates");
474        assert!(pin.is_complete());
475        assert_eq!(pin.cursor, 3);
476    }
477
478    #[test]
479    fn short_paste_leaves_the_cursor_on_the_next_empty_box() {
480        let mut pin = PinModel::new(6);
481        assert!(pin.paste("12"));
482        assert_eq!(pin.value(), "12");
483        assert_eq!(pin.cursor, 2);
484        assert!(!pin.paste("   "), "whitespace-only paste is a no-op");
485    }
486
487    #[test]
488    fn set_value_places_the_cursor_at_the_first_empty_box() {
489        let mut pin = PinModel::new(4);
490        pin.set_value("12");
491        assert_eq!(pin.cursor, 2);
492        pin.set_value("123456");
493        assert_eq!(pin.value(), "1234", "extra characters are dropped");
494        assert_eq!(pin.cursor, 3);
495        pin.set_value("");
496        assert_eq!(pin.value(), "");
497        assert_eq!(pin.cursor, 0);
498    }
499
500    #[test]
501    fn resize_preserves_slots_and_clamps_the_cursor() {
502        let mut pin = PinModel::new(6);
503        pin.paste("123456");
504        pin.resize(3);
505        assert_eq!(pin.value(), "123");
506        assert_eq!(pin.cursor, 2);
507        pin.resize(5);
508        assert_eq!(pin.value(), "123");
509        assert_eq!(pin.len(), 5);
510        // Zero-length requests are clamped to one box.
511        pin.resize(0);
512        assert_eq!(pin.len(), 1);
513        assert_eq!(pin.cursor, 0);
514    }
515}