From abb56567e4355f07999e3617db3cc985545fba77 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sun, 8 Nov 2026 11:00:39 +0700 Subject: [PATCH] audit combobox --- crates/core/src/combobox.rs | 73 ++++++- crates/core/src/input.rs | 362 +++++++++++++------------------ crates/core/tests/combobox.rs | 29 ++- crates/core/tests/field_bench.rs | 29 ++- crates/core/tests/input.rs | 51 +++-- docs/CHECKLIST.md | 23 +- examples/gallery/src/main.rs | 5 +- 7 files changed, 315 insertions(+), 257 deletions(-) diff --git a/crates/core/src/combobox.rs b/crates/core/src/combobox.rs index bbf50d6..2e19081 100644 --- a/crates/core/src/combobox.rs +++ b/crates/core/src/combobox.rs @@ -12,13 +12,13 @@ use gpui::prelude::FluentBuilder as _; use gpui::{ App, Bounds, Context, ElementId, Entity, EventEmitter, InteractiveElement as _, IntoElement, KeyBinding, MouseButton, MouseDownEvent, ParentElement as _, Pixels, Render, Role, - ScrollHandle, SharedString, StatefulInteractiveElement as _, Styled as _, Subscription, Task, - Window, actions, canvas, deferred, div, px, + ScrollHandle, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled, + Subscription, Task, Window, actions, anchored, canvas, deferred, div, point, px, }; use gpui_native_icons::{IconRole, icon}; -use gpui_native_theme::{ActiveTheme as _, Size}; +use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, Size}; -use crate::common::{Sizable, h_flex, v_flex}; +use crate::common::{Sizable, StyledExt as _, h_flex, v_flex}; use crate::input::{Input, InputState, InputStateEvent}; use crate::scroll_area::ScrollArea; @@ -81,6 +81,7 @@ where filter: Option>, unmatched: UnmatchedText, size: Size, + style: StyleRefinement, } impl Combobox @@ -103,6 +104,7 @@ where filter: None, unmatched: UnmatchedText::default(), size: Size::default(), + style: StyleRefinement::default(), } } @@ -165,6 +167,16 @@ where } } +/// Style refinements set the combobox's width. +impl Styled for Combobox +where + T: Clone + PartialEq + 'static, +{ + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + /// Emitted when the combobox's value changes. pub struct ComboboxChanged; @@ -189,6 +201,8 @@ where filter: ComboboxFilter, unmatched: UnmatchedText, size: Size, + /// Width refinements from the builder, applied to the wrapper each render. + style: StyleRefinement, /// Handler reports in flight; held so they die with the state. tasks: Vec>, /// Keeps the query-event subscription tied to the state's lifetime. @@ -225,6 +239,7 @@ where let subscription = cx.subscribe_in(&query, window, |state, _, event, window, cx| match event { + InputStateEvent::Focused => state.query_focused(window, cx), InputStateEvent::TextChanged => state.text_changed(window, cx), InputStateEvent::Blurred => state.blurred(window, cx), _ => {} @@ -248,6 +263,7 @@ where }), unmatched: combobox.unmatched, size: combobox.size, + style: combobox.style, tasks: Vec::new(), _subscription: subscription, } @@ -364,6 +380,16 @@ where cx.notify(); } + /// Gaining focus. + fn query_focused(&mut self, _window: &mut Window, cx: &mut Context) { + if self.selected.is_some() { + return; + } + self.refilter(""); + self.highlight = (!self.visible.is_empty()).then_some(0usize); + cx.notify(); + } + /// Losing focus. [`UnmatchedText`] decides what the text becomes. fn blurred(&mut self, window: &mut Window, cx: &mut Context) { self.highlight = None; @@ -447,11 +473,25 @@ where T: Clone + PartialEq + 'static, { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let (metrics, colors, radius) = { + let (metrics, colors, radius, panel_border, spacing_unit, shadows) = { let theme = cx.theme(); let metrics = theme.sizes.resolve(self.size); - (metrics, theme.colors, theme.radius) + + let panel_border = if theme.borders.overlays { + theme.colors.border + } else { + theme.colors.surface + }; + ( + metrics, + theme.colors, + theme.radius, + panel_border, + theme.spacing.unit, + theme.elevations.popover.shadows(), + ) }; + let icon_size = metrics.dense_icon_size; let id = self.id.clone(); let state = cx.entity(); @@ -490,7 +530,9 @@ where .id(id.clone()) .key_context(COMBOBOX_CONTEXT) .relative() - .w_full() + .w(window.rem_size() * 12.0) + .min_w(window.rem_size() * 6.0) + .refine_style(&self.style) .role(Role::ComboBox) .aria_expanded(open) .aria_label(self.name.clone()) @@ -532,13 +574,14 @@ where .child( Input::new((id.clone(), "query"), self.query.clone()) .placeholder(self.placeholder.clone()) + .placeholder_when_focused(true) .with_size(self.size), ) .child( h_flex() .absolute() .right(metrics.dense_padding_x) - .top((metrics.input_height - icon_size) / 2.) + .top((metrics.input_height + BORDER_WIDTH * 2.0 - icon_size) / 2.) .child(chevron), ); @@ -626,6 +669,10 @@ where }; let panel_bounds: Rc>>> = Rc::new(RefCell::new(None)); + let popup_width = wrapper_bounds + .borrow() + .map(|bounds| bounds.size.width) + .unwrap_or_else(|| window.rem_size() * 12.0); let panel = v_flex() .id((id.clone(), "panel")) @@ -633,14 +680,14 @@ where let name = format!("{id}-panel"); move || name }) - .mt_1() - .w_full() + .w(popup_width) .p_1() .bg(colors.surface) .text_color(colors.foreground) .rounded(radius.overlay) .border_1() - .border_color(colors.border) + .border_color(panel_border) + .shadow(shadows) .overflow_hidden() .child(rows) .child( @@ -684,7 +731,9 @@ where .inset_0(), ); - wrapper = wrapper.child(deferred(panel)); + wrapper = wrapper.child(deferred( + anchored().offset(point(px(0.), spacing_unit)).child(panel), + )); } wrapper diff --git a/crates/core/src/input.rs b/crates/core/src/input.rs index 2230852..b9c7015 100644 --- a/crates/core/src/input.rs +++ b/crates/core/src/input.rs @@ -7,13 +7,13 @@ use unicode_segmentation::UnicodeSegmentation; use gpui::prelude::FluentBuilder as _; use gpui::{ - 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, + Animation, AnimationExt as _, 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 _, TextAlign, TextRun, UTF16Selection, Window, + WrappedLine, actions, canvas, div, fill, point, px, }; use gpui_native_icons::IconRole; use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, ColorTokens, Size}; @@ -478,97 +478,6 @@ impl HistoryEntry { } } -/// Caret blink controller. -struct CaretBlink { - interval: Duration, - generation: usize, - visible: bool, - active: bool, - paused: bool, - /// Pending blink timers; held so they die with the controller. - tasks: Vec>, -} - -impl CaretBlink { - fn new(_cx: &mut Context) -> Self { - Self { - interval: CARET_BLINK_INTERVAL, - generation: 0, - visible: true, - active: false, - paused: false, - tasks: Vec::new(), - } - } - - fn visible(&self) -> bool { - self.visible - } - - /// Starts blinking; the caret shows first and hides after one interval. - fn enable(&mut self, cx: &mut Context) { - if self.active { - return; - } - self.active = true; - self.visible = true; - self.paused = false; - self.schedule(cx); - } - - /// Stops blinking. The caret stays hidden; paint gates on focus anyway. - fn disable(&mut self, cx: &mut Context) { - self.active = false; - self.visible = false; - self.paused = false; - cx.notify(); - } - - /// Holds the caret visible and restarts the hide timer. - fn pause(&mut self, cx: &mut Context) { - if !self.active { - return; - } - if !self.visible { - self.visible = true; - cx.notify(); - } - self.paused = true; - self.generation += 1; - self.schedule_pause(cx); - } - - fn tick(&mut self, cx: &mut Context) { - if !self.active || self.paused { - return; - } - self.visible = !self.visible; - cx.notify(); - self.schedule(cx); - } - - fn schedule(&mut self, cx: &mut Context) { - self.generation += 1; - self.schedule_pause(cx); - } - - fn schedule_pause(&mut self, cx: &mut Context) { - let generation = self.generation; - let interval = self.interval; - let task = cx.spawn(async move |this, cx| { - cx.background_executor().timer(interval).await; - this.update(cx, |this, cx| { - if this.generation == generation { - this.paused = false; - this.tick(cx); - } - }) - .ok(); - }); - self.tasks.push(task); - } -} - /// The application-owned state of a text field. pub struct InputState { text: String, @@ -608,20 +517,18 @@ pub struct InputState { scroll_offset: gpui::Point, /// Set by every caret-affecting change. scroll_to_caret_pending: bool, - /// Drives the focused caret's blink. - caret_blink: Entity, - /// The shaped line and element bounds, recorded during each paint, - /// and shared with hit-testing and the IME geometry conversions. + /// Bumped on every edit. + caret_epoch: usize, + /// When the current caret epoch started. + caret_epoch_started: Option, + /// The shaped line and element bounds, recorded during each paint. layout: Rc>>, - /// Keeps the field repainting on blink ticks. Dropping it unsubscribes. - _caret_blink_subscription: Subscription, } impl EventEmitter for InputState {} impl InputState { fn new_inner(cx: &mut Context) -> Self { - let caret_blink = cx.new(CaretBlink::new); Self { text: String::new(), cursor: 0, @@ -651,9 +558,9 @@ impl InputState { goal_x: None, scroll_offset: point(Pixels::ZERO, Pixels::ZERO), scroll_to_caret_pending: true, - caret_blink: caret_blink.clone(), + caret_epoch: 0, + caret_epoch_started: Some(Instant::now()), layout: Rc::new(RefCell::new(None)), - _caret_blink_subscription: cx.observe(&caret_blink, |_, _, cx| cx.notify()), } } @@ -663,9 +570,6 @@ impl InputState { } /// Creates the state entity with initial text, caret at the end. - /// - /// Newlines become spaces; flip the field to multiline with [`set_multiline`] - /// before `set_value` to seed lines. pub fn new_with_text(text: impl Into, cx: &mut App) -> Entity { let text = sanitize(text.into(), false); cx.new(|cx| { @@ -683,10 +587,6 @@ impl InputState { } /// Replaces the whole value and collapses the selection to the end. - /// - /// Newlines become spaces unless the field is multiline. Emits - /// [`InputStateEvent::TextChanged`] when the value changed; setting the - /// current value is a no-op. pub fn set_value(&mut self, value: V, window: &mut Window, cx: &mut Context) where V: Into, @@ -868,7 +768,8 @@ impl InputState { cx.notify(); self.scroll_to_caret_pending = true; self.goal_x = None; - self.caret_blink.update(cx, |blink, cx| blink.pause(cx)); + self.caret_epoch += 1; + self.caret_epoch_started = Some(Instant::now()); window.notify_text_input_changed(&self.focus_handle); } @@ -901,10 +802,11 @@ impl InputState { } self.focused = focused; + // A fresh focus starts a fresh blink epoch, so the caret shows solid + // for one interval before blinking resumes. if focused { - self.caret_blink.update(cx, |blink, cx| blink.enable(cx)); - } else { - self.caret_blink.update(cx, |blink, cx| blink.disable(cx)); + self.caret_epoch += 1; + self.caret_epoch_started = Some(Instant::now()); } cx.emit(if focused { @@ -915,8 +817,22 @@ impl InputState { } /// Whether the caret should paint. - pub fn caret_visible(&mut self, focused: bool, cx: &mut Context) -> bool { - focused && !self.read_only && self.caret_blink.read(cx).visible() + pub fn caret_visible(&mut self, focused: bool) -> bool { + let elapsed = self + .caret_epoch_started + .map(|started| started.elapsed()) + .unwrap_or_default(); + self.caret_blink_visible_at(focused, elapsed) + } + + /// The visibility math at a given time since the epoch started. + pub fn caret_blink_visible_at(&self, focused: bool, elapsed: Duration) -> bool { + if !focused || self.read_only { + return false; + } + let period = CARET_BLINK_INTERVAL * 2; + (elapsed.as_millis() as u64 % period.as_millis() as u64) + < CARET_BLINK_INTERVAL.as_millis() as u64 } /// Replaces a byte range with `text` and collapses the caret to its end. @@ -1317,7 +1233,7 @@ impl InputState { } // A caret promises that typing lands there; in read-only it does not. - if focused && self.caret_visible(focused, cx) && self.selection().is_empty() { + if focused && self.caret_visible(focused) && self.selection().is_empty() { self.paint_caret(origin, font_size, colors.foreground, window); } @@ -1903,8 +1819,10 @@ pub struct Input { placeholder: SharedString, size: Size, disabled: bool, + /// The placeholder stays visible while focused. + placeholder_when_focused: bool, a11y_label: Option, - /// Multiline field height in rows; ignored when the state is single-line. + /// Multiline field height in row, ignored when the state is single-line. rows: usize, prefix: Option, suffix: Option, @@ -1922,6 +1840,7 @@ impl Input { placeholder: SharedString::default(), size: Size::default(), disabled: false, + placeholder_when_focused: false, a11y_label: None, rows: DEFAULT_ROWS, prefix: None, @@ -1944,6 +1863,12 @@ impl Input { self } + /// Shows the placeholder while the field is focused. + pub fn placeholder_when_focused(mut self, when_focused: bool) -> Self { + self.placeholder_when_focused = when_focused; + self + } + /// Renders the field inert, unfocusable, and dimmed; disabled always wins. pub fn disabled(mut self, disabled: bool) -> Self { self.disabled = disabled; @@ -1991,12 +1916,13 @@ impl RenderOnce for Input { let radius = theme.radius; let metrics = theme.sizes.resolve(self.size); let focus_handle = self.state.read(cx).focus_handle.clone(); - let focused = focus_handle.is_focused(window); + let is_empty = self.state.read(cx).text.is_empty(); let is_password = self.state.read(cx).is_password(); let revealed = self.state.read(cx).is_revealed(); let multiline = self.state.read(cx).is_multiline(); + let recording = focused && !self.disabled && !self.state.read(cx).is_read_only(); let small = theme.sizes.resolve(Size::Small); let docked_height = small.control_height + BORDER_WIDTH * 2.0; @@ -2041,6 +1967,97 @@ impl RenderOnce for Input { .child(element) }; + let canvas_element = { + let canvas = 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); + } + + 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) + { + 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), + ), + }; + + 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); + } + } + }, + ) + .absolute() + .inset_0(); + if recording { + let epoch = self.state.read(cx).caret_epoch; + let animation_id = ElementId::from(SharedString::from(format!( + "{self_id}-caret-blink-{epoch}", + self_id = self.id, + ))); + canvas + .with_animation( + animation_id, + Animation::new(CARET_BLINK_INTERVAL * 2).repeat(), + |canvas, _| canvas, + ) + .into_any_element() + } else { + canvas.into_any_element() + } + }; + h_flex() .id(self.id.clone()) .debug_selector(|| "input-field".to_string()) @@ -2098,93 +2115,18 @@ impl RenderOnce for Input { .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); - } - - 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) - { - 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), - ), - }; - - 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); - } - } - }, - ) - .absolute() - .inset_0(), - ), + .when( + is_empty && (!focused || self.placeholder_when_focused), + |region| { + region.child( + div() + .text_color(colors.muted_foreground) + .when(multiline, |placeholder| placeholder.pt(vertical_inset)) + .child(self.placeholder.clone()), + ) + }, + ) + .child(canvas_element), ) .when_some(self.suffix, |input, suffix| { input.child(edge_zone("input-suffix", suffix)) diff --git a/crates/core/tests/combobox.rs b/crates/core/tests/combobox.rs index bea30c9..5518d8f 100644 --- a/crates/core/tests/combobox.rs +++ b/crates/core/tests/combobox.rs @@ -2,8 +2,8 @@ use std::cell::RefCell; use std::rc::Rc; use gpui::{ - AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, SharedString, - Styled as _, TestAppContext, VisualTestContext, Window, div, + AppContext as _, Context, Entity, IntoElement, MouseButton, ParentElement as _, Render, + SharedString, Styled as _, TestAppContext, VisualTestContext, Window, div, point, px, }; use gpui_native_core::combobox::{Combobox, ComboboxOption, ComboboxState}; use gpui_native_core::init; @@ -99,6 +99,31 @@ fn draw(visual: &mut VisualTestContext) { }); } +fn click(visual: &mut VisualTestContext, position: gpui::Point) { + visual.simulate_mouse_down(position, MouseButton::Left, gpui::Modifiers::default()); + visual.simulate_mouse_up(position, MouseButton::Left, gpui::Modifiers::default()); +} + +/// Clicking into a valueless combobox field opens the options right away: +/// no keystroke is needed to see the choices. +#[test] +fn clicking_the_empty_field_opens_the_popup() { + let mut host = combo_host(false, false); + + // Inside the field, which sits at the top of the padded host. + click(&mut host.visual, point(px(100.), px(27.))); + draw(&mut host.visual); + + assert!( + host.state.read_with(&host.cx, |state, _| state.is_open()), + "clicking the empty field should show the options" + ); + assert!( + host.visual.debug_bounds("test-combo-panel").is_some(), + "the options panel should be rendered" + ); +} + fn focus(host: &mut ComboHost) { let state = host.state.clone(); host.visual.update(|window, cx| { diff --git a/crates/core/tests/field_bench.rs b/crates/core/tests/field_bench.rs index b3a99c5..fd50560 100644 --- a/crates/core/tests/field_bench.rs +++ b/crates/core/tests/field_bench.rs @@ -38,8 +38,11 @@ impl Render for FieldHost { } } -/// A field page in a real window, with the url input focused. -fn field_host() -> ( +/// A field page in a real window; the url input is focused unless +/// `focus` is false, which leaves the page truly idle. +fn field_host( + focus: bool, +) -> ( TestAppContext, Entity, Rc>, @@ -64,8 +67,10 @@ fn field_host() -> ( }); let mut visual = VisualTestContext::from_window(window.into(), &cx); visual.update(|window, cx| { - let focus = url.read(cx).focus_handle(); - window.focus(&focus, cx); + if focus { + let focus = url.read(cx).focus_handle(); + window.focus(&focus, cx); + } window.draw(cx).clear(cx); }); (cx, url, renders, visual) @@ -94,17 +99,27 @@ fn type_and_time(visual: &mut VisualTestContext, text: &str) -> std::time::Durat #[test] fn an_idle_field_page_schedules_no_frames() { - let (_cx, _url, _renders, mut visual) = field_host(); + let (_cx, _url, _renders, mut visual) = field_host(false); let frames = visual.update(|window, cx| window.simulate_next_frame(cx)); assert_eq!( frames, 0, - "an idle field page must not request animation frames" + "an idle unfocused field page must not request animation frames" + ); +} + +#[test] +fn a_focused_field_page_schedules_frames_for_its_blink() { + let (_cx, _url, _renders, mut visual) = field_host(true); + let frames = visual.update(|window, cx| window.simulate_next_frame(cx)); + assert!( + frames > 0, + "the focused caret's blink needs frames to redraw at the blink cadence" ); } #[test] fn field_typing_frame_cost() { - let (_cx, _url, _renders, mut visual) = field_host(); + let (_cx, _url, _renders, mut visual) = field_host(true); let elapsed = type_and_time(&mut visual, "dsdsadsadadadada"); assert!( elapsed.as_millis() < 1000, diff --git a/crates/core/tests/input.rs b/crates/core/tests/input.rs index 7086659..ad7ba73 100644 --- a/crates/core/tests/input.rs +++ b/crates/core/tests/input.rs @@ -269,42 +269,45 @@ fn read_only_refuses_edits_but_keeps_selection_and_copy() { assert_eq!(clipboard, "lo"); // The caret is suppressed entirely while read-only. - let caret = cx.update(|cx| state.update(cx, |state, cx| state.caret_visible(true, cx))); + let caret = cx.update(|cx| state.update(cx, |state, _| state.caret_visible(true))); assert!(!caret); } #[test] -fn caret_blinks_while_focused_and_holds_solid_while_typing() { +fn the_caret_visibility_is_pure_time_math_against_the_epoch() { let (cx, state, mut visual) = input_window("", false); focus(&mut visual, &state); - visual.update(|window, cx| { - window.draw(cx).clear(cx); - }); - visual.run_until_parked(); + let caret = || cx.update(|cx| state.update(cx, |state, _| state.caret_visible(true))); - let caret = || cx.update(|cx| state.update(cx, |state, cx| state.caret_visible(true, cx))); - - // The caret shows on focus, hides after one interval, and reappears - // after another. - assert!(caret()); - cx.executor().advance_clock(Duration::from_millis(500)); - visual.run_until_parked(); - assert!(!caret()); - cx.executor().advance_clock(Duration::from_millis(500)); - visual.run_until_parked(); + // Fresh focus starts a new epoch: the caret shows solid — the elapsed + // time since the epoch began is microseconds, far under one interval. assert!(caret()); - // Typing holds the caret solid and restarts the cycle. + // Typing restarts the epoch, so the caret stays solid through edits. visual.simulate_keystrokes("a"); assert!(caret()); - cx.executor().advance_clock(Duration::from_millis(500)); - visual.run_until_parked(); - assert!(!caret()); - // An unfocused field reports no caret. - let caret_unfocused = - cx.update(|cx| state.update(cx, |state, cx| state.caret_visible(false, cx))); - assert!(!caret_unfocused); + // The blink cadence itself: hidden in the second half of the cycle. + let hidden = cx.update(|cx| { + state.update(cx, |state, _| { + state.caret_blink_visible_at(true, Duration::from_millis(700)) + }) + }); + assert!(!hidden); + let visible = cx.update(|cx| { + state.update(cx, |state, _| { + state.caret_blink_visible_at(true, Duration::from_millis(300)) + }) + }); + assert!(visible); + + // An unfocused field reports no caret, at any point in the cycle. + let unfocused = cx.update(|cx| { + state.update(cx, |state, _| { + state.caret_blink_visible_at(false, Duration::from_millis(100)) + }) + }); + assert!(!unfocused); } #[test] diff --git a/docs/CHECKLIST.md b/docs/CHECKLIST.md index 1192583..baec1a0 100644 --- a/docs/CHECKLIST.md +++ b/docs/CHECKLIST.md @@ -130,12 +130,33 @@ Gaps surfaced by the signed comparison; ordered by signed's usage. `Theme::borders.overlays` (and dark mode), taking the surface color where the platform paints none; it rides the popover elevation, so dark mode's no-shadow rule applies to it too +- [x] Combobox platform chrome — the popup border behind + `Theme::borders.overlays` (and dark mode), taking the surface color + where the platform paints none, and riding the popover elevation for + the light-mode shadow; the trigger is a plain `Input`, so it + already carries the full field treatment, and the chevron recenters on + the field's full height, border included; the popup mounts on + `deferred(anchored().offset(...))` like the Popover — out of the + wrapper's layout, one spacing gap below, window-clamped — and always + matches the combobox's width; the combobox itself is no longer full + width: it takes a compact default and style refinements on the builder + (`.w(...)`) set the width; the empty field keeps its placeholder while + focused (`Input::placeholder_when_focused`) and clicking into it opens + the options right away, with no keystroke needed +- [x] Combobox platform chrome — the popup border behind + `Theme::borders.overlays` (and dark mode), taking the surface color + where the platform paints none; the trigger is a plain `Input`, so it + already carries the full field treatment, and the chevron recenters on + the field's full height, border included - [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 determinate; replaces `Tab::busy`'s placeholder - [x] Input — single-line, password with reveal, multiline, undo/redo, actions - + key context, caret blink + + key context, caret blink (time-math driven: the focused field mounts a + repeating blink animation whose frames redraw the caret, the caret is + solid for one interval after each edit, and an idle unfocused page + schedules no frames) - [x] ScrollArea, Popover, Menu, Tooltip, Dialog / AlertDialog, Toast, Alert + alerts stack - [x] Select, Segmented, ContextMenu, EmptyState, Badge diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index 8b0418a..6744019 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -1257,7 +1257,10 @@ impl ComboboxDemo { ) .selected(SharedString::from("main")) .placeholder("Switch branch…") - .keep_unmatched_text(), + .keep_unmatched_text() + // Widths are style refinements on the builder; the popup + // always matches whatever the combobox ends up being. + .w(px(280.)), window, cx, )