1734 lines
57 KiB
Rust
1734 lines
57 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, 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,
|
|
};
|
|
use gpui_native_theme::{ActiveTheme as _, ColorTokens, Size};
|
|
|
|
use crate::common::{Sizable, h_flex};
|
|
|
|
/// The key context input bindings match against.
|
|
pub const INPUT_CONTEXT: &str = "Input";
|
|
/// Consecutive edits within this interval merge into one undo entry.
|
|
const DEFAULT_GROUP_INTERVAL: Duration = Duration::from_millis(300);
|
|
/// How often the focused caret blinks.
|
|
const CARET_BLINK_INTERVAL: Duration = Duration::from_millis(500);
|
|
/// Maximum undo entries kept; the oldest drops off the front.
|
|
const MAX_HISTORY: usize = 100;
|
|
|
|
/// 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,
|
|
}
|
|
|
|
actions!(
|
|
input,
|
|
[
|
|
Escape,
|
|
Submit,
|
|
Backspace,
|
|
Delete,
|
|
DeleteWordLeft,
|
|
DeleteWordRight,
|
|
DeleteToBeginningOfLine,
|
|
Left,
|
|
Right,
|
|
SelectLeft,
|
|
SelectRight,
|
|
WordLeft,
|
|
WordRight,
|
|
SelectWordLeft,
|
|
SelectWordRight,
|
|
Home,
|
|
End,
|
|
SelectHome,
|
|
SelectEnd,
|
|
SelectAll,
|
|
Copy,
|
|
Cut,
|
|
Paste,
|
|
Undo,
|
|
Redo,
|
|
]
|
|
);
|
|
|
|
/// Keybindings for input actions.
|
|
#[derive(Clone, Debug)]
|
|
pub struct InputBindings {
|
|
pub escape: Option<KeyBinding>,
|
|
pub submit: Option<KeyBinding>,
|
|
pub backspace: Option<KeyBinding>,
|
|
pub delete: Option<KeyBinding>,
|
|
pub delete_word_left: Option<KeyBinding>,
|
|
pub delete_word_right: Option<KeyBinding>,
|
|
pub delete_to_beginning_of_line: Option<KeyBinding>,
|
|
pub left: Option<KeyBinding>,
|
|
pub right: Option<KeyBinding>,
|
|
pub select_left: Option<KeyBinding>,
|
|
pub select_right: Option<KeyBinding>,
|
|
pub word_left: Option<KeyBinding>,
|
|
pub word_right: Option<KeyBinding>,
|
|
pub select_word_left: Option<KeyBinding>,
|
|
pub select_word_right: Option<KeyBinding>,
|
|
pub home: Option<KeyBinding>,
|
|
pub end: Option<KeyBinding>,
|
|
pub select_home: Option<KeyBinding>,
|
|
pub select_end: Option<KeyBinding>,
|
|
pub select_all: Option<KeyBinding>,
|
|
pub copy: Option<KeyBinding>,
|
|
pub cut: Option<KeyBinding>,
|
|
pub paste: Option<KeyBinding>,
|
|
pub undo: Option<KeyBinding>,
|
|
pub redo: Option<KeyBinding>,
|
|
}
|
|
|
|
impl Default for InputBindings {
|
|
/// The platform-specific defaults: cmd on macOS, ctrl elsewhere.
|
|
fn default() -> Self {
|
|
let context = Some(INPUT_CONTEXT);
|
|
|
|
#[cfg(target_os = "macos")]
|
|
let bindings = Self {
|
|
escape: Some(KeyBinding::new("escape", Escape, context)),
|
|
submit: Some(KeyBinding::new("enter", Submit, context)),
|
|
backspace: Some(KeyBinding::new("backspace", Backspace, context)),
|
|
delete: Some(KeyBinding::new("delete", Delete, context)),
|
|
delete_word_left: Some(KeyBinding::new("alt-backspace", DeleteWordLeft, context)),
|
|
delete_word_right: Some(KeyBinding::new("alt-delete", DeleteWordRight, context)),
|
|
delete_to_beginning_of_line: Some(KeyBinding::new(
|
|
"cmd-backspace",
|
|
DeleteToBeginningOfLine,
|
|
context,
|
|
)),
|
|
left: Some(KeyBinding::new("left", Left, context)),
|
|
right: Some(KeyBinding::new("right", Right, context)),
|
|
select_left: Some(KeyBinding::new("shift-left", SelectLeft, context)),
|
|
select_right: Some(KeyBinding::new("shift-right", SelectRight, context)),
|
|
word_left: Some(KeyBinding::new("alt-left", WordLeft, context)),
|
|
word_right: Some(KeyBinding::new("alt-right", WordRight, context)),
|
|
select_word_left: Some(KeyBinding::new("alt-shift-left", SelectWordLeft, context)),
|
|
select_word_right: Some(KeyBinding::new("alt-shift-right", SelectWordRight, context)),
|
|
home: Some(KeyBinding::new("home", Home, context)),
|
|
end: Some(KeyBinding::new("end", End, context)),
|
|
select_home: Some(KeyBinding::new("shift-home", SelectHome, context)),
|
|
select_end: Some(KeyBinding::new("shift-end", SelectEnd, context)),
|
|
select_all: Some(KeyBinding::new("cmd-a", SelectAll, context)),
|
|
copy: Some(KeyBinding::new("cmd-c", Copy, context)),
|
|
cut: Some(KeyBinding::new("cmd-x", Cut, context)),
|
|
paste: Some(KeyBinding::new("cmd-v", Paste, context)),
|
|
undo: Some(KeyBinding::new("cmd-z", Undo, context)),
|
|
redo: Some(KeyBinding::new("cmd-shift-z", Redo, context)),
|
|
};
|
|
|
|
#[cfg(not(target_os = "macos"))]
|
|
let bindings = Self {
|
|
escape: Some(KeyBinding::new("escape", Escape, context)),
|
|
submit: Some(KeyBinding::new("enter", Submit, context)),
|
|
backspace: Some(KeyBinding::new("backspace", Backspace, context)),
|
|
delete: Some(KeyBinding::new("delete", Delete, context)),
|
|
delete_word_left: Some(KeyBinding::new("ctrl-backspace", DeleteWordLeft, context)),
|
|
delete_word_right: Some(KeyBinding::new("ctrl-delete", DeleteWordRight, context)),
|
|
delete_to_beginning_of_line: Some(KeyBinding::new(
|
|
"ctrl-shift-backspace",
|
|
DeleteToBeginningOfLine,
|
|
context,
|
|
)),
|
|
left: Some(KeyBinding::new("left", Left, context)),
|
|
right: Some(KeyBinding::new("right", Right, context)),
|
|
select_left: Some(KeyBinding::new("shift-left", SelectLeft, context)),
|
|
select_right: Some(KeyBinding::new("shift-right", SelectRight, context)),
|
|
word_left: Some(KeyBinding::new("ctrl-left", WordLeft, context)),
|
|
word_right: Some(KeyBinding::new("ctrl-right", WordRight, context)),
|
|
select_word_left: Some(KeyBinding::new("ctrl-shift-left", SelectWordLeft, context)),
|
|
select_word_right: Some(KeyBinding::new(
|
|
"ctrl-shift-right",
|
|
SelectWordRight,
|
|
context,
|
|
)),
|
|
home: Some(KeyBinding::new("home", Home, context)),
|
|
end: Some(KeyBinding::new("end", End, context)),
|
|
select_home: Some(KeyBinding::new("shift-home", SelectHome, context)),
|
|
select_end: Some(KeyBinding::new("shift-end", SelectEnd, context)),
|
|
select_all: Some(KeyBinding::new("ctrl-a", SelectAll, context)),
|
|
copy: Some(KeyBinding::new("ctrl-c", Copy, context)),
|
|
cut: Some(KeyBinding::new("ctrl-x", Cut, context)),
|
|
paste: Some(KeyBinding::new("ctrl-v", Paste, context)),
|
|
undo: Some(KeyBinding::new("ctrl-z", Undo, context)),
|
|
redo: Some(KeyBinding::new("ctrl-shift-z", Redo, context)),
|
|
};
|
|
|
|
bindings
|
|
}
|
|
}
|
|
|
|
impl InputBindings {
|
|
/// A table with every key unbound, for building custom bindings.
|
|
pub fn empty() -> Self {
|
|
Self {
|
|
escape: None,
|
|
submit: None,
|
|
backspace: None,
|
|
delete: None,
|
|
delete_word_left: None,
|
|
delete_word_right: None,
|
|
delete_to_beginning_of_line: None,
|
|
left: None,
|
|
right: None,
|
|
select_left: None,
|
|
select_right: None,
|
|
word_left: None,
|
|
word_right: None,
|
|
select_word_left: None,
|
|
select_word_right: None,
|
|
home: None,
|
|
end: None,
|
|
select_home: None,
|
|
select_end: None,
|
|
select_all: None,
|
|
copy: None,
|
|
cut: None,
|
|
paste: None,
|
|
undo: None,
|
|
redo: None,
|
|
}
|
|
}
|
|
|
|
/// Collects the table into keybindings.
|
|
pub fn into_bindings(self) -> Vec<KeyBinding> {
|
|
let mut bindings: Vec<KeyBinding> = [
|
|
self.escape,
|
|
self.submit,
|
|
self.backspace,
|
|
self.delete,
|
|
self.delete_word_left,
|
|
self.delete_word_right,
|
|
self.delete_to_beginning_of_line,
|
|
self.left,
|
|
self.right,
|
|
self.select_left,
|
|
self.select_right,
|
|
self.word_left,
|
|
self.word_right,
|
|
self.select_word_left,
|
|
self.select_word_right,
|
|
self.home,
|
|
self.end,
|
|
self.select_home,
|
|
self.select_end,
|
|
self.select_all,
|
|
self.copy,
|
|
self.cut,
|
|
self.paste,
|
|
self.undo,
|
|
self.redo,
|
|
]
|
|
.into_iter()
|
|
.flatten()
|
|
.collect();
|
|
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
let context = Some(INPUT_CONTEXT);
|
|
bindings.extend([
|
|
KeyBinding::new("cmd-left", Home, context),
|
|
KeyBinding::new("cmd-right", End, context),
|
|
KeyBinding::new("cmd-up", Home, context),
|
|
KeyBinding::new("cmd-down", End, context),
|
|
KeyBinding::new("cmd-shift-left", SelectHome, context),
|
|
KeyBinding::new("cmd-shift-right", SelectEnd, context),
|
|
]);
|
|
}
|
|
|
|
bindings
|
|
}
|
|
}
|
|
|
|
/// Registers input keybindings on the application.
|
|
///
|
|
/// Pass `None` for the platform defaults.
|
|
pub fn bind_input_keys<B>(cx: &mut App, bindings: B)
|
|
where
|
|
B: Into<Option<InputBindings>>,
|
|
{
|
|
cx.bind_keys(bindings.into().unwrap_or_default().into_bindings());
|
|
}
|
|
|
|
/// 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)>;
|
|
|
|
/// 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,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Caret blink controller.
|
|
struct CaretBlink {
|
|
interval: Duration,
|
|
generation: usize,
|
|
visible: bool,
|
|
active: bool,
|
|
paused: bool,
|
|
}
|
|
|
|
impl CaretBlink {
|
|
fn new(_cx: &mut Context<Self>) -> Self {
|
|
Self {
|
|
interval: CARET_BLINK_INTERVAL,
|
|
generation: 0,
|
|
visible: true,
|
|
active: false,
|
|
paused: false,
|
|
}
|
|
}
|
|
|
|
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;
|
|
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);
|
|
}
|
|
})
|
|
})
|
|
.detach();
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
/// 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.
|
|
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,
|
|
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,
|
|
caret_blink: caret_blink.clone(),
|
|
layout: Rc::new(RefCell::new(None)),
|
|
_caret_blink_subscription: cx.observe(&caret_blink, |_, _, cx| cx.notify()),
|
|
}
|
|
}
|
|
|
|
/// Creates the state entity for an application-owned input.
|
|
pub fn new(cx: &mut App) -> Entity<Self> {
|
|
cx.new(Self::new_inner)
|
|
}
|
|
|
|
/// 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);
|
|
}
|
|
}
|
|
|
|
/// 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();
|
|
// Any change holds the caret solid and restarts the blink cycle.
|
|
self.caret_blink.update(cx, |blink, cx| blink.pause(cx));
|
|
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;
|
|
|
|
if focused {
|
|
self.caret_blink.update(cx, |blink, cx| blink.enable(cx));
|
|
} else {
|
|
self.caret_blink.update(cx, |blink, cx| blink.disable(cx));
|
|
}
|
|
|
|
cx.emit(if focused {
|
|
InputStateEvent::Focused
|
|
} else {
|
|
InputStateEvent::Blurred
|
|
});
|
|
}
|
|
|
|
/// 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()
|
|
}
|
|
|
|
/// 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.caret_visible(focused, cx) && 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: inserts typed characters.
|
|
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;
|
|
// Chars typed with a menu modifier are shortcuts, not text,
|
|
// and control characters (enter, tab) have their own semantics.
|
|
if let Some(text) = &keystroke.key_char
|
|
&& !keystroke.modifiers.platform
|
|
&& !keystroke.modifiers.control
|
|
&& text.chars().all(|ch| !ch.is_control())
|
|
{
|
|
let range = self.selection();
|
|
self.replace_range(range, text, window, cx);
|
|
cx.stop_propagation();
|
|
}
|
|
}
|
|
|
|
fn left(&mut self, _: &Left, window: &mut Window, cx: &mut Context<Self>) {
|
|
let to = previous_grapheme_start(&self.text, self.cursor);
|
|
self.move_cursor(to, false, window, cx);
|
|
}
|
|
|
|
fn select_left(&mut self, _: &SelectLeft, window: &mut Window, cx: &mut Context<Self>) {
|
|
let to = previous_grapheme_start(&self.text, self.cursor);
|
|
self.move_cursor(to, true, window, cx);
|
|
}
|
|
|
|
fn right(&mut self, _: &Right, window: &mut Window, cx: &mut Context<Self>) {
|
|
let to = next_grapheme_start(&self.text, self.cursor);
|
|
self.move_cursor(to, false, window, cx);
|
|
}
|
|
|
|
fn select_right(&mut self, _: &SelectRight, window: &mut Window, cx: &mut Context<Self>) {
|
|
let to = next_grapheme_start(&self.text, self.cursor);
|
|
self.move_cursor(to, true, window, cx);
|
|
}
|
|
|
|
fn word_left(&mut self, _: &WordLeft, window: &mut Window, cx: &mut Context<Self>) {
|
|
let to = previous_word_start(&self.text, self.cursor);
|
|
self.move_cursor(to, false, window, cx);
|
|
}
|
|
|
|
fn select_word_left(
|
|
&mut self,
|
|
_: &SelectWordLeft,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let to = previous_word_start(&self.text, self.cursor);
|
|
self.move_cursor(to, true, window, cx);
|
|
}
|
|
|
|
fn word_right(&mut self, _: &WordRight, window: &mut Window, cx: &mut Context<Self>) {
|
|
let to = next_word_end(&self.text, self.cursor);
|
|
self.move_cursor(to, false, window, cx);
|
|
}
|
|
|
|
fn select_word_right(
|
|
&mut self,
|
|
_: &SelectWordRight,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let to = next_word_end(&self.text, self.cursor);
|
|
self.move_cursor(to, true, window, cx);
|
|
}
|
|
|
|
fn home(&mut self, _: &Home, window: &mut Window, cx: &mut Context<Self>) {
|
|
self.move_cursor(0, false, window, cx);
|
|
}
|
|
|
|
fn end(&mut self, _: &End, window: &mut Window, cx: &mut Context<Self>) {
|
|
self.move_cursor(self.text.len(), false, window, cx);
|
|
}
|
|
|
|
fn select_home(&mut self, _: &SelectHome, window: &mut Window, cx: &mut Context<Self>) {
|
|
self.move_cursor(0, true, window, cx);
|
|
}
|
|
|
|
fn select_end(&mut self, _: &SelectEnd, window: &mut Window, cx: &mut Context<Self>) {
|
|
self.move_cursor(self.text.len(), true, window, cx);
|
|
}
|
|
|
|
fn backspace(&mut self, _: &Backspace, window: &mut Window, cx: &mut Context<Self>) {
|
|
self.delete_backward(false, window, cx);
|
|
}
|
|
|
|
fn delete(&mut self, _: &Delete, window: &mut Window, cx: &mut Context<Self>) {
|
|
self.delete_forward(false, window, cx);
|
|
}
|
|
|
|
fn delete_word_left(
|
|
&mut self,
|
|
_: &DeleteWordLeft,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.delete_backward(true, window, cx);
|
|
}
|
|
|
|
fn delete_word_right(
|
|
&mut self,
|
|
_: &DeleteWordRight,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.delete_forward(true, window, cx);
|
|
}
|
|
|
|
fn delete_to_beginning_of_line(
|
|
&mut self,
|
|
_: &DeleteToBeginningOfLine,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let range = 0..self.cursor;
|
|
self.replace_range(range, "", window, cx);
|
|
}
|
|
|
|
fn select_all(&mut self, _: &SelectAll, window: &mut Window, cx: &mut Context<Self>) {
|
|
let len = self.text.len();
|
|
self.select_range(0..len, window, cx);
|
|
}
|
|
|
|
fn copy(&mut self, _: &Copy, window: &mut Window, cx: &mut Context<Self>) {
|
|
if self.selection().is_empty() {
|
|
// Let an outer handler copy its own selection instead.
|
|
cx.propagate();
|
|
return;
|
|
}
|
|
self.copy_selection(window, cx);
|
|
}
|
|
|
|
fn cut(&mut self, _: &Cut, window: &mut Window, cx: &mut Context<Self>) {
|
|
// Read-only refuses cut entirely, clipboard included.
|
|
if self.read_only {
|
|
return;
|
|
}
|
|
|
|
if self.copy_selection(window, cx) {
|
|
self.delete_selection(window, cx);
|
|
}
|
|
}
|
|
|
|
fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
|
|
if let Some(item) = cx.read_from_clipboard()
|
|
&& let Some(text) = item.text()
|
|
{
|
|
let range = self.selection();
|
|
self.replace_range(range, &text, window, cx);
|
|
}
|
|
}
|
|
|
|
fn undo(&mut self, _: &Undo, 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);
|
|
}
|
|
|
|
fn redo(&mut self, _: &Redo, 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);
|
|
}
|
|
|
|
fn submit(&mut self, _: &Submit, _window: &mut Window, cx: &mut Context<Self>) {
|
|
cx.emit(InputStateEvent::Submitted);
|
|
}
|
|
|
|
fn escape(&mut self, _: &Escape, window: &mut Window, cx: &mut Context<Self>) {
|
|
window.blur(cx);
|
|
}
|
|
}
|
|
|
|
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>,
|
|
) {
|
|
// Simulated typing delivers named keys' control characters here
|
|
if text.chars().all(|ch| ch.is_control()) {
|
|
return;
|
|
}
|
|
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())
|
|
.key_context(INPUT_CONTEXT)
|
|
.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);
|
|
}
|
|
})
|
|
})
|
|
.on_action({
|
|
let state = self.state.clone();
|
|
move |action: &Escape, window, cx| {
|
|
state.update(cx, |state, cx| state.escape(action, window, cx))
|
|
}
|
|
})
|
|
.on_action({
|
|
let state = self.state.clone();
|
|
move |action: &Submit, window, cx| {
|
|
state.update(cx, |state, cx| state.submit(action, window, cx))
|
|
}
|
|
})
|
|
.on_action({
|
|
let state = self.state.clone();
|
|
move |action: &Backspace, window, cx| {
|
|
state.update(cx, |state, cx| state.backspace(action, window, cx))
|
|
}
|
|
})
|
|
.on_action({
|
|
let state = self.state.clone();
|
|
move |action: &Delete, window, cx| {
|
|
state.update(cx, |state, cx| state.delete(action, window, cx))
|
|
}
|
|
})
|
|
.on_action({
|
|
let state = self.state.clone();
|
|
move |action: &DeleteWordLeft, window, cx| {
|
|
state.update(cx, |state, cx| state.delete_word_left(action, window, cx))
|
|
}
|
|
})
|
|
.on_action({
|
|
let state = self.state.clone();
|
|
move |action: &DeleteWordRight, window, cx| {
|
|
state.update(cx, |state, cx| state.delete_word_right(action, window, cx))
|
|
}
|
|
})
|
|
.on_action({
|
|
let state = self.state.clone();
|
|
move |action: &DeleteToBeginningOfLine, window, cx| {
|
|
state.update(cx, |state, cx| {
|
|
state.delete_to_beginning_of_line(action, window, cx)
|
|
})
|
|
}
|
|
})
|
|
.on_action({
|
|
let state = self.state.clone();
|
|
move |action: &Left, window, cx| {
|
|
state.update(cx, |state, cx| state.left(action, window, cx))
|
|
}
|
|
})
|
|
.on_action({
|
|
let state = self.state.clone();
|
|
move |action: &Right, window, cx| {
|
|
state.update(cx, |state, cx| state.right(action, window, cx))
|
|
}
|
|
})
|
|
.on_action({
|
|
let state = self.state.clone();
|
|
move |action: &SelectLeft, window, cx| {
|
|
state.update(cx, |state, cx| state.select_left(action, window, cx))
|
|
}
|
|
})
|
|
.on_action({
|
|
let state = self.state.clone();
|
|
move |action: &SelectRight, window, cx| {
|
|
state.update(cx, |state, cx| state.select_right(action, window, cx))
|
|
}
|
|
})
|
|
.on_action({
|
|
let state = self.state.clone();
|
|
move |action: &WordLeft, window, cx| {
|
|
state.update(cx, |state, cx| state.word_left(action, window, cx))
|
|
}
|
|
})
|
|
.on_action({
|
|
let state = self.state.clone();
|
|
move |action: &WordRight, window, cx| {
|
|
state.update(cx, |state, cx| state.word_right(action, window, cx))
|
|
}
|
|
})
|
|
.on_action({
|
|
let state = self.state.clone();
|
|
move |action: &SelectWordLeft, window, cx| {
|
|
state.update(cx, |state, cx| state.select_word_left(action, window, cx))
|
|
}
|
|
})
|
|
.on_action({
|
|
let state = self.state.clone();
|
|
move |action: &SelectWordRight, window, cx| {
|
|
state.update(cx, |state, cx| state.select_word_right(action, window, cx))
|
|
}
|
|
})
|
|
.on_action({
|
|
let state = self.state.clone();
|
|
move |action: &Home, window, cx| {
|
|
state.update(cx, |state, cx| state.home(action, window, cx))
|
|
}
|
|
})
|
|
.on_action({
|
|
let state = self.state.clone();
|
|
move |action: &End, window, cx| {
|
|
state.update(cx, |state, cx| state.end(action, window, cx))
|
|
}
|
|
})
|
|
.on_action({
|
|
let state = self.state.clone();
|
|
move |action: &SelectHome, window, cx| {
|
|
state.update(cx, |state, cx| state.select_home(action, window, cx))
|
|
}
|
|
})
|
|
.on_action({
|
|
let state = self.state.clone();
|
|
move |action: &SelectEnd, window, cx| {
|
|
state.update(cx, |state, cx| state.select_end(action, window, cx))
|
|
}
|
|
})
|
|
.on_action({
|
|
let state = self.state.clone();
|
|
move |action: &SelectAll, window, cx| {
|
|
state.update(cx, |state, cx| state.select_all(action, window, cx))
|
|
}
|
|
})
|
|
.on_action({
|
|
let state = self.state.clone();
|
|
move |action: &Copy, window, cx| {
|
|
state.update(cx, |state, cx| state.copy(action, window, cx))
|
|
}
|
|
})
|
|
.on_action({
|
|
let state = self.state.clone();
|
|
move |action: &Cut, window, cx| {
|
|
state.update(cx, |state, cx| state.cut(action, window, cx))
|
|
}
|
|
})
|
|
.on_action({
|
|
let state = self.state.clone();
|
|
move |action: &Paste, window, cx| {
|
|
state.update(cx, |state, cx| state.paste(action, window, cx))
|
|
}
|
|
})
|
|
.on_action({
|
|
let state = self.state.clone();
|
|
move |action: &Undo, window, cx| {
|
|
state.update(cx, |state, cx| state.undo(action, window, cx))
|
|
}
|
|
})
|
|
.on_action({
|
|
let state = self.state.clone();
|
|
move |action: &Redo, window, cx| {
|
|
state.update(cx, |state, cx| state.redo(action, window, cx))
|
|
}
|
|
})
|
|
}
|
|
}
|