Add IME underline, I-beam cursor, and horizontal scroll to Input
This commit is contained in:
+101
-18
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user