From d95baae418f69a06aea87ee85293f95caae9e4ad Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 29 Oct 2026 14:32:49 +0700 Subject: [PATCH] add undo/redo and macOS modifier handling to input --- crates/core/src/input.rs | 202 +++++++++++++++++++++++++++++++++++-- crates/core/tests/input.rs | 101 +++++++++++++++++++ docs/PLAN.md | 6 +- 3 files changed, 296 insertions(+), 13 deletions(-) diff --git a/crates/core/src/input.rs b/crates/core/src/input.rs index 21084a4..f66e403 100644 --- a/crates/core/src/input.rs +++ b/crates/core/src/input.rs @@ -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; /// 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 { + /// The range the replacement occupies after the edit. + range: Range, + /// 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, /// Whether a drag-select is in progress. dragging: bool, + /// Patch history. `redo_stack` holds undone entries awaiting redo. + undo_stack: Vec, + redo_stack: Vec, + /// 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>>, @@ -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, 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) { + 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) { + 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; diff --git a/crates/core/tests/input.rs b/crates/core/tests/input.rs index f2b94ef..c499fab 100644 --- a/crates/core/tests/input.rs +++ b/crates/core/tests/input.rs @@ -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, @@ -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); diff --git a/docs/PLAN.md b/docs/PLAN.md index 1f8f812..6d213f4 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) — next +### 6.2 Input completion (unit 10 finishing pass) — in progress (steps 1–2 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. @@ -231,8 +231,8 @@ Remaining, in order: | Step | Scope | Notes | | --- | --- | --- | -| 1 | macOS modifier split | `cmd-left/right` → home/end, `cmd-up/down` → start/end of field, `cmd-backspace` → delete to start, `alt` modifiers → word ops. The handler currently treats cmd and alt alike — wrong on macOS (D8); ctrl stays the word-modifier fallback off-mac | -| 2 | Undo/redo | Patch-based history (range, replaced text, new length, prior selection), 300 ms grouping (`set_group_interval`), `cmd-z` / `cmd-shift-z`; `set_value` clears the stacks | +| 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 | Read-only: focus, movement, selection, copy still work; edits and paste refused — distinct from disabled. Escape → `window.blur()`. Triple-click selects the line (all of it, 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 | | 5 | Caret blink | 500 ms tick on the GPUI executor, paused (caret visible) while typing or moving, suppressed in read-only |