diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 67665971..38f4b481 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -4,20 +4,18 @@ use anyhow::Result; use app_state::AppState; use assets::Assets; use gpui::{actions, App, AppContext, KeyBinding, Menu, MenuItem}; +use ui::input::{Copy, Cut, Paste, Redo, Undo}; mod app_state; mod assets; mod story_workspace; -actions!(main_menu, [Quit, Copy]); +actions!(main_menu, [Quit]); fn init(app_state: Arc, cx: &mut AppContext) -> Result<()> { story_workspace::init(app_state.clone(), cx); - cx.bind_keys([ - KeyBinding::new("cmd-q", Quit, None), - KeyBinding::new("cmd-c", Copy, None), - ]); + cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]); Ok(()) } @@ -44,7 +42,14 @@ fn main() { }, Menu { name: "Edit".into(), - items: vec![MenuItem::action("Copy", Copy)], + items: vec![ + MenuItem::os_action("Undo", Undo, gpui::OsAction::Undo), + MenuItem::os_action("Redo", Redo, gpui::OsAction::Redo), + MenuItem::separator(), + MenuItem::os_action("Cut", Cut, gpui::OsAction::Cut), + MenuItem::os_action("Copy", Copy, gpui::OsAction::Copy), + MenuItem::os_action("Paste", Paste, gpui::OsAction::Paste), + ], }, ]); diff --git a/crates/ui/src/input/history.rs b/crates/ui/src/input/history.rs new file mode 100644 index 00000000..0ba1ee6b --- /dev/null +++ b/crates/ui/src/input/history.rs @@ -0,0 +1,116 @@ +use std::{ + fmt::Debug, + ops::Range, + time::{Duration, Instant}, +}; + +const MAX_UNDO: usize = 1000; +/// Group interval in milliseconds +const GROUP_INTERVAL: u64 = 1000; + +#[derive(Debug)] +pub struct History { + undos: Vec, + redos: Vec, + last_changed_at: Instant, + version: usize, + pub(crate) ignore: bool, +} + +#[derive(Debug, Clone)] +pub struct Change { + pub(crate) old_range: Range, + pub(crate) old_text: String, + pub(crate) new_range: Range, + pub(crate) new_text: String, + version: usize, +} + +impl History { + pub fn new() -> Self { + Self { + undos: Default::default(), + redos: Default::default(), + ignore: false, + last_changed_at: Instant::now(), + version: 0, + } + } + + /// Increment the version number if the last change was made more than `GROUP_INTERVAL` milliseconds ago. + fn inc_version(&mut self) -> usize { + let t = Instant::now(); + if self.last_changed_at.elapsed().as_millis() + > Duration::from_millis(GROUP_INTERVAL).as_millis() + { + self.version += 1; + } + + self.last_changed_at = t; + self.version + } + + pub fn push( + &mut self, + old_range: Range, + old_text: &str, + new_range: Range, + new_text: &str, + ) { + let version = self.inc_version(); + + if self.undos.len() >= MAX_UNDO { + self.undos.remove(0); + } + self.undos.push(Change { + old_range, + old_text: old_text.to_string(), + new_range, + new_text: new_text.to_string(), + version, + }); + } + + pub fn undo(&mut self) -> Option> { + if let Some(first_change) = self.undos.pop() { + let mut changes = vec![first_change.clone()]; + // pick the next all changes with the same version + while self + .undos + .iter() + .filter(|c| c.version == first_change.version) + .count() + > 0 + { + let change = self.undos.pop().unwrap(); + changes.push(change); + } + + self.redos.extend(changes.iter().rev().cloned()); + Some(changes) + } else { + None + } + } + + pub fn redo(&mut self) -> Option> { + if let Some(first_change) = self.redos.pop() { + let mut changes = vec![first_change.clone()]; + // pick the next all changes with the same version + while self + .redos + .iter() + .filter(|c| c.version == first_change.version) + .count() + > 0 + { + let change = self.redos.pop().unwrap(); + changes.push(change); + } + self.undos.extend(changes.iter().rev().cloned()); + Some(changes) + } else { + None + } + } +} diff --git a/crates/ui/src/input/input.rs b/crates/ui/src/input/input.rs index 92303fc2..0819d713 100644 --- a/crates/ui/src/input/input.rs +++ b/crates/ui/src/input/input.rs @@ -6,6 +6,7 @@ use std::ops::Range; use super::blink_cursor::BlinkCursor; +use super::history::{Change, History}; use crate::button::{Button, ButtonStyle}; use crate::styled_ext::Sizeful; use crate::theme::ActiveTheme; @@ -41,6 +42,8 @@ actions!( Copy, Cut, Paste, + Undo, + Redo, MoveToStartOfLine, MoveToEndOfLine, TextChanged, @@ -54,51 +57,62 @@ pub enum InputEvent { Blur, } +const CONTEXT: &str = "Input"; + pub fn init(cx: &mut AppContext) { cx.bind_keys([ - KeyBinding::new("backspace", Backspace, None), - KeyBinding::new("delete", Delete, None), - KeyBinding::new("enter", Enter, None), - KeyBinding::new("left", Left, None), - KeyBinding::new("right", Right, None), - KeyBinding::new("shift-left", SelectLeft, None), - KeyBinding::new("shift-right", SelectRight, None), - KeyBinding::new("home", Home, None), - KeyBinding::new("end", End, None), - KeyBinding::new("shift-home", SelectToHome, None), - KeyBinding::new("shift-end", SelectToEnd, None), + KeyBinding::new("backspace", Backspace, Some(CONTEXT)), + KeyBinding::new("delete", Delete, Some(CONTEXT)), + KeyBinding::new("enter", Enter, Some(CONTEXT)), + KeyBinding::new("left", Left, Some(CONTEXT)), + KeyBinding::new("right", Right, Some(CONTEXT)), + KeyBinding::new("shift-left", SelectLeft, Some(CONTEXT)), + KeyBinding::new("shift-right", SelectRight, Some(CONTEXT)), + KeyBinding::new("home", Home, Some(CONTEXT)), + KeyBinding::new("end", End, Some(CONTEXT)), + KeyBinding::new("shift-home", SelectToHome, Some(CONTEXT)), + KeyBinding::new("shift-end", SelectToEnd, Some(CONTEXT)), #[cfg(target_os = "macos")] - KeyBinding::new("ctrl-cmd-space", ShowCharacterPalette, None), + KeyBinding::new("ctrl-cmd-space", ShowCharacterPalette, Some(CONTEXT)), #[cfg(target_os = "macos")] - KeyBinding::new("cmd-a", SelectAll, None), + KeyBinding::new("cmd-a", SelectAll, Some(CONTEXT)), #[cfg(not(target_os = "macos"))] - KeyBinding::new("ctrl-a", SelectAll, None), + KeyBinding::new("ctrl-a", SelectAll, Some(CONTEXT)), #[cfg(target_os = "macos")] - KeyBinding::new("cmd-c", Copy, None), + KeyBinding::new("cmd-c", Copy, Some(CONTEXT)), #[cfg(not(target_os = "macos"))] - KeyBinding::new("ctrl-c", Copy, None), + KeyBinding::new("ctrl-c", Copy, Some(CONTEXT)), #[cfg(target_os = "macos")] - KeyBinding::new("cmd-x", Cut, None), + KeyBinding::new("cmd-x", Cut, Some(CONTEXT)), #[cfg(not(target_os = "macos"))] - KeyBinding::new("ctrl-x", Cut, None), + KeyBinding::new("ctrl-x", Cut, Some(CONTEXT)), #[cfg(target_os = "macos")] - KeyBinding::new("cmd-v", Paste, None), + KeyBinding::new("cmd-v", Paste, Some(CONTEXT)), #[cfg(not(target_os = "macos"))] - KeyBinding::new("ctrl-v", Paste, None), + KeyBinding::new("ctrl-v", Paste, Some(CONTEXT)), #[cfg(target_os = "macos")] - KeyBinding::new("ctrl-a", Home, None), + KeyBinding::new("ctrl-a", Home, Some(CONTEXT)), #[cfg(target_os = "macos")] - KeyBinding::new("ctrl-e", End, None), + KeyBinding::new("ctrl-e", End, Some(CONTEXT)), + #[cfg(target_os = "macos")] + KeyBinding::new("cmd-z", Undo, Some(CONTEXT)), + #[cfg(target_os = "macos")] + KeyBinding::new("cmd-shift-z", Redo, Some(CONTEXT)), + #[cfg(not(target_os = "macos"))] + KeyBinding::new("ctrl-z", Undo, Some(CONTEXT)), + #[cfg(not(target_os = "macos"))] + KeyBinding::new("ctrl-y", Redo, Some(CONTEXT)), ]); } pub struct TextInput { focus_handle: FocusHandle, text: SharedString, + history: History, + blink_cursor: Model, prefix: Option, suffix: Option, placeholder: SharedString, - blink_cursor: Model, selected_range: Range, selection_reversed: bool, marked_range: Option>, @@ -119,11 +133,13 @@ impl TextInput { pub fn new(cx: &mut ViewContext) -> Self { let focus_handle = cx.focus_handle(); let blink_cursor = cx.new_model(|cx| BlinkCursor::new(cx)); + let history = History::new(); let input = Self { focus_handle: focus_handle.clone(), text: "".into(), - placeholder: "".into(), blink_cursor, + history, + placeholder: "".into(), selected_range: 0..0, selection_reversed: false, marked_range: None, @@ -164,8 +180,12 @@ impl TextInput { /// Set the text of the input field. pub fn set_text(&mut self, text: impl Into, cx: &mut ViewContext) { - self.text = text.into(); - self.selected_range = self.text.len()..self.text.len(); + self.history.ignore = true; + let text: SharedString = text.into(); + let range = 0..self.text.chars().map(|c| c.len_utf16()).sum(); + self.replace_text_in_range(Some(range), &text, cx); + self.history.ignore = false; + cx.notify(); } @@ -356,6 +376,43 @@ impl TextInput { } } + fn push_history(&mut self, range: &Range, new_text: &str, cx: &mut ViewContext) { + if self.history.ignore { + return; + } + + let old_text = self + .text_for_range(self.range_to_utf16(&range), cx) + .unwrap_or("".to_string()); + + let new_range = range.start..range.start + new_text.len(); + + self.history + .push(range.clone(), &old_text, new_range, new_text); + } + + fn undo(&mut self, _: &Undo, cx: &mut ViewContext) { + self.history.ignore = true; + if let Some(changes) = self.history.undo() { + for change in changes { + let range_utf16 = self.range_to_utf16(&change.new_range); + self.replace_text_in_range(Some(range_utf16), &change.old_text, cx); + } + } + self.history.ignore = false; + } + + fn redo(&mut self, _: &Redo, cx: &mut ViewContext) { + self.history.ignore = true; + if let Some(changes) = self.history.redo() { + for change in changes { + let range_utf16 = self.range_to_utf16(&change.old_range); + self.replace_text_in_range(Some(range_utf16), &change.new_text, cx); + } + } + self.history.ignore = false; + } + fn move_to(&mut self, offset: usize, cx: &mut ViewContext) { self.selected_range = offset..offset; self.pause_blink_cursor(cx); @@ -567,6 +624,7 @@ impl ViewInputHandler for TextInput { .or(self.marked_range.clone()) .unwrap_or(self.selected_range.clone()); + self.push_history(&range, new_text, cx); self.text = (self.text[0..range.start].to_owned() + new_text + &self.text[range.end..]).into(); self.selected_range = range.start + new_text.len()..range.start + new_text.len(); @@ -592,6 +650,7 @@ impl ViewInputHandler for TextInput { .or(self.marked_range.clone()) .unwrap_or(self.selected_range.clone()); + self.push_history(&range, new_text, cx); self.text = (self.text[0..range.start].to_owned() + new_text + &self.text[range.end..]).into(); self.marked_range = Some(range.start..range.start + new_text.len()); @@ -851,7 +910,7 @@ impl Render for TextInput { div() .flex() - .key_context("TextInput") + .key_context(CONTEXT) .track_focus(&self.focus_handle) .when(!self.disabled, |this| { this.on_action(cx.listener(Self::backspace)) @@ -871,6 +930,9 @@ impl Render for TextInput { .on_action(cx.listener(Self::copy)) .on_action(cx.listener(Self::paste)) .on_action(cx.listener(Self::cut)) + .on_action(cx.listener(Self::undo)) + .on_action(cx.listener(Self::redo)) + .on_action(cx.listener(Self::redo)) // Double click to select all .on_double_click(cx.listener(|view, _, cx| { view.select_all(&SelectAll, cx); diff --git a/crates/ui/src/input/mod.rs b/crates/ui/src/input/mod.rs index 01008dec..b11478d2 100644 --- a/crates/ui/src/input/mod.rs +++ b/crates/ui/src/input/mod.rs @@ -1,4 +1,5 @@ mod blink_cursor; +mod history; mod input; mod input_otp;