use super::cells;
use super::edit_menu;
use super::numeric::Steps;
use super::text_input::TextInput;
use crate::event::{Event, MouseButton, MouseKind};
use crate::geometry::{Rect, Size};
use crate::keymap::Key;
use crate::style::CellStyle;
use crate::text;
use crate::theme::State;
use crate::widget::{EventCx, MeasureCx, PaintCx, Widget};
const SEGMENT: u16 = 3;
const LARGE_STEP: f64 = 10.0;
const DEFAULT_DIGITS: u16 = 8;
type ValueMessage<Msg> = Box<dyn Fn(f64) -> Msg>;
pub struct NumberInput<Msg> {
value: f64,
steps: Steps,
steppers: bool,
placeholder: String,
invalid: bool,
disabled: bool,
on_change: Option<ValueMessage<Msg>>,
}
#[derive(Debug, Default)]
struct NumberMemory {
draft: String,
seen: Option<f64>,
pressed: Option<usize>,
}
impl<Msg: 'static> NumberInput<Msg> {
#[must_use]
pub fn new(value: f64) -> Self {
Self {
value,
steps: Steps::new(f64::NEG_INFINITY, f64::INFINITY, 1.0),
steppers: false,
placeholder: String::new(),
invalid: false,
disabled: false,
on_change: None,
}
}
#[must_use]
pub fn range(mut self, min: f64, max: f64) -> Self {
self.steps = Steps::new(min, max, self.steps.step);
self
}
#[must_use]
pub fn step(mut self, step: f64) -> Self {
self.steps = Steps::new(self.steps.min, self.steps.max, step);
self
}
#[must_use]
pub fn steppers(mut self, steppers: bool) -> Self {
self.steppers = steppers;
self
}
#[must_use]
pub fn placeholder(mut self, text: impl Into<String>) -> Self {
self.placeholder = text.into();
self
}
#[must_use]
pub fn invalid(mut self, invalid: bool) -> Self {
self.invalid = invalid;
self
}
#[must_use]
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
#[must_use]
pub fn on_change(mut self, message: impl Fn(f64) -> Msg + 'static) -> Self {
self.on_change = Some(Box::new(message));
self
}
fn draft(&self, memory: &mut NumberMemory) -> String {
if memory.seen != Some(self.value) {
memory.draft = self.steps.write(self.value);
memory.seen = Some(self.value);
}
memory.draft.clone()
}
fn parse(&self, draft: &str) -> Option<f64> {
let value = draft.parse::<f64>().ok().filter(|value| value.is_finite())?;
(value >= self.steps.min && value <= self.steps.max).then_some(value)
}
fn field(&self, draft: String) -> TextInput<Msg> {
let negative = self.steps.min < 0.0;
let decimal = self.steps.decimals() > 0;
let broken = !draft.is_empty() && self.parse(&draft).is_none();
TextInput::new(draft)
.placeholder(self.placeholder.clone())
.invalid(self.invalid || broken)
.disabled(self.disabled)
.accept(move |c| c.is_ascii_digit() || (negative && c == '-') || (decimal && c == '.'))
}
fn segments(&self, area: Rect) -> Option<[Rect; 2]> {
if !self.steppers || area.width < SEGMENT * 2 {
return None;
}
let up = Rect::new(area.right() - i32::from(SEGMENT), area.y, SEGMENT, area.height);
let down = Rect::new(up.x - i32::from(SEGMENT), area.y, SEGMENT, area.height);
Some([down, up])
}
fn nudge(&self, cx: &mut EventCx<'_, Msg>, count: f64) {
let memory = cx.memory::<NumberMemory>();
let draft = self.draft(memory);
let base = draft.parse::<f64>().ok().filter(|value| value.is_finite()).unwrap_or(self.value);
let value = self.steps.clamp(base + count * self.steps.step);
memory.draft = self.steps.write(value);
memory.seen = Some(value);
if value != self.value
&& let Some(message) = &self.on_change
{
cx.emit(message(value));
}
}
}
impl<Msg: 'static> Widget<Msg> for NumberInput<Msg> {
fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
let style = cx.env().theme().style("text-input", None, &[]);
let (vertical, horizontal) = style.pair("padding").unwrap_or((0, 1));
let prompt = text::width(&cx.env().icons().glyph("prompt")) + 1;
let bounds = [self.steps.min, self.steps.max, self.value]
.iter()
.filter(|value| value.is_finite())
.map(|value| text::width(&self.steps.write(*value)))
.max()
.unwrap_or(0);
let digits = if self.steps.min.is_finite() && self.steps.max.is_finite() { bounds } else { DEFAULT_DIGITS };
let content = digits.max(text::width(&self.placeholder)).max(bounds).saturating_add(1);
let steppers = if self.steppers { SEGMENT * 2 } else { 0 };
Size::new(
cells::sum([prompt, horizontal.saturating_mul(2), content, steppers]),
vertical.saturating_mul(2).saturating_add(1),
)
.min(available)
}
fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
let draft = self.draft(cx.memory::<NumberMemory>());
let segments = self.segments(area);
let field_width = area.width.saturating_sub(if segments.is_some() { SEGMENT * 2 } else { 0 });
self.field(draft).paint(cx, Rect::new(area.x, area.y, field_width, area.height));
let Some(segments) = segments else {
return;
};
if !self.disabled {
cx.register_hit(area);
}
let focused = cx.is_focused();
let pressed = if cx.is_pressed() { cx.memory::<NumberMemory>().pressed } else { None };
let pointer = if self.disabled { None } else { cx.pointer() };
let limits = [self.value <= self.steps.min, self.value >= self.steps.max];
for (index, (rect, glyph_key)) in segments.into_iter().zip(["stepper-minus", "stepper-plus"]).enumerate() {
let mut states = Vec::new();
if pointer.is_some_and(|(x, y)| rect.contains(x, y)) {
states.push(State::Hover);
}
if focused {
states.push(State::Focus);
}
if pressed == Some(index) {
states.push(State::Pressed);
}
if self.disabled || limits[index] {
states.push(State::Disabled);
}
let style = cx.style("number-input-stepper", None, &states).text();
let background = style.bg.unwrap_or_else(|| cx.color("active"));
cx.clear(rect, background);
let glyph = cx.env().icons().glyph(glyph_key).into_owned();
let x = rect.x + i32::from(SEGMENT.saturating_sub(text::width(&glyph)) / 2);
let y = rect.y + i32::from(rect.height / 2);
cx.text(x, y, &glyph, CellStyle { bg: None, ..style }, SEGMENT);
}
}
fn paint_overlay(&self, cx: &mut PaintCx<'_>, anchor: Rect) {
let draft = self.draft(cx.memory::<NumberMemory>());
self.field(draft).paint_overlay(cx, anchor);
}
fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
if self.disabled {
return false;
}
let menu = edit_menu::is_open(cx) || edit_menu::asks(event);
match event {
_ if menu => {}
Event::Key(key) => {
let count = if key.is_plain(Key::Up) {
1.0
} else if key.is_plain(Key::Down) {
-1.0
} else if key.is_plain(Key::PageUp) {
LARGE_STEP
} else if key.is_plain(Key::PageDown) {
-LARGE_STEP
} else {
0.0
};
if count != 0.0 {
self.nudge(cx, count);
return true;
}
}
Event::Mouse(mouse) if matches!(mouse.kind, MouseKind::ScrollUp | MouseKind::ScrollDown) => {
self.nudge(cx, if mouse.kind == MouseKind::ScrollUp { 1.0 } else { -1.0 });
return true;
}
Event::Mouse(mouse) => {
let hit = self
.segments(cx.area())
.and_then(|segments| segments.iter().position(|rect| rect.contains(mouse.x, mouse.y)));
if let Some(index) = hit {
if mouse.kind == MouseKind::Down(MouseButton::Left) {
cx.memory::<NumberMemory>().pressed = Some(index);
cx.flash();
self.nudge(cx, if index == 0 { -1.0 } else { 1.0 });
}
return true;
}
}
Event::Paste(_) | Event::PointerOutside => {}
}
let draft = self.draft(cx.memory::<NumberMemory>());
let edit = self.field(draft).edit(cx, event);
if let Some(text) = edit.changed {
let value = self.parse(&text);
let memory = cx.memory::<NumberMemory>();
memory.draft = text;
if let Some(value) = value
&& value != self.value
{
memory.seen = Some(value);
if let Some(message) = &self.on_change {
cx.emit(message(value));
}
}
}
edit.handled
}
fn focusable(&self) -> bool {
!self.disabled
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::runtime::{App, Command, Harness};
use crate::widget::{Length, View};
struct Demo {
value: f64,
steppers: bool,
step: f64,
}
impl App for Demo {
type Msg = f64;
fn update(&mut self, value: f64) -> Command<f64> {
self.value = value;
Command::none()
}
fn view(&self, ui: &mut View<'_, f64>) {
ui.add(
NumberInput::new(self.value)
.range(-5.0, 20.0)
.step(self.step)
.steppers(self.steppers)
.on_change(|value| value),
)
.width(Length::Cells(16))
.id("replicas");
}
}
fn harness(value: f64, steppers: bool, step: f64) -> Harness<Demo> {
Harness::new(Demo { value, steppers, step }, 20, 1)
}
#[test]
fn arrows_step_and_clamp_to_the_range() {
let mut h = harness(3.0, false, 1.0);
assert_eq!(h.screen(), " ❯ 3\n");
h.press("tab").press("up").press("up");
assert_eq!(h.app().value, 5.0);
h.press("pgup").press("pgup");
assert_eq!(h.app().value, 20.0);
assert_eq!(h.screen(), "▌ ❯ 20\n");
h.press("pgdn").press("pgdn").press("pgdn");
assert_eq!(h.app().value, -5.0);
}
#[test]
fn typing_accepts_numbers_only_and_marks_unfinished_or_out_of_range_text() {
let mut h = harness(3.0, false, 1.0);
let focused = h.press("tab").bg(0, 0);
h.press("backspace").type_text("1x2.");
assert_eq!(h.screen(), "▌ ❯ 12\n", "letters and the point of a whole-number field are ignored");
assert_eq!(h.app().value, 12.0);
h.type_text("5");
assert_eq!(h.app().value, 12.0, "125 is out of range and not sent");
assert_ne!(h.bg(0, 0), focused, "the invalid tint replaces the focus surface");
h.press("ctrl+a").type_text("-");
assert_eq!(h.screen(), "▌ ❯ -\n");
assert_eq!(h.app().value, 12.0);
h.type_text("4");
assert_eq!(h.app().value, -4.0);
assert_eq!(h.bg(0, 0), focused);
}
#[test]
fn decimal_steps_allow_a_point_and_write_their_decimals() {
let mut h = harness(1.0, false, 0.25);
assert_eq!(h.screen(), " ❯ 1.00\n");
h.press("tab").press("up");
assert_eq!(h.app().value, 1.25);
h.press("ctrl+a").type_text("2.5");
assert_eq!(h.app().value, 2.5);
assert_eq!(h.screen(), "▌ ❯ 2.5\n", "a typed value keeps its own writing");
}
#[test]
fn stepper_segments_click_and_grey_out_at_the_limits() {
let mut h = harness(19.0, true, 1.0);
assert_eq!(h.screen(), " ❯ 19 − +\n");
h.click(14, 0);
assert_eq!(h.app().value, 20.0);
assert!(h.is_focused("replicas"));
let theme = h.env().theme();
assert_eq!(h.fg(14, 0), theme.color("muted"), "plus is spent at the maximum");
h.click(11, 0);
assert_eq!(h.app().value, 19.0);
h.set_glyph_mode(crate::icons::GlyphMode::Ascii);
assert_eq!(h.screen(), " > 19 - +\n");
}
#[test]
fn disabled_ignores_keys_and_clicks() {
struct Disabled(f64);
impl App for Disabled {
type Msg = f64;
fn update(&mut self, value: f64) -> Command<f64> {
self.0 = value;
Command::none()
}
fn view(&self, ui: &mut View<'_, f64>) {
ui.add(NumberInput::new(self.0).steppers(true).disabled(true).on_change(|v| v))
.width(Length::Cells(16));
}
}
let mut h = Harness::new(Disabled(4.0), 20, 1);
h.press("tab").press("up").click(14, 0).type_text("9");
assert_eq!(h.app().0, 4.0);
assert_eq!(h.fg(4, 0), h.env().theme().color("muted"));
}
#[test]
fn the_wheel_steps_and_a_right_click_opens_the_edit_menu() {
let mut h = Harness::new(Demo { value: 3.0, steppers: false, step: 1.0 }, 30, 6);
h.set_reduced_motion(true);
h.mouse(MouseKind::ScrollUp, 4, 0).mouse(MouseKind::ScrollUp, 4, 0);
assert_eq!(h.app().value, 5.0, "one step per notch");
h.mouse(MouseKind::ScrollDown, 4, 0);
assert_eq!(h.app().value, 4.0);
h.mouse(MouseKind::Down(MouseButton::Right), 4, 0).mouse(MouseKind::Up(MouseButton::Right), 4, 0);
assert!(h.screen().contains("Select all"), "{}", h.screen());
h.press("up").press("enter");
assert!(!h.screen().contains("Select all"), "the menu took ↑ and Enter");
assert_eq!(h.app().value, 4.0, "↑ moved the highlight, not the value");
h.set_system_clipboard(Some("12 replicas")).mouse(MouseKind::Down(MouseButton::Right), 4, 0);
h.click_text("Paste");
assert_eq!(h.app().value, 12.0, "Select all, then Paste kept the digits");
}
#[test]
fn external_changes_replace_the_draft() {
let mut h = harness(3.0, false, 1.0);
h.press("tab").press("backspace");
assert_eq!(h.screen(), "▌ ❯\n");
h.send(7.0);
assert_eq!(h.screen(), "▌ ❯ 7\n");
}
}