Files
gpui-native-kit/crates/core/src/input.rs
T
2026-10-29 14:42:11 +07:00

1205 lines
39 KiB
Rust

use std::cell::RefCell;
use std::ops::Range;
use std::rc::Rc;
use std::time::{Duration, Instant};
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, KeyDownEvent,
MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels,
RenderOnce, Role, SharedString, StatefulInteractiveElement as _, Styled as _, TextAlign,
TextRun, UTF16Selection, Window, canvas, div, fill, point, px,
};
use gpui_native_theme::{ActiveTheme as _, ColorTokens, Size};
use crate::common::{Sizable, h_flex};
/// Significant changes in an [`InputState`], observable via `cx.subscribe`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum InputStateEvent {
/// The text content changed: typing, paste, IME commit, or `set_value`.
TextChanged,
/// The field gained focus.
Focused,
/// The field lost focus.
Blurred,
/// Enter was pressed
Submitted,
}
/// Input layout information for hit testing and caret positioning.
struct InputLayout {
line: gpui::ShapedLine,
bounds: Bounds<Pixels>,
/// Where the text was painted inside `bounds` (padding minus scroll).
origin: gpui::Point<Pixels>,
}
/// Handler for key down events.
type KeyDownHandler = std::rc::Rc<dyn Fn(&KeyDownEvent, &mut Window, &mut App)>;
/// Handler for mouse down events.
type MouseDownHandler = std::rc::Rc<dyn Fn(&MouseDownEvent, &mut Window, &mut App)>;
/// Consecutive edits within this interval merge into one undo entry.
const DEFAULT_GROUP_INTERVAL: Duration = Duration::from_millis(300);
/// Maximum undo entries kept; the oldest drops off the front.
const MAX_HISTORY: usize = 100;
/// History entry for undo/redo operations.
#[derive(Clone, Debug)]
struct HistoryEntry {
/// The range the replacement occupies after the edit.
range: Range<usize>,
/// The text the edit replaced, restored on undo.
old_text: String,
/// The length of the replacement.
new_len: usize,
/// The selection before the edit.
cursor: usize,
anchor: usize,
/// When the edit happened, for grouping.
timestamp: Instant,
}
impl HistoryEntry {
/// Applies this entry in reverse, returning the entry that reverses it back.
fn reverse(&self, text: &mut String) -> HistoryEntry {
let end = (self.range.start + self.new_len).min(text.len());
let removed = text[self.range.start..end].to_string();
text.replace_range(self.range.start..end, &self.old_text);
HistoryEntry {
range: self.range.start..self.range.start + self.old_text.len(),
old_text: removed,
new_len: self.old_text.len(),
cursor: self.cursor,
anchor: self.anchor,
timestamp: self.timestamp,
}
}
}
/// Input state for a single-line text field.
pub struct InputState {
text: String,
/// The caret's byte offset.
cursor: usize,
/// The byte offset the selection extends from.
anchor: usize,
/// The byte range of in-progress IME composition, if any.
marked_range: Option<Range<usize>>,
/// Whether the field had focus at the last paint, for focus transitions.
focused: bool,
focus_handle: gpui::FocusHandle,
/// The key-down handler, bound to this entity with `cx.listener`.
key_down_handler: Option<KeyDownHandler>,
/// The mouse-down handler, bound to this entity with `cx.listener`.
mouse_down_handler: Option<MouseDownHandler>,
/// Whether a drag-select is in progress.
dragging: bool,
/// Patch history. `redo_stack` holds undone entries awaiting redo.
undo_stack: Vec<HistoryEntry>,
redo_stack: Vec<HistoryEntry>,
/// Consecutive edits within this interval merge into one undo entry.
group_interval: Duration,
/// When set, user edits are refused, focus, movement, selection and copy stay.
read_only: bool,
/// The shaped line and element bounds, recorded during each paint,
/// and shared with hit-testing and the IME geometry conversions.
layout: Rc<RefCell<Option<InputLayout>>>,
}
impl EventEmitter<InputStateEvent> for InputState {}
impl InputState {
fn new_inner(cx: &Context<Self>) -> Self {
Self {
text: String::new(),
cursor: 0,
anchor: 0,
marked_range: None,
focused: false,
focus_handle: cx.focus_handle(),
key_down_handler: Some(Rc::new(cx.listener(
|state, event: &KeyDownEvent, window, cx| {
state.handle_key_down(event, window, cx);
},
))),
mouse_down_handler: Some(Rc::new(cx.listener(
|state, event: &MouseDownEvent, window, cx| {
state.handle_mouse_down(event, window, cx);
},
))),
dragging: false,
undo_stack: Vec::new(),
redo_stack: Vec::new(),
group_interval: DEFAULT_GROUP_INTERVAL,
read_only: false,
layout: Rc::new(RefCell::new(None)),
}
}
/// Creates the state entity for an application-owned input.
pub fn new(cx: &mut App) -> Entity<Self> {
cx.new(|cx| Self::new_inner(cx))
}
/// Creates the state entity with initial text, caret at the end.
///
/// Newlines become spaces.
pub fn new_with_text(text: impl Into<String>, cx: &mut App) -> Entity<Self> {
let text = sanitize(text.into());
cx.new(|cx| {
let mut state = Self::new_inner(cx);
state.text = text;
state.cursor = state.text.len();
state.anchor = state.cursor;
state
})
}
/// The current text.
pub fn value(&self) -> &str {
&self.text
}
/// Replaces the whole value and collapses the selection to the end.
///
/// Newlines become spaces. 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>,
{
self.set_value_inner(value, true, window, cx);
}
/// Like [`set_value`], but does not emit `TextChanged`.
pub fn set_value_silent<V>(&mut self, value: V, window: &mut Window, cx: &mut Context<Self>)
where
V: Into<String>,
{
self.set_value_inner(value, false, window, cx);
}
fn set_value_inner<V>(
&mut self,
value: V,
emit: bool,
window: &mut Window,
cx: &mut Context<Self>,
) where
V: Into<String>,
{
let text = sanitize(value.into());
if text == self.text {
return;
}
self.text = text;
self.cursor = self.text.len();
self.anchor = self.cursor;
self.marked_range = None;
self.undo_stack.clear();
self.redo_stack.clear();
if emit {
cx.emit(InputStateEvent::TextChanged);
}
self.notify(window, cx);
}
/// Sets how long consecutive edits keep merging into one undo entry.
pub fn set_group_interval(&mut self, interval: Duration) {
self.group_interval = interval;
}
/// Sets whether user edits are refused.
pub fn set_read_only(&mut self, read_only: bool, cx: &mut Context<Self>) {
if self.read_only != read_only {
self.read_only = read_only;
cx.notify();
}
}
/// Whether user edits are refused.
pub fn is_read_only(&self) -> bool {
self.read_only
}
/// Records `old_text` at `range` being replaced by `new_len` bytes.
fn push_history(&mut self, range: Range<usize>, old_text: String, new_len: usize) {
self.redo_stack.clear();
let now = Instant::now();
if let Some(last) = self.undo_stack.last_mut()
&& now.duration_since(last.timestamp) < self.group_interval
{
let last_end = last.range.start + last.new_len;
// Typing that continues right after the previous edit.
if range.start == last_end {
last.new_len += new_len;
last.timestamp = now;
return;
}
// A deletion of text that sat right before the previous edit.
if range.end == last.range.start && new_len == 0 {
last.range.start = range.start;
last.old_text = old_text + &last.old_text;
last.timestamp = now;
return;
}
// An edit fully inside the previous edit's replacement.
if range.start >= last.range.start && range.end <= last_end {
last.new_len += new_len;
last.new_len -= range.len();
last.timestamp = now;
return;
}
}
self.undo_stack.push(HistoryEntry {
range: range.start..range.start + new_len,
old_text,
new_len,
cursor: self.cursor,
anchor: self.anchor,
timestamp: now,
});
if self.undo_stack.len() > MAX_HISTORY {
self.undo_stack.remove(0);
}
}
/// Reverts the most recent edit group (`cmd-z`).
fn undo(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.read_only {
return;
}
let Some(entry) = self.undo_stack.pop() else {
return;
};
let inverse = entry.reverse(&mut self.text);
self.redo_stack.push(inverse);
self.cursor = entry.cursor.min(self.text.len());
self.anchor = entry.anchor.min(self.text.len());
cx.emit(InputStateEvent::TextChanged);
self.notify(window, cx);
}
/// Reapplies the most recently undone edit group (`cmd-shift-z`).
fn redo(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.read_only {
return;
}
let Some(entry) = self.redo_stack.pop() else {
return;
};
let inverse = entry.reverse(&mut self.text);
self.undo_stack.push(inverse);
// The caret lands at the end of the restored text,
// not where it was before the original edit.
let caret = entry.range.start + entry.old_text.len();
self.cursor = caret.min(self.text.len());
self.anchor = self.cursor;
cx.emit(InputStateEvent::TextChanged);
self.notify(window, cx);
}
/// The focus handle, for applications that focus the field programmatically.
pub fn focus_handle(&self) -> gpui::FocusHandle {
self.focus_handle.clone()
}
/// The selected byte range of the buffer.
fn selection(&self) -> Range<usize> {
self.cursor.min(self.anchor)..self.cursor.max(self.anchor)
}
fn notify(&mut self, window: &mut Window, cx: &mut Context<Self>) {
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 {
return;
}
self.focused = focused;
cx.emit(if focused {
InputStateEvent::Focused
} else {
InputStateEvent::Blurred
});
}
/// Replaces a byte range with `text` and collapses the caret to its end.
fn replace_range(
&mut self,
range: Range<usize>,
text: &str,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.read_only {
return;
}
if range.is_empty() && text.is_empty() {
return;
}
let text = sanitize(text);
let old_text = self.text[range.clone()].to_string();
self.push_history(range.clone(), old_text, text.len());
self.text.replace_range(range.clone(), &text);
self.cursor = range.start + text.len();
self.anchor = self.cursor;
self.marked_range = None;
cx.emit(InputStateEvent::TextChanged);
self.notify(window, cx);
}
/// Deletes the selection, or one character/word before the caret.
fn delete_backward(&mut self, word: bool, window: &mut Window, cx: &mut Context<Self>) {
if !self.selection().is_empty() {
let range = self.selection();
self.replace_range(range, "", window, cx);
} else if self.cursor > 0 {
let start = if word {
previous_word_start(&self.text, self.cursor)
} else {
previous_grapheme_start(&self.text, self.cursor)
};
self.replace_range(start..self.cursor, "", window, cx);
}
}
/// Deletes the selection, or one character/word after the caret.
fn delete_forward(&mut self, word: bool, window: &mut Window, cx: &mut Context<Self>) {
if !self.selection().is_empty() {
let range = self.selection();
self.replace_range(range, "", window, cx);
} else if self.cursor < self.text.len() {
let end = if word {
next_word_end(&self.text, self.cursor)
} else {
next_grapheme_start(&self.text, self.cursor)
};
self.replace_range(self.cursor..end, "", window, cx);
}
}
/// Moves the caret, extending the selection when `extend`.
fn move_cursor(
&mut self,
to: usize,
extend: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.cursor = to.clamp(0, self.text.len());
if !extend {
self.anchor = self.cursor;
}
self.notify(window, cx);
}
/// Selects `range`.
fn select_range(&mut self, range: Range<usize>, window: &mut Window, cx: &mut Context<Self>) {
self.anchor = range.start;
self.cursor = range.end;
self.notify(window, cx);
}
/// Copies the selected text to the clipboard.
fn copy_selection(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> bool {
let range = self.selection();
if range.is_empty() {
return false;
}
cx.write_to_clipboard(ClipboardItem::new_string(
self.text[range.clone()].to_string(),
));
true
}
/// Deletes the selected range, if any.
fn delete_selection(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let range = self.selection();
if !range.is_empty() {
self.replace_range(range, "", window, cx);
}
}
/// Shapes the buffer with the paint-time text style.
fn shape_current_text(&self, window: &Window) -> gpui::ShapedLine {
let text = self.text.clone();
let text_style = window.text_style();
let font = Font {
family: text_style.font_family.clone(),
features: text_style.font_features.clone(),
weight: text_style.font_weight,
style: text_style.font_style,
fallbacks: text_style.font_fallbacks.clone(),
};
let font_size = text_style.font_size.to_pixels(window.rem_size());
let runs = [TextRun {
len: text.len(),
font,
color: text_style.color,
background_color: None,
underline: None,
strikethrough: None,
}];
window
.text_system()
.shape_line(text.into(), font_size, &runs, None)
}
/// Stores the paint geometry for hit-testing and the IME conversions.
fn store_layout(
&mut self,
line: gpui::ShapedLine,
bounds: Bounds<Pixels>,
padding: Pixels,
line_height: Pixels,
) -> (gpui::Point<Pixels>, Pixels) {
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 origin = point(
bounds.left() + padding - scroll_x,
bounds.top() + (bounds.size.height - line_height) / 2.0,
);
*self.layout.borrow_mut() = Some(InputLayout {
line,
bounds,
origin,
});
(origin, caret_x)
}
/// Paints the selection highlight behind the text.
fn paint_selection(
&self,
origin: gpui::Point<Pixels>,
caret_x: Pixels,
bounds: Bounds<Pixels>,
color: gpui::Hsla,
window: &mut Window,
) {
let selection = self.selection();
if selection.is_empty() {
return;
}
let layout = self.layout.borrow();
let Some(layout) = layout.as_ref() else {
return;
};
let start_x = layout.line.x_for_index(selection.start);
let rect = Bounds::from_corners(
point(origin.x + start_x.min(caret_x), bounds.top() + px(1.)),
point(origin.x + start_x.max(caret_x), bounds.bottom() - px(1.)),
);
window.paint_quad(fill(rect, color));
}
/// Paints the text itself.
fn paint_text(
&self,
origin: gpui::Point<Pixels>,
line_height: Pixels,
window: &mut Window,
cx: &mut App,
) {
let layout = self.layout.borrow();
let Some(layout) = layout.as_ref() else {
return;
};
layout
.line
.paint(origin, line_height, TextAlign::Left, None, window, cx)
.ok();
}
/// Paints the caret at the cursor position.
fn paint_caret(
&self,
origin: gpui::Point<Pixels>,
caret_x: Pixels,
font_size: Pixels,
line_height: Pixels,
color: gpui::Hsla,
window: &mut Window,
) {
let caret_top = origin.y + (line_height - font_size) / 2.0;
let caret = Bounds::from_corners(
point(origin.x + caret_x, caret_top),
point(origin.x + caret_x + px(1.5), caret_top + font_size),
);
window.paint_quad(fill(caret, color));
}
/// Paints the field contents; returns whether a drag-select is in progress.
fn paint(
&mut self,
line: gpui::ShapedLine,
bounds: Bounds<Pixels>,
padding: Pixels,
colors: ColorTokens,
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
let focused = self.focus_handle.is_focused(window);
self.sync_focus(focused, cx);
let text_style = window.text_style();
let font_size = text_style.font_size.to_pixels(window.rem_size());
let line_height = text_style
.line_height
.to_pixels(text_style.font_size, window.rem_size());
let (origin, caret_x) = self.store_layout(line, bounds, padding, line_height);
if focused && !self.selection().is_empty() {
self.paint_selection(origin, caret_x, bounds, colors.selection, window);
}
self.paint_text(origin, line_height, window, cx);
// A caret promises that typing lands there; in read-only it does not.
if focused && !self.read_only && self.selection().is_empty() {
self.paint_caret(
origin,
caret_x,
font_size,
line_height,
colors.foreground,
window,
);
}
self.dragging
}
/// The text index at window x, using the last painted layout.
fn index_at_x(&self, x: Pixels) -> usize {
let layout = self.layout.borrow();
let Some(layout) = layout.as_ref() else {
return 0;
};
layout
.line
.closest_index_for_x(x - layout.origin.x)
.min(self.text.len())
}
/// Extends the selection to `x` while drag-selecting.
fn drag_select_to(&mut self, x: Pixels, window: &mut Window, cx: &mut Context<Self>) {
self.cursor = self.index_at_x(x);
self.notify(window, cx);
}
/// 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) {
window.on_mouse_event({
let state = entity.clone();
move |event: &MouseMoveEvent, phase, window, cx| {
if phase != DispatchPhase::Bubble || event.pressed_button != Some(MouseButton::Left)
{
return;
}
state.update(cx, |state, cx| {
state.drag_select_to(event.position.x, window, cx)
});
}
});
window.on_mouse_event({
let state = entity.clone();
move |event: &MouseUpEvent, phase, _window, cx| {
if phase != DispatchPhase::Bubble || event.button != MouseButton::Left {
return;
}
state.update(cx, |state, _| state.dragging = false);
}
});
}
/// The mouse-down handler: starts a drag and places or extends the selection.
fn handle_mouse_down(
&mut self,
event: &MouseDownEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.dragging = true;
let index = self.index_at_x(event.position.x);
if event.click_count >= 3 {
// A single-line field is one line: triple-click selects all of it.
let len = self.text.len();
self.select_range(0..len, window, cx);
} else if event.click_count == 2 {
let start = previous_word_start(&self.text, index);
let end = next_word_end(&self.text, index);
self.select_range(start..end, window, cx);
} else if event.modifiers.shift {
self.cursor = index;
self.notify(window, cx);
} else {
self.cursor = index;
self.anchor = index;
self.notify(window, cx);
}
}
/// The key-down handler: navigation, editing, clipboard, and submit.
///
/// Runs only while the field is focused.
fn handle_key_down(
&mut self,
event: &KeyDownEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.focus_handle.contains_focused(window, cx) {
return;
}
let keystroke = &event.keystroke;
let modifiers = keystroke.modifiers;
// macOS: cmd jumps to the field ends,
// cmd-backspace deletes to the start, alt is the word modifier.
//
// Elsewhere ctrl is the word modifier
// and the Home/End keys already cover the ends.
#[cfg(target_os = "macos")]
let (command, word, jump) = (modifiers.platform, modifiers.alt, modifiers.platform);
#[cfg(not(target_os = "macos"))]
let (command, word, jump) = (modifiers.control, modifiers.control, false);
match keystroke.key.as_str() {
"left" => {
let to = if jump {
0
} else if word {
previous_word_start(&self.text, self.cursor)
} else {
previous_grapheme_start(&self.text, self.cursor)
};
self.move_cursor(to, modifiers.shift, window, cx);
cx.stop_propagation();
}
"right" => {
let to = if jump {
self.text.len()
} else if word {
next_word_end(&self.text, self.cursor)
} else {
next_grapheme_start(&self.text, self.cursor)
};
self.move_cursor(to, modifiers.shift, window, cx);
cx.stop_propagation();
}
"up" => {
if jump {
self.move_cursor(0, modifiers.shift, window, cx);
cx.stop_propagation();
}
}
"down" => {
if jump {
self.move_cursor(self.text.len(), modifiers.shift, window, cx);
cx.stop_propagation();
}
}
"home" => {
self.move_cursor(0, modifiers.shift, window, cx);
cx.stop_propagation();
}
"end" => {
self.move_cursor(self.text.len(), modifiers.shift, window, cx);
cx.stop_propagation();
}
"backspace" => {
if jump {
// macOS cmd-backspace deletes to the field start.
let range = 0..self.cursor;
self.replace_range(range, "", window, cx);
} else {
self.delete_backward(word, window, cx);
}
cx.stop_propagation();
}
"delete" => {
self.delete_forward(word, window, cx);
cx.stop_propagation();
}
"a" if command => {
let len = self.text.len();
self.select_range(0..len, window, cx);
cx.stop_propagation();
}
"c" if command => {
if self.copy_selection(window, cx) {
cx.stop_propagation();
}
}
"x" if command => {
// Read-only refuses cut entirely, clipboard included.
if !self.read_only && self.copy_selection(window, cx) {
self.delete_selection(window, cx);
cx.stop_propagation();
}
}
"v" if command => {
if let Some(item) = cx.read_from_clipboard()
&& let Some(text) = item.text()
{
let range = self.selection();
self.replace_range(range, &text, window, cx);
}
cx.stop_propagation();
}
"z" if command => {
if modifiers.shift {
self.redo(window, cx);
} else {
self.undo(window, cx);
}
cx.stop_propagation();
}
"enter" => {
cx.emit(InputStateEvent::Submitted);
cx.stop_propagation();
}
"escape" => {
window.blur(cx);
cx.stop_propagation();
}
"tab" => {}
_ => {
if let Some(text) = &keystroke.key_char
&& !modifiers.platform
&& !modifiers.control
{
let range = self.selection();
self.replace_range(range, text, window, cx);
cx.stop_propagation();
}
}
}
}
}
impl EntityInputHandler for InputState {
fn text_for_range(
&mut self,
range_utf16: Range<usize>,
adjusted_range: &mut Option<Range<usize>>,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> Option<String> {
let range = utf16_range_to_utf8(&self.text, range_utf16);
adjusted_range.replace(utf8_range_to_utf16(&self.text, range.clone()));
Some(self.text[range].to_string())
}
fn selected_text_range(
&mut self,
_ignore_disabled_input: bool,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> Option<UTF16Selection> {
Some(UTF16Selection {
range: utf8_range_to_utf16(&self.text, self.selection()),
reversed: self.cursor < self.anchor,
})
}
fn marked_text_range(
&self,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> Option<Range<usize>> {
self.marked_range
.clone()
.map(|range| utf8_range_to_utf16(&self.text, range))
}
fn unmark_text(&mut self, _window: &mut Window, _cx: &mut Context<Self>) {
self.marked_range = None;
}
fn replace_text_in_range(
&mut self,
range_utf16: Option<Range<usize>>,
text: &str,
window: &mut Window,
cx: &mut Context<Self>,
) {
let range = range_utf16.map_or_else(
|| self.selection(),
|range| utf16_range_to_utf8(&self.text, range),
);
self.replace_range(range, text, window, cx);
}
fn replace_and_mark_text_in_range(
&mut self,
range_utf16: Option<Range<usize>>,
new_text: &str,
new_selected_range_utf16: Option<Range<usize>>,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.read_only {
return;
}
let range = range_utf16.map_or_else(
|| self.selection(),
|range| utf16_range_to_utf8(&self.text, range),
);
let new_text = sanitize(new_text);
let old_text = self.text[range.clone()].to_string();
self.push_history(range.clone(), old_text, new_text.len());
self.text.replace_range(range.clone(), &new_text);
let start = range.start;
let end = start + new_text.len();
self.marked_range = Some(start..end);
let selected_end = new_selected_range_utf16
.map(|selected| utf16_range_to_utf8(&self.text, selected).end)
.unwrap_or(end);
self.cursor = selected_end;
self.anchor = selected_end;
cx.emit(InputStateEvent::TextChanged);
self.notify(window, cx);
}
fn bounds_for_range(
&mut self,
range_utf16: Range<usize>,
_element_bounds: Bounds<Pixels>,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> Option<Bounds<Pixels>> {
let layout = self.layout.borrow();
let layout = layout.as_ref()?;
let start = layout
.line
.x_for_index(utf16_to_utf8_offset(&self.text, range_utf16.start));
let end = layout
.line
.x_for_index(utf16_to_utf8_offset(&self.text, range_utf16.end));
// `origin` is already an absolute window point.
Some(Bounds::from_corners(
point(layout.origin.x + start.min(end), layout.bounds.top()),
point(layout.origin.x + start.max(end), layout.bounds.bottom()),
))
}
fn character_index_for_point(
&mut self,
window_point: gpui::Point<Pixels>,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> Option<usize> {
let layout = self.layout.borrow();
let layout = layout.as_ref()?;
Some(
layout
.line
.closest_index_for_x(window_point.x - layout.origin.x)
.min(self.text.len()),
)
}
fn text_length_utf16(
&mut self,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> Option<usize> {
Some(self.text.encode_utf16().count())
}
}
/// Newlines have no meaning in a single-line field.
fn sanitize(text: impl Into<String>) -> String {
text.into().replace('\n', " ").replace('\r', "")
}
/// Floors `index` to the nearest grapheme-cluster boundary at or below it.
fn previous_grapheme_start(text: &str, index: usize) -> usize {
text.grapheme_indices(true)
.rev()
.find(|(offset, _)| *offset < index)
.map(|(offset, _)| offset)
.unwrap_or(0)
}
/// Advances `index` to the next grapheme-cluster boundary.
fn next_grapheme_start(text: &str, index: usize) -> usize {
text.grapheme_indices(true)
.find(|(offset, _)| *offset > index)
.map(|(offset, _)| offset)
.unwrap_or(text.len())
}
/// The start of the word before `from`.
fn previous_word_start(text: &str, from: usize) -> usize {
let mut best = 0;
for (offset, word) in text.split_word_bound_indices() {
if offset >= from {
break;
}
if !word.starts_with(char::is_whitespace) {
best = offset;
}
}
best
}
/// The end of the word after `from`.
fn next_word_end(text: &str, from: usize) -> usize {
for (offset, word) in text.split_word_bound_indices() {
let end = offset + word.len();
if end > from && !word.starts_with(char::is_whitespace) {
return end;
}
}
text.len()
}
/// Converts a UTF-16 offset into a UTF-8 byte offset.
fn utf16_to_utf8_offset(text: &str, utf16_offset: usize) -> usize {
let mut utf16_count = 0;
for (byte_offset, char) in text.char_indices() {
if utf16_count >= utf16_offset {
return byte_offset;
}
utf16_count += char.len_utf16();
}
text.len()
}
/// Converts a UTF-8 byte range to a UTF-16 range.
fn utf8_range_to_utf16(text: &str, range: Range<usize>) -> Range<usize> {
utf16_to_utf8_offset(text, range.start)..utf16_to_utf8_offset(text, range.end)
}
/// Converts a UTF-16 range to a UTF-8 byte range.
fn utf16_range_to_utf8(text: &str, range: Range<usize>) -> Range<usize> {
utf16_to_utf8_offset(text, range.start)..utf16_to_utf8_offset(text, range.end)
}
/// A single-line text field bound to an application-owned [`InputState`].
#[derive(IntoElement)]
pub struct Input {
id: ElementId,
state: Entity<InputState>,
placeholder: SharedString,
size: Size,
disabled: bool,
a11y_label: Option<SharedString>,
}
impl Input {
/// Creates a text field bound to `state`.
pub fn new<I>(id: I, state: Entity<InputState>) -> Self
where
I: Into<ElementId>,
{
Self {
id: id.into(),
state,
placeholder: SharedString::default(),
size: Size::default(),
disabled: false,
a11y_label: None,
}
}
/// Sets the placeholder shown when the field is empty and unfocused.
pub fn placeholder<P>(mut self, placeholder: P) -> Self
where
P: Into<SharedString>,
{
self.placeholder = placeholder.into();
self
}
/// Renders the field inert, unfocusable, and dimmed; disabled always wins.
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
/// Overrides the accessibility label.
pub fn accessibility_label<L>(mut self, label: L) -> Self
where
L: Into<SharedString>,
{
self.a11y_label = Some(label.into());
self
}
}
impl Sizable for Input {
fn apply_size(&mut self, size: Size) {
self.size = size;
}
}
impl RenderOnce for Input {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors;
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 fallback_label = self
.a11y_label
.clone()
.or_else(|| (!self.placeholder.is_empty()).then_some(self.placeholder.clone()))
.unwrap_or_else(|| SharedString::from(self.id.to_string()));
h_flex()
.id(self.id.clone())
.role(Role::TextInput)
.aria_label(fallback_label)
.relative()
.overflow_hidden()
.w_full()
.h(metrics.input_height)
.px(metrics.padding_x / 2.0)
.rounded(radius.control)
.border_1()
.border_color(colors.border)
.bg(colors.input)
.text_size(metrics.text_size)
.text_color(colors.foreground)
.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)
.child(self.placeholder.clone()),
)
})
.child(
canvas(
{
let state = self.state.clone();
move |_bounds, window, cx| state.read(cx).shape_current_text(window)
},
{
let state = self.state.clone();
let focus = focus_handle.clone();
let padding = metrics.padding_x / 2.0;
let disabled = self.disabled;
// `line` comes from the prepaint closure above.
move |bounds, line, window, cx| {
let dragging = state.update(cx, |state, cx| {
state.paint(line, bounds, padding, colors, window, cx)
});
if disabled {
return;
}
window.handle_input(
&focus,
gpui::ElementInputHandler::new(bounds, state.clone()),
cx,
);
if dragging {
InputState::register_drag_listeners(&state, window);
}
}
},
)
.absolute()
.inset_0(),
)
.when(!self.disabled, |input| {
input
.on_mouse_down(MouseButton::Left, {
let mouse_down_handler = self
.state
.read(cx)
.mouse_down_handler
.clone()
.expect("input state always carries its mouse-down handler");
move |event: &MouseDownEvent, window, cx| {
mouse_down_handler(event, window, cx);
}
})
.on_key_down({
let key_down_handler = self
.state
.read(cx)
.key_down_handler
.clone()
.expect("input state always carries its key handler");
move |event: &KeyDownEvent, window, cx| {
key_down_handler(event, window, cx);
}
})
})
}
}