Charts · Navigation · Forms: band names widen a sideways chart's gutter; Pagination takes a size, speaks the locale and jumps to a typed page
- A horizontal chart's gutter grows to its widest band name, floored at the theme gutter and capped at half the chart; a name past the cap ends in an ellipsis (#10). - Pagination's previous and next tooltips read i18n (English and zh-CN built in, app catalogs override); `.size(ControlSize)` sizes every control, Sm by default; `.jump()` adds a Go to field (#11). - NumberInput keys the owner's value as shown: a draft survives a blur, a value set while focused lands when focus leaves, and an echo of its own commit is no new value. A ranged field holds room for its widest bound through `Input::least`.
This commit is contained in:
1 parent
2ce336895c
commit
bd3772ad54
17 files changed
+438
-51
No files matched your search
+19
-3
@@ -1,6 +1,6 @@
|
||||
use gpui::{
|
||||
AnyElement, App, Entity, InteractiveElement, IntoElement, MouseButton, ParentElement,
|
||||
RenderOnce, Styled, Window, div, prelude::*,
|
||||
RenderOnce, SharedString, Styled, Window, div, prelude::*,
|
||||
};
|
||||
|
||||
use super::TextInput;
|
||||
@@ -19,6 +19,7 @@ pub struct Input {
|
||||
suffix: Option<AnyElement>,
|
||||
clearable: bool,
|
||||
invalid: bool,
|
||||
least: Option<SharedString>,
|
||||
}
|
||||
|
||||
impl Input {
|
||||
@@ -30,6 +31,7 @@ impl Input {
|
||||
suffix: None,
|
||||
clearable: false,
|
||||
invalid: false,
|
||||
least: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +50,12 @@ impl Input {
|
||||
self
|
||||
}
|
||||
|
||||
/// Holds room to show `text` whole.
|
||||
pub fn least(mut self, text: impl Into<SharedString>) -> Self {
|
||||
self.least = Some(text.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Adds a clear button while there is text.
|
||||
pub fn clearable(mut self) -> Self {
|
||||
self.clearable = true;
|
||||
@@ -125,8 +133,16 @@ impl RenderOnce for Input {
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.child(self.state)
|
||||
// An empty strut keeps the frame's inset to type in.
|
||||
.child(div().px(theme.control_padding(self.size)).border_x_1()),
|
||||
// A strut keeps the frame's inset to type in.
|
||||
.child(
|
||||
div()
|
||||
.px(theme.control_padding(self.size))
|
||||
.border_x_1()
|
||||
.h_0()
|
||||
.overflow_hidden()
|
||||
.whitespace_nowrap()
|
||||
.children(self.least),
|
||||
),
|
||||
)
|
||||
.when(self.clearable && !empty && !disabled, |frame| {
|
||||
frame.child(
|
||||
|
||||
+26
-9
@@ -67,6 +67,9 @@ struct Numeric {
|
||||
limits: (f64, f64, f64, usize),
|
||||
/// Shows no number; an empty field commits nothing.
|
||||
blank: bool,
|
||||
/// The owner's value as shown; a new one replaces a draft once focus leaves.
|
||||
owner: Option<String>,
|
||||
pending: bool,
|
||||
_events: Subscription,
|
||||
}
|
||||
|
||||
@@ -162,6 +165,8 @@ fn numeric(id: &ElementId, window: &mut Window, cx: &mut App) -> Entity<Numeric>
|
||||
value: 0.0,
|
||||
limits: (f64::MIN, f64::MAX, 1.0, 0),
|
||||
blank: false,
|
||||
owner: None,
|
||||
pending: false,
|
||||
_events: events,
|
||||
}
|
||||
})
|
||||
@@ -331,14 +336,27 @@ 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, _| {
|
||||
let shown = match self.blank {
|
||||
Some(_) => String::new(),
|
||||
None => format::number(value, self.precision, Separators::EN),
|
||||
};
|
||||
let input = state.read(cx).input.clone();
|
||||
let editing = input.read(cx).focus().is_focused(window);
|
||||
let echo = input.read(cx).text() == shown;
|
||||
let reseed = 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);
|
||||
numeric.blank = self.blank.is_some();
|
||||
if numeric.owner.as_ref() != Some(&shown) {
|
||||
numeric.owner = Some(shown.clone());
|
||||
numeric.value = value;
|
||||
numeric.pending = !echo;
|
||||
}
|
||||
let reseed = numeric.pending && !editing;
|
||||
numeric.pending &= editing;
|
||||
reseed
|
||||
});
|
||||
let input = state.read(cx).input.clone();
|
||||
if input.read(cx).label_text() != &self.label {
|
||||
let label = self.label.clone();
|
||||
input.update(cx, |input, cx| input.set_label(label, cx));
|
||||
@@ -347,12 +365,7 @@ impl RenderOnce for NumberInput {
|
||||
if input.read(cx).placeholder_text() != &placeholder {
|
||||
input.update(cx, |input, cx| input.set_placeholder(placeholder, cx));
|
||||
}
|
||||
let shown = match self.blank {
|
||||
Some(_) => String::new(),
|
||||
None => format::number(value, self.precision, Separators::EN),
|
||||
};
|
||||
let editing = input.read(cx).focus().is_focused(window);
|
||||
if !editing && input.read(cx).text() != shown {
|
||||
if reseed {
|
||||
input.update(cx, |input, cx| input.set_text(shown, cx));
|
||||
}
|
||||
let now = state.read(cx).current(cx);
|
||||
@@ -430,8 +443,12 @@ impl RenderOnce for NumberInput {
|
||||
now > min,
|
||||
Box::new(down),
|
||||
));
|
||||
let ends = [min, max].map(|end| format::number(end, self.precision, Separators::EN));
|
||||
let widest = ends.into_iter().max_by_key(|end| end.chars().count());
|
||||
let ranged = min > f64::MIN && max < f64::MAX;
|
||||
let field = Input::new(&input)
|
||||
.size(self.size)
|
||||
.when_some(widest.filter(|_| ranged), Input::least)
|
||||
.prefix(
|
||||
div()
|
||||
.flex()
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::{parse, settle};
|
||||
use gpui::{Context, Entity, IntoElement, Render, TestAppContext, VisualTestContext, Window};
|
||||
|
||||
use super::{NumberInput, parse, settle};
|
||||
use crate::{forms::bind_keys, theme::Theme};
|
||||
|
||||
#[test]
|
||||
fn parses_grouped_and_typographic_minus_and_refuses_junk() {
|
||||
@@ -29,3 +32,92 @@ fn settle_rounds_before_it_clamps() {
|
||||
fn settle_refuses_a_range_without_a_number_at_its_precision() {
|
||||
settle(0.25, 0.21, 0.29, 1);
|
||||
}
|
||||
|
||||
/// A field whose owner keeps each commit and shows `precision` places.
|
||||
struct Owned {
|
||||
value: f64,
|
||||
precision: usize,
|
||||
commits: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Render for Owned {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let view = cx.entity();
|
||||
NumberInput::new("owned", self.value)
|
||||
.precision(self.precision)
|
||||
.on_commit(move |value, _, cx| {
|
||||
view.update(cx, |owned, cx| {
|
||||
owned.commits.push(value);
|
||||
owned.value = value;
|
||||
cx.notify();
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn owned(
|
||||
value: f64,
|
||||
precision: usize,
|
||||
cx: &mut TestAppContext,
|
||||
) -> (Entity<Owned>, &mut VisualTestContext) {
|
||||
cx.update(|cx| {
|
||||
Theme::init(cx);
|
||||
bind_keys(cx);
|
||||
});
|
||||
let (view, cx) = cx.add_window_view(move |_, _| Owned {
|
||||
value,
|
||||
precision,
|
||||
commits: Vec::new(),
|
||||
});
|
||||
cx.update(|window, _| window.activate_window());
|
||||
cx.run_until_parked();
|
||||
(view, cx)
|
||||
}
|
||||
|
||||
fn commits(view: &Entity<Owned>, cx: &mut VisualTestContext) -> Vec<f64> {
|
||||
view.read_with(cx, |owned, _| owned.commits.clone())
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_value_the_owner_sets_while_focused_shows_once_focus_leaves(cx: &mut TestAppContext) {
|
||||
let (view, cx) = owned(4.0, 0, cx);
|
||||
cx.update(|window, cx| window.focus_next(cx));
|
||||
view.update(cx, |owned, cx| {
|
||||
owned.value = 42.0;
|
||||
cx.notify();
|
||||
});
|
||||
cx.run_until_parked();
|
||||
cx.update(|window, cx| window.blur(cx));
|
||||
cx.run_until_parked();
|
||||
assert_eq!(commits(&view, cx), [42.0]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn new_places_reformat_the_same_value(cx: &mut TestAppContext) {
|
||||
let (view, cx) = owned(1.25, 0, cx);
|
||||
view.update(cx, |owned, cx| {
|
||||
owned.precision = 2;
|
||||
cx.notify();
|
||||
});
|
||||
cx.run_until_parked();
|
||||
cx.update(|window, cx| window.focus_next(cx));
|
||||
cx.simulate_keystrokes("enter");
|
||||
assert_eq!(commits(&view, cx), [1.25], "the field shows 1.25, not 1");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_owner_echo_of_a_commit_keeps_the_next_draft(cx: &mut TestAppContext) {
|
||||
let (view, cx) = owned(4.0, 0, cx);
|
||||
cx.update(|window, cx| window.focus_next(cx));
|
||||
for typed in ["12", "18"] {
|
||||
cx.simulate_keystrokes("secondary-a");
|
||||
cx.simulate_input(typed);
|
||||
if typed == "12" {
|
||||
cx.simulate_keystrokes("enter");
|
||||
cx.run_until_parked();
|
||||
}
|
||||
}
|
||||
cx.update(|window, cx| window.blur(cx));
|
||||
cx.run_until_parked();
|
||||
assert_eq!(commits(&view, cx), [12.0, 18.0]);
|
||||
}
|
||||
@@ -281,6 +281,21 @@ fn a_blank_field_commits_only_what_is_typed(cx: &mut TestAppContext) {
|
||||
assert_eq!(view.read_with(cx, |view, _| view.values.clone()), [0.0]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_blur_commits_the_typed_number(cx: &mut TestAppContext) {
|
||||
setup(cx);
|
||||
let (view, cx) = cx.add_window_view(|_, _| Committed { values: Vec::new() });
|
||||
cx.update(|window, cx| {
|
||||
window.activate_window();
|
||||
window.focus_next(cx);
|
||||
});
|
||||
cx.simulate_keystrokes("secondary-a");
|
||||
cx.simulate_input("12");
|
||||
cx.update(|window, cx| window.blur(cx));
|
||||
cx.run_until_parked();
|
||||
assert_eq!(view.read_with(cx, |view, _| view.values.clone()), [12.0]);
|
||||
}
|
||||
|
||||
struct Pin {
|
||||
code: String,
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user