From e836a979a244132981f10acc83ea3480d1bbe439 Mon Sep 17 00:00:00 2001 From: Hans Date: Fri, 15 Mar 2024 10:31:53 +0800 Subject: [PATCH] vim: Add Multi Replace mode in Vim (#8469) For #4440, I've only added support for normal, if it's visual mode, would we like this to delete the current selection row and enter insert mode? --------- Co-authored-by: Conrad Irwin --- assets/keymaps/vim.json | 10 + crates/editor/src/editor.rs | 4 +- crates/vim/src/motion.rs | 8 +- crates/vim/src/normal/case.rs | 2 +- crates/vim/src/object.rs | 2 +- crates/vim/src/replace.rs | 352 ++++++++++++++++++ crates/vim/src/state.rs | 30 +- crates/vim/src/test.rs | 5 + crates/vim/src/test/neovim_connection.rs | 2 +- crates/vim/src/vim.rs | 13 +- crates/vim/test_data/test_replace_mode.json | 48 +++ .../vim/test_data/test_replace_mode_undo.json | 124 ++++++ 12 files changed, 584 insertions(+), 16 deletions(-) create mode 100644 crates/vim/src/replace.rs create mode 100644 crates/vim/test_data/test_replace_mode.json create mode 100644 crates/vim/test_data/test_replace_mode_undo.json diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index ef10432479..9e150b8d0f 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -119,6 +119,7 @@ "shift-v": "vim::ToggleVisualLine", "ctrl-v": "vim::ToggleVisualBlock", "ctrl-q": "vim::ToggleVisualBlock", + "shift-r": "vim::ToggleReplace", "0": "vim::StartOfLine", // When no number operator present, use start of line motion "ctrl-f": "vim::PageDown", "pagedown": "vim::PageDown", @@ -520,6 +521,15 @@ "ctrl-r +": "editor::Paste" } }, + { + "context": "Editor && vim_mode == replace", + "bindings": { + "escape": "vim::NormalBefore", + "ctrl-c": "vim::NormalBefore", + "ctrl-[": "vim::NormalBefore", + "backspace": "vim::UndoReplace" + } + }, { "context": "Editor && VimWaiting", "bindings": { diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 3235ca6240..45fcfc1b75 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -363,7 +363,7 @@ pub struct Editor { buffer: Model, /// Map of how text in the buffer should be displayed. /// Handles soft wraps, folds, fake inlay text insertions, etc. - display_map: Model, + pub display_map: Model, pub selections: SelectionsCollection, pub scroll_manager: ScrollManager, columnar_selection_tail: Option, @@ -423,6 +423,7 @@ pub struct Editor { _subscriptions: Vec, pixel_position_of_newest_cursor: Option>, gutter_width: Pixels, + pub vim_replace_map: HashMap, String>, style: Option, editor_actions: Vec)>>, show_copilot_suggestions: bool, @@ -1568,6 +1569,7 @@ impl Editor { show_cursor_names: false, hovered_cursors: Default::default(), editor_actions: Default::default(), + vim_replace_map: Default::default(), show_copilot_suggestions: mode == EditorMode::Full, custom_context_menu: None, _subscriptions: vec![ diff --git a/crates/vim/src/motion.rs b/crates/vim/src/motion.rs index a421259de6..3d3b47b810 100644 --- a/crates/vim/src/motion.rs +++ b/crates/vim/src/motion.rs @@ -387,7 +387,7 @@ pub(crate) fn motion(motion: Motion, cx: &mut WindowContext) { let count = Vim::update(cx, |vim, cx| vim.take_count(cx)); let operator = Vim::read(cx).active_operator(); match Vim::read(cx).state().mode { - Mode::Normal => normal_motion(motion, operator, count, cx), + Mode::Normal | Mode::Replace => normal_motion(motion, operator, count, cx), Mode::Visual | Mode::VisualLine | Mode::VisualBlock => visual_motion(motion, count, cx), Mode::Insert => { // Shouldn't execute a motion in insert mode. Ignoring @@ -800,7 +800,11 @@ fn left(map: &DisplaySnapshot, mut point: DisplayPoint, times: usize) -> Display point } -fn backspace(map: &DisplaySnapshot, mut point: DisplayPoint, times: usize) -> DisplayPoint { +pub(crate) fn backspace( + map: &DisplaySnapshot, + mut point: DisplayPoint, + times: usize, +) -> DisplayPoint { for _ in 0..times { point = movement::left(map, point); if point.is_zero() { diff --git a/crates/vim/src/normal/case.rs b/crates/vim/src/normal/case.rs index d8111312f9..85a416d9a9 100644 --- a/crates/vim/src/normal/case.rs +++ b/crates/vim/src/normal/case.rs @@ -63,7 +63,7 @@ where cursor_positions.push(selection.start..selection.start); } } - Mode::Insert | Mode::Normal => { + Mode::Insert | Mode::Normal | Mode::Replace => { let start = selection.start; let mut end = start; for _ in 0..count { diff --git a/crates/vim/src/object.rs b/crates/vim/src/object.rs index 56b9499594..a525cda102 100644 --- a/crates/vim/src/object.rs +++ b/crates/vim/src/object.rs @@ -98,7 +98,7 @@ fn object(object: Object, cx: &mut WindowContext) { match Vim::read(cx).state().mode { Mode::Normal => normal_object(object, cx), Mode::Visual | Mode::VisualLine | Mode::VisualBlock => visual_object(object, cx), - Mode::Insert => { + Mode::Insert | Mode::Replace => { // Shouldn't execute a text object in insert mode. Ignoring } } diff --git a/crates/vim/src/replace.rs b/crates/vim/src/replace.rs new file mode 100644 index 0000000000..859289903e --- /dev/null +++ b/crates/vim/src/replace.rs @@ -0,0 +1,352 @@ +use crate::{ + motion::{self}, + state::Mode, + Vim, +}; +use editor::{display_map::ToDisplayPoint, Bias, ToPoint}; +use gpui::{actions, ViewContext, WindowContext}; +use language::{AutoindentMode, Point}; +use std::ops::Range; +use std::sync::Arc; +use workspace::Workspace; + +actions!(vim, [ToggleReplace, UndoReplace]); + +pub fn register(workspace: &mut Workspace, _: &mut ViewContext) { + workspace.register_action(|_, _: &ToggleReplace, cx: &mut ViewContext| { + Vim::update(cx, |vim, cx| { + vim.update_state(|state| state.replacements = vec![]); + vim.switch_mode(Mode::Replace, false, cx); + }); + }); + + workspace.register_action(|_, _: &UndoReplace, cx: &mut ViewContext| { + Vim::update(cx, |vim, cx| { + if vim.state().mode != Mode::Replace { + return; + } + let count = vim.take_count(cx); + undo_replace(vim, count, cx) + }); + }); +} + +pub(crate) fn multi_replace(text: Arc, cx: &mut WindowContext) { + Vim::update(cx, |vim, cx| { + vim.update_active_editor(cx, |vim, editor, cx| { + editor.transact(cx, |editor, cx| { + editor.set_clip_at_line_ends(false, cx); + let map = editor.snapshot(cx); + let display_selections = editor.selections.all::(cx); + + // Handles all string that require manipulation, including inserts and replaces + let edits = display_selections + .into_iter() + .map(|selection| { + let is_new_line = text.as_ref() == "\n"; + let mut range = selection.range(); + // "\n" need to be handled separately, because when a "\n" is typing, + // we don't do a replace, we need insert a "\n" + if !is_new_line { + range.end.column += 1; + range.end = map.buffer_snapshot.clip_point(range.end, Bias::Right); + } + let replace_range = map.buffer_snapshot.anchor_before(range.start) + ..map.buffer_snapshot.anchor_after(range.end); + let current_text = map + .buffer_snapshot + .text_for_range(replace_range.clone()) + .collect(); + vim.update_state(|state| { + state + .replacements + .push((replace_range.clone(), current_text)) + }); + (replace_range, text.clone()) + }) + .collect::>(); + + editor.buffer().update(cx, |buffer, cx| { + buffer.edit( + edits.clone(), + Some(AutoindentMode::Block { + original_indent_columns: Vec::new(), + }), + cx, + ); + }); + + editor.change_selections(None, cx, |s| { + s.select_anchor_ranges(edits.iter().map(|(range, _)| range.end..range.end)); + }); + editor.set_clip_at_line_ends(true, cx); + }); + }); + }); +} + +fn undo_replace(vim: &mut Vim, maybe_times: Option, cx: &mut WindowContext) { + vim.update_active_editor(cx, |vim, editor, cx| { + editor.transact(cx, |editor, cx| { + editor.set_clip_at_line_ends(false, cx); + let map = editor.snapshot(cx); + let selections = editor.selections.all::(cx); + let mut new_selections = vec![]; + let edits: Vec<(Range, String)> = selections + .into_iter() + .filter_map(|selection| { + let end = selection.head(); + let start = motion::backspace( + &map, + end.to_display_point(&map), + maybe_times.unwrap_or(1), + ) + .to_point(&map); + new_selections.push( + map.buffer_snapshot.anchor_before(start) + ..map.buffer_snapshot.anchor_before(start), + ); + + let mut undo = None; + let edit_range = start..end; + for (i, (range, inverse)) in vim.state().replacements.iter().rev().enumerate() { + if range.start.to_point(&map.buffer_snapshot) <= edit_range.start + && range.end.to_point(&map.buffer_snapshot) >= edit_range.end + { + undo = Some(inverse.clone()); + vim.update_state(|state| { + state.replacements.remove(state.replacements.len() - i - 1); + }); + break; + } + } + Some((edit_range, undo?)) + }) + .collect::>(); + + editor.buffer().update(cx, |buffer, cx| { + buffer.edit(edits, None, cx); + }); + + editor.change_selections(None, cx, |s| { + s.select_ranges(new_selections); + }); + editor.set_clip_at_line_ends(true, cx); + }); + }); +} + +#[cfg(test)] +mod test { + use indoc::indoc; + + use crate::{ + state::Mode, + test::{NeovimBackedTestContext, VimTestContext}, + }; + + #[gpui::test] + async fn test_enter_and_exit_replace_mode(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + cx.simulate_keystroke("shift-r"); + assert_eq!(cx.mode(), Mode::Replace); + cx.simulate_keystroke("escape"); + assert_eq!(cx.mode(), Mode::Normal); + } + + #[gpui::test] + async fn test_replace_mode(cx: &mut gpui::TestAppContext) { + let mut cx: NeovimBackedTestContext = NeovimBackedTestContext::new(cx).await; + + // test normal replace + cx.set_shared_state(indoc! {" + ˇThe quick brown + fox jumps over + the lazy dog."}) + .await; + cx.simulate_shared_keystrokes(["shift-r", "O", "n", "e"]) + .await; + cx.assert_shared_state(indoc! {" + Oneˇ quick brown + fox jumps over + the lazy dog."}) + .await; + assert_eq!(Mode::Replace, cx.neovim_mode().await); + + // test replace with line ending + cx.set_shared_state(indoc! {" + The quick browˇn + fox jumps over + the lazy dog."}) + .await; + cx.simulate_shared_keystrokes(["shift-r", "O", "n", "e"]) + .await; + cx.assert_shared_state(indoc! {" + The quick browOneˇ + fox jumps over + the lazy dog."}) + .await; + + // test replace with blank line + cx.set_shared_state(indoc! {" + The quick brown + ˇ + fox jumps over + the lazy dog."}) + .await; + cx.simulate_shared_keystrokes(["shift-r", "O", "n", "e"]) + .await; + cx.assert_shared_state(indoc! {" + The quick brown + Oneˇ + fox jumps over + the lazy dog."}) + .await; + + // test replace with multi cursor + cx.set_shared_state(indoc! {" + ˇThe quick brown + fox jumps over + the lazy ˇdog."}) + .await; + cx.simulate_shared_keystrokes(["shift-r", "O", "n", "e"]) + .await; + cx.assert_shared_state(indoc! {" + Oneˇ quick brown + fox jumps over + the lazy Oneˇ."}) + .await; + + // test replace with newline + cx.set_shared_state(indoc! {" + The quˇick brown + fox jumps over + the lazy dog."}) + .await; + cx.simulate_shared_keystrokes(["shift-r", "enter", "O", "n", "e"]) + .await; + cx.assert_shared_state(indoc! {" + The qu + Oneˇ brown + fox jumps over + the lazy dog."}) + .await; + + // test replace with multi cursor and newline + cx.set_shared_state(indoc! {" + ˇThe quick brown + fox jumps over + the lazy ˇdog."}) + .await; + cx.simulate_shared_keystrokes(["shift-r", "O", "n", "e"]) + .await; + cx.assert_shared_state(indoc! {" + Oneˇ quick brown + fox jumps over + the lazy Oneˇ."}) + .await; + cx.simulate_shared_keystrokes(["enter", "T", "w", "o"]) + .await; + cx.assert_shared_state(indoc! {" + One + Twoˇck brown + fox jumps over + the lazy One + Twoˇ"}) + .await; + } + + #[gpui::test] + async fn test_replace_mode_undo(cx: &mut gpui::TestAppContext) { + let mut cx: NeovimBackedTestContext = NeovimBackedTestContext::new(cx).await; + + const UNDO_REPLACE_EXAMPLES: &[&'static str] = &[ + // replace undo with single line + "ˇThe quick brown fox jumps over the lazy dog.", + // replace undo with ending line + indoc! {" + The quick browˇn + fox jumps over + the lazy dog." + }, + // replace undo with empty line + indoc! {" + The quick brown + ˇ + fox jumps over + the lazy dog." + }, + // replace undo with multi cursor + indoc! {" + The quick browˇn + fox jumps over + the lazy ˇdog." + }, + ]; + + for example in UNDO_REPLACE_EXAMPLES { + // normal undo + cx.assert_binding_matches( + [ + "shift-r", + "O", + "n", + "e", + "backspace", + "backspace", + "backspace", + ], + example, + ) + .await; + // undo with new line + cx.assert_binding_matches( + [ + "shift-r", + "O", + "enter", + "e", + "backspace", + "backspace", + "backspace", + ], + example, + ) + .await; + cx.assert_binding_matches( + [ + "shift-r", + "O", + "enter", + "n", + "enter", + "e", + "backspace", + "backspace", + "backspace", + "backspace", + "backspace", + ], + example, + ) + .await; + } + } + + #[gpui::test] + async fn test_replace_multicursor(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + cx.set_state("ˇabcˇabcabc", Mode::Normal); + cx.simulate_keystrokes(["shift-r", "1", "2", "3", "4"]); + cx.assert_state("1234ˇ234ˇbc", Mode::Replace); + assert_eq!(cx.mode(), Mode::Replace); + cx.simulate_keystrokes([ + "backspace", + "backspace", + "backspace", + "backspace", + "backspace", + ]); + cx.assert_state("ˇabˇcabcabc", Mode::Replace); + } +} diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index 664e213431..6a52a3c684 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -1,5 +1,6 @@ use std::{fmt::Display, ops::Range, sync::Arc}; +use crate::motion::Motion; use collections::HashMap; use editor::Anchor; use gpui::{Action, KeyContext}; @@ -7,12 +8,11 @@ use language::{CursorShape, Selection, TransactionId}; use serde::{Deserialize, Serialize}; use workspace::searchable::Direction; -use crate::motion::Motion; - #[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)] pub enum Mode { Normal, Insert, + Replace, Visual, VisualLine, VisualBlock, @@ -23,6 +23,7 @@ impl Display for Mode { match self { Mode::Normal => write!(f, "NORMAL"), Mode::Insert => write!(f, "INSERT"), + Mode::Replace => write!(f, "REPLACE"), Mode::Visual => write!(f, "VISUAL"), Mode::VisualLine => write!(f, "VISUAL LINE"), Mode::VisualBlock => write!(f, "VISUAL BLOCK"), @@ -33,7 +34,7 @@ impl Display for Mode { impl Mode { pub fn is_visual(&self) -> bool { match self { - Mode::Normal | Mode::Insert => false, + Mode::Normal | Mode::Insert | Mode::Replace => false, Mode::Visual | Mode::VisualLine | Mode::VisualBlock => true, } } @@ -67,6 +68,7 @@ pub struct EditorState { pub post_count: Option, pub operator_stack: Vec, + pub replacements: Vec<(Range, String)>, pub current_tx: Option, pub current_anchor: Option>, @@ -159,17 +161,21 @@ impl EditorState { CursorShape::Underscore } } + Mode::Replace => CursorShape::Underscore, Mode::Visual | Mode::VisualLine | Mode::VisualBlock => CursorShape::Block, Mode::Insert => CursorShape::Bar, } } pub fn vim_controlled(&self) -> bool { - !matches!(self.mode, Mode::Insert) - || matches!( - self.operator_stack.last(), - Some(Operator::FindForward { .. }) | Some(Operator::FindBackward { .. }) - ) + let is_insert_mode = matches!(self.mode, Mode::Insert); + if !is_insert_mode { + return true; + } + matches!( + self.operator_stack.last(), + Some(Operator::FindForward { .. }) | Some(Operator::FindBackward { .. }) + ) } pub fn should_autoindent(&self) -> bool { @@ -178,7 +184,9 @@ impl EditorState { pub fn clip_at_line_ends(&self) -> bool { match self.mode { - Mode::Insert | Mode::Visual | Mode::VisualLine | Mode::VisualBlock => false, + Mode::Insert | Mode::Visual | Mode::VisualLine | Mode::VisualBlock | Mode::Replace => { + false + } Mode::Normal => true, } } @@ -195,6 +203,7 @@ impl EditorState { Mode::Normal => "normal", Mode::Visual | Mode::VisualLine | Mode::VisualBlock => "visual", Mode::Insert => "insert", + Mode::Replace => "replace", }, ); @@ -221,6 +230,9 @@ impl EditorState { active_operator.map(|op| op.id()).unwrap_or_else(|| "none"), ); + if self.mode == Mode::Replace { + context.add("VimWaiting"); + } context } } diff --git a/crates/vim/src/test.rs b/crates/vim/src/test.rs index dbc993dbc4..9eff14f4fb 100644 --- a/crates/vim/src/test.rs +++ b/crates/vim/src/test.rs @@ -270,6 +270,11 @@ async fn test_status_indicator(cx: &mut gpui::TestAppContext) { cx.workspace(|_, cx| mode_indicator.read(cx).mode), Some(Mode::Insert) ); + cx.simulate_keystrokes(["escape", "shift-r"]); + assert_eq!( + cx.workspace(|_, cx| mode_indicator.read(cx).mode), + Some(Mode::Replace) + ); // shows even in search cx.simulate_keystrokes(["escape", "v", "/"]); diff --git a/crates/vim/src/test/neovim_connection.rs b/crates/vim/src/test/neovim_connection.rs index 3d47789fac..616959160f 100644 --- a/crates/vim/src/test/neovim_connection.rs +++ b/crates/vim/src/test/neovim_connection.rs @@ -439,7 +439,7 @@ impl NeovimConnection { Point::new(selection_row, selection_col)..Point::new(cursor_row, cursor_col), ) } - Some(Mode::Insert) | Some(Mode::Normal) | None => selections + Some(Mode::Insert) | Some(Mode::Normal) | Some(Mode::Replace) | None => selections .push(Point::new(selection_row, selection_col)..Point::new(cursor_row, cursor_col)), } diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index 2171153fbb..b3541eefdc 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -10,6 +10,7 @@ mod mode_indicator; mod motion; mod normal; mod object; +mod replace; mod state; mod utils; mod visual; @@ -29,6 +30,7 @@ use language::{CursorShape, Point, Selection, SelectionGoal, TransactionId}; pub use mode_indicator::ModeIndicator; use motion::Motion; use normal::normal_replace; +use replace::multi_replace; use schemars::JsonSchema; use serde::Deserialize; use serde_derive::Serialize; @@ -132,6 +134,7 @@ fn register(workspace: &mut Workspace, cx: &mut ViewContext) { insert::register(workspace, cx); motion::register(workspace, cx); command::register(workspace, cx); + replace::register(workspace, cx); object::register(workspace, cx); visual::register(workspace, cx); } @@ -418,6 +421,11 @@ impl Vim { if selection.is_empty() { selection.end = movement::right(map, selection.start); } + } else if last_mode == Mode::Replace { + if selection.head().column() != 0 { + let point = movement::left(map, selection.head()); + selection.collapse_to(point, selection.goal) + } } }); }) @@ -608,7 +616,10 @@ impl Vim { Mode::Visual | Mode::VisualLine | Mode::VisualBlock => visual_replace(text, cx), _ => Vim::update(cx, |vim, cx| vim.clear_operator(cx)), }, - _ => {} + _ => match Vim::read(cx).state().mode { + Mode::Replace => multi_replace(text, cx), + _ => {} + }, } } diff --git a/crates/vim/test_data/test_replace_mode.json b/crates/vim/test_data/test_replace_mode.json new file mode 100644 index 0000000000..e07630daed --- /dev/null +++ b/crates/vim/test_data/test_replace_mode.json @@ -0,0 +1,48 @@ +{"Put":{"state":"ˇThe quick brown\nfox jumps over\nthe lazy dog."}} +{"Key":"shift-r"} +{"Key":"O"} +{"Key":"n"} +{"Key":"e"} +{"Get":{"state":"Oneˇ quick brown\nfox jumps over\nthe lazy dog.","mode":"Replace"}} +{"Put":{"state":"The quick browˇn\nfox jumps over\nthe lazy dog."}} +{"Key":"shift-r"} +{"Key":"O"} +{"Key":"n"} +{"Key":"e"} +{"Get":{"state":"The quick browOneˇ\nfox jumps over\nthe lazy dog.","mode":"Replace"}} +{"Put":{"state":"The quick brown\nˇ\nfox jumps over\nthe lazy dog."}} +{"Key":"shift-r"} +{"Key":"O"} +{"Key":"n"} +{"Key":"e"} +{"Get":{"state":"The quick brown\nOneˇ\nfox jumps over\nthe lazy dog.","mode":"Replace"}} +{"Put":{"state":"ˇThe quick brown\nfox jumps over\nthe lazy ˇdog."}} +{"Key":"shift-r"} +{"Key":"O"} +{"Key":"n"} +{"Key":"e"} +{"Get":{"state":"Oneˇ quick brown\nfox jumps over\nthe lazy Oneˇ.","mode":"Replace"}} +{"Put":{"state":"The quˇick brown\nfox jumps over\nthe lazy dog."}} +{"Key":"shift-r"} +{"Key":"enter"} +{"Key":"O"} +{"Key":"n"} +{"Key":"e"} +{"Get":{"state":"The qu\nOneˇ brown\nfox jumps over\nthe lazy dog.","mode":"Replace"}} +{"Put":{"state":"ˇThe quick brown\nfox jumps over\nthe lazy ˇdog."}} +{"Key":"shift-r"} +{"Key":"O"} +{"Key":"n"} +{"Key":"e"} +{"Get":{"state":"Oneˇ quick brown\nfox jumps over\nthe lazy Oneˇ.","mode":"Replace"}} +{"Key":"enter"} +{"Key":"T"} +{"Key":"w"} +{"Key":"o"} +{"Get":{"state":"One\nTwoˇck brown\nfox jumps over\nthe lazy One\nTwoˇ","mode":"Replace"}} +{"Put":{"state":"ˇThe quick brown\nfox jumps over\nthe lazy ˇdog."}} +{"Key":"shift-r"} +{"Key":"O"} +{"Key":"n"} +{"Key":"e"} +{"Get":{"state":"Oneˇ quick brown\nfox jumps over\nthe lazy Oneˇ.","mode":"Replace"}} diff --git a/crates/vim/test_data/test_replace_mode_undo.json b/crates/vim/test_data/test_replace_mode_undo.json new file mode 100644 index 0000000000..7628a27fb4 --- /dev/null +++ b/crates/vim/test_data/test_replace_mode_undo.json @@ -0,0 +1,124 @@ +{"Put":{"state":"ˇThe quick brown fox jumps over the lazy dog."}} +{"Key":"shift-r"} +{"Key":"O"} +{"Key":"n"} +{"Key":"e"} +{"Key":"backspace"} +{"Key":"backspace"} +{"Key":"backspace"} +{"Get":{"state":"ˇThe quick brown fox jumps over the lazy dog.","mode":"Replace"}} +{"Put":{"state":"ˇThe quick brown fox jumps over the lazy dog."}} +{"Key":"shift-r"} +{"Key":"O"} +{"Key":"enter"} +{"Key":"e"} +{"Key":"backspace"} +{"Key":"backspace"} +{"Key":"backspace"} +{"Get":{"state":"ˇThe quick brown fox jumps over the lazy dog.","mode":"Replace"}} +{"Put":{"state":"ˇThe quick brown fox jumps over the lazy dog."}} +{"Key":"shift-r"} +{"Key":"O"} +{"Key":"enter"} +{"Key":"n"} +{"Key":"enter"} +{"Key":"e"} +{"Key":"backspace"} +{"Key":"backspace"} +{"Key":"backspace"} +{"Key":"backspace"} +{"Key":"backspace"} +{"Get":{"state":"ˇThe quick brown fox jumps over the lazy dog.","mode":"Replace"}} +{"Put":{"state":"The quick browˇn\nfox jumps over\nthe lazy dog."}} +{"Key":"shift-r"} +{"Key":"O"} +{"Key":"n"} +{"Key":"e"} +{"Key":"backspace"} +{"Key":"backspace"} +{"Key":"backspace"} +{"Get":{"state":"The quick browˇn\nfox jumps over\nthe lazy dog.","mode":"Replace"}} +{"Put":{"state":"The quick browˇn\nfox jumps over\nthe lazy dog."}} +{"Key":"shift-r"} +{"Key":"O"} +{"Key":"enter"} +{"Key":"e"} +{"Key":"backspace"} +{"Key":"backspace"} +{"Key":"backspace"} +{"Get":{"state":"The quick browˇn\nfox jumps over\nthe lazy dog.","mode":"Replace"}} +{"Put":{"state":"The quick browˇn\nfox jumps over\nthe lazy dog."}} +{"Key":"shift-r"} +{"Key":"O"} +{"Key":"enter"} +{"Key":"n"} +{"Key":"enter"} +{"Key":"e"} +{"Key":"backspace"} +{"Key":"backspace"} +{"Key":"backspace"} +{"Key":"backspace"} +{"Key":"backspace"} +{"Get":{"state":"The quick browˇn\nfox jumps over\nthe lazy dog.","mode":"Replace"}} +{"Put":{"state":"The quick brown\nˇ\nfox jumps over\nthe lazy dog."}} +{"Key":"shift-r"} +{"Key":"O"} +{"Key":"n"} +{"Key":"e"} +{"Key":"backspace"} +{"Key":"backspace"} +{"Key":"backspace"} +{"Get":{"state":"The quick brown\nˇ\nfox jumps over\nthe lazy dog.","mode":"Replace"}} +{"Put":{"state":"The quick brown\nˇ\nfox jumps over\nthe lazy dog."}} +{"Key":"shift-r"} +{"Key":"O"} +{"Key":"enter"} +{"Key":"e"} +{"Key":"backspace"} +{"Key":"backspace"} +{"Key":"backspace"} +{"Get":{"state":"The quick brown\nˇ\nfox jumps over\nthe lazy dog.","mode":"Replace"}} +{"Put":{"state":"The quick brown\nˇ\nfox jumps over\nthe lazy dog."}} +{"Key":"shift-r"} +{"Key":"O"} +{"Key":"enter"} +{"Key":"n"} +{"Key":"enter"} +{"Key":"e"} +{"Key":"backspace"} +{"Key":"backspace"} +{"Key":"backspace"} +{"Key":"backspace"} +{"Key":"backspace"} +{"Get":{"state":"The quick brown\nˇ\nfox jumps over\nthe lazy dog.","mode":"Replace"}} +{"Put":{"state":"The quick browˇn\nfox jumps over\nthe lazy ˇdog."}} +{"Key":"shift-r"} +{"Key":"O"} +{"Key":"n"} +{"Key":"e"} +{"Key":"backspace"} +{"Key":"backspace"} +{"Key":"backspace"} +{"Get":{"state":"The quick browˇn\nfox jumps over\nthe lazy ˇdog.","mode":"Replace"}} +{"Put":{"state":"The quick browˇn\nfox jumps over\nthe lazy ˇdog."}} +{"Key":"shift-r"} +{"Key":"O"} +{"Key":"enter"} +{"Key":"e"} +{"Key":"backspace"} +{"Key":"backspace"} +{"Key":"backspace"} +{"Get":{"state":"The quick browˇn\nfox jumps over\nthe lazy ˇdog.","mode":"Replace"}} +{"Put":{"state":"The quick browˇn\nfox jumps over\nthe lazy ˇdog."}} +{"Key":"shift-r"} +{"Key":"O"} +{"Key":"enter"} +{"Key":"n"} +{"Key":"enter"} +{"Key":"e"} +{"Key":"backspace"} +{"Key":"backspace"} +{"Key":"backspace"} +{"Key":"backspace"} +{"Key":"backspace"} +{"Get":{"state":"The quick browˇn\nfox jumps over\nthe lazy ˇdog.","mode":"Replace"}} \ No newline at end of file