add undo/redo and macOS modifier handling to input

This commit is contained in:
2026-10-29 14:32:49 +07:00
parent 5d29c61f39
commit d95baae418
3 changed files with 296 additions and 13 deletions
+192 -10
View File
@@ -1,6 +1,7 @@
use std::cell::RefCell;
use std::ops::Range;
use std::rc::Rc;
use std::time::{Duration, Instant};
use unicode_segmentation::UnicodeSegmentation;
@@ -42,6 +43,45 @@ type KeyDownHandler = std::rc::Rc<dyn Fn(&KeyDownEvent, &mut Window, &mut App)>;
/// Handler for mouse down events.
type MouseDownHandler = std::rc::Rc<dyn Fn(&MouseDownEvent, &mut Window, &mut App)>;
/// Consecutive edits within this interval merge into one undo entry.
const DEFAULT_GROUP_INTERVAL: Duration = Duration::from_millis(300);
/// Maximum undo entries kept; the oldest drops off the front.
const MAX_HISTORY: usize = 100;
/// History entry for undo/redo operations.
#[derive(Clone, Debug)]
struct HistoryEntry {
/// The range the replacement occupies after the edit.
range: Range<usize>,
/// The text the edit replaced, restored on undo.
old_text: String,
/// The length of the replacement.
new_len: usize,
/// The selection before the edit.
cursor: usize,
anchor: usize,
/// When the edit happened, for grouping.
timestamp: Instant,
}
impl HistoryEntry {
/// Applies this entry in reverse, returning the entry that reverses it back.
fn reverse(&self, text: &mut String) -> HistoryEntry {
let end = (self.range.start + self.new_len).min(text.len());
let removed = text[self.range.start..end].to_string();
text.replace_range(self.range.start..end, &self.old_text);
HistoryEntry {
range: self.range.start..self.range.start + self.old_text.len(),
old_text: removed,
new_len: self.old_text.len(),
cursor: self.cursor,
anchor: self.anchor,
timestamp: self.timestamp,
}
}
}
/// Input state for a single-line text field.
pub struct InputState {
text: String,
@@ -60,6 +100,11 @@ pub struct InputState {
mouse_down_handler: Option<MouseDownHandler>,
/// Whether a drag-select is in progress.
dragging: bool,
/// Patch history. `redo_stack` holds undone entries awaiting redo.
undo_stack: Vec<HistoryEntry>,
redo_stack: Vec<HistoryEntry>,
/// Consecutive edits within this interval merge into one undo entry.
group_interval: Duration,
/// The shaped line and element bounds, recorded during each paint,
/// and shared with hit-testing and the IME geometry conversions.
layout: Rc<RefCell<Option<InputLayout>>>,
@@ -87,6 +132,9 @@ impl InputState {
},
))),
dragging: false,
undo_stack: Vec::new(),
redo_stack: Vec::new(),
group_interval: DEFAULT_GROUP_INTERVAL,
layout: Rc::new(RefCell::new(None)),
}
}
@@ -151,12 +199,101 @@ impl InputState {
self.cursor = self.text.len();
self.anchor = self.cursor;
self.marked_range = None;
self.undo_stack.clear();
self.redo_stack.clear();
if emit {
cx.emit(InputStateEvent::TextChanged);
}
self.notify(window, cx);
}
/// Sets how long consecutive edits keep merging into one undo entry.
pub fn set_group_interval(&mut self, interval: Duration) {
self.group_interval = interval;
}
/// Records `old_text` at `range` being replaced by `new_len` bytes.
fn push_history(&mut self, range: Range<usize>, old_text: String, new_len: usize) {
self.redo_stack.clear();
let now = Instant::now();
if let Some(last) = self.undo_stack.last_mut()
&& now.duration_since(last.timestamp) < self.group_interval
{
let last_end = last.range.start + last.new_len;
// Typing that continues right after the previous edit.
if range.start == last_end {
last.new_len += new_len;
last.timestamp = now;
return;
}
// A deletion of text that sat right before the previous edit.
if range.end == last.range.start && new_len == 0 {
last.range.start = range.start;
last.old_text = old_text + &last.old_text;
last.timestamp = now;
return;
}
// An edit fully inside the previous edit's replacement.
if range.start >= last.range.start && range.end <= last_end {
last.new_len += new_len;
last.new_len -= range.len();
last.timestamp = now;
return;
}
}
self.undo_stack.push(HistoryEntry {
range: range.start..range.start + new_len,
old_text,
new_len,
cursor: self.cursor,
anchor: self.anchor,
timestamp: now,
});
if self.undo_stack.len() > MAX_HISTORY {
self.undo_stack.remove(0);
}
}
/// Reverts the most recent edit group (`cmd-z`).
fn undo(&mut self, window: &mut Window, cx: &mut Context<Self>) {
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>) {
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()
@@ -199,6 +336,8 @@ impl InputState {
}
let text = sanitize(text);
let old_text = self.text[range.clone()].to_string();
self.push_history(range.clone(), old_text, text.len());
self.text.replace_range(range.clone(), &text);
self.cursor = range.start + text.len();
self.anchor = self.cursor;
@@ -529,41 +668,73 @@ impl InputState {
}
let keystroke = &event.keystroke;
let command = keystroke.modifiers.platform || keystroke.modifiers.control;
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 command || keystroke.modifiers.alt {
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, keystroke.modifiers.shift, window, cx);
self.move_cursor(to, modifiers.shift, window, cx);
cx.stop_propagation();
}
"right" => {
let to = if command || keystroke.modifiers.alt {
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, keystroke.modifiers.shift, window, cx);
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, keystroke.modifiers.shift, window, cx);
self.move_cursor(0, modifiers.shift, window, cx);
cx.stop_propagation();
}
"end" => {
self.move_cursor(self.text.len(), keystroke.modifiers.shift, window, cx);
self.move_cursor(self.text.len(), modifiers.shift, window, cx);
cx.stop_propagation();
}
"backspace" => {
self.delete_backward(command || keystroke.modifiers.alt, window, cx);
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(command || keystroke.modifiers.alt, window, cx);
self.delete_forward(word, window, cx);
cx.stop_propagation();
}
"a" if command => {
@@ -591,6 +762,14 @@ impl InputState {
}
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();
@@ -598,7 +777,8 @@ impl InputState {
"tab" => {}
_ => {
if let Some(text) = &keystroke.key_char
&& !command
&& !modifiers.platform
&& !modifiers.control
{
let range = self.selection();
self.replace_range(range, text, window, cx);
@@ -676,6 +856,8 @@ impl EntityInputHandler for InputState {
);
let new_text = sanitize(new_text);
let old_text = self.text[range.clone()].to_string();
self.push_history(range.clone(), old_text, new_text.len());
self.text.replace_range(range.clone(), &new_text);
let start = range.start;
+101
View File
@@ -6,6 +6,7 @@ use gpui_native_core::input::{Input, InputState, InputStateEvent};
use gpui_native_theme::Theme;
use std::cell::RefCell;
use std::rc::Rc;
use std::time::Duration;
struct Harness {
state: Entity<InputState>,
@@ -110,6 +111,106 @@ fn select_all_then_type_replaces_the_value() {
assert_eq!(value(&cx, &state), "z");
}
#[cfg(target_os = "macos")]
#[test]
fn cmd_arrow_jumps_to_the_field_ends() {
let (cx, state, mut visual) = input_window("hello world", false);
focus(&mut visual, &state);
// Caret starts at the end; cmd-left brings it to the start.
visual.simulate_keystrokes("cmd-left");
visual.simulate_keystrokes("x");
assert_eq!(value(&cx, &state), "xhello world");
// cmd-shift-right selects from the caret to the end.
visual.simulate_keystrokes("cmd-shift-right");
visual.simulate_keystrokes("backspace");
assert_eq!(value(&cx, &state), "x");
// cmd-up / cmd-down mirror the jumps.
visual.simulate_keystrokes("cmd-down");
visual.simulate_keystrokes("x");
assert_eq!(value(&cx, &state), "xx");
visual.simulate_keystrokes("cmd-up");
visual.simulate_keystrokes("y");
assert_eq!(value(&cx, &state), "yxx");
}
#[cfg(target_os = "macos")]
#[test]
fn cmd_backspace_deletes_to_the_field_start() {
let (cx, state, mut visual) = input_window("hello", false);
focus(&mut visual, &state);
// The caret starts at the end; cmd-backspace clears the whole field.
visual.simulate_keystrokes("cmd-backspace");
assert_eq!(value(&cx, &state), "");
}
#[test]
fn undo_reverts_a_typing_burst_as_one_step() {
let (cx, state, mut visual) = input_window("", false);
focus(&mut visual, &state);
visual.simulate_keystrokes("h e l l o");
visual.simulate_keystrokes("cmd-z");
assert_eq!(value(&cx, &state), "");
visual.simulate_keystrokes("cmd-shift-z");
assert_eq!(value(&cx, &state), "hello");
}
#[test]
fn undo_steps_through_edits_outside_the_group_interval() {
let (cx, state, mut visual) = input_window("", false);
focus(&mut visual, &state);
visual.update(|_, cx| {
state.update(cx, |state, _| state.set_group_interval(Duration::ZERO));
});
visual.simulate_keystrokes("a b");
assert_eq!(value(&cx, &state), "ab");
visual.simulate_keystrokes("cmd-z");
assert_eq!(value(&cx, &state), "a");
visual.simulate_keystrokes("cmd-z");
assert_eq!(value(&cx, &state), "");
visual.simulate_keystrokes("cmd-shift-z");
assert_eq!(value(&cx, &state), "a");
visual.simulate_keystrokes("cmd-shift-z");
assert_eq!(value(&cx, &state), "ab");
// A deletion gets its own entry and undoes back to the typed text.
visual.simulate_keystrokes("backspace");
assert_eq!(value(&cx, &state), "a");
visual.simulate_keystrokes("cmd-z");
assert_eq!(value(&cx, &state), "ab");
}
#[test]
fn new_edits_clear_the_redo_stack() {
let (cx, state, mut visual) = input_window("", false);
focus(&mut visual, &state);
visual.simulate_keystrokes("a");
visual.simulate_keystrokes("cmd-z");
visual.simulate_keystrokes("b");
visual.simulate_keystrokes("cmd-shift-z");
assert_eq!(value(&cx, &state), "b");
visual.simulate_keystrokes("cmd-z");
assert_eq!(value(&cx, &state), "");
}
#[test]
fn set_value_clears_the_history() {
let (cx, state, mut visual) = input_window("", false);
focus(&mut visual, &state);
visual.simulate_keystrokes("typed");
visual.update(|window, cx| {
state.update(cx, |state, cx| state.set_value("preset", window, cx));
});
visual.simulate_keystrokes("cmd-z");
assert_eq!(value(&cx, &state), "preset");
}
#[test]
fn shift_arrow_selects_and_backspace_removes_the_selection() {
let (cx, state, mut visual) = input_window("ab", false);