audit combobox
This commit is contained in:
+61
-12
@@ -12,13 +12,13 @@ use gpui::prelude::FluentBuilder as _;
|
|||||||
use gpui::{
|
use gpui::{
|
||||||
App, Bounds, Context, ElementId, Entity, EventEmitter, InteractiveElement as _, IntoElement,
|
App, Bounds, Context, ElementId, Entity, EventEmitter, InteractiveElement as _, IntoElement,
|
||||||
KeyBinding, MouseButton, MouseDownEvent, ParentElement as _, Pixels, Render, Role,
|
KeyBinding, MouseButton, MouseDownEvent, ParentElement as _, Pixels, Render, Role,
|
||||||
ScrollHandle, SharedString, StatefulInteractiveElement as _, Styled as _, Subscription, Task,
|
ScrollHandle, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled,
|
||||||
Window, actions, canvas, deferred, div, px,
|
Subscription, Task, Window, actions, anchored, canvas, deferred, div, point, px,
|
||||||
};
|
};
|
||||||
use gpui_native_icons::{IconRole, icon};
|
use gpui_native_icons::{IconRole, icon};
|
||||||
use gpui_native_theme::{ActiveTheme as _, Size};
|
use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, Size};
|
||||||
|
|
||||||
use 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::input::{Input, InputState, InputStateEvent};
|
||||||
use crate::scroll_area::ScrollArea;
|
use crate::scroll_area::ScrollArea;
|
||||||
|
|
||||||
@@ -81,6 +81,7 @@ where
|
|||||||
filter: Option<ComboboxFilter<T>>,
|
filter: Option<ComboboxFilter<T>>,
|
||||||
unmatched: UnmatchedText,
|
unmatched: UnmatchedText,
|
||||||
size: Size,
|
size: Size,
|
||||||
|
style: StyleRefinement,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T> Combobox<T>
|
impl<T> Combobox<T>
|
||||||
@@ -103,6 +104,7 @@ where
|
|||||||
filter: None,
|
filter: None,
|
||||||
unmatched: UnmatchedText::default(),
|
unmatched: UnmatchedText::default(),
|
||||||
size: Size::default(),
|
size: Size::default(),
|
||||||
|
style: StyleRefinement::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,6 +167,16 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Style refinements set the combobox's width.
|
||||||
|
impl<T> Styled for Combobox<T>
|
||||||
|
where
|
||||||
|
T: Clone + PartialEq + 'static,
|
||||||
|
{
|
||||||
|
fn style(&mut self) -> &mut StyleRefinement {
|
||||||
|
&mut self.style
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Emitted when the combobox's value changes.
|
/// Emitted when the combobox's value changes.
|
||||||
pub struct ComboboxChanged;
|
pub struct ComboboxChanged;
|
||||||
|
|
||||||
@@ -189,6 +201,8 @@ where
|
|||||||
filter: ComboboxFilter<T>,
|
filter: ComboboxFilter<T>,
|
||||||
unmatched: UnmatchedText,
|
unmatched: UnmatchedText,
|
||||||
size: Size,
|
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.
|
/// Handler reports in flight; held so they die with the state.
|
||||||
tasks: Vec<Task<()>>,
|
tasks: Vec<Task<()>>,
|
||||||
/// Keeps the query-event subscription tied to the state's lifetime.
|
/// Keeps the query-event subscription tied to the state's lifetime.
|
||||||
@@ -225,6 +239,7 @@ where
|
|||||||
|
|
||||||
let subscription =
|
let subscription =
|
||||||
cx.subscribe_in(&query, window, |state, _, event, window, cx| match event {
|
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::TextChanged => state.text_changed(window, cx),
|
||||||
InputStateEvent::Blurred => state.blurred(window, cx),
|
InputStateEvent::Blurred => state.blurred(window, cx),
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -248,6 +263,7 @@ where
|
|||||||
}),
|
}),
|
||||||
unmatched: combobox.unmatched,
|
unmatched: combobox.unmatched,
|
||||||
size: combobox.size,
|
size: combobox.size,
|
||||||
|
style: combobox.style,
|
||||||
tasks: Vec::new(),
|
tasks: Vec::new(),
|
||||||
_subscription: subscription,
|
_subscription: subscription,
|
||||||
}
|
}
|
||||||
@@ -364,6 +380,16 @@ where
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Gaining focus.
|
||||||
|
fn query_focused(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
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.
|
/// Losing focus. [`UnmatchedText`] decides what the text becomes.
|
||||||
fn blurred(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn blurred(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
self.highlight = None;
|
self.highlight = None;
|
||||||
@@ -447,11 +473,25 @@ where
|
|||||||
T: Clone + PartialEq + 'static,
|
T: Clone + PartialEq + 'static,
|
||||||
{
|
{
|
||||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
let (metrics, colors, radius) = {
|
let (metrics, colors, radius, panel_border, spacing_unit, shadows) = {
|
||||||
let theme = cx.theme();
|
let theme = cx.theme();
|
||||||
let metrics = theme.sizes.resolve(self.size);
|
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 icon_size = metrics.dense_icon_size;
|
||||||
let id = self.id.clone();
|
let id = self.id.clone();
|
||||||
let state = cx.entity();
|
let state = cx.entity();
|
||||||
@@ -490,7 +530,9 @@ where
|
|||||||
.id(id.clone())
|
.id(id.clone())
|
||||||
.key_context(COMBOBOX_CONTEXT)
|
.key_context(COMBOBOX_CONTEXT)
|
||||||
.relative()
|
.relative()
|
||||||
.w_full()
|
.w(window.rem_size() * 12.0)
|
||||||
|
.min_w(window.rem_size() * 6.0)
|
||||||
|
.refine_style(&self.style)
|
||||||
.role(Role::ComboBox)
|
.role(Role::ComboBox)
|
||||||
.aria_expanded(open)
|
.aria_expanded(open)
|
||||||
.aria_label(self.name.clone())
|
.aria_label(self.name.clone())
|
||||||
@@ -532,13 +574,14 @@ where
|
|||||||
.child(
|
.child(
|
||||||
Input::new((id.clone(), "query"), self.query.clone())
|
Input::new((id.clone(), "query"), self.query.clone())
|
||||||
.placeholder(self.placeholder.clone())
|
.placeholder(self.placeholder.clone())
|
||||||
|
.placeholder_when_focused(true)
|
||||||
.with_size(self.size),
|
.with_size(self.size),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.absolute()
|
.absolute()
|
||||||
.right(metrics.dense_padding_x)
|
.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),
|
.child(chevron),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -626,6 +669,10 @@ where
|
|||||||
};
|
};
|
||||||
|
|
||||||
let panel_bounds: Rc<RefCell<Option<Bounds<Pixels>>>> = Rc::new(RefCell::new(None));
|
let panel_bounds: Rc<RefCell<Option<Bounds<Pixels>>>> = 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()
|
let panel = v_flex()
|
||||||
.id((id.clone(), "panel"))
|
.id((id.clone(), "panel"))
|
||||||
@@ -633,14 +680,14 @@ where
|
|||||||
let name = format!("{id}-panel");
|
let name = format!("{id}-panel");
|
||||||
move || name
|
move || name
|
||||||
})
|
})
|
||||||
.mt_1()
|
.w(popup_width)
|
||||||
.w_full()
|
|
||||||
.p_1()
|
.p_1()
|
||||||
.bg(colors.surface)
|
.bg(colors.surface)
|
||||||
.text_color(colors.foreground)
|
.text_color(colors.foreground)
|
||||||
.rounded(radius.overlay)
|
.rounded(radius.overlay)
|
||||||
.border_1()
|
.border_1()
|
||||||
.border_color(colors.border)
|
.border_color(panel_border)
|
||||||
|
.shadow(shadows)
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
.child(rows)
|
.child(rows)
|
||||||
.child(
|
.child(
|
||||||
@@ -684,7 +731,9 @@ where
|
|||||||
.inset_0(),
|
.inset_0(),
|
||||||
);
|
);
|
||||||
|
|
||||||
wrapper = wrapper.child(deferred(panel));
|
wrapper = wrapper.child(deferred(
|
||||||
|
anchored().offset(point(px(0.), spacing_unit)).child(panel),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
wrapper
|
wrapper
|
||||||
|
|||||||
+152
-210
@@ -7,13 +7,13 @@ use unicode_segmentation::UnicodeSegmentation;
|
|||||||
|
|
||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, AppContext as _, Bounds, ClipboardItem, ContentMask, Context, CursorStyle,
|
Animation, AnimationExt as _, AnyElement, App, AppContext as _, Bounds, ClipboardItem,
|
||||||
DefiniteLength, DispatchPhase, ElementId, Entity, EntityInputHandler, EventEmitter, Font,
|
ContentMask, Context, CursorStyle, DefiniteLength, DispatchPhase, ElementId, Entity,
|
||||||
HitboxBehavior, InteractiveElement as _, IntoElement, KeyBinding, KeyDownEvent, MouseButton,
|
EntityInputHandler, EventEmitter, Font, HitboxBehavior, InteractiveElement as _, IntoElement,
|
||||||
MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, RenderOnce, Role,
|
KeyBinding, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent,
|
||||||
ScrollDelta, ScrollEvent, SharedString, StatefulInteractiveElement as _, Styled as _,
|
ParentElement as _, Pixels, RenderOnce, Role, ScrollDelta, ScrollEvent, SharedString,
|
||||||
Subscription, Task, TextAlign, TextRun, UTF16Selection, Window, WrappedLine, actions, canvas,
|
StatefulInteractiveElement as _, Styled as _, TextAlign, TextRun, UTF16Selection, Window,
|
||||||
div, fill, point, px,
|
WrappedLine, actions, canvas, div, fill, point, px,
|
||||||
};
|
};
|
||||||
use gpui_native_icons::IconRole;
|
use gpui_native_icons::IconRole;
|
||||||
use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, ColorTokens, Size};
|
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<Task<()>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CaretBlink {
|
|
||||||
fn new(_cx: &mut Context<Self>) -> 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<Self>) {
|
|
||||||
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>) {
|
|
||||||
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<Self>) {
|
|
||||||
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<Self>) {
|
|
||||||
if !self.active || self.paused {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.visible = !self.visible;
|
|
||||||
cx.notify();
|
|
||||||
self.schedule(cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn schedule(&mut self, cx: &mut Context<Self>) {
|
|
||||||
self.generation += 1;
|
|
||||||
self.schedule_pause(cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn schedule_pause(&mut self, cx: &mut Context<Self>) {
|
|
||||||
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.
|
/// The application-owned state of a text field.
|
||||||
pub struct InputState {
|
pub struct InputState {
|
||||||
text: String,
|
text: String,
|
||||||
@@ -608,20 +517,18 @@ pub struct InputState {
|
|||||||
scroll_offset: gpui::Point<Pixels>,
|
scroll_offset: gpui::Point<Pixels>,
|
||||||
/// Set by every caret-affecting change.
|
/// Set by every caret-affecting change.
|
||||||
scroll_to_caret_pending: bool,
|
scroll_to_caret_pending: bool,
|
||||||
/// Drives the focused caret's blink.
|
/// Bumped on every edit.
|
||||||
caret_blink: Entity<CaretBlink>,
|
caret_epoch: usize,
|
||||||
/// The shaped line and element bounds, recorded during each paint,
|
/// When the current caret epoch started.
|
||||||
/// and shared with hit-testing and the IME geometry conversions.
|
caret_epoch_started: Option<Instant>,
|
||||||
|
/// The shaped line and element bounds, recorded during each paint.
|
||||||
layout: Rc<RefCell<Option<InputLayout>>>,
|
layout: Rc<RefCell<Option<InputLayout>>>,
|
||||||
/// Keeps the field repainting on blink ticks. Dropping it unsubscribes.
|
|
||||||
_caret_blink_subscription: Subscription,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EventEmitter<InputStateEvent> for InputState {}
|
impl EventEmitter<InputStateEvent> for InputState {}
|
||||||
|
|
||||||
impl InputState {
|
impl InputState {
|
||||||
fn new_inner(cx: &mut Context<Self>) -> Self {
|
fn new_inner(cx: &mut Context<Self>) -> Self {
|
||||||
let caret_blink = cx.new(CaretBlink::new);
|
|
||||||
Self {
|
Self {
|
||||||
text: String::new(),
|
text: String::new(),
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
@@ -651,9 +558,9 @@ impl InputState {
|
|||||||
goal_x: None,
|
goal_x: None,
|
||||||
scroll_offset: point(Pixels::ZERO, Pixels::ZERO),
|
scroll_offset: point(Pixels::ZERO, Pixels::ZERO),
|
||||||
scroll_to_caret_pending: true,
|
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)),
|
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.
|
/// 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<String>, cx: &mut App) -> Entity<Self> {
|
pub fn new_with_text(text: impl Into<String>, cx: &mut App) -> Entity<Self> {
|
||||||
let text = sanitize(text.into(), false);
|
let text = sanitize(text.into(), false);
|
||||||
cx.new(|cx| {
|
cx.new(|cx| {
|
||||||
@@ -683,10 +587,6 @@ impl InputState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Replaces the whole value and collapses the selection to the end.
|
/// 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<V>(&mut self, value: V, window: &mut Window, cx: &mut Context<Self>)
|
pub fn set_value<V>(&mut self, value: V, window: &mut Window, cx: &mut Context<Self>)
|
||||||
where
|
where
|
||||||
V: Into<String>,
|
V: Into<String>,
|
||||||
@@ -868,7 +768,8 @@ impl InputState {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
self.scroll_to_caret_pending = true;
|
self.scroll_to_caret_pending = true;
|
||||||
self.goal_x = None;
|
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);
|
window.notify_text_input_changed(&self.focus_handle);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -901,10 +802,11 @@ impl InputState {
|
|||||||
}
|
}
|
||||||
self.focused = focused;
|
self.focused = focused;
|
||||||
|
|
||||||
|
// A fresh focus starts a fresh blink epoch, so the caret shows solid
|
||||||
|
// for one interval before blinking resumes.
|
||||||
if focused {
|
if focused {
|
||||||
self.caret_blink.update(cx, |blink, cx| blink.enable(cx));
|
self.caret_epoch += 1;
|
||||||
} else {
|
self.caret_epoch_started = Some(Instant::now());
|
||||||
self.caret_blink.update(cx, |blink, cx| blink.disable(cx));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cx.emit(if focused {
|
cx.emit(if focused {
|
||||||
@@ -915,8 +817,22 @@ impl InputState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the caret should paint.
|
/// Whether the caret should paint.
|
||||||
pub fn caret_visible(&mut self, focused: bool, cx: &mut Context<Self>) -> bool {
|
pub fn caret_visible(&mut self, focused: bool) -> bool {
|
||||||
focused && !self.read_only && self.caret_blink.read(cx).visible()
|
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.
|
/// 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.
|
// 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);
|
self.paint_caret(origin, font_size, colors.foreground, window);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1903,8 +1819,10 @@ pub struct Input {
|
|||||||
placeholder: SharedString,
|
placeholder: SharedString,
|
||||||
size: Size,
|
size: Size,
|
||||||
disabled: bool,
|
disabled: bool,
|
||||||
|
/// The placeholder stays visible while focused.
|
||||||
|
placeholder_when_focused: bool,
|
||||||
a11y_label: Option<SharedString>,
|
a11y_label: Option<SharedString>,
|
||||||
/// 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,
|
rows: usize,
|
||||||
prefix: Option<AnyElement>,
|
prefix: Option<AnyElement>,
|
||||||
suffix: Option<AnyElement>,
|
suffix: Option<AnyElement>,
|
||||||
@@ -1922,6 +1840,7 @@ impl Input {
|
|||||||
placeholder: SharedString::default(),
|
placeholder: SharedString::default(),
|
||||||
size: Size::default(),
|
size: Size::default(),
|
||||||
disabled: false,
|
disabled: false,
|
||||||
|
placeholder_when_focused: false,
|
||||||
a11y_label: None,
|
a11y_label: None,
|
||||||
rows: DEFAULT_ROWS,
|
rows: DEFAULT_ROWS,
|
||||||
prefix: None,
|
prefix: None,
|
||||||
@@ -1944,6 +1863,12 @@ impl Input {
|
|||||||
self
|
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.
|
/// Renders the field inert, unfocusable, and dimmed; disabled always wins.
|
||||||
pub fn disabled(mut self, disabled: bool) -> Self {
|
pub fn disabled(mut self, disabled: bool) -> Self {
|
||||||
self.disabled = disabled;
|
self.disabled = disabled;
|
||||||
@@ -1991,12 +1916,13 @@ impl RenderOnce for Input {
|
|||||||
let radius = theme.radius;
|
let radius = theme.radius;
|
||||||
let metrics = theme.sizes.resolve(self.size);
|
let metrics = theme.sizes.resolve(self.size);
|
||||||
let focus_handle = self.state.read(cx).focus_handle.clone();
|
let focus_handle = self.state.read(cx).focus_handle.clone();
|
||||||
|
|
||||||
let focused = focus_handle.is_focused(window);
|
let focused = focus_handle.is_focused(window);
|
||||||
|
|
||||||
let is_empty = self.state.read(cx).text.is_empty();
|
let is_empty = self.state.read(cx).text.is_empty();
|
||||||
let is_password = self.state.read(cx).is_password();
|
let is_password = self.state.read(cx).is_password();
|
||||||
let revealed = self.state.read(cx).is_revealed();
|
let revealed = self.state.read(cx).is_revealed();
|
||||||
let multiline = self.state.read(cx).is_multiline();
|
let multiline = self.state.read(cx).is_multiline();
|
||||||
|
let recording = focused && !self.disabled && !self.state.read(cx).is_read_only();
|
||||||
|
|
||||||
let small = theme.sizes.resolve(Size::Small);
|
let small = theme.sizes.resolve(Size::Small);
|
||||||
let docked_height = small.control_height + BORDER_WIDTH * 2.0;
|
let docked_height = small.control_height + BORDER_WIDTH * 2.0;
|
||||||
@@ -2041,6 +1967,97 @@ impl RenderOnce for Input {
|
|||||||
.child(element)
|
.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()
|
h_flex()
|
||||||
.id(self.id.clone())
|
.id(self.id.clone())
|
||||||
.debug_selector(|| "input-field".to_string())
|
.debug_selector(|| "input-field".to_string())
|
||||||
@@ -2098,93 +2115,18 @@ impl RenderOnce for Input {
|
|||||||
.when_some(line_height, |region, line_height| {
|
.when_some(line_height, |region, line_height| {
|
||||||
region.items_start().line_height(line_height)
|
region.items_start().line_height(line_height)
|
||||||
})
|
})
|
||||||
.when(is_empty && !focused, |region| {
|
.when(
|
||||||
region.child(
|
is_empty && (!focused || self.placeholder_when_focused),
|
||||||
div()
|
|region| {
|
||||||
.text_color(colors.muted_foreground)
|
region.child(
|
||||||
.when(multiline, |placeholder| placeholder.pt(vertical_inset))
|
div()
|
||||||
.child(self.placeholder.clone()),
|
.text_color(colors.muted_foreground)
|
||||||
)
|
.when(multiline, |placeholder| placeholder.pt(vertical_inset))
|
||||||
})
|
.child(self.placeholder.clone()),
|
||||||
.child(
|
)
|
||||||
canvas(
|
},
|
||||||
{
|
)
|
||||||
let state = self.state.clone();
|
.child(canvas_element),
|
||||||
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_some(self.suffix, |input, suffix| {
|
.when_some(self.suffix, |input, suffix| {
|
||||||
input.child(edge_zone("input-suffix", suffix))
|
input.child(edge_zone("input-suffix", suffix))
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ use std::cell::RefCell;
|
|||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, SharedString,
|
AppContext as _, Context, Entity, IntoElement, MouseButton, ParentElement as _, Render,
|
||||||
Styled as _, TestAppContext, VisualTestContext, Window, div,
|
SharedString, Styled as _, TestAppContext, VisualTestContext, Window, div, point, px,
|
||||||
};
|
};
|
||||||
use gpui_native_core::combobox::{Combobox, ComboboxOption, ComboboxState};
|
use gpui_native_core::combobox::{Combobox, ComboboxOption, ComboboxState};
|
||||||
use gpui_native_core::init;
|
use gpui_native_core::init;
|
||||||
@@ -99,6 +99,31 @@ fn draw(visual: &mut VisualTestContext) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn click(visual: &mut VisualTestContext, position: gpui::Point<gpui::Pixels>) {
|
||||||
|
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) {
|
fn focus(host: &mut ComboHost) {
|
||||||
let state = host.state.clone();
|
let state = host.state.clone();
|
||||||
host.visual.update(|window, cx| {
|
host.visual.update(|window, cx| {
|
||||||
|
|||||||
@@ -38,8 +38,11 @@ impl Render for FieldHost {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A field page in a real window, with the url input focused.
|
/// A field page in a real window; the url input is focused unless
|
||||||
fn field_host() -> (
|
/// `focus` is false, which leaves the page truly idle.
|
||||||
|
fn field_host(
|
||||||
|
focus: bool,
|
||||||
|
) -> (
|
||||||
TestAppContext,
|
TestAppContext,
|
||||||
Entity<InputState>,
|
Entity<InputState>,
|
||||||
Rc<RefCell<usize>>,
|
Rc<RefCell<usize>>,
|
||||||
@@ -64,8 +67,10 @@ fn field_host() -> (
|
|||||||
});
|
});
|
||||||
let mut visual = VisualTestContext::from_window(window.into(), &cx);
|
let mut visual = VisualTestContext::from_window(window.into(), &cx);
|
||||||
visual.update(|window, cx| {
|
visual.update(|window, cx| {
|
||||||
let focus = url.read(cx).focus_handle();
|
if focus {
|
||||||
window.focus(&focus, cx);
|
let focus = url.read(cx).focus_handle();
|
||||||
|
window.focus(&focus, cx);
|
||||||
|
}
|
||||||
window.draw(cx).clear(cx);
|
window.draw(cx).clear(cx);
|
||||||
});
|
});
|
||||||
(cx, url, renders, visual)
|
(cx, url, renders, visual)
|
||||||
@@ -94,17 +99,27 @@ fn type_and_time(visual: &mut VisualTestContext, text: &str) -> std::time::Durat
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_idle_field_page_schedules_no_frames() {
|
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));
|
let frames = visual.update(|window, cx| window.simulate_next_frame(cx));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
frames, 0,
|
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]
|
#[test]
|
||||||
fn field_typing_frame_cost() {
|
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");
|
let elapsed = type_and_time(&mut visual, "dsdsadsadadadada");
|
||||||
assert!(
|
assert!(
|
||||||
elapsed.as_millis() < 1000,
|
elapsed.as_millis() < 1000,
|
||||||
|
|||||||
+27
-24
@@ -269,42 +269,45 @@ fn read_only_refuses_edits_but_keeps_selection_and_copy() {
|
|||||||
assert_eq!(clipboard, "lo");
|
assert_eq!(clipboard, "lo");
|
||||||
|
|
||||||
// The caret is suppressed entirely while read-only.
|
// 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);
|
assert!(!caret);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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);
|
let (cx, state, mut visual) = input_window("", false);
|
||||||
focus(&mut visual, &state);
|
focus(&mut visual, &state);
|
||||||
visual.update(|window, cx| {
|
let caret = || cx.update(|cx| state.update(cx, |state, _| state.caret_visible(true)));
|
||||||
window.draw(cx).clear(cx);
|
|
||||||
});
|
|
||||||
visual.run_until_parked();
|
|
||||||
|
|
||||||
let caret = || cx.update(|cx| state.update(cx, |state, cx| state.caret_visible(true, cx)));
|
// Fresh focus starts a new epoch: the caret shows solid — the elapsed
|
||||||
|
// time since the epoch began is microseconds, far under one interval.
|
||||||
// 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();
|
|
||||||
assert!(caret());
|
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");
|
visual.simulate_keystrokes("a");
|
||||||
assert!(caret());
|
assert!(caret());
|
||||||
cx.executor().advance_clock(Duration::from_millis(500));
|
|
||||||
visual.run_until_parked();
|
|
||||||
assert!(!caret());
|
|
||||||
|
|
||||||
// An unfocused field reports no caret.
|
// The blink cadence itself: hidden in the second half of the cycle.
|
||||||
let caret_unfocused =
|
let hidden = cx.update(|cx| {
|
||||||
cx.update(|cx| state.update(cx, |state, cx| state.caret_visible(false, cx)));
|
state.update(cx, |state, _| {
|
||||||
assert!(!caret_unfocused);
|
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]
|
#[test]
|
||||||
|
|||||||
+22
-1
@@ -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
|
`Theme::borders.overlays` (and dark mode), taking the surface color
|
||||||
where the platform paints none; it rides the popover elevation, so
|
where the platform paints none; it rides the popover elevation, so
|
||||||
dark mode's no-shadow rule applies to it too
|
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,
|
- [x] Button, Checkbox, Switch, Radio + RadioGroup, Toggle, Link, Slider,
|
||||||
Progress, Avatar (image + seeded pixel-art fallback)
|
Progress, Avatar (image + seeded pixel-art fallback)
|
||||||
- [x] Spinner — painted ring + rotating-icon variant, indeterminate and
|
- [x] Spinner — painted ring + rotating-icon variant, indeterminate and
|
||||||
determinate; replaces `Tab::busy`'s placeholder
|
determinate; replaces `Tab::busy`'s placeholder
|
||||||
- [x] Input — single-line, password with reveal, multiline, undo/redo, actions
|
- [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,
|
- [x] ScrollArea, Popover, Menu, Tooltip, Dialog / AlertDialog, Toast,
|
||||||
Alert + alerts stack
|
Alert + alerts stack
|
||||||
- [x] Select, Segmented, ContextMenu, EmptyState, Badge
|
- [x] Select, Segmented, ContextMenu, EmptyState, Badge
|
||||||
|
|||||||
@@ -1257,7 +1257,10 @@ impl ComboboxDemo {
|
|||||||
)
|
)
|
||||||
.selected(SharedString::from("main"))
|
.selected(SharedString::from("main"))
|
||||||
.placeholder("Switch branch…")
|
.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,
|
window,
|
||||||
cx,
|
cx,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user