use std::{cell::Cell, rc::Rc}; use gpui::{ AnyElement, App, Context, CursorStyle, DragMoveEvent, ElementId, EmptyView, Entity, EntityId, InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Subscription, Window, div, prelude::*, }; use super::{ Input, InputEvent, TextInput, text::{Down, Up}, }; use crate::{ primitives::{Icon, IconName}, theme::{ActiveTheme, ControlSize, IconSize, TextSize}, typography::format::{self, Separators}, }; type OnChange = Rc; type Nudge = Box; /// Reads a typed number: groups and the typographic minus are allowed. pub(crate) fn parse(text: &str) -> Option { let plain: String = text .chars() .filter(|ch| *ch != ',') .map(|ch| if ch == format::MINUS { '-' } else { ch }) .collect(); plain .trim() .parse::() .ok() .filter(|value| value.is_finite()) } /// Rounds to `precision` places, then clamps to the rounded numbers in `min..=max`. fn settle(value: f64, min: f64, max: f64, precision: usize) -> f64 { let scale = 10f64.powi(precision as i32); let (low, high) = ( on_grid(min, scale, f64::ceil), on_grid(max, scale, f64::floor), ); assert!( low <= high, "no {precision}-place number lies in {min}..={max}" ); ((value * scale).round() / scale).clamp(low, high) } /// `bound` on the `1 / scale` grid: kept if float error alone put it off, else moved `inward`. fn on_grid(bound: f64, scale: f64, inward: fn(f64) -> f64) -> f64 { let scaled = bound * scale; let near = scaled.round(); if (scaled - near).abs() <= 4.0 * f64::EPSILON * near.abs().max(1.0) { near / scale } else { inward(scaled) / scale } } /// A number's live settings, read by its input's event handler. struct Numeric { input: Entity, on_change: Option, value: f64, limits: (f64, f64, f64, usize), _events: Subscription, } impl Numeric { /// The typed number, or the owner's value while the text is not one. fn current(&self, cx: &App) -> f64 { parse(self.input.read(cx).text()).unwrap_or(self.value) } } /// Shows `value` in the field, then tells the owner. fn show( input: &Entity, on_change: Option<&OnChange>, value: f64, precision: usize, window: &mut Window, cx: &mut App, ) { let shown = format::number(value, precision, Separators::EN); input.update(cx, |input, cx| input.set_text(shown, cx)); log::info!("number input: {value}"); if let Some(on_change) = on_change { on_change(value, window, cx); } } /// Settles `next` into the limits and shows it. fn commit(state: &Entity, next: f64, window: &mut Window, cx: &mut App) { let (input, on_change, (min, max, _, precision)) = { let numeric = state.read(cx); ( numeric.input.clone(), numeric.on_change.clone(), numeric.limits, ) }; let value = settle(next, min, max, precision); state.update(cx, |numeric, _| numeric.value = value); show(&input, on_change.as_ref(), value, precision, window, cx); } fn numeric(id: &ElementId, window: &mut Window, cx: &mut App) -> Entity { window.use_keyed_state(id.clone(), cx, |window, cx: &mut Context| { let input = cx.new(|cx| { TextInput::new(window, cx) .filter(|ch| ch.is_ascii_digit() || matches!(ch, '.' | ',' | '-' | format::MINUS)) }); let events = cx.subscribe_in(&input, window, |numeric, input, event, window, cx| { let (min, max, _, precision) = numeric.limits; match event { InputEvent::Blur | InputEvent::Submit => { let value = settle(numeric.current(cx), min, max, precision); numeric.value = value; show( input, numeric.on_change.as_ref(), value, precision, window, cx, ); } InputEvent::Changed if input.read(cx).focus().is_focused(window) => { let Some(typed) = parse(input.read(cx).text()) else { return; }; let settled = typed == settle(typed, min, max, precision); if typed == numeric.value || !settled { return; } log::info!("number input: typed {typed}"); numeric.value = typed; if let Some(on_change) = numeric.on_change.clone() { on_change(typed, window, cx); } } _ => {} } }); Numeric { input, on_change: None, value: 0.0, limits: (f64::MIN, f64::MAX, 1.0, 0), _events: events, } }) } struct Scrub { owner: EntityId, start: Rc>>, } /// Wraps `face` so dragging it sideways moves the number, one step per notch. fn scrub_handle( id: ElementId, face: AnyElement, state: Entity, window: &mut Window, cx: &mut App, ) -> impl IntoElement + use<> { let owner = window .use_keyed_state(id.clone(), cx, |_, _| ()) .entity_id(); let notch = cx.theme().handle_hit().to_pixels(window.rem_size()); div() .id(id) .cursor(CursorStyle::ResizeLeftRight) .on_drag( Scrub { owner, start: Rc::new(Cell::new(None)), }, |scrub, _, _, cx| { scrub.start.set(None); cx.new(|_| EmptyView) }, ) .on_drag_move(move |event: &DragMoveEvent, window, cx| { let scrub = event.drag(cx); if scrub.owner != owner { return; } let (start, x) = (scrub.start.clone(), event.event.position.x); let (anchor, from) = start.get().unwrap_or_else(|| { let fresh = (x, state.read(cx).current(cx)); start.set(Some(fresh)); fresh }); let notches = f64::from(((x - anchor) / notch).trunc()); let step = state.read(cx).limits.2; commit(&state, from + notches * step, window, cx); }) .child(face) } /// A number field: arrows or the stepper move it, dragging the grip scrubs it. #[derive(IntoElement)] pub struct NumberInput { pub(super) id: ElementId, value: f64, min: f64, max: f64, step: f64, precision: usize, prefix: Option, suffix: Option, size: ControlSize, on_change: Option, scrub_label: Option, } impl NumberInput { pub fn new(id: impl Into, value: f64) -> Self { Self { id: id.into(), value, min: f64::MIN, max: f64::MAX, step: 1.0, precision: 0, prefix: None, suffix: None, size: ControlSize::default(), on_change: None, scrub_label: None, } } pub fn range(mut self, min: f64, max: f64) -> Self { assert!(min <= max, "number range {min}..{max} is empty"); self.min = min; self.max = max; self } pub fn step(mut self, step: f64) -> Self { assert!(step > 0.0, "number step {step} must be positive"); self.step = step; self } /// Decimal places kept and shown. pub fn precision(mut self, places: usize) -> Self { self.precision = places; self } /// A symbol before the digits, such as `$`. pub fn prefix(mut self, symbol: impl Into) -> Self { self.prefix = Some(symbol.into()); self } /// Money in an ISO 4217 currency: its symbol and minor units. pub fn currency(mut self, code: &str) -> Self { let (symbol, places, _) = format::currency_parts(code); self.prefix = Some(SharedString::from(symbol.to_string())); self.precision = places; self } /// A percentage, marked with `%`. pub fn percent(self) -> Self { self.suffix(div().child("%")) } /// Something after the digits, such as `%` or a unit. pub fn suffix(mut self, suffix: impl IntoElement) -> Self { self.suffix = Some(suffix.into_any_element()); self } pub fn size(mut self, size: ControlSize) -> Self { self.size = size; self } pub fn on_change(mut self, handler: impl Fn(f64, &mut Window, &mut App) + 'static) -> Self { self.on_change = Some(Rc::new(handler)); self } } impl RenderOnce for NumberInput { fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement { let state = numeric(&self.id, window, cx); let (value, min, max, step) = (self.value, self.min, self.max, self.step); state.update(cx, |numeric, _| { numeric.on_change = self.on_change.clone(); numeric.value = value; numeric.limits = (min, max, step, self.precision); }); let input = state.read(cx).input.clone(); let shown = format::number(value, self.precision, Separators::EN); let editing = input.read(cx).focus().is_focused(window); if !editing && input.read(cx).text() != shown { input.update(cx, |input, cx| input.set_text(shown, cx)); } let now = state.read(cx).current(cx); let nudge = |by: f64| { let state = state.clone(); move |window: &mut Window, cx: &mut App| { let from = state.read(cx).current(cx); commit(&state, from + by, window, cx); } }; let (up, down, key_up, key_down) = (nudge(step), nudge(-step), nudge(step), nudge(-step)); let label = self.scrub_label.take().map(|label| { let theme = cx.theme(); let face = div() .flex_none() .min_w(theme.control_height(ControlSize::Md)) .whitespace_nowrap() .text_size(theme.text_size(TextSize::Sm)) .text_color(theme.colors.fg_muted) .child(label) .into_any_element(); scrub_handle( (self.id.clone(), "label").into(), face, state.clone(), window, cx, ) }); let subtle = cx.theme().colors.fg_subtle; let grip = scrub_handle( (self.id.clone(), "grip").into(), Icon::new(IconName::ChevronsUpDown) .size(IconSize::Xs) .color(subtle) .into_any_element(), state, window, cx, ); let theme = cx.theme(); let colors = &theme.colors; let half = |id: &'static str, icon: IconName, enabled: bool, act: Nudge| { div() .id((self.id.clone(), id)) .flex() .flex_1() .items_center() .justify_center() .w(theme.icon_size(IconSize::Lg)) .map(|half| { if enabled { half.cursor_pointer() .hover(|style| style.bg(colors.hover)) .on_mouse_down(MouseButton::Left, |_, window, _| { window.prevent_default() }) .on_click(move |_, window, cx| act(window, cx)) } else { half.opacity(0.4) } }) .child(Icon::new(icon).size(IconSize::Xs).color(colors.fg_muted)) }; let stepper = div() .flex() .flex_col() .h_full() .border_l_1() .border_color(colors.border) .child(half("up", IconName::ChevronUp, now < max, Box::new(up))) .child(half( "down", IconName::ChevronDown, now > min, Box::new(down), )); let field = Input::new(&input) .size(self.size) .prefix( div() .flex() .items_center() .gap_1() .child(grip) .when_some(self.prefix, |prefix, symbol| { prefix.child(div().text_color(subtle).child(symbol)) }), ) .suffix( div() .flex() .items_center() .gap_1() .children(self.suffix) .child(stepper), ); div() .flex() .items_center() .gap_2() .w_full() .children(label) .child( div() .id(self.id) .flex_1() .min_w_0() .capture_action(move |_: &Up, window, cx| { cx.stop_propagation(); key_up(window, cx); }) .capture_action(move |_: &Down, window, cx| { cx.stop_propagation(); key_down(window, cx); }) .child(field), ) } } /// A label you drag sideways to change a number, beside its field. #[derive(IntoElement)] pub struct ScrubInput { field: NumberInput, } impl ScrubInput { pub fn new(id: impl Into, label: impl Into, value: f64) -> Self { let mut field = NumberInput::new(id, value); field.scrub_label = Some(label.into()); Self { field } } pub fn range(mut self, min: f64, max: f64) -> Self { self.field = self.field.range(min, max); self } pub fn step(mut self, step: f64) -> Self { self.field = self.field.step(step); self } pub fn precision(mut self, places: usize) -> Self { self.field = self.field.precision(places); self } pub fn on_change(mut self, handler: impl Fn(f64, &mut Window, &mut App) + 'static) -> Self { self.field = self.field.on_change(handler); self } } impl RenderOnce for ScrubInput { fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { self.field } } #[cfg(test)] mod tests { use super::{parse, settle}; #[test] fn parses_grouped_and_typographic_minus_and_refuses_junk() { assert_eq!(parse("1,234.5"), Some(1234.5)); assert_eq!(parse("\u{2212}3"), Some(-3.0)); assert_eq!(parse("abc"), None); assert_eq!(parse("1e999"), None); } #[test] fn settle_clamps_then_rounds() { assert_eq!(settle(1.23456, 0.0, 10.0, 2), 1.23); assert_eq!(settle(-5.0, 0.0, 10.0, 0), 0.0); assert_eq!(settle(12.6, 0.0, 10.0, 0), 10.0); } #[test] fn settle_rounds_before_it_clamps() { assert_eq!(settle(0.2 + 0.1, 0.0, 0.25, 1), 0.2); assert_eq!(settle(-0.26, -0.25, 1.0, 1), -0.2); assert_eq!(settle(0.3, 0.0, 0.29, 2), 0.29); assert_eq!(settle(0.29, 0.29, 0.29, 2), 0.29); assert_eq!(settle(10000000.01, 0.0, 10000000.005, 2), 10000000.0); } #[test] #[should_panic(expected = "no 1-place number lies in 0.21..=0.29")] fn settle_refuses_a_range_without_a_number_at_its_precision() { settle(0.25, 0.21, 0.29, 1); } }