Files
Ely-GPUI-Components/src/forms/number.rs
T
ZacharyZhang-NYandClaude Opus 5.5 e482bac865 Capture · Roots: the roots round 2 missed
A sweep that read every render found 28 roots still on w_full,
reached by 64 components: the field button under Select and every
picker, the checked and mention fields, the slider track,
NumberInput, Steps, the state views, the agent previews, and every
chart and finance frame. All drop it. LivePreview's size_full becomes
h_full, since its card no longer fills.

Every host of those roots was listed and read, 231 in all. Five
tests that mounted a picker as the window root sit in a size_full
box, the index manager gives its method select a flex_1 box, and the
notification center and the gallery's state frames stack their state
views in a column so they stretch.

Each dropped root carries a debug selector, and six chapter tests
mount 43 components in a padded card whose column is measured by
content, through layout::tests::narrow_width: each root must take the
full 240px. With w_full back on the roots, all six fail.

AGENTS.md names the roots that keep w_full and why: picture frames
that keep a ratio, and rows that hand a text field its share.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 19:34:01 -04:00

501 lines
16 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>,
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: 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<Numeric>, 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<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(),
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<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>,
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,
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
}
}
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()
.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 {
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);
}
}