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
+9 -7
View File
@@ -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
View File
@@ -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");
}