chore: audit all components #2
@@ -3,10 +3,11 @@
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
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_theme::{ActiveTheme as _, Size};
|
||||
use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, Size};
|
||||
use gpui_native_utils::focus_ring;
|
||||
use std::rc::Rc;
|
||||
|
||||
@@ -161,6 +162,7 @@ impl RenderOnce for Button {
|
||||
let borders = theme.borders;
|
||||
let metrics = theme.sizes.resolve(self.size);
|
||||
|
||||
let show_border = borders.buttons && self.variant != ButtonVariant::Ghost;
|
||||
let on_click = self.on_click;
|
||||
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()
|
||||
.id(self.id.clone())
|
||||
.when_some(self.debug_selector, |button, selector| {
|
||||
@@ -198,7 +213,7 @@ impl RenderOnce for Button {
|
||||
})
|
||||
.role(Role::Button)
|
||||
.justify_center()
|
||||
.h(metrics.control_height)
|
||||
.h(metrics.control_height + BORDER_WIDTH * 2.0)
|
||||
.when(self.icon.is_some(), |button| {
|
||||
button.px(metrics.dense_padding_x)
|
||||
})
|
||||
@@ -207,10 +222,8 @@ impl RenderOnce for Button {
|
||||
.gap_2()
|
||||
.rounded(theme.radius.control)
|
||||
.text_size(metrics.text_size)
|
||||
.when(
|
||||
borders.buttons && self.variant != ButtonVariant::Ghost,
|
||||
|button| button.border_1().border_color(colors.border),
|
||||
)
|
||||
.border_1()
|
||||
.border_color(border)
|
||||
.when_some(background, |button, background| button.bg(background))
|
||||
.text_color(foreground)
|
||||
.when_some(a11y_label, |button, label| button.aria_label(label))
|
||||
@@ -237,8 +250,8 @@ impl RenderOnce for Button {
|
||||
.cursor_pointer()
|
||||
.focusable()
|
||||
.tab_stop(self.tab_stop)
|
||||
.hover(move |style| style.bg(hover))
|
||||
.active(move |style| style.bg(pressed))
|
||||
.hover(move |style| style.bg(hover).border_color(hover_border))
|
||||
.active(move |style| style.bg(pressed).border_color(pressed_border))
|
||||
.focus_visible(|style| style.shadow(focus_ring(colors.ring)))
|
||||
.when_some(on_click, |button, on_click| {
|
||||
button.on_click(move |event, window, cx| on_click(event, window, cx))
|
||||
|
||||
+175
-94
@@ -7,15 +7,17 @@ use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
App, AppContext as _, Bounds, ClipboardItem, ContentMask, Context, CursorStyle, DefiniteLength,
|
||||
DispatchPhase, ElementId, Entity, EntityInputHandler, EventEmitter, Font, HitboxBehavior,
|
||||
InteractiveElement as _, IntoElement, KeyBinding, KeyDownEvent, MouseButton, MouseDownEvent,
|
||||
MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, RenderOnce, Role, ScrollDelta,
|
||||
ScrollEvent, SharedString, StatefulInteractiveElement as _, Styled as _, Subscription, Task,
|
||||
TextAlign, TextRun, UTF16Selection, Window, WrappedLine, actions, canvas, div, fill, point, px,
|
||||
AnyElement, App, AppContext as _, Bounds, ClipboardItem, ContentMask, Context, CursorStyle,
|
||||
DefiniteLength, DispatchPhase, ElementId, Entity, EntityInputHandler, EventEmitter, Font,
|
||||
HitboxBehavior, InteractiveElement as _, IntoElement, KeyBinding, KeyDownEvent, MouseButton,
|
||||
MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, RenderOnce, Role,
|
||||
ScrollDelta, ScrollEvent, SharedString, StatefulInteractiveElement as _, Styled as _,
|
||||
Subscription, Task, TextAlign, TextRun, UTF16Selection, Window, WrappedLine, actions, canvas,
|
||||
div, fill, point, px,
|
||||
};
|
||||
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::common::{Sizable, h_flex};
|
||||
@@ -1904,6 +1906,8 @@ pub struct Input {
|
||||
a11y_label: Option<SharedString>,
|
||||
/// Multiline field height in rows; ignored when the state is single-line.
|
||||
rows: usize,
|
||||
prefix: Option<AnyElement>,
|
||||
suffix: Option<AnyElement>,
|
||||
}
|
||||
|
||||
impl Input {
|
||||
@@ -1920,6 +1924,8 @@ impl Input {
|
||||
disabled: false,
|
||||
a11y_label: None,
|
||||
rows: DEFAULT_ROWS,
|
||||
prefix: None,
|
||||
suffix: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1952,6 +1958,24 @@ impl Input {
|
||||
self.a11y_label = Some(label.into());
|
||||
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 {
|
||||
@@ -1974,18 +1998,20 @@ impl RenderOnce for Input {
|
||||
let revealed = self.state.read(cx).is_revealed();
|
||||
let multiline = self.state.read(cx).is_multiline();
|
||||
|
||||
let right_padding = if is_password {
|
||||
metrics.dense_padding_x + metrics.dense_icon_size
|
||||
} else {
|
||||
metrics.dense_padding_x
|
||||
};
|
||||
let small = theme.sizes.resolve(Size::Small);
|
||||
let docked_height = small.control_height + BORDER_WIDTH * 2.0;
|
||||
let edge_inset = ((metrics.input_height - docked_height) / 2.0).max(Pixels::ZERO);
|
||||
|
||||
// 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 reveal_width = small.dense_padding_x * 2.0 + small.icon_size;
|
||||
|
||||
let field_padding = FieldPadding {
|
||||
left: metrics.dense_padding_x,
|
||||
right: right_padding,
|
||||
left: Pixels::ZERO,
|
||||
right: if is_password {
|
||||
(edge_inset + reveal_width - metrics.dense_padding_x).max(Pixels::ZERO)
|
||||
} else {
|
||||
Pixels::ZERO
|
||||
},
|
||||
top: vertical_inset,
|
||||
};
|
||||
|
||||
@@ -1993,9 +2019,10 @@ impl RenderOnce for Input {
|
||||
DefiniteLength::Fraction(ratio) => metrics.text_size * ratio,
|
||||
DefiniteLength::Absolute(length) => length.to_pixels(window.rem_size()),
|
||||
});
|
||||
|
||||
let field_height = line_height.map_or(metrics.input_height, |line_height| {
|
||||
line_height * self.rows as f32 + vertical_inset * 2.0
|
||||
});
|
||||
}) + BORDER_WIDTH * 2.0;
|
||||
|
||||
let fallback_label = self
|
||||
.a11y_label
|
||||
@@ -2003,8 +2030,20 @@ impl RenderOnce for Input {
|
||||
.or_else(|| (!self.placeholder.is_empty()).then_some(self.placeholder.clone()))
|
||||
.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()
|
||||
.id(self.id.clone())
|
||||
.debug_selector(|| "input-field".to_string())
|
||||
.key_context(INPUT_CONTEXT)
|
||||
.role(Role::TextInput)
|
||||
.aria_label(fallback_label)
|
||||
@@ -2012,117 +2051,159 @@ impl RenderOnce for Input {
|
||||
.overflow_hidden()
|
||||
.w_full()
|
||||
.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)
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.bg(colors.input)
|
||||
.text_size(metrics.text_size)
|
||||
.text_color(colors.foreground)
|
||||
.when_some(line_height, |input, line_height| {
|
||||
input.items_start().line_height(line_height)
|
||||
.border_1()
|
||||
.border_color(if theme.borders.fields {
|
||||
colors.border
|
||||
} else {
|
||||
colors.input
|
||||
})
|
||||
.when(self.disabled, |input| input.opacity(0.5))
|
||||
.when(!self.disabled, |input| {
|
||||
input.track_focus(&focus_handle).tab_stop(true)
|
||||
})
|
||||
.when(focused, |input| input.border_color(colors.ring))
|
||||
.when(is_empty && !focused, |input| {
|
||||
input.child(
|
||||
div()
|
||||
.text_color(colors.muted_foreground)
|
||||
.when(multiline, |placeholder| placeholder.pt(vertical_inset))
|
||||
.child(self.placeholder.clone()),
|
||||
)
|
||||
.when(focused && !theme.borders.focus_ring, |input| {
|
||||
input.border_color(colors.ring)
|
||||
})
|
||||
.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(
|
||||
canvas(
|
||||
{
|
||||
let state = self.state.clone();
|
||||
move |bounds, window, cx| {
|
||||
// The hitbox backs the I-beam hover cursor.
|
||||
let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal);
|
||||
let wrap_width = multiline.then(|| {
|
||||
(bounds.size.width - field_padding.left - field_padding.right)
|
||||
.max(Pixels::ZERO)
|
||||
});
|
||||
let text = state.read(cx).shape_current_text(wrap_width, window);
|
||||
(text, hitbox)
|
||||
}
|
||||
},
|
||||
{
|
||||
let state = self.state.clone();
|
||||
let focus = focus_handle.clone();
|
||||
let disabled = self.disabled;
|
||||
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()
|
||||
.text_color(colors.muted_foreground)
|
||||
.when(multiline, |placeholder| placeholder.pt(vertical_inset))
|
||||
.child(self.placeholder.clone()),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
canvas(
|
||||
{
|
||||
let state = self.state.clone();
|
||||
move |bounds, window, cx| {
|
||||
// The hitbox backs the I-beam hover cursor.
|
||||
let hitbox =
|
||||
window.insert_hitbox(bounds, HitboxBehavior::Normal);
|
||||
let wrap_width = multiline.then(|| {
|
||||
(bounds.size.width
|
||||
- field_padding.left
|
||||
- field_padding.right)
|
||||
.max(Pixels::ZERO)
|
||||
});
|
||||
let text =
|
||||
state.read(cx).shape_current_text(wrap_width, window);
|
||||
(text, hitbox)
|
||||
}
|
||||
},
|
||||
{
|
||||
let state = self.state.clone();
|
||||
let focus = focus_handle.clone();
|
||||
let disabled = self.disabled;
|
||||
|
||||
move |bounds, (text, hitbox), window, cx| {
|
||||
if !disabled {
|
||||
window.set_cursor_style(CursorStyle::IBeam, &hitbox);
|
||||
}
|
||||
move |bounds, (text, hitbox), window, cx| {
|
||||
if !disabled {
|
||||
window.set_cursor_style(CursorStyle::IBeam, &hitbox);
|
||||
}
|
||||
|
||||
let dragging = state.update(cx, |state, cx| {
|
||||
state.paint(text, bounds, field_padding, colors, window, cx)
|
||||
});
|
||||
let dragging = state.update(cx, |state, cx| {
|
||||
state.paint(text, bounds, field_padding, colors, window, cx)
|
||||
});
|
||||
|
||||
if disabled {
|
||||
return;
|
||||
}
|
||||
|
||||
window.handle_input(
|
||||
&focus,
|
||||
gpui::ElementInputHandler::new(bounds, state.clone()),
|
||||
cx,
|
||||
);
|
||||
|
||||
window.on_mouse_event({
|
||||
let state = state.clone();
|
||||
move |event: &ScrollEvent, phase, window, cx| {
|
||||
if phase != DispatchPhase::Bubble
|
||||
|| !bounds.contains(&event.position)
|
||||
{
|
||||
if disabled {
|
||||
return;
|
||||
}
|
||||
|
||||
let delta = match event.delta {
|
||||
ScrollDelta::Pixels(delta) => delta,
|
||||
ScrollDelta::Lines(lines) => point(
|
||||
px(lines.x * SCROLL_LINES_HEIGHT),
|
||||
px(lines.y * SCROLL_LINES_HEIGHT),
|
||||
),
|
||||
};
|
||||
window.handle_input(
|
||||
&focus,
|
||||
gpui::ElementInputHandler::new(bounds, state.clone()),
|
||||
cx,
|
||||
);
|
||||
|
||||
let consumed = state
|
||||
.update(cx, |state, cx| state.scroll_by(delta, window, cx));
|
||||
window.on_mouse_event({
|
||||
let state = state.clone();
|
||||
move |event: &ScrollEvent, phase, window, cx| {
|
||||
if phase != DispatchPhase::Bubble
|
||||
|| !bounds.contains(&event.position)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if consumed {
|
||||
cx.stop_propagation();
|
||||
let delta = match event.delta {
|
||||
ScrollDelta::Pixels(delta) => delta,
|
||||
ScrollDelta::Lines(lines) => point(
|
||||
px(lines.x * SCROLL_LINES_HEIGHT),
|
||||
px(lines.y * SCROLL_LINES_HEIGHT),
|
||||
),
|
||||
};
|
||||
|
||||
let consumed = state.update(cx, |state, cx| {
|
||||
state.scroll_by(delta, window, cx)
|
||||
});
|
||||
|
||||
if consumed {
|
||||
cx.stop_propagation();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if dragging {
|
||||
InputState::register_drag_listeners(&state, window);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if dragging {
|
||||
InputState::register_drag_listeners(&state, window);
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.absolute()
|
||||
.inset_0(),
|
||||
},
|
||||
)
|
||||
.absolute()
|
||||
.inset_0(),
|
||||
),
|
||||
)
|
||||
.when_some(self.suffix, |input, suffix| {
|
||||
input.child(edge_zone("input-suffix", suffix))
|
||||
})
|
||||
.when(is_password && !self.disabled, |input| {
|
||||
input.child(
|
||||
div()
|
||||
.id((self.id.clone(), "input-reveal"))
|
||||
.debug_selector(|| "input-reveal".to_string())
|
||||
.absolute()
|
||||
.right(metrics.dense_padding_x / 2.0)
|
||||
.top_0p5()
|
||||
.bottom_0p5()
|
||||
.right(edge_inset)
|
||||
.top_0()
|
||||
.bottom_0()
|
||||
.flex()
|
||||
.items_center()
|
||||
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
|
||||
.child(
|
||||
Button::new((self.id.clone(), "input-reveal-button"))
|
||||
.debug_selector(|| "input-reveal-button".to_string())
|
||||
.icon(if revealed {
|
||||
IconRole::EyeOff
|
||||
} else {
|
||||
|
||||
@@ -5,7 +5,7 @@ use gpui::{
|
||||
use gpui_native_core::button::{Button, ButtonVariant};
|
||||
use gpui_native_core::input::{Input, InputState};
|
||||
use gpui_native_theme::ActiveTheme as _;
|
||||
use gpui_native_theme::Theme;
|
||||
use gpui_native_theme::{BORDER_WIDTH, Theme};
|
||||
|
||||
struct Harness {
|
||||
state: Entity<InputState>,
|
||||
@@ -36,9 +36,10 @@ impl Render for Harness {
|
||||
}
|
||||
}
|
||||
|
||||
/// The medium input renders at the theme's stated field height and the
|
||||
/// medium button at the stated control height; the two match wherever the
|
||||
/// platform states equal values (macOS, Windows, GNOME).
|
||||
/// The medium input and button render at the stated height plus the
|
||||
/// hairline border sized outside it, so the two match wherever the platform
|
||||
/// states equal values (macOS, Windows, GNOME) and stay equal to each other
|
||||
/// on every platform.
|
||||
#[gpui::test]
|
||||
fn controls_render_at_the_stated_heights(cx: &mut TestAppContext) {
|
||||
cx.update(Theme::init);
|
||||
@@ -59,9 +60,10 @@ fn controls_render_at_the_stated_heights(cx: &mut TestAppContext) {
|
||||
.debug_bounds("input-wrapper")
|
||||
.expect("input rendered");
|
||||
|
||||
// Both bounds are snapped to device pixels, so allow half a pixel.
|
||||
let input_height = cx.update(|cx| cx.theme().sizes.medium.input_height);
|
||||
let button_height = cx.update(|cx| cx.theme().sizes.medium.control_height);
|
||||
// Both bounds are snapped to device pixels, so allow half a pixel. The
|
||||
// stated height is the interior; the border sizes outside it.
|
||||
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!((button.size.height - button_height).abs() <= gpui::px(0.5));
|
||||
}
|
||||
|
||||
+163
-2
@@ -3,12 +3,15 @@ use gpui::{
|
||||
MouseUpEvent, ParentElement as _, Render, ScrollDelta, ScrollEvent, Styled as _,
|
||||
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::{
|
||||
INPUT_CONTEXT, Input, InputBindings, InputState, InputStateEvent, Submit, SubmitOn,
|
||||
bind_input_keys,
|
||||
};
|
||||
use gpui_native_theme::Theme;
|
||||
use std::cell::RefCell;
|
||||
use gpui_native_icons::IconRole;
|
||||
use gpui_native_theme::{BORDER_WIDTH, Size, Theme};
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -763,6 +766,36 @@ fn the_reveal_toggle_flips_masking() {
|
||||
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]
|
||||
fn the_caret_can_rest_on_a_line_break() {
|
||||
let (cx, state, mut visual) = multiline_window("ab\ncdef");
|
||||
@@ -906,3 +939,131 @@ fn vertical_scroll_keeps_the_caret_in_view() {
|
||||
.unwrap_or_default();
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -23,6 +23,10 @@ pub use system::{AccessibilityPreferences, PartialPalette, SourceKind, SystemThe
|
||||
use gpui::{App, Global, Hsla, Pixels, WindowAppearance, px};
|
||||
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.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
@@ -459,17 +463,17 @@ impl SizeTable {
|
||||
/// 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) {
|
||||
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.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.
|
||||
pub fn set_control_height(&mut self, medium_control_height: Pixels) {
|
||||
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.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.
|
||||
|
||||
@@ -449,17 +449,11 @@ mod tests {
|
||||
let mut stated = system(ThemeVariant::Light, PartialPalette::default(), None);
|
||||
stated.input_height = Some(px(24.));
|
||||
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.));
|
||||
|
||||
// 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 scale = px(24.) / derived.medium.input_height;
|
||||
let expected_small = derived.small.input_height * scale;
|
||||
assert!((resolved.sizes.small.input_height - expected_small).abs() < px(0.01));
|
||||
let expected_small = (derived.small.input_height * scale).floor();
|
||||
assert_eq!(resolved.sizes.small.input_height, expected_small);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -471,14 +465,10 @@ mod tests {
|
||||
|
||||
let derived = SizeTable::with_root_font_size(px(16.));
|
||||
let scale = px(32.) / derived.medium.control_height;
|
||||
assert!(
|
||||
(resolved.sizes.small.control_height - derived.small.control_height * scale).abs()
|
||||
< px(0.01)
|
||||
);
|
||||
assert!(
|
||||
(resolved.sizes.large.control_height - derived.large.control_height * scale).abs()
|
||||
< px(0.01)
|
||||
);
|
||||
let expected_small = (derived.small.control_height * scale).floor();
|
||||
let expected_large = (derived.large.control_height * scale).floor();
|
||||
assert_eq!(resolved.sizes.small.control_height, expected_small);
|
||||
assert_eq!(resolved.sizes.large.control_height, expected_large);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -35,9 +35,9 @@ pub fn focus_handle(id: &ElementId, window: &mut Window, cx: &mut App) -> FocusH
|
||||
.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> {
|
||||
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.
|
||||
|
||||
+18
-9
@@ -9,17 +9,14 @@ is the first downstream consumer; its screens drive the order.
|
||||
|
||||
## Next
|
||||
|
||||
- [ ] **Input platform chrome** — field border behind
|
||||
`Theme::borders.fields`; focus split between the ring shadow and
|
||||
border recolor via `Theme::borders.focus_ring`.
|
||||
- [ ] **Select platform chrome** — the input treatment on the trigger; the
|
||||
popup border behind `Theme::borders.overlays`.
|
||||
|
||||
## Queue — to follow
|
||||
|
||||
Platform metric adoption continues one component at a time; each stop goes
|
||||
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,
|
||||
and the row min-height through `Theme::spacing` and the stated menu
|
||||
row height.
|
||||
@@ -87,13 +84,25 @@ Gaps surfaced by the signed comparison; ordered by signed's usage.
|
||||
|
||||
### core (`crates/core`)
|
||||
|
||||
- [x] Button platform chrome — the filled variants' 1px border gated behind
|
||||
`Theme::borders.buttons` (Windows / KDE keep it, macOS / GNOME paint
|
||||
fills, ghost stays borderless); icon-bearing buttons pad with
|
||||
`dense_padding_x` instead of `padding_x`
|
||||
- [x] Button platform chrome — every variant always paints the hairline
|
||||
border, sized outside the stated control height (`BORDER_WIDTH`); where
|
||||
the platform paints no border (`Theme::borders.buttons`, ghost) the
|
||||
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
|
||||
the icon and suppresses activation; Spinner gained a `.color` override
|
||||
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,
|
||||
Progress, Avatar (image + seeded pixel-art fallback)
|
||||
- [x] Spinner — painted ring + rotating-icon variant, indeterminate and
|
||||
|
||||
@@ -974,6 +974,7 @@ struct InputDemo {
|
||||
text: Entity<InputState>,
|
||||
password: Entity<InputState>,
|
||||
notes: Entity<InputState>,
|
||||
search: Entity<InputState>,
|
||||
_subscriptions: Vec<gpui::Subscription>,
|
||||
}
|
||||
|
||||
@@ -984,17 +985,20 @@ impl InputDemo {
|
||||
password.update(cx, |state, cx| state.set_password(true, cx));
|
||||
let notes = InputState::new(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
|
||||
// only when the inputs it reads are notified.
|
||||
let subscriptions = vec![
|
||||
cx.observe(&text, |_, _, cx| cx.notify()),
|
||||
cx.observe(&password, |_, _, cx| cx.notify()),
|
||||
cx.observe(¬es, |_, _, cx| cx.notify()),
|
||||
cx.observe(&search, |_, _, cx| cx.notify()),
|
||||
];
|
||||
Self {
|
||||
text,
|
||||
password,
|
||||
notes,
|
||||
search,
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
@@ -1007,7 +1011,36 @@ impl Render for InputDemo {
|
||||
ScrollArea::new("input-demo").size_full().p_4().child(
|
||||
v_flex()
|
||||
.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(
|
||||
Input::new("input-demo-password", self.password.clone())
|
||||
.placeholder("Password"),
|
||||
|
||||
Reference in New Issue
Block a user