use crate::Input;
use gpui::{
App, Context, Entity, FocusHandle, Focusable, Hsla, IntoElement, MouseButton, Pixels, Render,
SharedString, Task, Window, div, prelude::*, px,
};
use liora_core::Config;
use std::{ops::Range, time::Duration};
type OtpInputChangeCallback = Box<dyn Fn(SharedString, &mut Context<OtpInput>) + 'static>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OtpInputStatus {
Default,
Success,
Warning,
Error,
}
pub struct OtpInput {
input: Entity<Input>,
length: usize,
gap: Pixels,
cell_size: Pixels,
masked: bool,
disabled: bool,
status: OtpInputStatus,
on_change: Option<OtpInputChangeCallback>,
cursor_visible: bool,
blink_task: Option<Task<()>>,
}
impl OtpInput {
pub fn new(value: impl Into<SharedString>, cx: &mut Context<Self>) -> Self {
let length = 6;
let value = normalize_otp_value(value.into().as_ref(), length);
let owner = cx.entity().downgrade();
Self {
input: cx.new(move |cx| {
Input::new(value, cx)
.width(px(1.0))
.on_change(move |value, cx| {
let owner = owner.clone();
let value = SharedString::from(value.to_string());
cx.defer(move |cx| {
let _ = owner.update(cx, |otp, cx| otp.handle_input_change(value, cx));
});
})
}),
length,
gap: px(8.0),
cell_size: px(40.0),
masked: false,
disabled: false,
status: OtpInputStatus::Default,
on_change: None,
cursor_visible: true,
blink_task: None,
}
}
pub fn entity(value: impl Into<SharedString>, cx: &mut App) -> Entity<Self> {
cx.new(|cx| Self::new(value, cx))
}
pub fn length(mut self, length: usize, cx: &mut Context<Self>) -> Self {
let length = length.clamp(1, 12);
self.length = length;
self.sync_input_constraints(cx);
self
}
pub fn gap(mut self, gap: impl Into<Pixels>) -> Self {
self.gap = gap.into().max(px(0.0));
self
}
pub fn cell_size(mut self, size: impl Into<Pixels>) -> Self {
self.cell_size = size.into().max(px(28.0));
self
}
pub fn masked(mut self, masked: bool) -> Self {
self.masked = masked;
self
}
pub fn disabled(mut self, disabled: bool, cx: &mut Context<Self>) -> Self {
self.disabled = disabled;
self.sync_input_constraints(cx);
self
}
pub fn success(mut self) -> Self {
self.status = OtpInputStatus::Success;
self
}
pub fn warning(mut self) -> Self {
self.status = OtpInputStatus::Warning;
self
}
pub fn error(mut self) -> Self {
self.status = OtpInputStatus::Error;
self
}
pub fn on_change(mut self, cb: impl Fn(SharedString, &mut Context<Self>) + 'static) -> Self {
self.on_change = Some(Box::new(cb));
self
}
pub fn set_on_change(
&mut self,
cb: impl Fn(SharedString, &mut Context<Self>) + 'static,
cx: &mut Context<Self>,
) {
self.on_change = Some(Box::new(cb));
cx.notify();
}
pub fn value(&self, cx: &App) -> SharedString {
self.input.read(cx).value()
}
pub fn cells(&self, cx: &App) -> Vec<Option<char>> {
otp_cells(self.value(cx).as_ref(), self.length)
}
pub fn selected_range(&self, cx: &App) -> Range<usize> {
self.input.read(cx).selected_range()
}
pub fn set_active_index(&mut self, index: usize, cx: &mut Context<Self>) {
let range = self.byte_range_for_index(index, cx);
cx.update_entity(&self.input, |input, cx| {
input.set_selection(range, cx);
});
self.reset_blink(cx);
cx.notify();
}
fn byte_range_for_index(&self, index: usize, cx: &App) -> Range<usize> {
let value = self.input.read(cx).value();
let char_index = index.min(self.length);
let start = value
.char_indices()
.nth(char_index)
.map(|(offset, _)| offset)
.unwrap_or(value.len());
let end = value[start..]
.chars()
.next()
.map(|ch| start + ch.len_utf8())
.unwrap_or(start);
start..end
}
fn sync_input_constraints(&mut self, cx: &mut Context<Self>) {
let length = self.length;
let disabled = self.disabled;
cx.update_entity(&self.input, |input, cx| {
let normalized = normalize_otp_value(input.value().as_ref(), length);
input.set_value(normalized, cx);
input.set_disabled(disabled, cx);
});
}
fn handle_input_change(&mut self, raw: SharedString, cx: &mut Context<Self>) {
let normalized = normalize_otp_value(raw.as_ref(), self.length);
if normalized != raw {
cx.update_entity(&self.input, |input, cx| {
input.set_value(normalized.clone(), cx);
});
}
if let Some(on_change) = self.on_change.take() {
on_change(normalized, cx);
self.on_change = Some(on_change);
}
self.reset_blink(cx);
cx.notify();
}
fn start_blink(&mut self, cx: &mut Context<Self>) {
self.cursor_visible = true;
let executor = cx.background_executor().clone();
self.blink_task = Some(cx.spawn(async move |this, cx| {
loop {
executor.timer(Duration::from_millis(500)).await;
let result = this.update(cx, |otp, cx| {
otp.cursor_visible = !otp.cursor_visible;
cx.notify();
});
if result.is_err() {
break;
}
}
}));
}
fn reset_blink(&mut self, cx: &mut Context<Self>) {
self.cursor_visible = true;
if self.blink_task.is_none() {
self.start_blink(cx);
} else {
cx.notify();
}
}
fn status_color(&self, theme: &liora_theme::Theme) -> Hsla {
match self.status {
OtpInputStatus::Default => theme.neutral.border,
OtpInputStatus::Success => theme.success.base,
OtpInputStatus::Warning => theme.warning.base,
OtpInputStatus::Error => theme.danger.base,
}
}
}
impl Focusable for OtpInput {
fn focus_handle(&self, cx: &App) -> FocusHandle {
self.input.read(cx).focus_handle(cx)
}
}
impl Render for OtpInput {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = cx.global::<Config>().theme.clone();
let focused = self.focus_handle(cx).is_focused(window);
if focused && self.blink_task.is_none() {
self.start_blink(cx);
} else if !focused && self.blink_task.is_some() {
self.blink_task = None;
}
let value = self.input.read(cx).value();
let selected_range = self.input.read(cx).selected_range();
let active_offset = if selected_range.is_empty() {
selected_range.end
} else {
selected_range.start
};
let active_index = byte_offset_to_cell_index(value.as_ref(), active_offset, self.length);
let cells = otp_cells(value.as_ref(), self.length);
let status_color = self.status_color(&theme);
let focus_bg = theme.primary.light_9;
let focus_caret_color = theme.primary.base.opacity(0.85);
let disabled = self.disabled;
let masked = self.masked;
let cell_size = self.cell_size;
let cursor_visible = self.cursor_visible;
cx.update_entity(&self.input, |input, cx| {
input.set_disabled(disabled, cx);
});
let input = self.input.clone();
let hidden_input = div()
.absolute()
.w(px(1.0))
.h(px(1.0))
.opacity(0.0)
.child(input);
div()
.relative()
.flex()
.items_center()
.gap(self.gap)
.child(hidden_input)
.children(cells.into_iter().enumerate().map(move |(index, value)| {
let active = focused && active_index == index;
let border_color = if disabled {
theme.neutral.border.opacity(0.5)
} else if active || self.status != OtpInputStatus::Default {
status_color
} else {
theme.neutral.border
};
let text = value.map(|ch| if masked { '•' } else { ch });
let input = self.input.clone();
let host = cx.entity().clone();
let display_text = text.map(|ch| ch.to_string());
let cell = div()
.w(cell_size)
.h(cell_size)
.rounded_md()
.border_1()
.border_color(border_color)
.bg(if disabled {
theme.neutral.hover
} else if active {
focus_bg
} else {
theme.neutral.card
})
.flex()
.items_center()
.justify_center()
.text_lg()
.font_weight(gpui::FontWeight::SEMIBOLD)
.text_color(if disabled {
theme.neutral.text_disabled
} else {
theme.neutral.text_1
})
.when(!disabled, |s| {
s.cursor_text()
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
window.focus(&input.read(cx).focus_handle(cx), cx);
host.update(cx, |host, cx| {
host.set_active_index(index, cx);
});
cx.stop_propagation();
})
})
.when(active, |s| s.shadow_sm());
if let Some(text) = display_text {
cell.child(text)
} else if active && cursor_visible {
cell.child(
div()
.w(px(2.0))
.h(cell_size * 0.48)
.rounded_full()
.bg(focus_caret_color),
)
} else {
cell.child("·")
}
}))
}
}
pub fn otp_cells(value: &str, length: usize) -> Vec<Option<char>> {
let mut chars = value.chars().filter(|ch| !ch.is_whitespace());
(0..length.clamp(1, 12)).map(|_| chars.next()).collect()
}
pub fn normalize_otp_value(value: &str, length: usize) -> SharedString {
SharedString::from(
value
.chars()
.filter(|ch| !ch.is_whitespace())
.take(length.clamp(1, 12))
.collect::<String>(),
)
}
pub fn otp_candidate_is_valid(value: &str, length: usize) -> bool {
value.chars().filter(|ch| !ch.is_whitespace()).count() <= length.clamp(1, 12)
}
fn byte_offset_to_cell_index(value: &str, offset: usize, length: usize) -> usize {
let offset = offset.min(value.len());
let mut count = 0;
for (byte_offset, _) in value.char_indices() {
if byte_offset >= offset {
break;
}
count += 1;
}
count.min(length.saturating_sub(1))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn otp_cells_trim_whitespace_and_cap_length() {
assert_eq!(
otp_cells("1 2 3", 4),
vec![Some('1'), Some('2'), Some('3'), None]
);
assert_eq!(otp_cells("1234567890123", 20).len(), 12);
}
#[test]
fn otp_candidate_validation_accepts_paste_up_to_length() {
assert!(otp_candidate_is_valid("12 34", 4));
assert!(!otp_candidate_is_valid("12345", 4));
}
#[test]
fn otp_normalization_filters_spaces_and_caps_length() {
assert_eq!(normalize_otp_value("1 2 3 4 5", 4).as_ref(), "1234");
}
#[test]
fn byte_offsets_map_to_cell_indices() {
assert_eq!(byte_offset_to_cell_index("1234", 0, 4), 0);
assert_eq!(byte_offset_to_cell_index("1234", 2, 4), 2);
assert_eq!(byte_offset_to_cell_index("1234", 4, 4), 3);
}
#[test]
fn otp_source_uses_real_input_for_editing() {
let source = include_str!("otp_input.rs");
assert!(source.contains("input: Entity<Input>"));
assert!(source.contains("cursor_visible: bool"));
assert!(source.contains("blink_task: Option<Task<()>>"));
assert!(source.contains("window.focus(&input.read(cx).focus_handle(cx))"));
assert!(source.contains("input.set_selection(range, cx)"));
let input_change_pipeline = [
"Input::new(value, cx)",
".width(px(1.0))",
".on_change",
"cx.defer(move |cx|",
"otp.handle_input_change(value, cx)",
]
.join(".*");
assert!(regex_like_in_order(source, &input_change_pipeline));
assert!(source.contains("fn start_blink(&mut self"));
assert!(source.contains("executor.timer(Duration::from_millis(500))"));
assert!(source.contains("self.reset_blink(cx)"));
assert!(source.contains("focused && self.blink_task.is_none()"));
assert!(source.contains("active && cursor_visible"));
assert!(source.contains("cx.notify()"));
let stale_handler_signature = ["fn handle_input_change(&mut self", ", cx"].join("");
assert!(!source.contains(&stale_handler_signature));
let stale_nested_read = ["let raw = self.input", ".read(cx)", ".value()"].join("");
assert!(!source.contains(&stale_nested_read));
let old_block_caret = char::from_u32(0x258c).unwrap();
assert!(!source.contains(old_block_caret));
let click_change_bypass = ["host", ".emit_change(", "window"].join("");
assert!(!source.contains(&click_change_bypass));
}
#[test]
fn otp_focus_visuals_use_theme_tokens() {
let source = include_str!("otp_input.rs");
assert!(source.contains("let focus_bg = theme.primary.light_9"));
assert!(source.contains("let focus_caret_color = theme.primary.base.opacity(0.85)"));
assert!(source.contains(".bg(focus_bg)"));
assert!(source.contains(".bg(focus_caret_color)"));
let stale_focus_bg = ["theme.primary.base", ".opacity(0.08)"].join("");
assert!(!source.contains(&stale_focus_bg));
let hardcoded_rgb = ["rgb", "(0x"].join("");
assert!(!source.contains(&hardcoded_rgb));
let black_token = ["gpui", "::black()"].join("");
assert!(!source.contains(&black_token));
}
fn regex_like_in_order(source: &str, pattern: &str) -> bool {
let mut cursor = 0;
for part in pattern.split(".*") {
let Some(offset) = source[cursor..].find(part) else {
return false;
};
cursor += offset + part.len();
}
true
}
}