input: dispatch editing keys through actions
This commit is contained in:
+595
-176
@@ -8,15 +8,22 @@ 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,
|
||||
EntityInputHandler, EventEmitter, Font, InteractiveElement as _, IntoElement, KeyBinding,
|
||||
KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _,
|
||||
Pixels, RenderOnce, Role, SharedString, StatefulInteractiveElement as _, Styled as _,
|
||||
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);
|
||||
/// 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 {
|
||||
@@ -30,6 +37,238 @@ pub enum InputStateEvent {
|
||||
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,
|
||||
@@ -43,11 +282,6 @@ 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 {
|
||||
@@ -276,48 +510,6 @@ impl InputState {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()
|
||||
@@ -687,9 +879,7 @@ impl InputState {
|
||||
}
|
||||
}
|
||||
|
||||
/// The key-down handler: navigation, editing, clipboard, and submit.
|
||||
///
|
||||
/// Runs only while the field is focused.
|
||||
/// The key-down handler: inserts typed characters.
|
||||
fn handle_key_down(
|
||||
&mut self,
|
||||
event: &KeyDownEvent,
|
||||
@@ -701,130 +891,202 @@ impl InputState {
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
// 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 {
|
||||
@@ -873,6 +1135,10 @@ impl EntityInputHandler for InputState {
|
||||
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),
|
||||
@@ -1113,6 +1379,7 @@ impl RenderOnce for Input {
|
||||
|
||||
h_flex()
|
||||
.id(self.id.clone())
|
||||
.key_context(INPUT_CONTEXT)
|
||||
.role(Role::TextInput)
|
||||
.aria_label(fallback_label)
|
||||
.relative()
|
||||
@@ -1200,5 +1467,157 @@ impl RenderOnce for Input {
|
||||
}
|
||||
})
|
||||
})
|
||||
.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))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
//! The components every application needs, one cargo feature each.
|
||||
//!
|
||||
//! Call [`init`] once at startup, before opening windows.
|
||||
|
||||
use gpui::App;
|
||||
|
||||
pub mod common;
|
||||
|
||||
@@ -31,3 +35,10 @@ pub mod avatar;
|
||||
|
||||
#[cfg(feature = "input")]
|
||||
pub mod input;
|
||||
|
||||
/// Initializes everything the components need at startup.
|
||||
pub fn init(cx: &mut App) {
|
||||
gpui_native_theme::Theme::init(cx);
|
||||
#[cfg(feature = "input")]
|
||||
input::bind_input_keys(cx, None);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use gpui::{
|
||||
Entity, Modifiers, MouseButton, MouseDownEvent, MouseUpEvent, ParentElement as _, Render,
|
||||
Styled as _, TestAppContext, VisualTestContext, Window, div, point, px,
|
||||
Entity, KeyBinding, Modifiers, MouseButton, MouseDownEvent, MouseUpEvent, ParentElement as _,
|
||||
Render, Styled as _, TestAppContext, VisualTestContext, Window, div, point, px,
|
||||
};
|
||||
use gpui_native_core::input::{
|
||||
INPUT_CONTEXT, Input, InputBindings, InputState, InputStateEvent, Submit, bind_input_keys,
|
||||
};
|
||||
use gpui_native_core::input::{Input, InputState, InputStateEvent};
|
||||
use gpui_native_theme::Theme;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
@@ -30,9 +32,20 @@ impl Render for Harness {
|
||||
fn input_window(
|
||||
initial: &str,
|
||||
disabled: bool,
|
||||
) -> (TestAppContext, Entity<InputState>, VisualTestContext) {
|
||||
input_window_with_bindings(initial, disabled, InputBindings::default())
|
||||
}
|
||||
|
||||
fn input_window_with_bindings(
|
||||
initial: &str,
|
||||
disabled: bool,
|
||||
bindings: InputBindings,
|
||||
) -> (TestAppContext, Entity<InputState>, VisualTestContext) {
|
||||
let mut cx = TestAppContext::single();
|
||||
cx.update(Theme::init);
|
||||
cx.update(|cx| {
|
||||
Theme::init(cx);
|
||||
bind_input_keys(cx, bindings);
|
||||
});
|
||||
let state = cx.update(|cx| InputState::new_with_text(initial, cx));
|
||||
let state_for_window = state.clone();
|
||||
let window = cx.add_window(move |_, _| Harness {
|
||||
@@ -293,6 +306,30 @@ fn escape_blurs_the_field() {
|
||||
assert_eq!(count(&sink, &InputStateEvent::Blurred), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_bindings_replace_and_unbind_keys() {
|
||||
// Only submit is bound, and on a custom key.
|
||||
let mut bindings = InputBindings::empty();
|
||||
bindings.submit = Some(KeyBinding::new("cmd-enter", Submit, Some(INPUT_CONTEXT)));
|
||||
let (mut cx, state, mut visual) = input_window_with_bindings("", false, bindings);
|
||||
let (sink, _subscription) = record_events(&mut cx, &state);
|
||||
focus(&mut visual, &state);
|
||||
|
||||
// The default enter binding is gone.
|
||||
visual.simulate_keystrokes("enter");
|
||||
assert_eq!(count(&sink, &InputStateEvent::Submitted), 0);
|
||||
|
||||
// The override fires.
|
||||
visual.simulate_keystrokes("cmd-enter");
|
||||
assert_eq!(count(&sink, &InputStateEvent::Submitted), 1);
|
||||
|
||||
// Unbound actions do nothing; unbound characters still type.
|
||||
visual.simulate_keystrokes("cmd-z");
|
||||
assert_eq!(value(&cx, &state), "");
|
||||
visual.simulate_keystrokes("a");
|
||||
assert_eq!(value(&cx, &state), "a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triple_click_selects_all_and_typing_replaces_it() {
|
||||
let (cx, state, mut visual) = input_window("hello world", false);
|
||||
|
||||
Reference in New Issue
Block a user