diff --git a/crates/core/src/button.rs b/crates/core/src/button.rs index b0b39e4..619a0d2 100644 --- a/crates/core/src/button.rs +++ b/crates/core/src/button.rs @@ -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)) diff --git a/crates/core/src/input.rs b/crates/core/src/input.rs index 3b4269f..2230852 100644 --- a/crates/core/src/input.rs +++ b/crates/core/src/input.rs @@ -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, /// Multiline field height in rows; ignored when the state is single-line. rows: usize, + prefix: Option, + suffix: Option, } 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(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(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 { diff --git a/crates/core/tests/control_sizes.rs b/crates/core/tests/control_sizes.rs index e83662b..3a06744 100644 --- a/crates/core/tests/control_sizes.rs +++ b/crates/core/tests/control_sizes.rs @@ -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, @@ -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)); } diff --git a/crates/core/tests/input.rs b/crates/core/tests/input.rs index 1e9e625..7086659 100644 --- a/crates/core/tests/input.rs +++ b/crates/core/tests/input.rs @@ -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, + clicked: Rc>, +} + +impl Render for EdgeHarness { + fn render( + &mut self, + _window: &mut Window, + _cx: &mut gpui::Context, + ) -> 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, + VisualTestContext, + Rc>, +) { + 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"); +} diff --git a/crates/theme/src/lib.rs b/crates/theme/src/lib.rs index 4b068eb..63ab0e3 100644 --- a/crates/theme/src/lib.rs +++ b/crates/theme/src/lib.rs @@ -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. diff --git a/crates/theme/src/resolve.rs b/crates/theme/src/resolve.rs index 7155736..389b2a7 100644 --- a/crates/theme/src/resolve.rs +++ b/crates/theme/src/resolve.rs @@ -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] diff --git a/crates/utils/src/lib.rs b/crates/utils/src/lib.rs index 7297b2b..880ff50 100644 --- a/crates/utils/src/lib.rs +++ b/crates/utils/src/lib.rs @@ -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 { - 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. diff --git a/docs/CHECKLIST.md b/docs/CHECKLIST.md index f751e23..bfa2743 100644 --- a/docs/CHECKLIST.md +++ b/docs/CHECKLIST.md @@ -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 diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index 422f550..de91736 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -974,6 +974,7 @@ struct InputDemo { text: Entity, password: Entity, notes: Entity, + search: Entity, _subscriptions: Vec, } @@ -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"),