add undo/redo and macOS modifier handling to input
This commit is contained in:
+192
-10
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user