use crate::ComponentTheme;
use crate::theme::ThemeExt;
use gpui::prelude::*;
use gpui::*;
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
thread_local! {
static NUMBER_INPUT_FOCUS_HANDLES: RefCell<HashMap<ElementId, FocusHandle>> = RefCell::new(HashMap::new());
}
thread_local! {
static NUMBER_INPUT_EDIT_STATES: RefCell<HashMap<ElementId, Rc<RefCell<NumberEditState>>>> = RefCell::new(HashMap::new());
}
pub fn cleanup_number_input_state(id: &ElementId) {
NUMBER_INPUT_FOCUS_HANDLES.with(|handles| {
handles.borrow_mut().remove(id);
});
NUMBER_INPUT_EDIT_STATES.with(|states| {
states.borrow_mut().remove(id);
});
}
#[derive(Clone, Default)]
struct NumberEditState {
editing: bool,
text: String,
cursor: usize,
text_selected: bool,
}
impl NumberEditState {
fn new(value: &str) -> Self {
Self {
editing: true,
text: value.to_string(),
cursor: value.chars().count(),
text_selected: true,
}
}
fn select_all(&mut self) {
self.text_selected = true;
self.cursor = self.text.chars().count();
}
fn do_backspace(&mut self) {
if self.text_selected {
self.text.clear();
self.cursor = 0;
self.text_selected = false;
} else if self.cursor > 0 {
let byte_pos = self
.text
.char_indices()
.nth(self.cursor - 1)
.map(|(i, _)| i)
.unwrap_or(0);
let next_byte = self
.text
.char_indices()
.nth(self.cursor)
.map(|(i, _)| i)
.unwrap_or(self.text.len());
self.text.replace_range(byte_pos..next_byte, "");
self.cursor -= 1;
}
}
fn do_delete(&mut self) {
if self.text_selected {
self.text.clear();
self.cursor = 0;
self.text_selected = false;
} else {
let len = self.text.chars().count();
if self.cursor < len {
let byte_pos = self
.text
.char_indices()
.nth(self.cursor)
.map(|(i, _)| i)
.unwrap_or(self.text.len());
let next_byte = self
.text
.char_indices()
.nth(self.cursor + 1)
.map(|(i, _)| i)
.unwrap_or(self.text.len());
self.text.replace_range(byte_pos..next_byte, "");
}
}
}
fn insert_char(&mut self, ch: char) {
if !ch.is_ascii_digit() && ch != '.' && ch != '-' && ch != '+' {
return;
}
if self.text_selected {
self.text.clear();
self.cursor = 0;
self.text_selected = false;
}
let byte_pos = self
.text
.char_indices()
.nth(self.cursor)
.map(|(i, _)| i)
.unwrap_or(self.text.len());
self.text.insert(byte_pos, ch);
self.cursor += 1;
}
fn move_left(&mut self) {
if self.cursor > 0 {
self.cursor -= 1;
}
self.text_selected = false;
}
fn move_right(&mut self) {
let len = self.text.chars().count();
if self.cursor < len {
self.cursor += 1;
}
self.text_selected = false;
}
fn move_to_start(&mut self) {
self.cursor = 0;
self.text_selected = false;
}
fn move_to_end(&mut self) {
self.cursor = self.text.chars().count();
self.text_selected = false;
}
}
#[derive(Debug, Clone, ComponentTheme)]
pub struct NumberInputTheme {
#[theme(default = 0x1e1e1eff, from = background)]
pub background: Rgba,
#[theme(default = 0xffffffff, from = text_primary)]
pub text: Rgba,
#[theme(default = 0x2a2a2aff, from = surface)]
pub button_bg: Rgba,
#[theme(default = 0x3a3a3aff, from = surface_hover)]
pub button_hover: Rgba,
#[theme(default = 0x007accff, from = accent)]
pub button_active: Rgba,
#[theme(default = 0xccccccff, from = text_secondary)]
pub button_text: Rgba,
#[theme(default = 0x3a3a3aff, from = border)]
pub border: Rgba,
#[theme(default = 0x007accff, from = accent)]
pub border_focus: Rgba,
#[theme(default = 0xaaaaaaff, from = text_secondary)]
pub label: Rgba,
#[theme(default_f32 = 0.5, from_expr = "0.5")]
pub disabled_opacity: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum NumberInputSize {
Sm,
#[default]
Md,
Lg,
}
impl From<crate::ComponentSize> for NumberInputSize {
fn from(size: crate::ComponentSize) -> Self {
match size {
crate::ComponentSize::Xs | crate::ComponentSize::Sm => Self::Sm,
crate::ComponentSize::Md => Self::Md,
crate::ComponentSize::Lg | crate::ComponentSize::Xl => Self::Lg,
}
}
}
impl NumberInputSize {
fn height(&self) -> f32 {
match self {
Self::Sm => 24.0,
Self::Md => 32.0,
Self::Lg => 40.0,
}
}
fn button_width(&self) -> f32 {
match self {
Self::Sm => 20.0,
Self::Md => 28.0,
Self::Lg => 36.0,
}
}
fn font_size(&self) -> f32 {
match self {
Self::Sm => 11.0,
Self::Md => 13.0,
Self::Lg => 15.0,
}
}
fn padding(&self) -> f32 {
match self {
Self::Sm => 4.0,
Self::Md => 8.0,
Self::Lg => 12.0,
}
}
}
#[derive(IntoElement)]
pub struct NumberInput {
id: ElementId,
value: f64,
min: f64,
max: f64,
step: f64,
decimals: usize,
unit: Option<SharedString>,
label: Option<SharedString>,
size: NumberInputSize,
width: Option<f32>,
disabled: bool,
theme: Option<NumberInputTheme>,
on_change: Option<Box<dyn Fn(f64, &mut Window, &mut App) + 'static>>,
}
impl NumberInput {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
value: 0.0,
min: f64::NEG_INFINITY,
max: f64::INFINITY,
step: 1.0,
decimals: 0,
unit: None,
label: None,
size: NumberInputSize::default(),
width: None,
disabled: false,
theme: None,
on_change: None,
}
}
pub fn value(mut self, value: f64) -> Self {
let value = if value.is_nan() {
if self.min.is_finite() {
self.min
} else if self.max.is_finite() {
self.max
} else {
0.0
}
} else {
value
};
self.value = value.clamp(self.min, self.max);
self
}
pub fn min(mut self, min: f64) -> Self {
assert!(!min.is_nan(), "NumberInput min cannot be NaN");
self.min = min;
self
}
pub fn max(mut self, max: f64) -> Self {
assert!(!max.is_nan(), "NumberInput max cannot be NaN");
self.max = max;
self
}
pub fn range(mut self, min: f64, max: f64) -> Self {
assert!(!min.is_nan(), "NumberInput min cannot be NaN");
assert!(!max.is_nan(), "NumberInput max cannot be NaN");
assert!(
min <= max,
"NumberInput range invalid: min ({}) > max ({})",
min,
max
);
self.min = min;
self.max = max;
self
}
pub fn step(mut self, step: f64) -> Self {
assert!(
step > 0.0 && !step.is_nan(),
"NumberInput step must be positive, got: {}",
step
);
self.step = step;
self
}
pub fn decimals(mut self, decimals: usize) -> Self {
self.decimals = decimals;
self
}
pub fn unit(mut self, unit: impl Into<SharedString>) -> Self {
self.unit = Some(unit.into());
self
}
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
self.label = Some(label.into());
self
}
pub fn size(mut self, size: NumberInputSize) -> Self {
self.size = size;
self
}
pub fn width(mut self, width: f32) -> Self {
self.width = Some(width);
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub fn theme(mut self, theme: NumberInputTheme) -> Self {
self.theme = Some(theme);
self
}
pub fn on_change(mut self, handler: impl Fn(f64, &mut Window, &mut App) + 'static) -> Self {
self.on_change = Some(Box::new(handler));
self
}
fn format_value_str(value: f64, decimals: usize, unit: Option<&SharedString>) -> String {
let formatted = format!("{:.prec$}", value, prec = decimals);
if let Some(unit) = unit {
format!("{} {}", formatted, unit)
} else {
formatted
}
}
fn parse_value_str(text: &str, unit: Option<&SharedString>, min: f64, max: f64) -> Option<f64> {
let text = if let Some(unit) = unit {
text.trim().trim_end_matches(unit.as_ref()).trim()
} else {
text.trim()
};
text.parse::<f64>().ok().map(|v| v.clamp(min, max))
}
}
impl RenderOnce for NumberInput {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let global_theme = cx.theme();
let default_theme = NumberInputTheme::from(&global_theme);
let theme = self.theme.clone().unwrap_or(default_theme);
let height = self.size.height();
let button_width = self.size.button_width();
let padding = self.size.padding();
let disabled = self.disabled;
let current_value = self.value;
let min = self.min;
let max = self.max;
let step = self.step;
let decimals = self.decimals;
let unit_clone = self.unit.clone();
let focus_handle = NUMBER_INPUT_FOCUS_HANDLES.with(|handles| {
let mut handles = handles.borrow_mut();
handles
.entry(self.id.clone())
.or_insert_with(|| cx.focus_handle())
.clone()
});
let edit_state = NUMBER_INPUT_EDIT_STATES.with(|states| {
let mut states = states.borrow_mut();
states
.entry(self.id.clone())
.or_insert_with(|| Rc::new(RefCell::new(NumberEditState::default())))
.clone()
});
let is_focused = focus_handle.is_focused(_window);
{
let mut state = edit_state.borrow_mut();
if state.editing && !is_focused {
if let Some(value) =
Self::parse_value_str(&state.text, self.unit.as_ref(), min, max)
&& let Some(ref handler) = self.on_change
{
handler(value, _window, cx);
}
state.editing = false;
state.text.clear();
state.text_selected = false;
}
}
let state = edit_state.borrow();
let editing = state.editing && is_focused; let text_selected = state.text_selected;
let edit_text = if editing {
state.text.clone()
} else {
Self::format_value_str(current_value, decimals, unit_clone.as_ref())
};
let cursor_pos = state.cursor;
drop(state);
let parent_id = format!("{:?}", self.id);
let dec_id = ElementId::Name(SharedString::from(format!("{}-dec", parent_id)));
let value_id = ElementId::Name(SharedString::from(format!("{}-value", parent_id)));
let inc_id = ElementId::Name(SharedString::from(format!("{}-inc", parent_id)));
let on_change_rc = self.on_change.map(Rc::new);
let mut container = div().flex().flex_col().gap_1();
if let Some(label) = self.label {
container = container.child(
div()
.text_sm()
.text_color(theme.label)
.font_weight(FontWeight::MEDIUM)
.child(label),
);
}
let mut input_row = div()
.id(self.id.clone())
.flex()
.items_center()
.h(px(height))
.rounded_md()
.border_1()
.border_color(if editing {
theme.border_focus
} else {
theme.border
})
.bg(theme.background)
.overflow_hidden();
if let Some(width) = self.width {
input_row = input_row.w(px(width));
}
if disabled {
input_row = input_row.opacity(theme.disabled_opacity);
}
let button_bg = theme.button_bg;
let button_hover = theme.button_hover;
let button_active = theme.button_active;
let button_text = theme.button_text;
let text_color = theme.text;
let mut dec_button = div()
.id(dec_id)
.flex()
.items_center()
.justify_center()
.w(px(button_width))
.h_full()
.bg(button_bg)
.text_color(button_text)
.font_weight(FontWeight::BOLD)
.child("−");
if !disabled {
dec_button = dec_button
.cursor_pointer()
.hover(move |s| s.bg(button_hover))
.active(move |s| s.bg(button_active));
if let Some(ref handler_rc) = on_change_rc {
let handler = handler_rc.clone();
dec_button = dec_button.on_mouse_down(MouseButton::Left, move |_, window, cx| {
let new_value = (current_value - step).clamp(min, max);
handler(new_value, window, cx);
});
}
} else {
dec_button = dec_button.cursor_not_allowed();
}
input_row = input_row.child(dec_button);
let (value_bg, value_text_color) = if editing && text_selected {
(Some(theme.button_active), rgba(0xffffffff))
} else {
(None, text_color)
};
let display_element: AnyElement = if editing && !text_selected {
let chars: Vec<char> = edit_text.chars().collect();
let before: String = chars[..cursor_pos].iter().collect();
let after: String = chars[cursor_pos..].iter().collect();
div()
.flex()
.items_center()
.child(before)
.child(
div()
.w(px(1.0))
.h(px(self.size.font_size() + 2.0))
.bg(text_color),
)
.child(after)
.into_any_element()
} else {
div().child(edit_text.clone()).into_any_element()
};
let mut value_field = div()
.id(value_id)
.flex_1()
.flex()
.items_center()
.justify_center()
.h_full()
.px(px(padding))
.text_color(value_text_color)
.track_focus(&focus_handle)
.focusable()
.child(display_element);
if let Some(bg) = value_bg {
value_field = value_field.bg(bg);
}
value_field = value_field.text_size(px(self.size.font_size()));
if !disabled {
let edit_state_for_click = edit_state.clone();
let focus_handle_for_click = focus_handle.clone();
let formatted_value =
Self::format_value_str(current_value, decimals, unit_clone.as_ref());
value_field = value_field.cursor_text().on_mouse_down(
MouseButton::Left,
move |event, window, cx| {
window.focus(&focus_handle_for_click);
let mut state = edit_state_for_click.borrow_mut();
if event.click_count == 2 {
if state.editing {
state.select_all();
} else {
*state = NumberEditState::new(&formatted_value);
}
drop(state);
window.refresh();
return;
}
if !state.editing {
*state = NumberEditState::new(&formatted_value);
} else {
state.text_selected = false;
}
},
);
let edit_state_for_key = edit_state.clone();
let on_change_key = on_change_rc.clone();
let unit_for_key = unit_clone.clone();
value_field = value_field.on_key_down(move |event, window, cx| {
let mut state = edit_state_for_key.borrow_mut();
if state.editing {
match event.keystroke.key.as_str() {
"enter" => {
let parsed =
Self::parse_value_str(&state.text, unit_for_key.as_ref(), min, max);
state.editing = false;
state.text.clear();
state.text_selected = false;
drop(state);
if let Some(ref handler) = on_change_key
&& let Some(value) = parsed
{
handler(value, window, cx);
}
window.refresh();
}
"escape" => {
state.editing = false;
state.text.clear();
state.text_selected = false;
drop(state);
window.refresh();
}
"backspace" => {
state.do_backspace();
drop(state);
window.refresh();
}
"delete" => {
state.do_delete();
drop(state);
window.refresh();
}
"left" => {
state.move_left();
drop(state);
window.refresh();
}
"right" => {
state.move_right();
drop(state);
window.refresh();
}
"home" => {
state.move_to_start();
drop(state);
window.refresh();
}
"end" => {
state.move_to_end();
drop(state);
window.refresh();
}
_ => {
if let Some(text) = event.keystroke.key_char.as_ref()
&& let Some(ch) = text.chars().next()
{
state.insert_char(ch);
drop(state);
window.refresh();
}
}
}
} else {
let new_value = match event.keystroke.key.as_str() {
"up" | "right" => Some((current_value + step).clamp(min, max)),
"down" | "left" => Some((current_value - step).clamp(min, max)),
_ => None,
};
drop(state);
if let Some(v) = new_value
&& let Some(ref handler) = on_change_key
{
handler(v, window, cx);
}
}
});
}
input_row = input_row.child(value_field);
let mut inc_button = div()
.id(inc_id)
.flex()
.items_center()
.justify_center()
.w(px(button_width))
.h_full()
.bg(button_bg)
.text_color(button_text)
.font_weight(FontWeight::BOLD)
.child("+");
if !disabled {
inc_button = inc_button
.cursor_pointer()
.hover(move |s| s.bg(button_hover))
.active(move |s| s.bg(button_active));
if let Some(ref handler_rc) = on_change_rc {
let handler = handler_rc.clone();
inc_button = inc_button.on_mouse_down(MouseButton::Left, move |_, window, cx| {
let new_value = (current_value + step).clamp(min, max);
handler(new_value, window, cx);
});
}
} else {
inc_button = inc_button.cursor_not_allowed();
}
input_row = input_row.child(inc_button);
container.child(input_row)
}
}