1use 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#[derive(Debug, Clone)]
29pub enum PinInputEvent {
30 Change(String),
32 Complete(String),
34}
35
36#[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 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 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 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 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 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
147pub 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 pub fn length(mut self, length: usize) -> Self {
171 self.model.resize(length);
172 self
173 }
174
175 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 pub fn value(mut self, value: &str) -> Self {
193 self.model.set_value(value);
194 self
195 }
196
197 pub fn focus_handle(&self) -> FocusHandle {
199 self.focus.clone()
200 }
201
202 pub fn text(&self) -> String {
204 self.model.value()
205 }
206
207 pub fn set_text(&mut self, value: &str, cx: &mut Context<Self>) {
209 self.model.set_value(value);
210 cx.notify();
211 }
212
213 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 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 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 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 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 assert!(!pin.insert('4'));
402 assert_eq!(pin.value(), "1234");
403 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 assert!(pin.backspace());
436 assert_eq!(pin.value(), "1");
437 assert_eq!(pin.cursor, 1);
438 assert!(pin.backspace());
440 assert_eq!(pin.value(), "");
441 assert_eq!(pin.cursor, 0);
442 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 pin.resize(0);
513 assert_eq!(pin.len(), 1);
514 assert_eq!(pin.cursor, 0);
515 }
516}