use std::panic::Location;
use crate::event::{UiEvent, UiEventKind};
use crate::selection::Selection;
use crate::tokens;
use crate::tree::*;
use crate::widgets::button::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>,
}
impl Default for NumericInputOpts<'_> {
fn default() -> Self {
Self {
min: None,
max: None,
step: 1.0,
decimals: None,
placeholder: None,
}
}
}
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
}
}
#[track_caller]
pub fn numeric_input(
value: &str,
selection: &Selection,
key: &str,
opts: NumericInputOpts<'_>,
) -> El {
let caller = Location::caller();
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));
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));
row([dec, field, inc])
.at_loc(caller)
.key(key.to_string())
.gap(tokens::RING_WIDTH)
.align(Align::Center)
.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);
return true;
}
if event.route() == Some(dec_key.as_str()) {
step_value(value, opts, -1);
return true;
}
}
let field_key = format!("{key}:field");
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) {
let parsed = value
.parse::<f64>()
.ok()
.unwrap_or_else(|| opts.min.unwrap_or(0.0));
let stepped = parsed + (dir as f64) * opts.step;
let clamped = clamp_opt(stepped, opts.min, opts.max);
*value = format_numeric(clamped, opts.decimals);
}
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::tree::Rect;
fn click(key: &str) -> UiEvent {
UiEvent::synthetic_click(key)
}
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,
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"));
}
}