use std::panic::Location;
use crate::event::{KeyModifiers, UiEvent, UiEventKind, UiKey};
use crate::selection::Selection;
use crate::tokens;
use crate::tree::*;
use crate::widgets::button::{button, icon_button};
use crate::widgets::text_input::{
TextInputOpts, apply_event_with as text_input_apply, text_input_with,
};
#[derive(Clone, Copy, Debug)]
pub struct NumericInputOpts<'a> {
pub min: Option<f64>,
pub max: Option<f64>,
pub step: f64,
pub decimals: Option<u8>,
pub placeholder: Option<&'a str>,
pub stacked: bool,
}
impl Default for NumericInputOpts<'_> {
fn default() -> Self {
Self {
min: None,
max: None,
step: 1.0,
decimals: None,
placeholder: None,
stacked: false,
}
}
}
impl<'a> NumericInputOpts<'a> {
pub fn min(mut self, v: f64) -> Self {
self.min = Some(v);
self
}
pub fn max(mut self, v: f64) -> Self {
self.max = Some(v);
self
}
pub fn step(mut self, v: f64) -> Self {
self.step = v;
self
}
pub fn decimals(mut self, v: u8) -> Self {
self.decimals = Some(v);
self
}
pub fn placeholder(mut self, p: &'a str) -> Self {
self.placeholder = Some(p);
self
}
pub fn stacked(mut self) -> Self {
self.stacked = true;
self
}
}
#[track_caller]
pub fn numeric_input(
value: &str,
selection: &Selection,
key: &str,
opts: NumericInputOpts<'_>,
) -> El {
let caller = Location::caller();
let mut text_opts = TextInputOpts::default();
if let Some(p) = opts.placeholder {
text_opts = text_opts.placeholder(p);
}
let field_key = format!("{key}:field");
let field = text_input_with(value, selection, &field_key, text_opts).width(Size::Fill(1.0));
let children: Vec<El> = if opts.stacked {
vec![field, stacked_chevron_column(key, caller)]
} else {
let dec = button("−")
.at_loc(caller)
.key(format!("{key}:dec"))
.ghost()
.width(Size::Fixed(tokens::CONTROL_HEIGHT))
.height(Size::Fixed(tokens::CONTROL_HEIGHT));
let inc = button("+")
.at_loc(caller)
.key(format!("{key}:inc"))
.ghost()
.width(Size::Fixed(tokens::CONTROL_HEIGHT))
.height(Size::Fixed(tokens::CONTROL_HEIGHT));
vec![dec, field, inc]
};
row(children)
.at_loc(caller)
.key(key.to_string())
.gap(tokens::RING_WIDTH)
.align(Align::Center)
.default_width(Size::Fixed(DEFAULT_WIDTH))
.default_height(Size::Fixed(tokens::CONTROL_HEIGHT))
}
const STACKED_CHEVRON_WIDTH: f32 = 22.0;
pub const DEFAULT_WIDTH: f32 = 144.0;
fn stacked_chevron_column(key: &str, caller: &'static Location<'static>) -> El {
let half_h = (tokens::CONTROL_HEIGHT * 0.5).floor();
let inc = icon_button("chevron-up")
.at_loc(caller)
.key(format!("{key}:inc"))
.ghost()
.icon_size(tokens::ICON_XS)
.focus_ring_inside()
.width(Size::Fixed(STACKED_CHEVRON_WIDTH))
.height(Size::Fixed(half_h));
let dec = icon_button("chevron-down")
.at_loc(caller)
.key(format!("{key}:dec"))
.ghost()
.icon_size(tokens::ICON_XS)
.focus_ring_inside()
.width(Size::Fixed(STACKED_CHEVRON_WIDTH))
.height(Size::Fixed(half_h));
column([inc, dec])
.at_loc(caller)
.gap(0.0)
.width(Size::Fixed(STACKED_CHEVRON_WIDTH))
.height(Size::Fixed(tokens::CONTROL_HEIGHT))
}
pub fn apply_event(
value: &mut String,
selection: &mut Selection,
key: &str,
opts: &NumericInputOpts<'_>,
event: &UiEvent,
) -> bool {
if matches!(event.kind, UiEventKind::Click | UiEventKind::Activate) {
let inc_key = format!("{key}:inc");
let dec_key = format!("{key}:dec");
if event.route() == Some(inc_key.as_str()) {
step_value(value, opts, 1, event.modifiers);
return true;
}
if event.route() == Some(dec_key.as_str()) {
step_value(value, opts, -1, event.modifiers);
return true;
}
}
let field_key = format!("{key}:field");
if event.kind == UiEventKind::KeyDown
&& event.is_route(&field_key)
&& let Some(kp) = event.key_press.as_ref()
{
let dir = match kp.key {
UiKey::ArrowUp => Some(1),
UiKey::ArrowDown => Some(-1),
_ => None,
};
if let Some(d) = dir {
step_value(value, opts, d, kp.modifiers);
return true;
}
}
if event.target_key() != Some(field_key.as_str()) {
return false;
}
let text_opts = match opts.placeholder {
Some(p) => TextInputOpts::default().placeholder(p),
None => TextInputOpts::default(),
};
let prev_value = value.clone();
let prev_selection = selection.clone();
let changed = text_input_apply(value, selection, &field_key, event, &text_opts);
if changed && !is_acceptable_numeric_progress(value) {
*value = prev_value;
*selection = prev_selection;
return false;
}
changed
}
fn is_acceptable_numeric_progress(s: &str) -> bool {
s.is_empty()
|| s.chars()
.all(|c| matches!(c, '0'..='9' | '.' | 'e' | 'E' | '+' | '-'))
}
fn step_value(value: &mut String, opts: &NumericInputOpts<'_>, dir: i32, mods: KeyModifiers) {
let parsed = value
.parse::<f64>()
.ok()
.unwrap_or_else(|| opts.min.unwrap_or(0.0));
let stepped = parsed + (dir as f64) * opts.step * step_scale(mods);
let clamped = clamp_opt(stepped, opts.min, opts.max);
*value = format_numeric(clamped, opts.decimals);
}
fn step_scale(mods: KeyModifiers) -> f64 {
if mods.shift {
10.0
} else if mods.alt {
0.1
} else {
1.0
}
}
fn clamp_opt(n: f64, min: Option<f64>, max: Option<f64>) -> f64 {
let n = if let Some(hi) = max { n.min(hi) } else { n };
if let Some(lo) = min { n.max(lo) } else { n }
}
fn format_numeric(n: f64, decimals: Option<u8>) -> String {
match decimals {
Some(d) => format!("{:.*}", d as usize, n),
None if n.fract() == 0.0 && n.is_finite() && n.abs() < 1e18 => {
format!("{}", n as i64)
}
None => format!("{n}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::{KeyModifiers, UiTarget};
use crate::layout::layout;
use crate::state::UiState;
use crate::tree::Rect;
fn click(key: &str) -> UiEvent {
UiEvent::synthetic_click(key)
}
#[test]
fn default_is_fixed_width_with_inner_field_filling() {
let value = String::from("42");
let sel = Selection::default();
let widget = numeric_input(&value, &sel, "n", NumericInputOpts::default());
let mut tree = crate::widgets::form::form([crate::widgets::form::form_item([
crate::widgets::form::form_control(widget),
])]);
let mut state = UiState::new();
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 320.0, 200.0));
let row_rect = state.rect_of_key(&tree, "n").expect("row rect");
let field_rect = state.rect_of_key(&tree, "n:field").expect("field rect");
assert_eq!(
row_rect.w, DEFAULT_WIDTH,
"row should keep its fixed default width inside a wide form parent"
);
let expected_field_w =
DEFAULT_WIDTH - 2.0 * tokens::CONTROL_HEIGHT - 2.0 * tokens::RING_WIDTH;
assert!(
(field_rect.w - expected_field_w).abs() < 0.5,
"field should take leftover space inside wrapper, got {} expected ~{}",
field_rect.w,
expected_field_w,
);
}
#[test]
fn explicit_width_fill_still_works() {
let value = String::from("42");
let sel = Selection::default();
let widget =
numeric_input(&value, &sel, "n", NumericInputOpts::default()).width(Size::Fill(1.0));
let mut tree = crate::widgets::form::form([crate::widgets::form::form_item([
crate::widgets::form::form_control(widget),
])]);
let mut state = UiState::new();
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 320.0, 200.0));
let row_rect = state.rect_of_key(&tree, "n").expect("row rect");
assert!(
row_rect.w > DEFAULT_WIDTH,
"explicit `.width(Fill)` should override the fixed default, got {}",
row_rect.w,
);
}
fn text_event(target_key: &str, text: &str) -> UiEvent {
UiEvent {
path: None,
key: Some(target_key.to_string()),
target: Some(UiTarget {
key: target_key.to_string(),
node_id: format!("/{target_key}"),
rect: Rect::new(0.0, 0.0, 100.0, 32.0),
tooltip: None,
scroll_offset_y: 0.0,
}),
pointer: None,
key_press: None,
text: Some(text.to_string()),
selection: None,
modifiers: KeyModifiers::default(),
click_count: 0,
pointer_kind: None,
kind: UiEventKind::TextInput,
}
}
#[test]
fn inc_steps_value_up_by_step() {
let mut value = String::from("3");
let mut sel = Selection::default();
let opts = NumericInputOpts::default().step(2.0);
assert!(apply_event(
&mut value,
&mut sel,
"n",
&opts,
&click("n:inc")
));
assert_eq!(value, "5");
}
#[test]
fn dec_steps_value_down_by_step() {
let mut value = String::from("3");
let mut sel = Selection::default();
let opts = NumericInputOpts::default().step(0.5).decimals(1);
assert!(apply_event(
&mut value,
&mut sel,
"n",
&opts,
&click("n:dec")
));
assert_eq!(value, "2.5");
}
#[test]
fn inc_clamps_to_max() {
let mut value = String::from("99");
let mut sel = Selection::default();
let opts = NumericInputOpts::default().min(0.0).max(100.0);
let opts = opts.step(5.0);
assert!(apply_event(
&mut value,
&mut sel,
"n",
&opts,
&click("n:inc")
));
assert_eq!(value, "100");
}
#[test]
fn dec_clamps_to_min() {
let mut value = String::from("1");
let mut sel = Selection::default();
let opts = NumericInputOpts::default().min(0.0).max(100.0);
assert!(apply_event(
&mut value,
&mut sel,
"n",
&opts,
&click("n:dec")
));
assert_eq!(value, "0");
assert!(apply_event(
&mut value,
&mut sel,
"n",
&opts,
&click("n:dec")
));
assert_eq!(value, "0");
}
#[test]
fn empty_value_treated_as_min_when_set() {
let mut value = String::new();
let mut sel = Selection::default();
let opts = NumericInputOpts::default().min(10.0).max(100.0);
assert!(apply_event(
&mut value,
&mut sel,
"n",
&opts,
&click("n:inc")
));
assert_eq!(value, "11");
}
#[test]
fn empty_value_treated_as_zero_when_no_min() {
let mut value = String::new();
let mut sel = Selection::default();
let opts = NumericInputOpts::default();
assert!(apply_event(
&mut value,
&mut sel,
"n",
&opts,
&click("n:inc")
));
assert_eq!(value, "1");
}
#[test]
fn unparseable_value_treated_as_zero_when_no_min() {
let mut value = String::from("abc");
let mut sel = Selection::default();
let opts = NumericInputOpts::default();
assert!(apply_event(
&mut value,
&mut sel,
"n",
&opts,
&click("n:inc")
));
assert_eq!(value, "1");
}
#[test]
fn ignores_unrelated_keys() {
let mut value = String::from("3");
let mut sel = Selection::default();
let opts = NumericInputOpts::default();
assert!(!apply_event(
&mut value,
&mut sel,
"n",
&opts,
&click("other:inc")
));
assert_eq!(value, "3");
}
#[test]
fn decimals_format_pads_zeros() {
let mut value = String::from("0");
let mut sel = Selection::default();
let opts = NumericInputOpts::default().step(0.10).decimals(2);
assert!(apply_event(
&mut value,
&mut sel,
"n",
&opts,
&click("n:inc")
));
assert_eq!(value, "0.10");
}
#[test]
fn no_decimals_strips_trailing_zero() {
let mut value = String::from("0");
let mut sel = Selection::default();
let opts = NumericInputOpts::default().step(1.0);
assert!(apply_event(
&mut value,
&mut sel,
"n",
&opts,
&click("n:inc")
));
assert_eq!(value, "1");
}
#[test]
fn text_event_for_other_widget_is_ignored() {
let mut value = String::from("42");
let mut sel = Selection::default();
let opts = NumericInputOpts::default();
assert!(!apply_event(
&mut value,
&mut sel,
"n",
&opts,
&text_event("other-input", "x"),
));
assert_eq!(value, "42");
}
#[test]
fn text_event_filter_rejects_non_numeric_chars() {
let mut value = String::from("12");
let mut sel = Selection::default();
let opts = NumericInputOpts::default();
assert!(!apply_event(
&mut value,
&mut sel,
"n",
&opts,
&text_event("n:field", "abc"),
));
assert_eq!(value, "12");
}
#[test]
fn text_event_filter_accepts_partial_numeric_states() {
for partial in ["-", "1.", "1.5e", "1.5e+", ".5", "+"] {
let mut value = String::new();
let mut sel = Selection::default();
let opts = NumericInputOpts::default();
assert!(
apply_event(
&mut value,
&mut sel,
"n",
&opts,
&text_event("n:field", partial),
),
"filter should accept partial value {partial:?}",
);
assert_eq!(value, partial, "value should equal {partial:?}");
}
}
#[test]
fn text_event_filter_accepts_full_numeric_paste() {
let mut value = String::new();
let mut sel = Selection::default();
let opts = NumericInputOpts::default();
assert!(apply_event(
&mut value,
&mut sel,
"n",
&opts,
&text_event("n:field", "42.5"),
));
assert_eq!(value, "42.5");
}
#[test]
fn build_widget_has_three_children_and_correct_keys() {
let value = String::from("0");
let sel = Selection::default();
let opts = NumericInputOpts::default();
let el = numeric_input(&value, &sel, "n", opts);
assert_eq!(el.key.as_deref(), Some("n"));
assert_eq!(el.children.len(), 3, "decrement, field, increment");
assert_eq!(el.children[0].key.as_deref(), Some("n:dec"));
assert_eq!(el.children[1].key.as_deref(), Some("n:field"));
assert_eq!(el.children[2].key.as_deref(), Some("n:inc"));
}
fn key_event(key: &str, ui_key: UiKey, modifiers: KeyModifiers) -> UiEvent {
use crate::event::KeyPress;
UiEvent {
path: None,
key: Some(key.to_string()),
target: Some(UiTarget {
key: key.to_string(),
node_id: format!("/{key}"),
rect: Rect::new(0.0, 0.0, 100.0, 32.0),
tooltip: None,
scroll_offset_y: 0.0,
}),
pointer: None,
key_press: Some(KeyPress {
key: ui_key,
modifiers,
repeat: false,
}),
text: None,
selection: None,
modifiers,
click_count: 0,
pointer_kind: None,
kind: UiEventKind::KeyDown,
}
}
#[test]
fn arrow_up_on_field_steps_up() {
let mut value = String::from("3");
let mut sel = Selection::default();
let opts = NumericInputOpts::default().step(1.0);
assert!(apply_event(
&mut value,
&mut sel,
"n",
&opts,
&key_event("n:field", UiKey::ArrowUp, KeyModifiers::default()),
));
assert_eq!(value, "4");
}
#[test]
fn arrow_down_on_field_steps_down() {
let mut value = String::from("3");
let mut sel = Selection::default();
let opts = NumericInputOpts::default().step(1.0);
assert!(apply_event(
&mut value,
&mut sel,
"n",
&opts,
&key_event("n:field", UiKey::ArrowDown, KeyModifiers::default()),
));
assert_eq!(value, "2");
}
#[test]
fn shift_arrow_steps_by_ten_times() {
let mut value = String::from("3");
let mut sel = Selection::default();
let opts = NumericInputOpts::default().step(1.0);
let shift = KeyModifiers {
shift: true,
..KeyModifiers::default()
};
assert!(apply_event(
&mut value,
&mut sel,
"n",
&opts,
&key_event("n:field", UiKey::ArrowUp, shift),
));
assert_eq!(value, "13");
}
#[test]
fn alt_arrow_steps_by_one_tenth() {
let mut value = String::from("0");
let mut sel = Selection::default();
let opts = NumericInputOpts::default().step(0.1).decimals(2);
let alt = KeyModifiers {
alt: true,
..KeyModifiers::default()
};
assert!(apply_event(
&mut value,
&mut sel,
"n",
&opts,
&key_event("n:field", UiKey::ArrowUp, alt),
));
assert_eq!(value, "0.01");
}
#[test]
fn shift_click_on_inc_button_scales_step() {
let mut value = String::from("3");
let mut sel = Selection::default();
let opts = NumericInputOpts::default().step(1.0);
let mut ev = click("n:inc");
ev.modifiers = KeyModifiers {
shift: true,
..KeyModifiers::default()
};
assert!(apply_event(&mut value, &mut sel, "n", &opts, &ev));
assert_eq!(value, "13");
}
#[test]
fn arrow_key_on_field_clamps_to_max() {
let mut value = String::from("99");
let mut sel = Selection::default();
let opts = NumericInputOpts::default().step(5.0).max(100.0);
assert!(apply_event(
&mut value,
&mut sel,
"n",
&opts,
&key_event("n:field", UiKey::ArrowUp, KeyModifiers::default()),
));
assert_eq!(value, "100");
}
#[test]
fn arrow_key_routed_elsewhere_is_ignored() {
let mut value = String::from("3");
let mut sel = Selection::default();
let opts = NumericInputOpts::default();
assert!(!apply_event(
&mut value,
&mut sel,
"n",
&opts,
&key_event("other:field", UiKey::ArrowUp, KeyModifiers::default()),
));
assert_eq!(value, "3");
}
#[test]
fn non_arrow_keydown_on_field_falls_through() {
let mut value = String::from("3");
let mut sel = Selection::default();
let opts = NumericInputOpts::default();
assert!(!apply_event(
&mut value,
&mut sel,
"n",
&opts,
&key_event("n:field", UiKey::Tab, KeyModifiers::default()),
));
assert_eq!(value, "3");
}
#[test]
fn stacked_variant_has_field_and_chevron_column() {
let value = String::from("0");
let sel = Selection::default();
let opts = NumericInputOpts::default().stacked();
let el = numeric_input(&value, &sel, "n", opts);
assert_eq!(el.key.as_deref(), Some("n"));
assert_eq!(el.children.len(), 2, "field + chevron column");
assert_eq!(el.children[0].key.as_deref(), Some("n:field"));
let column_children = &el.children[1].children;
assert_eq!(column_children.len(), 2, "chevron-up over chevron-down");
assert_eq!(column_children[0].key.as_deref(), Some("n:inc"));
assert_eq!(column_children[1].key.as_deref(), Some("n:dec"));
}
#[test]
fn stacked_variant_keeps_apply_event_contract() {
let mut value = String::from("3");
let mut sel = Selection::default();
let opts = NumericInputOpts::default().stacked();
assert!(apply_event(
&mut value,
&mut sel,
"n",
&opts,
&click("n:inc"),
));
assert_eq!(value, "4");
assert!(apply_event(
&mut value,
&mut sel,
"n",
&opts,
&key_event("n:field", UiKey::ArrowDown, KeyModifiers::default()),
));
assert_eq!(value, "3");
}
}