Add IME underline, I-beam cursor, and horizontal scroll to Input

This commit is contained in:
2026-10-29 15:59:42 +07:00
parent b57547ab8c
commit 2ecde30ae2
3 changed files with 150 additions and 22 deletions
+101 -18
View File
@@ -7,12 +7,12 @@ use unicode_segmentation::UnicodeSegmentation;
use gpui::prelude::FluentBuilder as _;
use gpui::{
App, AppContext as _, Bounds, ClipboardItem, Context, DispatchPhase, ElementId, Entity,
EntityInputHandler, EventEmitter, Font, InteractiveElement as _, IntoElement, KeyBinding,
KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _,
Pixels, RenderOnce, Role, SharedString, StatefulInteractiveElement as _, Styled as _,
Subscription, TextAlign, TextRun, UTF16Selection, Window, actions, canvas, div, fill, point,
px,
App, AppContext as _, Bounds, ClipboardItem, Context, CursorStyle, 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, TextAlign, TextRun,
UTF16Selection, Window, actions, canvas, div, fill, point, px,
};
use gpui_native_theme::{ActiveTheme as _, ColorTokens, Size};
@@ -24,6 +24,8 @@ pub const INPUT_CONTEXT: &str = "Input";
const DEFAULT_GROUP_INTERVAL: Duration = Duration::from_millis(300);
/// How often the focused caret blinks.
const CARET_BLINK_INTERVAL: Duration = Duration::from_millis(500);
/// Pixel height of one scroll "line" for horizontal wheel scrolling.
const HORIZONTAL_SCROLL_LINES_HEIGHT: f32 = 20.0;
/// Maximum undo entries kept; the oldest drops off the front.
const MAX_HISTORY: usize = 100;
@@ -431,6 +433,10 @@ pub struct InputState {
group_interval: Duration,
/// When set, user edits are refused, focus, movement, selection and copy stay.
read_only: bool,
/// Horizontal scroll of the content inside the field, clamped at paint.
scroll_offset: 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,
@@ -467,6 +473,8 @@ impl InputState {
redo_stack: Vec::new(),
group_interval: DEFAULT_GROUP_INTERVAL,
read_only: false,
scroll_offset: Pixels::ZERO,
scroll_to_caret_pending: true,
caret_blink: caret_blink.clone(),
layout: Rc::new(RefCell::new(None)),
_caret_blink_subscription: cx.observe(&caret_blink, |_, _, cx| cx.notify()),
@@ -619,11 +627,18 @@ impl InputState {
fn notify(&mut self, window: &mut Window, cx: &mut Context<Self>) {
cx.notify();
// Any change holds the caret solid and restarts the blink cycle.
self.scroll_to_caret_pending = true;
self.caret_blink.update(cx, |blink, cx| blink.pause(cx));
window.notify_text_input_changed(&self.focus_handle);
}
/// Scrolls the field horizontally by a wheel delta, clamped at paint.
fn scroll_by(&mut self, delta: Pixels, window: &mut Window, cx: &mut Context<Self>) {
self.scroll_offset = (self.scroll_offset - delta).max(Pixels::ZERO);
cx.notify();
window.notify_text_input_changed(&self.focus_handle);
}
/// Emits `Focused`/`Blurred` when the field's focus state changed since the last paint.
fn sync_focus(&mut self, focused: bool, cx: &mut Context<Self>) {
if self.focused == focused {
@@ -788,16 +803,23 @@ impl InputState {
let selection = self.selection();
let caret_x = line.x_for_index(selection.end);
// Horizontal scroll keeps the caret inside the field.
let visible = bounds.size.width - padding * 2.0;
let scroll_x = if caret_x > visible {
caret_x - visible
} else {
Pixels::ZERO
};
let visible = (bounds.size.width - padding * 2.0).max(Pixels::ZERO);
let max_scroll = (line.width - visible).max(Pixels::ZERO);
if self.scroll_to_caret_pending {
self.scroll_to_caret_pending = false;
// Pull the scroll just far enough to reveal the caret on whichever edge it left.
if caret_x - self.scroll_offset > visible {
self.scroll_offset = caret_x - visible;
}
if caret_x < self.scroll_offset {
self.scroll_offset = caret_x;
}
}
self.scroll_offset = self.scroll_offset.clamp(Pixels::ZERO, max_scroll);
let origin = point(
bounds.left() + padding - scroll_x,
bounds.left() + padding - self.scroll_offset,
bounds.top() + (bounds.size.height - line_height) / 2.0,
);
@@ -901,6 +923,13 @@ impl InputState {
self.paint_text(origin, line_height, window, cx);
// The composition underline shows the in-progress IME text.
if let Some(range) = self.marked_range.clone()
&& !range.is_empty()
{
self.paint_marked_underline(origin, line_height, range, colors.foreground, window);
}
// A caret promises that typing lands there; in read-only it does not.
if focused && self.caret_visible(focused, cx) && self.selection().is_empty() {
self.paint_caret(
@@ -934,6 +963,32 @@ impl InputState {
self.notify(window, cx);
}
/// Paints the composition underline under the marked range.
fn paint_marked_underline(
&self,
origin: gpui::Point<Pixels>,
line_height: Pixels,
range: Range<usize>,
color: gpui::Hsla,
window: &mut Window,
) {
let layout = self.layout.borrow();
let Some(layout) = layout.as_ref() else {
return;
};
let start_x = layout.line.x_for_index(range.start);
let end_x = layout.line.x_for_index(range.end);
let thickness = px(1.0);
let underline_y = origin.y + line_height - thickness;
let rect = Bounds::from_corners(
point(origin.x + start_x, underline_y),
point(origin.x + end_x, underline_y + thickness),
);
window.paint_quad(fill(rect, color));
}
/// Registers the window-level listeners that continue a drag-select
/// while the pointer moves outside the field.
fn register_drag_listeners(entity: &Entity<Self>, window: &mut Window) {
@@ -1519,7 +1574,12 @@ impl RenderOnce for Input {
canvas(
{
let state = self.state.clone();
move |_bounds, window, cx| state.read(cx).shape_current_text(window)
move |bounds, window, cx| {
// The hitbox backs the I-beam hover cursor.
let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal);
let line = state.read(cx).shape_current_text(window);
(line, hitbox)
}
},
{
let state = self.state.clone();
@@ -1527,8 +1587,12 @@ impl RenderOnce for Input {
let padding = metrics.padding_x / 2.0;
let disabled = self.disabled;
// `line` comes from the prepaint closure above.
move |bounds, line, window, cx| {
// `line` and the hitbox come from the prepaint closure above.
move |bounds, (line, hitbox), window, cx| {
if !disabled {
window.set_cursor_style(CursorStyle::IBeam, &hitbox);
}
let dragging = state.update(cx, |state, cx| {
state.paint(line, bounds, padding, colors, window, cx)
});
@@ -1543,6 +1607,25 @@ impl RenderOnce for Input {
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.x,
ScrollDelta::Lines(lines) => {
px(lines.x * HORIZONTAL_SCROLL_LINES_HEIGHT)
}
};
state
.update(cx, |state, cx| state.scroll_by(delta, window, cx));
}
});
if dragging {
InputState::register_drag_listeners(&state, window);
}