Files
Ely-GPUI-Components/src/forms/number.rs
T

485 lines
15 KiB
Rust

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<dyn Fn(f64, &mut Window, &mut App)>;
type Nudge = Box<dyn Fn(&mut Window, &mut App)>;
/// Reads a typed number: groups and the typographic minus are allowed.
pub(crate) fn parse(text: &str) -> Option<f64> {
let plain: String = text
.chars()
.filter(|ch| *ch != ',')
.map(|ch| if ch == format::MINUS { '-' } else { ch })
.collect();
plain
.trim()
.parse::<f64>()
.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<TextInput>,
on_change: Option<OnChange>,
on_commit: Option<OnChange>,
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<TextInput>,
(on_change, on_commit): (Option<&OnChange>, 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);
}
if let Some(on_commit) = on_commit {
on_commit(value, window, cx);
}
}
/// Settles `next` into the limits and shows it.
fn commit(state: &Entity<Numeric>, next: f64, window: &mut Window, cx: &mut App) {
let (input, on_change, on_commit, (min, max, _, precision)) = {
let numeric = state.read(cx);
(
numeric.input.clone(),
numeric.on_change.clone(),
numeric.on_commit.clone(),
numeric.limits,
)
};
let value = settle(next, min, max, precision);
state.update(cx, |numeric, _| numeric.value = value);
let handlers = (on_change.as_ref(), on_commit.as_ref());
show(&input, handlers, value, precision, window, cx);
}
fn numeric(id: &ElementId, window: &mut Window, cx: &mut App) -> Entity<Numeric> {
window.use_keyed_state(id.clone(), cx, |window, cx: &mut Context<Numeric>| {
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(), numeric.on_commit.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,
on_commit: None,
value: 0.0,
limits: (f64::MIN, f64::MAX, 1.0, 0),
_events: events,
}
})
}
struct Scrub {
owner: EntityId,
start: Rc<Cell<Option<(Pixels, f64)>>>,
}
/// Wraps `face` so dragging it sideways moves the number, one step per notch.
fn scrub_handle(
id: ElementId,
face: AnyElement,
state: Entity<Numeric>,
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<Scrub>, 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<SharedString>,
suffix: Option<AnyElement>,
size: ControlSize,
on_change: Option<OnChange>,
on_commit: Option<OnChange>,
scrub_label: Option<SharedString>,
}
impl NumberInput {
pub fn new(id: impl Into<ElementId>, 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,
on_commit: 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<SharedString>) -> 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
}
/// Runs on Enter, blur, steps and scrubs; never while typing.
pub fn on_commit(mut self, handler: impl Fn(f64, &mut Window, &mut App) + 'static) -> Self {
self.on_commit = 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.on_commit = self.on_commit.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()
.debug_selector(|| "number-root".into())
.flex()
.items_center()
.gap_2()
.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<ElementId>, label: impl Into<SharedString>, 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;