diff --git a/crates/core/src/input.rs b/crates/core/src/input.rs index 950454e..0531c17 100644 --- a/crates/core/src/input.rs +++ b/crates/core/src/input.rs @@ -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, + pub submit: Option, + pub backspace: Option, + pub delete: Option, + pub delete_word_left: Option, + pub delete_word_right: Option, + pub delete_to_beginning_of_line: Option, + pub left: Option, + pub right: Option, + pub select_left: Option, + pub select_right: Option, + pub word_left: Option, + pub word_right: Option, + pub select_word_left: Option, + pub select_word_right: Option, + pub home: Option, + pub end: Option, + pub select_home: Option, + pub select_end: Option, + pub select_all: Option, + pub copy: Option, + pub cut: Option, + pub paste: Option, + pub undo: Option, + pub redo: Option, +} + +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 { + let mut bindings: Vec = [ + 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(cx: &mut App, bindings: B) +where + B: Into>, +{ + 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; /// Handler for mouse down events. type MouseDownHandler = std::rc::Rc; -/// 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) { - 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) { - 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) { + 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) { + 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) { + 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) { + 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) { + 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, + ) { + 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) { + 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, + ) { + 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.move_cursor(0, false, window, cx); + } + + fn end(&mut self, _: &End, window: &mut Window, cx: &mut Context) { + self.move_cursor(self.text.len(), false, window, cx); + } + + fn select_home(&mut self, _: &SelectHome, window: &mut Window, cx: &mut Context) { + self.move_cursor(0, true, window, cx); + } + + fn select_end(&mut self, _: &SelectEnd, window: &mut Window, cx: &mut Context) { + self.move_cursor(self.text.len(), true, window, cx); + } + + fn backspace(&mut self, _: &Backspace, window: &mut Window, cx: &mut Context) { + self.delete_backward(false, window, cx); + } + + fn delete(&mut self, _: &Delete, window: &mut Window, cx: &mut Context) { + self.delete_forward(false, window, cx); + } + + fn delete_word_left( + &mut self, + _: &DeleteWordLeft, + window: &mut Window, + cx: &mut Context, + ) { + self.delete_backward(true, window, cx); + } + + fn delete_word_right( + &mut self, + _: &DeleteWordRight, + window: &mut Window, + cx: &mut Context, + ) { + self.delete_forward(true, window, cx); + } + + fn delete_to_beginning_of_line( + &mut self, + _: &DeleteToBeginningOfLine, + window: &mut Window, + cx: &mut Context, + ) { + let range = 0..self.cursor; + self.replace_range(range, "", window, cx); + } + + fn select_all(&mut self, _: &SelectAll, window: &mut Window, cx: &mut Context) { + let len = self.text.len(); + self.select_range(0..len, window, cx); + } + + fn copy(&mut self, _: &Copy, window: &mut Window, cx: &mut Context) { + 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) { + // 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) { + 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) { + 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) { + 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) { + cx.emit(InputStateEvent::Submitted); + } + + fn escape(&mut self, _: &Escape, window: &mut Window, cx: &mut Context) { + window.blur(cx); + } } impl EntityInputHandler for InputState { @@ -873,6 +1135,10 @@ impl EntityInputHandler for InputState { window: &mut Window, cx: &mut Context, ) { + // 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)) + } + }) } } diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index a4a7097..2a9fc0a 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -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); +} diff --git a/crates/core/tests/input.rs b/crates/core/tests/input.rs index a5d69c1..65d4b2f 100644 --- a/crates/core/tests/input.rs +++ b/crates/core/tests/input.rs @@ -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, VisualTestContext) { + input_window_with_bindings(initial, disabled, InputBindings::default()) +} + +fn input_window_with_bindings( + initial: &str, + disabled: bool, + bindings: InputBindings, ) -> (TestAppContext, Entity, 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); diff --git a/docs/PLAN.md b/docs/PLAN.md index 7eee282..4f12de6 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -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. -### 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. @@ -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 | | 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) | -| 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` 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` 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 | | 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 | diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index 3d22277..ee2f864 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -596,7 +596,7 @@ where fn main() { application().with_assets(IconAssets).run(|cx: &mut App| { - Theme::init(cx); + gpui_native_core::init(cx); let input_state = InputState::new(cx); let bounds = Bounds::centered(None, size(px(720.0), px(560.0)), cx); if let Err(err) = cx.open_window( diff --git a/src/lib.rs b/src/lib.rs index ffffad2..f43ce60 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,7 +13,12 @@ pub use gpui_native_core::*; use gpui::App; /// 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) { + #[cfg(feature = "core")] + gpui_native_core::init(cx); + + #[cfg(not(feature = "core"))] theme::Theme::init(cx); }