use gpui::prelude::*;
use gpui::{
div, px, App, Context, Entity, EventEmitter, FocusHandle, IntoElement, KeyDownEvent,
MouseButton, SharedString, Window,
};
use super::control_metrics;
use crate::reactive::Signal;
use crate::theme::{theme, Size};
#[derive(Debug, Clone)]
pub enum PinInputEvent {
Change(String),
Complete(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PinModel {
slots: Vec<Option<char>>,
cursor: usize,
}
impl PinModel {
fn new(length: usize) -> Self {
PinModel {
slots: vec![None; length.max(1)],
cursor: 0,
}
}
fn len(&self) -> usize {
self.slots.len()
}
fn value(&self) -> String {
self.slots.iter().flatten().collect()
}
fn is_complete(&self) -> bool {
self.slots.iter().all(|slot| slot.is_some())
}
fn insert(&mut self, ch: char) -> bool {
if ch.is_whitespace() || ch.is_control() {
return false;
}
let changed = self.slots[self.cursor] != Some(ch);
self.slots[self.cursor] = Some(ch);
if self.cursor + 1 < self.len() {
self.cursor += 1;
}
changed
}
fn backspace(&mut self) -> bool {
if self.slots[self.cursor].is_some() {
self.slots[self.cursor] = None;
true
} else if self.cursor > 0 {
self.cursor -= 1;
self.slots[self.cursor] = None;
true
} else {
false
}
}
fn left(&mut self) {
self.cursor = self.cursor.saturating_sub(1);
}
fn right(&mut self) {
if self.cursor + 1 < self.len() {
self.cursor += 1;
}
}
fn paste(&mut self, text: &str) -> bool {
let len = self.len();
let chars: Vec<char> = text
.chars()
.filter(|c| !c.is_whitespace() && !c.is_control())
.take(len)
.collect();
if chars.is_empty() {
return false;
}
let before = self.slots.clone();
self.slots.fill(None);
for (i, ch) in chars.iter().enumerate() {
self.slots[i] = Some(*ch);
}
self.cursor = chars.len().min(len - 1);
self.slots != before
}
fn set_value(&mut self, value: &str) {
let len = self.len();
self.slots.fill(None);
for (i, ch) in value.chars().take(len).enumerate() {
self.slots[i] = Some(ch);
}
self.cursor = self
.slots
.iter()
.position(|slot| slot.is_none())
.unwrap_or(len - 1);
}
fn resize(&mut self, length: usize) {
self.slots.resize(length.max(1), None);
self.cursor = self.cursor.min(self.slots.len() - 1);
}
}
pub struct PinInput {
model: PinModel,
focus: FocusHandle,
mask: bool,
size: Size,
disabled: bool,
}
impl EventEmitter<PinInputEvent> for PinInput {}
impl PinInput {
pub fn new(cx: &mut Context<Self>) -> Self {
PinInput {
model: PinModel::new(4),
focus: cx.focus_handle(),
mask: false,
size: Size::Sm,
disabled: false,
}
}
pub fn length(mut self, length: usize) -> Self {
self.model.resize(length);
self
}
pub fn mask(mut self, mask: bool) -> Self {
self.mask = mask;
self
}
pub fn size(mut self, size: Size) -> Self {
self.size = size;
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub fn value(mut self, value: &str) -> Self {
self.model.set_value(value);
self
}
pub fn focus_handle(&self) -> FocusHandle {
self.focus.clone()
}
pub fn text(&self) -> String {
self.model.value()
}
pub fn set_text(&mut self, value: &str, cx: &mut Context<Self>) {
self.model.set_value(value);
cx.notify();
}
pub fn bind(entity: &Entity<PinInput>, signal: &Signal<String>, cx: &mut App) {
let initial = signal.get(cx);
entity.update(cx, |this, cx| {
if this.text() != initial {
this.set_text(&initial, cx);
}
});
let sink = signal.clone();
cx.subscribe(entity, move |_pin, event: &PinInputEvent, cx| {
if let PinInputEvent::Change(text) = event {
sink.set_if_changed(cx, text.clone());
}
})
.detach();
let pin = entity.downgrade();
cx.observe(signal.entity(), move |observed, cx| {
let value = observed.read(cx).clone();
pin.update(cx, |this, cx| {
if this.text() != value {
this.set_text(&value, cx);
}
})
.ok();
})
.detach();
}
fn emit_edit(&mut self, cx: &mut Context<Self>) {
let value = self.model.value();
cx.emit(PinInputEvent::Change(value.clone()));
if self.model.is_complete() {
cx.emit(PinInputEvent::Complete(value));
}
cx.notify();
cx.stop_propagation();
}
fn on_key(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context<Self>) {
if self.disabled {
return;
}
let ks = &event.keystroke;
let m = &ks.modifiers;
match ks.key.as_str() {
"left" => {
self.model.left();
cx.notify();
cx.stop_propagation();
}
"right" => {
self.model.right();
cx.notify();
cx.stop_propagation();
}
"backspace" => {
if self.model.backspace() {
self.emit_edit(cx);
} else {
cx.stop_propagation();
}
}
"v" if m.platform => {
if let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) {
if self.model.paste(&text) {
self.emit_edit(cx);
}
}
cx.stop_propagation();
}
_ => {
if !m.platform && !m.control {
if let Some(typed) = ks.key_char.as_deref().filter(|t| !t.is_empty()) {
let cursor_before = self.model.cursor;
let mut changed = false;
for ch in typed.chars() {
changed |= self.model.insert(ch);
}
if changed {
self.emit_edit(cx);
} else if self.model.cursor != cursor_before {
cx.notify();
cx.stop_propagation();
}
}
}
}
}
}
}
impl Render for PinInput {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let t = theme(cx);
let (height, _pad_x, font) = control_metrics(self.size);
let radius = t.radius(t.default_radius);
let focused = self.focus.is_focused(window) && !self.disabled;
let border = t.border().hsla();
let active_border = t.primary().hsla();
let surface = t.surface().hsla();
let text_color = t.text().hsla();
let cursor = self.model.cursor;
let mask = self.mask;
let mut row = div()
.id("guise-pininput")
.track_focus(&self.focus)
.on_key_down(cx.listener(Self::on_key))
.flex()
.items_center()
.gap(px(8.0));
for (i, slot) in self.model.slots.iter().enumerate() {
let ch = slot.map(|c| if mask { '\u{2022}' } else { c });
let active = focused && i == cursor;
let mut cell = div()
.id(("guise-pin-box", i))
.flex()
.items_center()
.justify_center()
.w(px(height))
.h(px(height))
.rounded(px(radius))
.border_1()
.border_color(if active { active_border } else { border })
.bg(surface)
.text_size(px(font))
.text_color(text_color)
.on_mouse_down(
MouseButton::Left,
cx.listener(move |this, _ev, window, cx| {
window.focus(&this.focus);
this.model.cursor = i.min(this.model.len() - 1);
cx.notify();
}),
);
if let Some(ch) = ch {
cell = cell.child(SharedString::from(ch.to_string()));
}
row = row.child(cell);
}
if self.disabled {
row.opacity(0.6)
} else {
row
}
}
}
#[cfg(test)]
mod tests {
use super::PinModel;
#[test]
fn typing_advances_and_stops_at_the_last_box() {
let mut pin = PinModel::new(4);
assert!(pin.insert('1'));
assert!(pin.insert('2'));
assert_eq!(pin.value(), "12");
assert_eq!(pin.cursor, 2);
pin.insert('3');
pin.insert('4');
assert_eq!(pin.cursor, 3, "cursor parks on the last box");
assert!(pin.is_complete());
assert!(pin.insert('9'));
assert_eq!(pin.value(), "1239");
assert_eq!(pin.cursor, 3);
}
#[test]
fn retyping_the_same_char_reports_no_change() {
let mut pin = PinModel::new(4);
pin.paste("1234");
assert!(!pin.insert('4'));
assert_eq!(pin.value(), "1234");
pin.left();
assert!(!pin.insert('3'));
assert_eq!(pin.cursor, 3);
assert_eq!(pin.value(), "1234");
}
#[test]
fn identical_paste_reports_no_change() {
let mut pin = PinModel::new(4);
assert!(pin.paste("1234"));
assert!(!pin.paste("1234"));
assert!(pin.paste("129"), "different content still reports a change");
assert_eq!(pin.value(), "129");
}
#[test]
fn whitespace_and_control_chars_are_rejected() {
let mut pin = PinModel::new(4);
assert!(!pin.insert(' '));
assert!(!pin.insert('\t'));
assert!(!pin.insert('\u{7}'));
assert_eq!(pin.value(), "");
assert_eq!(pin.cursor, 0);
}
#[test]
fn backspace_clears_current_then_retreats() {
let mut pin = PinModel::new(4);
pin.insert('1');
pin.insert('2');
assert!(pin.backspace());
assert_eq!(pin.value(), "1");
assert_eq!(pin.cursor, 1);
assert!(pin.backspace());
assert_eq!(pin.value(), "");
assert_eq!(pin.cursor, 0);
assert!(!pin.backspace());
}
#[test]
fn backspace_clears_a_filled_box_in_place() {
let mut pin = PinModel::new(4);
pin.paste("1234");
assert_eq!(pin.cursor, 3);
assert!(pin.backspace());
assert_eq!(pin.value(), "123");
assert_eq!(pin.cursor, 3, "clears the filled box without retreating");
}
#[test]
fn arrows_clamp_to_the_boxes() {
let mut pin = PinModel::new(3);
pin.left();
assert_eq!(pin.cursor, 0);
pin.right();
pin.right();
pin.right();
assert_eq!(pin.cursor, 2);
pin.left();
assert_eq!(pin.cursor, 1);
}
#[test]
fn paste_fills_from_the_start_and_skips_whitespace() {
let mut pin = PinModel::new(4);
pin.insert('9');
assert!(pin.paste(" 12 34 56 "));
assert_eq!(pin.value(), "1234", "replaces old content, truncates");
assert!(pin.is_complete());
assert_eq!(pin.cursor, 3);
}
#[test]
fn short_paste_leaves_the_cursor_on_the_next_empty_box() {
let mut pin = PinModel::new(6);
assert!(pin.paste("12"));
assert_eq!(pin.value(), "12");
assert_eq!(pin.cursor, 2);
assert!(!pin.paste(" "), "whitespace-only paste is a no-op");
}
#[test]
fn set_value_places_the_cursor_at_the_first_empty_box() {
let mut pin = PinModel::new(4);
pin.set_value("12");
assert_eq!(pin.cursor, 2);
pin.set_value("123456");
assert_eq!(pin.value(), "1234", "extra characters are dropped");
assert_eq!(pin.cursor, 3);
pin.set_value("");
assert_eq!(pin.value(), "");
assert_eq!(pin.cursor, 0);
}
#[test]
fn resize_preserves_slots_and_clamps_the_cursor() {
let mut pin = PinModel::new(6);
pin.paste("123456");
pin.resize(3);
assert_eq!(pin.value(), "123");
assert_eq!(pin.cursor, 2);
pin.resize(5);
assert_eq!(pin.value(), "123");
assert_eq!(pin.len(), 5);
pin.resize(0);
assert_eq!(pin.len(), 1);
assert_eq!(pin.cursor, 0);
}
}