input: dispatch editing keys through actions

This commit is contained in:
2026-10-29 15:27:56 +07:00
parent e2331f2ba5
commit 5f53e0c89f
6 changed files with 656 additions and 184 deletions
+595 -176
View File
@@ -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))
}
})
} }
} }
+11
View File
@@ -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);
}
+41 -4
View File
@@ -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
View File
@@ -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 |
+1 -1
View File
@@ -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
View File
@@ -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);
} }