audit input

This commit is contained in:
2026-11-07 17:00:36 +07:00
parent fd36ada35e
commit ffbb732f5f
9 changed files with 437 additions and 144 deletions
+22 -9
View File
@@ -3,10 +3,11 @@
use gpui::prelude::FluentBuilder as _; use gpui::prelude::FluentBuilder as _;
use gpui::{ use gpui::{
AnyView, App, ClickEvent, ElementId, InteractiveElement as _, IntoElement, ParentElement as _, AnyView, App, ClickEvent, ElementId, InteractiveElement as _, IntoElement, ParentElement as _,
RenderOnce, Role, SharedString, StatefulInteractiveElement as _, Styled as _, Window, relative, RenderOnce, Role, SharedString, StatefulInteractiveElement as _, Styled as _, Window, hsla,
relative,
}; };
use gpui_native_icons::{IconRole, icon}; use gpui_native_icons::{IconRole, icon};
use gpui_native_theme::{ActiveTheme as _, Size}; use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, Size};
use gpui_native_utils::focus_ring; use gpui_native_utils::focus_ring;
use std::rc::Rc; use std::rc::Rc;
@@ -161,6 +162,7 @@ impl RenderOnce for Button {
let borders = theme.borders; let borders = theme.borders;
let metrics = theme.sizes.resolve(self.size); let metrics = theme.sizes.resolve(self.size);
let show_border = borders.buttons && self.variant != ButtonVariant::Ghost;
let on_click = self.on_click; let on_click = self.on_click;
let a11y_label = self.a11y_label.or_else(|| self.label.clone()); let a11y_label = self.a11y_label.or_else(|| self.label.clone());
@@ -191,6 +193,19 @@ impl RenderOnce for Button {
), ),
}; };
let (border, hover_border, pressed_border) = if show_border {
(colors.border, colors.border, colors.border)
} else {
match background {
Some(fill) => (fill, hover, pressed),
None => (
hsla(0.0, 0.0, 0.0, 0.0),
hsla(0.0, 0.0, 0.0, 0.0),
hsla(0.0, 0.0, 0.0, 0.0),
),
}
};
h_flex() h_flex()
.id(self.id.clone()) .id(self.id.clone())
.when_some(self.debug_selector, |button, selector| { .when_some(self.debug_selector, |button, selector| {
@@ -198,7 +213,7 @@ impl RenderOnce for Button {
}) })
.role(Role::Button) .role(Role::Button)
.justify_center() .justify_center()
.h(metrics.control_height) .h(metrics.control_height + BORDER_WIDTH * 2.0)
.when(self.icon.is_some(), |button| { .when(self.icon.is_some(), |button| {
button.px(metrics.dense_padding_x) button.px(metrics.dense_padding_x)
}) })
@@ -207,10 +222,8 @@ impl RenderOnce for Button {
.gap_2() .gap_2()
.rounded(theme.radius.control) .rounded(theme.radius.control)
.text_size(metrics.text_size) .text_size(metrics.text_size)
.when( .border_1()
borders.buttons && self.variant != ButtonVariant::Ghost, .border_color(border)
|button| button.border_1().border_color(colors.border),
)
.when_some(background, |button, background| button.bg(background)) .when_some(background, |button, background| button.bg(background))
.text_color(foreground) .text_color(foreground)
.when_some(a11y_label, |button, label| button.aria_label(label)) .when_some(a11y_label, |button, label| button.aria_label(label))
@@ -237,8 +250,8 @@ impl RenderOnce for Button {
.cursor_pointer() .cursor_pointer()
.focusable() .focusable()
.tab_stop(self.tab_stop) .tab_stop(self.tab_stop)
.hover(move |style| style.bg(hover)) .hover(move |style| style.bg(hover).border_color(hover_border))
.active(move |style| style.bg(pressed)) .active(move |style| style.bg(pressed).border_color(pressed_border))
.focus_visible(|style| style.shadow(focus_ring(colors.ring))) .focus_visible(|style| style.shadow(focus_ring(colors.ring)))
.when_some(on_click, |button, on_click| { .when_some(on_click, |button, on_click| {
button.on_click(move |event, window, cx| on_click(event, window, cx)) button.on_click(move |event, window, cx| on_click(event, window, cx))
+113 -32
View File
@@ -7,15 +7,17 @@ use unicode_segmentation::UnicodeSegmentation;
use gpui::prelude::FluentBuilder as _; use gpui::prelude::FluentBuilder as _;
use gpui::{ use gpui::{
App, AppContext as _, Bounds, ClipboardItem, ContentMask, Context, CursorStyle, DefiniteLength, AnyElement, App, AppContext as _, Bounds, ClipboardItem, ContentMask, Context, CursorStyle,
DispatchPhase, ElementId, Entity, EntityInputHandler, EventEmitter, Font, HitboxBehavior, DefiniteLength, DispatchPhase, ElementId, Entity, EntityInputHandler, EventEmitter, Font,
InteractiveElement as _, IntoElement, KeyBinding, KeyDownEvent, MouseButton, MouseDownEvent, HitboxBehavior, InteractiveElement as _, IntoElement, KeyBinding, KeyDownEvent, MouseButton,
MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, RenderOnce, Role, ScrollDelta, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, RenderOnce, Role,
ScrollEvent, SharedString, StatefulInteractiveElement as _, Styled as _, Subscription, Task, ScrollDelta, ScrollEvent, SharedString, StatefulInteractiveElement as _, Styled as _,
TextAlign, TextRun, UTF16Selection, Window, WrappedLine, actions, canvas, div, fill, point, px, Subscription, Task, TextAlign, TextRun, UTF16Selection, Window, WrappedLine, actions, canvas,
div, fill, point, px,
}; };
use gpui_native_icons::IconRole; use gpui_native_icons::IconRole;
use gpui_native_theme::{ActiveTheme as _, ColorTokens, Size}; use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, ColorTokens, Size};
use gpui_native_utils::focus_ring;
use crate::button::{Button, ButtonVariant}; use crate::button::{Button, ButtonVariant};
use crate::common::{Sizable, h_flex}; use crate::common::{Sizable, h_flex};
@@ -1904,6 +1906,8 @@ pub struct Input {
a11y_label: Option<SharedString>, a11y_label: Option<SharedString>,
/// Multiline field height in rows; ignored when the state is single-line. /// Multiline field height in rows; ignored when the state is single-line.
rows: usize, rows: usize,
prefix: Option<AnyElement>,
suffix: Option<AnyElement>,
} }
impl Input { impl Input {
@@ -1920,6 +1924,8 @@ impl Input {
disabled: false, disabled: false,
a11y_label: None, a11y_label: None,
rows: DEFAULT_ROWS, rows: DEFAULT_ROWS,
prefix: None,
suffix: None,
} }
} }
@@ -1952,6 +1958,24 @@ impl Input {
self.a11y_label = Some(label.into()); self.a11y_label = Some(label.into());
self self
} }
/// Docks an element inside the field's leading edge.
pub fn prefix<E>(mut self, prefix: E) -> Self
where
E: IntoElement,
{
self.prefix = Some(prefix.into_any_element());
self
}
/// Docks an element inside the field's trailing edge.
pub fn suffix<E>(mut self, suffix: E) -> Self
where
E: IntoElement,
{
self.suffix = Some(suffix.into_any_element());
self
}
} }
impl Sizable for Input { impl Sizable for Input {
@@ -1974,18 +1998,20 @@ impl RenderOnce for Input {
let revealed = self.state.read(cx).is_revealed(); let revealed = self.state.read(cx).is_revealed();
let multiline = self.state.read(cx).is_multiline(); let multiline = self.state.read(cx).is_multiline();
let right_padding = if is_password { let small = theme.sizes.resolve(Size::Small);
metrics.dense_padding_x + metrics.dense_icon_size let docked_height = small.control_height + BORDER_WIDTH * 2.0;
} else { let edge_inset = ((metrics.input_height - docked_height) / 2.0).max(Pixels::ZERO);
metrics.dense_padding_x
};
// The field's own breathing room; the text starts here from the top when multiline.
let vertical_inset = metrics.dense_padding_x * 0.5; let vertical_inset = metrics.dense_padding_x * 0.5;
let reveal_width = small.dense_padding_x * 2.0 + small.icon_size;
let field_padding = FieldPadding { let field_padding = FieldPadding {
left: metrics.dense_padding_x, left: Pixels::ZERO,
right: right_padding, right: if is_password {
(edge_inset + reveal_width - metrics.dense_padding_x).max(Pixels::ZERO)
} else {
Pixels::ZERO
},
top: vertical_inset, top: vertical_inset,
}; };
@@ -1993,9 +2019,10 @@ impl RenderOnce for Input {
DefiniteLength::Fraction(ratio) => metrics.text_size * ratio, DefiniteLength::Fraction(ratio) => metrics.text_size * ratio,
DefiniteLength::Absolute(length) => length.to_pixels(window.rem_size()), DefiniteLength::Absolute(length) => length.to_pixels(window.rem_size()),
}); });
let field_height = line_height.map_or(metrics.input_height, |line_height| { let field_height = line_height.map_or(metrics.input_height, |line_height| {
line_height * self.rows as f32 + vertical_inset * 2.0 line_height * self.rows as f32 + vertical_inset * 2.0
}); }) + BORDER_WIDTH * 2.0;
let fallback_label = self let fallback_label = self
.a11y_label .a11y_label
@@ -2003,8 +2030,20 @@ impl RenderOnce for Input {
.or_else(|| (!self.placeholder.is_empty()).then_some(self.placeholder.clone())) .or_else(|| (!self.placeholder.is_empty()).then_some(self.placeholder.clone()))
.unwrap_or_else(|| SharedString::from(self.id.to_string())); .unwrap_or_else(|| SharedString::from(self.id.to_string()));
let edge_zone = |name: &'static str, element: AnyElement| {
div()
.debug_selector(|| name.to_string())
.flex_none()
.h_full()
.flex()
.items_center()
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
.child(element)
};
h_flex() h_flex()
.id(self.id.clone()) .id(self.id.clone())
.debug_selector(|| "input-field".to_string())
.key_context(INPUT_CONTEXT) .key_context(INPUT_CONTEXT)
.role(Role::TextInput) .role(Role::TextInput)
.aria_label(fallback_label) .aria_label(fallback_label)
@@ -2012,23 +2051,55 @@ impl RenderOnce for Input {
.overflow_hidden() .overflow_hidden()
.w_full() .w_full()
.h(field_height) .h(field_height)
.px(metrics.dense_padding_x) .pl(if self.prefix.is_some() {
edge_inset
} else {
metrics.dense_padding_x
})
.pr(if self.suffix.is_some() {
edge_inset
} else {
metrics.dense_padding_x
})
.gap_1p5()
.rounded(radius.control) .rounded(radius.control)
.border_1()
.border_color(colors.border)
.bg(colors.input) .bg(colors.input)
.text_size(metrics.text_size) .text_size(metrics.text_size)
.text_color(colors.foreground) .text_color(colors.foreground)
.when_some(line_height, |input, line_height| { .border_1()
input.items_start().line_height(line_height) .border_color(if theme.borders.fields {
colors.border
} else {
colors.input
}) })
.when(self.disabled, |input| input.opacity(0.5)) .when(self.disabled, |input| input.opacity(0.5))
.when(!self.disabled, |input| { .when(!self.disabled, |input| {
input.track_focus(&focus_handle).tab_stop(true) input.track_focus(&focus_handle).tab_stop(true)
}) })
.when(focused, |input| input.border_color(colors.ring)) .when(focused && !theme.borders.focus_ring, |input| {
.when(is_empty && !focused, |input| { input.border_color(colors.ring)
input.child( })
.when(focused && theme.borders.focus_ring, |input| {
input.shadow(focus_ring(colors.ring))
})
.when_some(self.prefix, |input, prefix| {
input.child(edge_zone("input-prefix", prefix))
})
.child(
div()
.relative()
.flex_1()
.min_w_0()
.h_full()
.overflow_hidden()
.flex()
.flex_row()
.items_center()
.when_some(line_height, |region, line_height| {
region.items_start().line_height(line_height)
})
.when(is_empty && !focused, |region| {
region.child(
div() div()
.text_color(colors.muted_foreground) .text_color(colors.muted_foreground)
.when(multiline, |placeholder| placeholder.pt(vertical_inset)) .when(multiline, |placeholder| placeholder.pt(vertical_inset))
@@ -2041,12 +2112,16 @@ impl RenderOnce for Input {
let state = self.state.clone(); let state = self.state.clone();
move |bounds, window, cx| { move |bounds, window, cx| {
// The hitbox backs the I-beam hover cursor. // The hitbox backs the I-beam hover cursor.
let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal); let hitbox =
window.insert_hitbox(bounds, HitboxBehavior::Normal);
let wrap_width = multiline.then(|| { let wrap_width = multiline.then(|| {
(bounds.size.width - field_padding.left - field_padding.right) (bounds.size.width
- field_padding.left
- field_padding.right)
.max(Pixels::ZERO) .max(Pixels::ZERO)
}); });
let text = state.read(cx).shape_current_text(wrap_width, window); let text =
state.read(cx).shape_current_text(wrap_width, window);
(text, hitbox) (text, hitbox)
} }
}, },
@@ -2091,8 +2166,9 @@ impl RenderOnce for Input {
), ),
}; };
let consumed = state let consumed = state.update(cx, |state, cx| {
.update(cx, |state, cx| state.scroll_by(delta, window, cx)); state.scroll_by(delta, window, cx)
});
if consumed { if consumed {
cx.stop_propagation(); cx.stop_propagation();
@@ -2108,21 +2184,26 @@ impl RenderOnce for Input {
) )
.absolute() .absolute()
.inset_0(), .inset_0(),
),
) )
.when_some(self.suffix, |input, suffix| {
input.child(edge_zone("input-suffix", suffix))
})
.when(is_password && !self.disabled, |input| { .when(is_password && !self.disabled, |input| {
input.child( input.child(
div() div()
.id((self.id.clone(), "input-reveal")) .id((self.id.clone(), "input-reveal"))
.debug_selector(|| "input-reveal".to_string()) .debug_selector(|| "input-reveal".to_string())
.absolute() .absolute()
.right(metrics.dense_padding_x / 2.0) .right(edge_inset)
.top_0p5() .top_0()
.bottom_0p5() .bottom_0()
.flex() .flex()
.items_center() .items_center()
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
.child( .child(
Button::new((self.id.clone(), "input-reveal-button")) Button::new((self.id.clone(), "input-reveal-button"))
.debug_selector(|| "input-reveal-button".to_string())
.icon(if revealed { .icon(if revealed {
IconRole::EyeOff IconRole::EyeOff
} else { } else {
+9 -7
View File
@@ -5,7 +5,7 @@ use gpui::{
use gpui_native_core::button::{Button, ButtonVariant}; use gpui_native_core::button::{Button, ButtonVariant};
use gpui_native_core::input::{Input, InputState}; use gpui_native_core::input::{Input, InputState};
use gpui_native_theme::ActiveTheme as _; use gpui_native_theme::ActiveTheme as _;
use gpui_native_theme::Theme; use gpui_native_theme::{BORDER_WIDTH, Theme};
struct Harness { struct Harness {
state: Entity<InputState>, state: Entity<InputState>,
@@ -36,9 +36,10 @@ impl Render for Harness {
} }
} }
/// The medium input renders at the theme's stated field height and the /// The medium input and button render at the stated height plus the
/// medium button at the stated control height; the two match wherever the /// hairline border sized outside it, so the two match wherever the platform
/// platform states equal values (macOS, Windows, GNOME). /// states equal values (macOS, Windows, GNOME) and stay equal to each other
/// on every platform.
#[gpui::test] #[gpui::test]
fn controls_render_at_the_stated_heights(cx: &mut TestAppContext) { fn controls_render_at_the_stated_heights(cx: &mut TestAppContext) {
cx.update(Theme::init); cx.update(Theme::init);
@@ -59,9 +60,10 @@ fn controls_render_at_the_stated_heights(cx: &mut TestAppContext) {
.debug_bounds("input-wrapper") .debug_bounds("input-wrapper")
.expect("input rendered"); .expect("input rendered");
// Both bounds are snapped to device pixels, so allow half a pixel. // Both bounds are snapped to device pixels, so allow half a pixel. The
let input_height = cx.update(|cx| cx.theme().sizes.medium.input_height); // stated height is the interior; the border sizes outside it.
let button_height = cx.update(|cx| cx.theme().sizes.medium.control_height); let input_height = cx.update(|cx| cx.theme().sizes.medium.input_height) + BORDER_WIDTH * 2.0;
let button_height = cx.update(|cx| cx.theme().sizes.medium.control_height) + BORDER_WIDTH * 2.0;
assert!((input.size.height - input_height).abs() <= gpui::px(0.5)); assert!((input.size.height - input_height).abs() <= gpui::px(0.5));
assert!((button.size.height - button_height).abs() <= gpui::px(0.5)); assert!((button.size.height - button_height).abs() <= gpui::px(0.5));
} }
+163 -2
View File
@@ -3,12 +3,15 @@ use gpui::{
MouseUpEvent, ParentElement as _, Render, ScrollDelta, ScrollEvent, Styled as _, MouseUpEvent, ParentElement as _, Render, ScrollDelta, ScrollEvent, Styled as _,
TestAppContext, VisualTestContext, Window, div, point, px, TestAppContext, VisualTestContext, Window, div, point, px,
}; };
use gpui_native_core::button::{Button, ButtonVariant};
use gpui_native_core::common::Sizable as _;
use gpui_native_core::input::{ use gpui_native_core::input::{
INPUT_CONTEXT, Input, InputBindings, InputState, InputStateEvent, Submit, SubmitOn, INPUT_CONTEXT, Input, InputBindings, InputState, InputStateEvent, Submit, SubmitOn,
bind_input_keys, bind_input_keys,
}; };
use gpui_native_theme::Theme; use gpui_native_icons::IconRole;
use std::cell::RefCell; use gpui_native_theme::{BORDER_WIDTH, Size, Theme};
use std::cell::{Cell, RefCell};
use std::rc::Rc; use std::rc::Rc;
use std::time::Duration; use std::time::Duration;
@@ -763,6 +766,36 @@ fn the_reveal_toggle_flips_masking() {
assert!(!revealed, "the toggle should mask the field again"); assert!(!revealed, "the toggle should mask the field again");
} }
/// The reveal pill sits with equal insets on its three free sides; the
/// small control metric must stay whole-pixel or snapping skews it.
#[test]
fn the_reveal_pill_insets_equally() {
let (cx, _state, mut visual) = reveal_window();
let field = visual.debug_bounds("input-field").expect("field missing");
let pill = visual
.debug_bounds("input-reveal-button")
.expect("reveal pill missing");
cx.update(|cx| {
let theme = Theme::global(cx);
let small = theme.sizes.resolve(Size::Small);
let border = BORDER_WIDTH;
let docked = small.control_height + border * 2.0;
let inset = ((field.size.height - border * 2.0 - docked) / 2.0).max(px(0.));
let top_gap = pill.top() - (field.top() + border);
let bottom_gap = (field.bottom() - border) - pill.bottom();
let right_gap = (field.right() - border) - pill.right();
assert!(
(top_gap - inset).abs() < px(0.01)
&& (bottom_gap - inset).abs() < px(0.01)
&& (right_gap - inset).abs() < px(0.01),
"pill gaps top {top_gap:?} bottom {bottom_gap:?} right {right_gap:?} vs inset {inset:?}"
);
});
}
#[test] #[test]
fn the_caret_can_rest_on_a_line_break() { fn the_caret_can_rest_on_a_line_break() {
let (cx, state, mut visual) = multiline_window("ab\ncdef"); let (cx, state, mut visual) = multiline_window("ab\ncdef");
@@ -906,3 +939,131 @@ fn vertical_scroll_keeps_the_caret_in_view() {
.unwrap_or_default(); .unwrap_or_default();
assert!(first_row_top < px(0.), "first row top: {first_row_top:?}"); assert!(first_row_top < px(0.), "first row top: {first_row_top:?}");
} }
struct EdgeHarness {
state: Entity<InputState>,
clicked: Rc<Cell<bool>>,
}
impl Render for EdgeHarness {
fn render(
&mut self,
_window: &mut Window,
_cx: &mut gpui::Context<Self>,
) -> impl gpui::IntoElement {
let clicked = self.clicked.clone();
div().w(px(400.)).p_4().child(
Input::new("edge-input", self.state.clone())
.placeholder("Search")
.prefix(
Button::new("edge-input-prefix-button")
.icon(IconRole::Search)
.variant(ButtonVariant::Ghost)
.small(),
)
.suffix(
Button::new("edge-input-suffix-button")
.debug_selector(|| "edge-suffix-button".to_string())
.icon(IconRole::Plus)
.variant(ButtonVariant::Ghost)
.small()
.on_click(move |_, _, _| clicked.set(true)),
),
)
}
}
fn edge_input_window(
initial: &str,
) -> (
TestAppContext,
Entity<InputState>,
VisualTestContext,
Rc<Cell<bool>>,
) {
let clicked = Rc::new(Cell::new(false));
let clicked_for_window = clicked.clone();
let mut cx = TestAppContext::single();
cx.update(|cx| {
Theme::init(cx);
bind_input_keys(cx, InputBindings::default());
});
let state = cx.update(|cx| InputState::new_with_text(initial, cx));
let state_for_window = state.clone();
let window = cx.add_window(move |_, _| EdgeHarness {
state: state_for_window,
clicked: clicked_for_window,
});
let mut visual = VisualTestContext::from_window(window.into(), &cx);
visual.update(|window, cx| {
window.draw(cx).clear(cx);
});
(cx, state, visual, clicked)
}
/// Prefix and suffix dock flush inside the field, prefix leading, and a
/// small button in a zone centers with equal insets like the reveal pill.
#[test]
fn prefix_and_suffix_dock_inside_the_field() {
let (cx, _state, mut visual, _clicked) = edge_input_window("abc");
let field = visual.debug_bounds("input-field").expect("field missing");
let prefix = visual
.debug_bounds("input-prefix")
.expect("prefix zone missing");
let suffix = visual
.debug_bounds("input-suffix")
.expect("suffix zone missing");
let pill = visual
.debug_bounds("edge-suffix-button")
.expect("suffix button missing");
assert!(prefix.left() >= field.left());
assert!(prefix.right() <= suffix.left());
assert!(suffix.right() <= field.right());
cx.update(|cx| {
let theme = Theme::global(cx);
let small = theme.sizes.resolve(Size::Small);
let border = BORDER_WIDTH;
let docked = small.control_height + border * 2.0;
let inset = ((field.size.height - border * 2.0 - docked) / 2.0).max(px(0.));
let top_gap = pill.top() - (field.top() + border);
let bottom_gap = (field.bottom() - border) - pill.bottom();
let right_gap = (field.right() - border) - pill.right();
assert!(
(top_gap - inset).abs() < px(0.01)
&& (bottom_gap - inset).abs() < px(0.01)
&& (right_gap - inset).abs() < px(0.01),
"suffix pill gaps top {top_gap:?} bottom {bottom_gap:?} right {right_gap:?} vs inset {inset:?}"
);
});
}
/// A click on a docked control activates it without running the field's
/// caret handler underneath.
#[test]
fn a_suffix_click_activates_without_focusing_the_field() {
let (cx, state, mut visual, clicked) = edge_input_window("abc");
let bounds = visual
.debug_bounds("input-suffix")
.expect("suffix zone missing");
click(
&mut visual,
point(
bounds.left() + bounds.size.width / 2.0,
bounds.top() + bounds.size.height / 2.0,
),
);
assert!(clicked.get(), "the suffix button should activate");
let focused = visual.update(|window, cx| state.read(cx).focus_handle().is_focused(window));
assert!(!focused, "the suffix click must not focus the field");
// The zone must not swallow text interaction either.
focus(&mut visual, &state);
visual.simulate_keystrokes("x");
assert_eq!(value(&cx, &state), "abcx");
}
+8 -4
View File
@@ -23,6 +23,10 @@ pub use system::{AccessibilityPreferences, PartialPalette, SourceKind, SystemThe
use gpui::{App, Global, Hsla, Pixels, WindowAppearance, px}; use gpui::{App, Global, Hsla, Pixels, WindowAppearance, px};
use std::time::Duration; use std::time::Duration;
/// The hairline width of the control border every button and field paints;
/// sized outside the stated metrics so control sizes match across platforms.
pub const BORDER_WIDTH: Pixels = px(1.0);
/// Light or dark, resolved from the system appearance. /// Light or dark, resolved from the system appearance.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Deserialize)] #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
@@ -459,17 +463,17 @@ impl SizeTable {
/// Sets the medium class's text-field height to the platform's stated value. /// Sets the medium class's text-field height to the platform's stated value.
pub fn set_input_height(&mut self, medium_input_height: Pixels) { pub fn set_input_height(&mut self, medium_input_height: Pixels) {
let ratio = medium_input_height / self.medium.input_height; let ratio = medium_input_height / self.medium.input_height;
self.small.input_height *= ratio; self.small.input_height = (self.small.input_height * ratio).floor();
self.medium.input_height = medium_input_height; self.medium.input_height = medium_input_height;
self.large.input_height *= ratio; self.large.input_height = (self.large.input_height * ratio).floor();
} }
/// Sets the medium class's control height to the platform's stated value. /// Sets the medium class's control height to the platform's stated value.
pub fn set_control_height(&mut self, medium_control_height: Pixels) { pub fn set_control_height(&mut self, medium_control_height: Pixels) {
let ratio = medium_control_height / self.medium.control_height; let ratio = medium_control_height / self.medium.control_height;
self.small.control_height *= ratio; self.small.control_height = (self.small.control_height * ratio).floor();
self.medium.control_height = medium_control_height; self.medium.control_height = medium_control_height;
self.large.control_height *= ratio; self.large.control_height = (self.large.control_height * ratio).floor();
} }
/// Sets the medium class's menu row height to the platform's stated value. /// Sets the medium class's menu row height to the platform's stated value.
+6 -16
View File
@@ -449,17 +449,11 @@ mod tests {
let mut stated = system(ThemeVariant::Light, PartialPalette::default(), None); let mut stated = system(ThemeVariant::Light, PartialPalette::default(), None);
stated.input_height = Some(px(24.)); stated.input_height = Some(px(24.));
let resolved = resolve(&stated, None, None); let resolved = resolve(&stated, None, None);
// The medium class takes the stated height verbatim, and the other
// classes keep their derived ratio to the medium class.
assert_eq!(resolved.sizes.medium.input_height, px(24.)); assert_eq!(resolved.sizes.medium.input_height, px(24.));
// Derive what small would be without the statement, scale it by the
// stated-to-derived ratio, and compare.
let derived = SizeTable::with_root_font_size(px(16.)); let derived = SizeTable::with_root_font_size(px(16.));
let scale = px(24.) / derived.medium.input_height; let scale = px(24.) / derived.medium.input_height;
let expected_small = derived.small.input_height * scale; let expected_small = (derived.small.input_height * scale).floor();
assert!((resolved.sizes.small.input_height - expected_small).abs() < px(0.01)); assert_eq!(resolved.sizes.small.input_height, expected_small);
} }
#[test] #[test]
@@ -471,14 +465,10 @@ mod tests {
let derived = SizeTable::with_root_font_size(px(16.)); let derived = SizeTable::with_root_font_size(px(16.));
let scale = px(32.) / derived.medium.control_height; let scale = px(32.) / derived.medium.control_height;
assert!( let expected_small = (derived.small.control_height * scale).floor();
(resolved.sizes.small.control_height - derived.small.control_height * scale).abs() let expected_large = (derived.large.control_height * scale).floor();
< px(0.01) assert_eq!(resolved.sizes.small.control_height, expected_small);
); assert_eq!(resolved.sizes.large.control_height, expected_large);
assert!(
(resolved.sizes.large.control_height - derived.large.control_height * scale).abs()
< px(0.01)
);
} }
#[test] #[test]
+2 -2
View File
@@ -35,9 +35,9 @@ pub fn focus_handle(id: &ElementId, window: &mut Window, cx: &mut App) -> FocusH
.clone() .clone()
} }
/// Returns a focus ring shadow with the given color. /// Returns a focus ring shadow with the given color at half ink.
pub fn focus_ring(color: Hsla) -> Vec<BoxShadow> { pub fn focus_ring(color: Hsla) -> Vec<BoxShadow> {
vec![BoxShadow::new(px(0.), px(0.), color).spread_radius(px(3.))] vec![BoxShadow::new(px(0.), px(0.), color.alpha(0.5)).spread_radius(px(3.))]
} }
/// The next selectable row in the direction, or `None` at the ends. /// The next selectable row in the direction, or `None` at the ends.
+18 -9
View File
@@ -9,17 +9,14 @@ is the first downstream consumer; its screens drive the order.
## Next ## Next
- [ ] **Input platform chrome** — field border behind - [ ] **Select platform chrome** — the input treatment on the trigger; the
`Theme::borders.fields`; focus split between the ring shadow and popup border behind `Theme::borders.overlays`.
border recolor via `Theme::borders.focus_ring`.
## Queue — to follow ## Queue — to follow
Platform metric adoption continues one component at a time; each stop goes Platform metric adoption continues one component at a time; each stop goes
through the review gate and a gallery pass. through the review gate and a gallery pass.
- [ ] **Select platform chrome** — the input treatment on the trigger; the
popup border behind `Theme::borders.overlays`.
- [ ] **Menu platform chrome** — overlay border gate; paddings, separators, - [ ] **Menu platform chrome** — overlay border gate; paddings, separators,
and the row min-height through `Theme::spacing` and the stated menu and the row min-height through `Theme::spacing` and the stated menu
row height. row height.
@@ -87,13 +84,25 @@ Gaps surfaced by the signed comparison; ordered by signed's usage.
### core (`crates/core`) ### core (`crates/core`)
- [x] Button platform chrome — the filled variants' 1px border gated behind - [x] Button platform chrome — every variant always paints the hairline
`Theme::borders.buttons` (Windows / KDE keep it, macOS / GNOME paint border, sized outside the stated control height (`BORDER_WIDTH`); where
fills, ghost stays borderless); icon-bearing buttons pad with the platform paints no border (`Theme::borders.buttons`, ghost) the
`dense_padding_x` instead of `padding_x` border takes the fill's color and disappears, so sizes match across
platforms; icon-bearing buttons pad with `dense_padding_x` instead of
`padding_x`
- [x] Button loading state — `.loading(true)` shows the spinner in place of - [x] Button loading state — `.loading(true)` shows the spinner in place of
the icon and suppresses activation; Spinner gained a `.color` override the icon and suppresses activation; Spinner gained a `.color` override
for accent-filled surfaces for accent-filled surfaces
- [x] Input platform chrome — the field always paints the hairline border,
sized outside the stated field height; where the platform paints no
border (`Theme::borders.fields`) it takes the field's fill; focus
recolors the border on Windows / KDE and paints the accent ring on
macOS / GNOME via `Theme::borders.focus_ring`; the shared `focus_ring`
band paints at half ink (every focusable control picks it up)
- [x] Input prefix / suffix — elements docked inside the field's edges,
typically small ghost buttons; a docked click activates the control
without reaching the field's caret handler, and the text region shrinks
to make room
- [x] Button, Checkbox, Switch, Radio + RadioGroup, Toggle, Link, Slider, - [x] Button, Checkbox, Switch, Radio + RadioGroup, Toggle, Link, Slider,
Progress, Avatar (image + seeded pixel-art fallback) Progress, Avatar (image + seeded pixel-art fallback)
- [x] Spinner — painted ring + rotating-icon variant, indeterminate and - [x] Spinner — painted ring + rotating-icon variant, indeterminate and
+34 -1
View File
@@ -974,6 +974,7 @@ struct InputDemo {
text: Entity<InputState>, text: Entity<InputState>,
password: Entity<InputState>, password: Entity<InputState>,
notes: Entity<InputState>, notes: Entity<InputState>,
search: Entity<InputState>,
_subscriptions: Vec<gpui::Subscription>, _subscriptions: Vec<gpui::Subscription>,
} }
@@ -984,17 +985,20 @@ impl InputDemo {
password.update(cx, |state, cx| state.set_password(true, cx)); password.update(cx, |state, cx| state.set_password(true, cx));
let notes = InputState::new(cx); let notes = InputState::new(cx);
notes.update(cx, |state, cx| state.set_multiline(true, cx)); notes.update(cx, |state, cx| state.set_multiline(true, cx));
let search = InputState::new(cx);
// The panel is a cached view in the dock: the value readouts re-render // The panel is a cached view in the dock: the value readouts re-render
// only when the inputs it reads are notified. // only when the inputs it reads are notified.
let subscriptions = vec![ let subscriptions = vec![
cx.observe(&text, |_, _, cx| cx.notify()), cx.observe(&text, |_, _, cx| cx.notify()),
cx.observe(&password, |_, _, cx| cx.notify()), cx.observe(&password, |_, _, cx| cx.notify()),
cx.observe(&notes, |_, _, cx| cx.notify()), cx.observe(&notes, |_, _, cx| cx.notify()),
cx.observe(&search, |_, _, cx| cx.notify()),
]; ];
Self { Self {
text, text,
password, password,
notes, notes,
search,
_subscriptions: subscriptions, _subscriptions: subscriptions,
} }
} }
@@ -1007,7 +1011,36 @@ impl Render for InputDemo {
ScrollArea::new("input-demo").size_full().p_4().child( ScrollArea::new("input-demo").size_full().p_4().child(
v_flex() v_flex()
.gap_3() .gap_3()
.child(Input::new("input-demo-text", self.text.clone()).placeholder("Type here…")) .child(
Input::new("input-demo-search", self.search.clone())
.placeholder("Search")
.prefix(
Button::new("input-demo-search-prefix")
.icon(IconRole::Search)
.variant(ButtonVariant::Ghost)
.small()
.accessibility_label("Search"),
),
)
.child(
Input::new("input-demo-text", self.text.clone())
.placeholder("Type here…")
.suffix(
Button::new("input-demo-clear")
.icon(IconRole::Close)
.variant(ButtonVariant::Ghost)
.small()
.accessibility_label("Clear")
.on_click({
let text = self.text.clone();
move |_, window, cx| {
text.update(cx, |state, cx| {
state.set_value("", window, cx)
});
}
}),
),
)
.child( .child(
Input::new("input-demo-password", self.password.clone()) Input::new("input-demo-password", self.password.clone())
.placeholder("Password"), .placeholder("Password"),