T06a forms text: text input core, fields, numbers, masks, pin, inline, groups, tags, pairs, path, regex, expression, hotkey, mention
This commit is contained in:
41 files changed
+6233
-45
No files matched your search
@@ -0,0 +1,482 @@
|
||||
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);
|
||||
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| {
|
||||
if !matches!(event, InputEvent::Blur | InputEvent::Submit) {
|
||||
return;
|
||||
}
|
||||
let (min, max, _, precision) = numeric.limits;
|
||||
let value = settle(numeric.current(cx), min, max, precision);
|
||||
show(
|
||||
input,
|
||||
numeric.on_change.as_ref(),
|
||||
value,
|
||||
precision,
|
||||
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()
|
||||
.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<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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user