chore: audit all components (#2)

Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
2026-11-08 06:27:12 +00:00
parent 0ad5c3fe90
commit 11b29dc91a
51 changed files with 2096 additions and 958 deletions
+243 -220
View File
@@ -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,
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 _, 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};
@@ -476,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.
pub struct InputState {
text: String,
@@ -606,20 +517,18 @@ pub struct InputState {
scroll_offset: gpui::Point<Pixels>,
/// Set by every caret-affecting change.
scroll_to_caret_pending: bool,
/// Drives the focused caret's blink.
caret_blink: Entity<CaretBlink>,
/// 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<Instant>,
/// The shaped line and element bounds, recorded during each paint.
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 InputState {
fn new_inner(cx: &mut Context<Self>) -> Self {
let caret_blink = cx.new(CaretBlink::new);
Self {
text: String::new(),
cursor: 0,
@@ -649,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()),
}
}
@@ -661,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<String>, cx: &mut App) -> Entity<Self> {
let text = sanitize(text.into(), false);
cx.new(|cx| {
@@ -681,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<V>(&mut self, value: V, window: &mut Window, cx: &mut Context<Self>)
where
V: Into<String>,
@@ -866,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);
}
@@ -899,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 {
@@ -913,8 +817,22 @@ impl InputState {
}
/// Whether the caret should paint.
pub fn caret_visible(&mut self, focused: bool, cx: &mut Context<Self>) -> 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.
@@ -1315,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);
}
@@ -1901,9 +1819,13 @@ pub struct Input {
placeholder: SharedString,
size: Size,
disabled: bool,
/// The placeholder stays visible while focused.
placeholder_when_focused: bool,
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,
prefix: Option<AnyElement>,
suffix: Option<AnyElement>,
}
impl Input {
@@ -1918,8 +1840,11 @@ impl Input {
placeholder: SharedString::default(),
size: Size::default(),
disabled: false,
placeholder_when_focused: false,
a11y_label: None,
rows: DEFAULT_ROWS,
prefix: None,
suffix: None,
}
}
@@ -1938,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;
@@ -1952,6 +1883,24 @@ impl Input {
self.a11y_label = Some(label.into());
self
}
/// Docks an element inside the field's leading edge.
pub fn prefix<E>(mut self, prefix: E) -> Self
where
E: IntoElement,
{
self.prefix = Some(prefix.into_any_element());
self
}
/// Docks an element inside the field's trailing edge.
pub fn suffix<E>(mut self, suffix: E) -> Self
where
E: IntoElement,
{
self.suffix = Some(suffix.into_any_element());
self
}
}
impl Sizable for Input {
@@ -1967,25 +1916,28 @@ 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 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 +1945,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 +1956,111 @@ 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)
};
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())
.key_context(INPUT_CONTEXT)
.role(Role::TextInput)
.aria_label(fallback_label)
@@ -2012,117 +2068,84 @@ 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;
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(),
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 || 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))
})
.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 {