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::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext as _, Bounds, ClipboardItem, Context, DispatchPhase, ElementId, Entity,
|
App, AppContext as _, Bounds, ClipboardItem, Context, DispatchPhase, ElementId, Entity,
|
||||||
EntityInputHandler, EventEmitter, Font, InteractiveElement as _, IntoElement, KeyDownEvent,
|
EntityInputHandler, EventEmitter, Font, InteractiveElement as _, IntoElement, KeyBinding,
|
||||||
MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels,
|
KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _,
|
||||||
RenderOnce, Role, SharedString, StatefulInteractiveElement as _, Styled as _, TextAlign,
|
Pixels, RenderOnce, Role, SharedString, StatefulInteractiveElement as _, Styled as _,
|
||||||
TextRun, UTF16Selection, Window, canvas, div, fill, point, px,
|
TextAlign, TextRun, UTF16Selection, Window, actions, canvas, div, fill, point, px,
|
||||||
};
|
};
|
||||||
use gpui_native_theme::{ActiveTheme as _, ColorTokens, Size};
|
use gpui_native_theme::{ActiveTheme as _, ColorTokens, Size};
|
||||||
|
|
||||||
use crate::common::{Sizable, h_flex};
|
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`.
|
/// Significant changes in an [`InputState`], observable via `cx.subscribe`.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub enum InputStateEvent {
|
pub enum InputStateEvent {
|
||||||
@@ -30,6 +37,238 @@ pub enum InputStateEvent {
|
|||||||
Submitted,
|
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.
|
/// Input layout information for hit testing and caret positioning.
|
||||||
struct InputLayout {
|
struct InputLayout {
|
||||||
line: gpui::ShapedLine,
|
line: gpui::ShapedLine,
|
||||||
@@ -43,11 +282,6 @@ type KeyDownHandler = std::rc::Rc<dyn Fn(&KeyDownEvent, &mut Window, &mut App)>;
|
|||||||
/// Handler for mouse down events.
|
/// Handler for mouse down events.
|
||||||
type MouseDownHandler = std::rc::Rc<dyn Fn(&MouseDownEvent, &mut Window, &mut App)>;
|
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.
|
/// History entry for undo/redo operations.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
struct HistoryEntry {
|
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.
|
/// The focus handle, for applications that focus the field programmatically.
|
||||||
pub fn focus_handle(&self) -> gpui::FocusHandle {
|
pub fn focus_handle(&self) -> gpui::FocusHandle {
|
||||||
self.focus_handle.clone()
|
self.focus_handle.clone()
|
||||||
@@ -687,9 +879,7 @@ impl InputState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The key-down handler: navigation, editing, clipboard, and submit.
|
/// The key-down handler: inserts typed characters.
|
||||||
///
|
|
||||||
/// Runs only while the field is focused.
|
|
||||||
fn handle_key_down(
|
fn handle_key_down(
|
||||||
&mut self,
|
&mut self,
|
||||||
event: &KeyDownEvent,
|
event: &KeyDownEvent,
|
||||||
@@ -701,130 +891,202 @@ impl InputState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let keystroke = &event.keystroke;
|
let keystroke = &event.keystroke;
|
||||||
let modifiers = keystroke.modifiers;
|
// Chars typed with a menu modifier are shortcuts, not text,
|
||||||
|
// and control characters (enter, tab) have their own semantics.
|
||||||
// macOS: cmd jumps to the field ends,
|
if let Some(text) = &keystroke.key_char
|
||||||
// cmd-backspace deletes to the start, alt is the word modifier.
|
&& !keystroke.modifiers.platform
|
||||||
//
|
&& !keystroke.modifiers.control
|
||||||
// Elsewhere ctrl is the word modifier
|
&& text.chars().all(|ch| !ch.is_control())
|
||||||
// and the Home/End keys already cover the ends.
|
{
|
||||||
#[cfg(target_os = "macos")]
|
let range = self.selection();
|
||||||
let (command, word, jump) = (modifiers.platform, modifiers.alt, modifiers.platform);
|
self.replace_range(range, text, window, cx);
|
||||||
#[cfg(not(target_os = "macos"))]
|
cx.stop_propagation();
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
impl EntityInputHandler for InputState {
|
||||||
@@ -873,6 +1135,10 @@ impl EntityInputHandler for InputState {
|
|||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
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(
|
let range = range_utf16.map_or_else(
|
||||||
|| self.selection(),
|
|| self.selection(),
|
||||||
|range| utf16_range_to_utf8(&self.text, range),
|
|range| utf16_range_to_utf8(&self.text, range),
|
||||||
@@ -1113,6 +1379,7 @@ impl RenderOnce for Input {
|
|||||||
|
|
||||||
h_flex()
|
h_flex()
|
||||||
.id(self.id.clone())
|
.id(self.id.clone())
|
||||||
|
.key_context(INPUT_CONTEXT)
|
||||||
.role(Role::TextInput)
|
.role(Role::TextInput)
|
||||||
.aria_label(fallback_label)
|
.aria_label(fallback_label)
|
||||||
.relative()
|
.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.
|
//! The components every application needs, one cargo feature each.
|
||||||
|
//!
|
||||||
|
//! Call [`init`] once at startup, before opening windows.
|
||||||
|
|
||||||
|
use gpui::App;
|
||||||
|
|
||||||
pub mod common;
|
pub mod common;
|
||||||
|
|
||||||
@@ -31,3 +35,10 @@ pub mod avatar;
|
|||||||
|
|
||||||
#[cfg(feature = "input")]
|
#[cfg(feature = "input")]
|
||||||
pub mod 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::{
|
use gpui::{
|
||||||
Entity, Modifiers, MouseButton, MouseDownEvent, MouseUpEvent, ParentElement as _, Render,
|
Entity, KeyBinding, Modifiers, MouseButton, MouseDownEvent, MouseUpEvent, ParentElement as _,
|
||||||
Styled as _, TestAppContext, VisualTestContext, Window, div, point, px,
|
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 gpui_native_theme::Theme;
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
@@ -30,9 +32,20 @@ impl Render for Harness {
|
|||||||
fn input_window(
|
fn input_window(
|
||||||
initial: &str,
|
initial: &str,
|
||||||
disabled: bool,
|
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) {
|
) -> (TestAppContext, Entity<InputState>, VisualTestContext) {
|
||||||
let mut cx = TestAppContext::single();
|
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 = cx.update(|cx| InputState::new_with_text(initial, cx));
|
||||||
let state_for_window = state.clone();
|
let state_for_window = state.clone();
|
||||||
let window = cx.add_window(move |_, _| Harness {
|
let window = cx.add_window(move |_, _| Harness {
|
||||||
@@ -293,6 +306,30 @@ fn escape_blurs_the_field() {
|
|||||||
assert_eq!(count(&sink, &InputStateEvent::Blurred), 1);
|
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]
|
#[test]
|
||||||
fn triple_click_selects_all_and_typing_replaces_it() {
|
fn triple_click_selects_all_and_typing_replaces_it() {
|
||||||
let (cx, state, mut visual) = input_window("hello world", false);
|
let (cx, state, mut visual) = input_window("hello world", false);
|
||||||
|
|||||||
+2
-2
@@ -221,7 +221,7 @@ Deferred until a real need: Markdown/TextView, Dock, VirtualList, window-wide te
|
|||||||
|
|
||||||
Our divergence from gpui-kit held: the per-size metrics resolve from **the theme's size table at paint time**, not from constants in the component layer. All ten controls implement `Sizable`; `Size::Custom(side)` derives the other metrics from the side length. Still open, when a real need arrives: TOML overrides for the size table in `ThemeDefinition`, and per-platform size tables from the Phase F native-metrics work.
|
Our divergence from gpui-kit held: the per-size metrics resolve from **the theme's size table at paint time**, not from constants in the component layer. All ten controls implement `Sizable`; `Size::Custom(side)` derives the other metrics from the side length. Still open, when a real need arrives: TOML overrides for the size table in `ThemeDefinition`, and per-platform size tables from the Phase F native-metrics work.
|
||||||
|
|
||||||
### 6.2 Input completion (unit 10 finishing pass) — in progress (steps 1–3 landed 2026-10-29)
|
### 6.2 Input completion (unit 10 finishing pass) — in progress (steps 1–4 landed 2026-10-29)
|
||||||
|
|
||||||
Gap analysis against gpuikit's input (2026-10-29; source read as an API reference, §9 — no porting). gpuikit splits input three ways: core state whose edits are actions, infrastructure (`bindings` / `blink` / `bidi`), and a chrome-less element with chrome in wrapper elements. We keep one component with chrome included; only the ideas below are adopted.
|
Gap analysis against gpuikit's input (2026-10-29; source read as an API reference, §9 — no porting). gpuikit splits input three ways: core state whose edits are actions, infrastructure (`bindings` / `blink` / `bidi`), and a chrome-less element with chrome in wrapper elements. We keep one component with chrome included; only the ideas below are adopted.
|
||||||
|
|
||||||
@@ -234,7 +234,7 @@ Remaining, in order:
|
|||||||
| 1 | macOS modifier split | ✅ Landed. `cmd-left/right` → home/end, `cmd-up/down` → start/end of field, `cmd-backspace` → delete to start, `alt` modifiers → word ops. The handler used to treat cmd and alt alike — wrong on macOS (D8); ctrl stays the word-modifier fallback off-mac |
|
| 1 | macOS modifier split | ✅ Landed. `cmd-left/right` → home/end, `cmd-up/down` → start/end of field, `cmd-backspace` → delete to start, `alt` modifiers → word ops. The handler used to treat cmd and alt alike — wrong on macOS (D8); ctrl stays the word-modifier fallback off-mac |
|
||||||
| 2 | Undo/redo | ✅ Landed. Patch-based history (range, replaced text, new length, prior selection), 300 ms grouping (`set_group_interval`, `Duration::ZERO` disables merging), `cmd-z` / `cmd-shift-z`, 100-entry cap; `set_value` clears the stacks, any new edit clears redo. Unlike gpuikit — which skips recording inside the interval and so undoes groups lossily — entries merge so a group fully reverses. Redo lands the caret at the end of the restored text; undo restores the pre-edit selection |
|
| 2 | Undo/redo | ✅ Landed. Patch-based history (range, replaced text, new length, prior selection), 300 ms grouping (`set_group_interval`, `Duration::ZERO` disables merging), `cmd-z` / `cmd-shift-z`, 100-entry cap; `set_value` clears the stacks, any new edit clears redo. Unlike gpuikit — which skips recording inside the interval and so undoes groups lossily — entries merge so a group fully reverses. Redo lands the caret at the end of the restored text; undo restores the pre-edit selection |
|
||||||
| 3 | Read-only, Escape-blur, triple-click | ✅ Landed. Read-only: focus, movement, selection, copy still work; edits, paste, cut (clipboard included), undo/redo and IME refused — distinct from disabled; programmatic setters unaffected; the caret is hidden while read-only (a caret promises typing lands there). Escape → `window.blur()`. Triple-click selects all (single-line) |
|
| 3 | Read-only, Escape-blur, triple-click | ✅ Landed. Read-only: focus, movement, selection, copy still work; edits, paste, cut (clipboard included), undo/redo and IME refused — distinct from disabled; programmatic setters unaffected; the caret is hidden while read-only (a caret promises typing lands there). Escape → `window.blur()`. Triple-click selects all (single-line) |
|
||||||
| 4 | Actions + key context | Replace the monolithic `on_key_down` match with `actions!(input, [...])` + an `"Input"` key context; `bind_input_keys(cx, overrides)` over a per-platform-default `InputBindings` table of `Option<KeyBinding>` fields (override or unbind each). Step 1's bindings become data |
|
| 4 | Actions + key context | ✅ Landed. 25 `actions!(input, …)` + an `"Input"` key context; the element registers `on_action` handlers and the residual `on_key_down` only inserts printable typed chars. `InputBindings` table of `Option<KeyBinding>` fields — `Default` is platform defaults (cmd/alt on macOS, ctrl elsewhere), `empty()` starts bare, any field replaceable/unbindable; `bind_input_keys(cx, None | table)` registers at startup (gallery + tests call it). Verified gpui semantics first: matched bindings consume the keystroke before `on_key_down`, and actions claim by default in bubble (`cx.propagate()` yields — Copy does when the selection is empty). macOS jump extras (`cmd-left/right/up/down`, `cmd-shift-left/right`) are appended in `into_bindings`, not field-configurable |
|
||||||
| 5 | Caret blink | 500 ms tick on the GPUI executor, paused (caret visible) while typing or moving, suppressed in read-only |
|
| 5 | Caret blink | 500 ms tick on the GPUI executor, paused (caret visible) while typing or moving, suppressed in read-only |
|
||||||
| 6 | Paint polish | IME marked-text underline; I-beam cursor on hover; two-sided horizontal scroll (clamped offset in state, follows caret) plus horizontal wheel scroll |
|
| 6 | Paint polish | IME marked-text underline; I-beam cursor on hover; two-sided horizontal scroll (clamped offset in state, follows caret) plus horizontal wheel scroll |
|
||||||
|
|
||||||
|
|||||||
@@ -596,7 +596,7 @@ where
|
|||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
application().with_assets(IconAssets).run(|cx: &mut App| {
|
application().with_assets(IconAssets).run(|cx: &mut App| {
|
||||||
Theme::init(cx);
|
gpui_native_core::init(cx);
|
||||||
let input_state = InputState::new(cx);
|
let input_state = InputState::new(cx);
|
||||||
let bounds = Bounds::centered(None, size(px(720.0), px(560.0)), cx);
|
let bounds = Bounds::centered(None, size(px(720.0), px(560.0)), cx);
|
||||||
if let Err(err) = cx.open_window(
|
if let Err(err) = cx.open_window(
|
||||||
|
|||||||
+6
-1
@@ -13,7 +13,12 @@ pub use gpui_native_core::*;
|
|||||||
use gpui::App;
|
use gpui::App;
|
||||||
|
|
||||||
/// Initializes the framework: installs the theme global from the current
|
/// Initializes the framework: installs the theme global from the current
|
||||||
/// system appearance. Call once before opening windows.
|
/// system appearance and, with the `core` feature, the component globals
|
||||||
|
/// and keybindings. Call once before opening windows.
|
||||||
pub fn init(cx: &mut App) {
|
pub fn init(cx: &mut App) {
|
||||||
|
#[cfg(feature = "core")]
|
||||||
|
gpui_native_core::init(cx);
|
||||||
|
|
||||||
|
#[cfg(not(feature = "core"))]
|
||||||
theme::Theme::init(cx);
|
theme::Theme::init(cx);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user