From 7bc5eab22f3963dd80ac6bdfc19ea85882c33894 Mon Sep 17 00:00:00 2001 From: Valdemar Lorenzen Date: Mon, 12 Oct 2026 13:08:56 +0200 Subject: [PATCH] fix: preserve selector text input and close on escape --- README.md | 2 +- TODO.md | 4 +- docs/configuration.md | 4 +- docs/editing.md | 6 +- src/diff.rs | 128 ++++++++++++++++++------------------- src/editor.rs | 145 ++++++++++++++++++++++++++++++------------ src/main.rs | 4 +- src/ruby.rs | 6 +- src/tui.rs | 14 ++-- 9 files changed, 189 insertions(+), 124 deletions(-) diff --git a/README.md b/README.md index 73df3b1..c80a5b9 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Vamp starts in Normal mode. Press `i`, type text, then Esc to return to Normal. | Paste in Normal | `p`/`P` after/before the caret or row for linewise fragments; system clipboard by default | | Command palette | Ctrl+p; type to filter, arrows select, Enter accepts, Esc cancels | | Floating/docked explorer in Normal or Visual | Space then `o` / Space then `e` | -| Repository diff in Normal | `:diff`; choose Before then After, Tab changes category, type to search; Working checkout After is editable; `:diff.close` exits | +| Repository diff in Normal | `:diff`; choose Before then After, Tab changes category, type to search; Esc closes the selector; Working checkout After is editable; `:diff.close` exits the comparison | Space is the default leader: press its suffix keys in order, not simultaneously. The [editing guide](docs/editing.md) contains the complete key table and behavior reference. diff --git a/TODO.md b/TODO.md index 73da96b..5c1e4f2 100644 --- a/TODO.md +++ b/TODO.md @@ -8,8 +8,8 @@ - [x] diff view: add character-wise change highlight (enabled by default). - [x] add tabs: tab support, editor action to open a buffer in a new tab, default tab navigation is `ctrl+option+h/l`, close tab action. - per-tab pane layouts, views, explorers, and comparisons; shared buffers, tab bar, and guarded close/force-close actions. -- [ ] make diff selector close on escape. -- [ ] `:` currently overrides the diff selector input (and maybe other input fields) and spawns the command input on top. this is of course not intended behavior and should be fixed. +- [x] make diff selector close on escape. +- [x] `:` currently overrides the diff selector input (and maybe other input fields) and spawns the command input on top. this is of course not intended behavior and should be fixed. - [ ] diff view: lsp actions should act as they normally would. if an lsp action would open another file, then the file should be opened in another tab outside the diff view - if the referenced file/symbol is part of the changes in the diff, it should just be opened in the diff view. - this will require tab support. - [ ] diff view: change diff view modes; choose between split and unified diff view (split by default). diff --git a/docs/configuration.md b/docs/configuration.md index c7076c2..5877bcb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -199,7 +199,9 @@ config.bind "ctrl+h", nil, mode: config.modes.tree The diff's changed-file explorer and floating picker use these same tree and selection contexts, including remaps and disabled keys. Enter opens the selected file's comparison; Working checkout After reuses its editable buffer. The catalog contains only changed paths, with A/M/D/R status markers, rather than a disk scan. -Ordinary `config.core.file_picker` and `config.core.file_explorer` bindings work inside comparisons. Ctrl+w closes a focused picker/explorer, retaining its query, selection, and expansion state; the panes use the reclaimed width. Ctrl+e has no reserved diff behavior. Close the comparison explicitly with `:diff.close`, its palette action, or the generated action value below. `config.core.diff` remains the opening action and also exposes its immutable `close` child; Escape never closes a comparison. +Ordinary `config.core.file_picker` and `config.core.file_explorer` bindings work inside comparisons. Ctrl+w closes a focused picker/explorer, retaining its query, selection, and expansion state; the panes use the reclaimed width. Ctrl+e has no reserved diff behavior. Escape closes the revision selector in either stage, but an opened comparison still closes explicitly through `:diff.close`, its palette action, or the generated action value below. `config.core.diff` remains the opening action and also exposes its immutable `close` child. + +Focused selector/filter input receives printable text before Normal-mode action bindings, so `:` stays query text instead of opening Command input. Explicit tree/selection navigation overrides remain effective. Control-key editor action bindings and Ctrl+p remain available. ```ruby config.bind "leader d q", config.core.diff.close diff --git a/docs/editing.md b/docs/editing.md index 301d257..7351fd0 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -295,7 +295,9 @@ The floating selector has **Revisions / Bookmarks** tabs for jj, or **Commits / | Up / Down, Ctrl+k / Ctrl+j | Previous / next entry | | Backspace / Ctrl+u | Remove a query grapheme / clear query | | Enter | Choose the entry | -| Esc | Cancel loading; After → return to Before; Before → clear query without closing | +| Esc | Close either selector stage, cancel outstanding work, and restore the source buffer without discarding edits | + +Printable input belongs to the focused query, including `:`, `_`, and `|`; Normal-mode command or pane bindings cannot steal it. Explicit selection-navigation bindings still take priority. Use Ctrl+p to select an action, or configure a control-key action binding, when you need an editor action while the selector is focused. The selector initially lists local revisions and known tracking refs **without contacting remotes**. **Load refs from origin** (or another configured remote) in Bookmarks/Branches/Tags lists advertised references, including refs never fetched locally, without advancing the selection stage. Selecting a reference such as `some-bookmark@origin` reuses available content or fetches the missing selected reference into a temporary comparison repository. **Load history from origin** in Revisions/Commits explicitly imports searchable remote history. Network operations use the repository's existing credentials; errors stay in the selector. They do not update active refs, snapshot jj's working copy, or change the checkout. @@ -331,7 +333,7 @@ Diff backgrounds blend the terminal's default background with its red/green pale Missing counterparts use adjacent muted `╱` diagonals (U+2571, box drawings light diagonal) on the normal terminal background, one per column. Unlike the text slash `/`, this glyph is designed to reach cell corners so diagonals can join end to end across successive rows. Pixel-perfect joins depend on the terminal's font and line-spacing rendering. Actual empty source lines retain their normal/change background and are not hatched. -In Normal mode, Tab/Shift+Tab cycles focus between Files and the visible text panes, skipping absent panes. Switching Before/After synchronizes the primary cursor by aligned diff row and display column. A missing counterpart uses the nearest visible source row; shorter lines clamp the column while retaining the preferred display column for the return trip. Vertical wheel input scrolls the visible panes without moving the focused text caret; switching sides by vertical wheel also synchronizes the cursor. Over Files it navigates visible tree rows without opening them. Horizontal wheel input in either split synchronizes the Before/After horizontal offsets, bounded by the longer side, without changing focus, cursor, or selections; over Files it is consumed. Added/deleted files retain single-pane scrolling. Escape cancels modes, prompts, or filtering, or returns picker focus to the last bound text pane; **it never closes the diff**. Use **`:diff.close`**, find `diff.close` in the palette, or bind **`config.core.diff.close`** to leave the comparison explicitly. The dotted command is intentional; there is no `:diffclose` alias. +In Normal mode, Tab/Shift+Tab cycles focus between Files and the visible text panes, skipping absent panes. Switching Before/After synchronizes the primary cursor by aligned diff row and display column. A missing counterpart uses the nearest visible source row; shorter lines clamp the column while retaining the preferred display column for the return trip. Vertical wheel input scrolls the visible panes without moving the focused text caret; switching sides by vertical wheel also synchronizes the cursor. Over Files it navigates visible tree rows without opening them. Horizontal wheel input in either split synchronizes the Before/After horizontal offsets, bounded by the longer side, without changing focus, cursor, or selections; over Files it is consumed. Added/deleted files retain single-pane scrolling. While a comparison is open, Escape cancels modes, prompts, or filtering, or returns picker focus to the last bound text pane; it leaves the comparison open. Use **`:diff.close`**, find `diff.close` in the palette, or bind **`config.core.diff.close`** to leave the comparison explicitly. The dotted command is intentional; there is no `:diffclose` alias. Both diff panes apply available semantic tokens from their own buffer, independently of keyboard focus. Historical snapshots never borrow the working buffer's tokens; live After tokens must match the displayed text revision. Missing or invalidated tokens leave Tree-sitter syntax colors intact. Semantic colors and modifiers preserve the change backgrounds. diff --git a/src/diff.rs b/src/diff.rs index e993dae..4b0bad5 100644 --- a/src/diff.rs +++ b/src/diff.rs @@ -164,12 +164,6 @@ pub enum DisplayRow { Fold { start: usize, end: usize }, } -#[derive(Debug)] -struct SelectorPosition { - category: usize, - query: String, - selected: RevisionTarget, -} #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum RequestKind { Catalog, @@ -214,7 +208,6 @@ pub struct DiffState { request_kind: Option, visible: Vec, search_labels: Vec, - before_position: Option, max_before: usize, max_after: usize, after_widths: Vec, @@ -255,7 +248,6 @@ impl DiffState { request_kind: Some(RequestKind::Catalog), visible: Vec::new(), search_labels: Vec::new(), - before_position: None, max_before: 0, max_after: 0, after_widths: Vec::new(), @@ -468,44 +460,20 @@ impl DiffState { } } - /// Handles modal input; closing is an explicit editor action. + /// Handles selector filtering and comparison navigation; the editor owns selector closing. pub(crate) fn handle(&mut self, input: Input) { if input == Input::Escape { if self.phase == Phase::View && self.focus == Focus::Files && let Some(files) = &mut self.files - && files.searching - && files.view == FileView::Tree { - files.finish_search(); - return; - } - if self.phase == Phase::View { - if self.focus == Focus::Files - && let Some(files) = &mut self.files - { + if files.searching && files.view == FileView::Tree { + files.finish_search(); + } else { files.focused = false; self.files_visible = files.presentation == FilePresentation::Docked; self.focus = self.first_text_focus(); } - return; - } - self.pending = None; - self.request_kind = None; - self.loading = false; - self.error = None; - if self.phase == Phase::Before { - self.query.clear(); - self.refilter(); - return; - } - self.phase = Phase::Before; - self.after = None; - if let Some(position) = self.before_position.take() { - self.category = position.category; - self.query = position.query; - self.refilter(); - self.select_target(&position.selected); } return; } @@ -562,11 +530,6 @@ impl DiffState { } else if let RevisionTarget::ListRemote(remote) = &revision.target { self.queue(Operation::ListRemote(remote.clone())); } else if self.phase == Phase::Before { - self.before_position = Some(SelectorPosition { - category: self.category, - query: self.query.clone(), - selected: revision.target.clone(), - }); self.before = Some(revision); self.after = None; self.phase = Phase::After; @@ -2106,32 +2069,66 @@ mod tests { } #[test] - fn after_escape_restores_before_query_and_selected_revision_and_cancels_request() { + fn selector_escape_closes_each_stage_and_rejects_late_results() { + for after in [false, true] { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("unsaved source".into())]) + .unwrap(); + let buffer = editor.buffer_id(); + open(&mut editor); + let request = if after { + Some(choose_pair(&mut editor)) + } else { + text(&mut editor, "query"); + None + }; + editor.handle(Input::Escape); + assert!(editor.diff().is_none()); + assert_eq!(editor.buffer_id(), buffer); + assert_eq!(editor.lines(), ["unsaved source"]); + assert!(editor.dirty()); + if let Some(id) = request { + assert!(!editor.apply_diff_result(id, Ok(Outcome::Comparison(comparison())))); + assert!(editor.diff().is_none()); + } + } + } + + #[test] + fn selector_printable_input_precedes_normal_bindings_but_not_selection_overrides() { + for after in [false, true] { + let mut editor = editor(); + open(&mut editor); + if after { + editor.handle(Input::Enter); + } + editor + .bind("normal", "x", Some("core.command".into())) + .unwrap(); + for ch in ":_| x".chars() { + editor.handle(Input::Char(ch)); + assert_eq!(editor.mode(), Mode::Normal); + assert_eq!(editor.pane_layout().len(), 1); + } + assert_eq!(editor.diff().unwrap().query, ":_| x"); + editor.handle(Input::Ctrl('u')); + editor + .bind("selection", ":", Some("core.down".into())) + .unwrap(); + editor.handle(Input::Char(':')); + assert_eq!(editor.diff().unwrap().selected, 1); + assert!(editor.diff().unwrap().query.is_empty()); + } let mut editor = editor(); open(&mut editor); - editor.handle(Input::Char('\t')); - text(&mut editor, "topic"); - editor.handle(Input::Down); - editor.handle(Input::Enter); - assert!(editor.diff().unwrap().query.is_empty()); - editor.handle(Input::Enter); - let (id, _) = editor.take_diff_request().unwrap(); + editor.handle(Input::Ctrl('p')); + editor.handle(Input::Char(':')); + assert_eq!(editor.palette().unwrap().query, ":"); + assert_eq!(editor.mode(), Mode::Normal); editor.handle(Input::Escape); - let state = editor.diff().unwrap(); - assert_eq!(state.phase, Phase::Before); - assert_eq!(state.category, 1); - assert_eq!(state.query, "topic"); - assert_eq!(state.selected, 1); - assert!(!state.loading); - assert!(!editor.apply_diff_result(id, Ok(Outcome::Comparison(comparison())))); - editor.handle(Input::Enter); - assert_eq!(editor.diff().unwrap().phase, Phase::After); - editor.handle(Input::Escape); - editor.handle(Input::Escape); - assert_eq!(editor.diff().unwrap().phase, Phase::Before); - assert!(editor.diff().unwrap().query.is_empty()); - editor.execute("diff.close"); - assert!(editor.diff().is_none()); + assert!(editor.palette().is_none()); + assert!(editor.diff().is_some()); } #[test] @@ -2560,8 +2557,7 @@ mod tests { buffers ); editor.handle(Input::Escape); - assert!(editor.diff().is_some()); - editor.execute("diff.close"); + assert!(editor.diff().is_none()); assert_eq!(editor.mode(), Mode::Normal); open(&mut editor); let id = choose_pair(&mut editor); diff --git a/src/editor.rs b/src/editor.rs index 1f69030..40ca3fc 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -2628,6 +2628,32 @@ impl Editor { } fn handle_diff(&mut self, input: Input) -> Option { + if self.palette.is_some() + || self.diff_prompt_mode.is_some() + || self.startup_diagnostic.is_some() + || self.hover.is_some() + { + return self.handle_input(input); + } + let phase = self.diff.as_ref().expect("active diff").phase; + let files_focused = + phase == Phase::View && self.diff.as_ref().unwrap().focus == Focus::Files; + if phase != Phase::View && input == Input::Escape { + return self.execute_action(Action::DiffClose.entry(), ""); + } + let text_focused = phase != Phase::View + || (files_focused + && self + .diff + .as_ref() + .and_then(|diff| diff.files.as_ref()) + .is_some_and(|files| files.searching || files.view == FileView::List)); + if text_focused && matches!(input, Input::Char(ch) if !ch.is_control()) { + if !self.handle_selection_binding(&input) { + self.diff.as_mut().unwrap().handle(input); + } + return None; + } if self .bindings .get(&self.binding_mode()) @@ -2655,9 +2681,7 @@ impl Editor { { return self.handle_input(input); } - if self.palette.is_some() - || self.diff_prompt_mode.is_some() - || (self.diff_interacting() && self.active.view.mode != Mode::Normal) + if (self.diff_interacting() && self.active.view.mode != Mode::Normal) || self.count.is_some() || self.sequence_pending().is_some() || self.g_pending @@ -2666,9 +2690,6 @@ impl Editor { { return self.handle_input(input); } - let phase = self.diff.as_ref().expect("active diff").phase; - let files_focused = - phase == Phase::View && self.diff.as_ref().unwrap().focus == Focus::Files; if self.diff_interacting() { if matches!(input, Input::Char('[' | ']')) { let focus = self.diff_buffer_focus().unwrap(); @@ -2704,29 +2725,7 @@ impl Editor { if files_focused && self.handle_tree_binding(&input) { return None; } - if !self.diff_interacting() { - match self.selection_bindings.get(&input) { - Some(Binding::Action(Action::Down)) => { - self.diff.as_mut().unwrap().navigate(true); - return None; - } - Some(Binding::Action(Action::Up)) => { - self.diff.as_mut().unwrap().navigate(false); - return None; - } - Some(Binding::Disabled) => return None, - _ => {} - } - } - if files_focused - && matches!(input, Input::Char(ch) if !ch.is_control()) - && self - .diff - .as_ref() - .and_then(|diff| diff.files.as_ref()) - .is_some_and(|files| files.searching || files.view == FileView::List) - { - self.diff.as_mut().unwrap().handle(input); + if !self.diff_interacting() && self.handle_selection_binding(&input) { return None; } if self.sequence_pending().is_some() @@ -2758,7 +2757,6 @@ impl Editor { return self.handle_input(input); } let escape = input == Input::Escape; - let back = input == Input::Escape && phase != Phase::View; let return_focus = files_focused .then(|| self.diff_bound.map(|bound| bound.2)) .flatten(); @@ -2772,9 +2770,6 @@ impl Editor { diff.focus = focus; diff.normalize_focus(); } - if back { - self.outstanding_diff_request = None; - } None } @@ -4286,6 +4281,10 @@ impl Editor { } else { palette.selected - 1 }; + } else if self.palette.is_none() + && let Some(diff) = &mut self.diff + { + diff.navigate(next); } true } @@ -8721,6 +8720,70 @@ mod tests { assert_eq!(editor.active.view.targets, targets); } + #[test] + fn focused_text_fields_keep_printable_normal_bindings_as_text() { + let directory = Directory::new(); + fs::write(directory.path("sample.txt"), "text").unwrap(); + for field in [ + "floating", + "docked", + "diff_tree", + "diff_list", + "palette", + "command", + "search", + ] { + let mut editor = editor(); + editor.project_root = directory.0.clone(); + editor + .bind("normal", "x", Some("core.pane.split_vertical".into())) + .unwrap(); + match field { + "floating" => editor.open_file_picker(FilePresentation::Floating), + "docked" => { + editor.open_file_picker(FilePresentation::Docked); + editor.handle(Input::Char('i')); + } + "diff_tree" | "diff_list" => { + historical_diff( + &mut editor, + &directory.0, + vec!["before".into()], + vec!["after".into()], + ); + if field == "diff_list" { + editor.set_file_explorer_view("list").unwrap(); + } + editor.open_file_picker(FilePresentation::Docked); + if field == "diff_tree" { + editor.handle(Input::Char('i')); + } + } + "palette" => { + editor.handle(Input::Ctrl('p')); + } + "command" => { + editor.handle(Input::Char(':')); + } + "search" => { + editor.handle(Input::Char('/')); + } + _ => unreachable!(), + } + let document = editor.lines().to_vec(); + editor.handle(Input::Char(':')); + editor.handle(Input::Char('x')); + let query = match field { + "palette" => editor.palette().unwrap().query.as_str(), + "command" | "search" => editor.command_line(), + _ => editor.file_picker().unwrap().query.as_str(), + }; + assert_eq!(query, ":x", "{field}"); + assert_eq!(editor.pane_layout().len(), 1, "{field}"); + assert_eq!(editor.lines(), document, "{field}"); + } + } + #[test] fn empty_notification_does_not_swallow_escape_but_cursor_diagnostic_does() { let mut editor = editor(); @@ -9637,12 +9700,12 @@ mod tests { ] { editor.handle(input); } - editor.handle(Input::Escape); - assert!(editor.diff().is_some()); type_text(&mut editor, ":unknown\n"); editor.handle(Input::Ctrl('p')); editor.handle(Input::Escape); - editor.execute("diff.close"); + assert!(editor.diff().is_some()); + editor.handle(Input::Escape); + assert!(editor.diff().is_none()); assert_eq!(editor.mode(), Mode::Visual); assert_eq!(editor.carets(), carets); assert_eq!(editor.selections(), selections); @@ -9665,14 +9728,12 @@ mod tests { let caret = editor.cursor(); editor.execute("diff"); editor.handle(Input::Paste("ignored".into())); - editor.handle(Input::Escape); - assert!(editor.diff().is_some()); type_text(&mut editor, ":unfinished"); - editor.handle(Input::Escape); editor .apply(vec![Effect::Message("message without edits".into())]) .unwrap(); - editor.execute("diff.close"); + editor.handle(Input::Escape); + assert!(editor.diff().is_none()); assert_eq!(editor.mode(), Mode::Insert); assert_eq!(editor.cursor(), caret); type_text(&mut editor, "finish"); @@ -9822,7 +9883,7 @@ mod tests { editor.handle(Input::Char('q')); assert_eq!(editor.diff().unwrap().query, "q"); editor.handle(Input::Escape); - assert_eq!(editor.diff().unwrap().query, ""); + assert!(editor.diff().is_none()); assert!(editor.notification_visible()); } @@ -10406,7 +10467,7 @@ mod tests { editor.execute("diff"); type_text(&mut editor, ":diffclose\n"); assert!(editor.diff().is_some()); - type_text(&mut editor, ":diff.close\n"); + editor.execute("diff.close"); assert!(editor.diff().is_none()); } diff --git a/src/main.rs b/src/main.rs index 003d2ff..03c1d01 100644 --- a/src/main.rs +++ b/src/main.rs @@ -51,9 +51,9 @@ Diff: :diff (Normal); choose Before then After; Working checkout After edits liv Before and historical/remote After stay read-only; Tab/Shift+Tab categories/Normal focus All text panes use native motions, selections and copy; read-only panes reject edits/saves Switching Before/After preserves the aligned cursor row and display column - Type to search IDs/refs; navigation skips omitted lines without expanding excerpts + Selector queries own printable keys, including :; text navigation skips omitted lines Normal [/] explicitly expands context; Insert brackets remain literal text - :diff.close exits explicitly; Esc cancels modes/prompts/filtering without closing the diff + Esc closes Before/After selectors; :diff.close exits an opened comparison explicitly Remote refs/history load into temporary comparison repos without changing active refs/checkout Changed files: normal picker/explorer bindings, Ctrl+w close; A/M/D/R status and rename paths diff --git a/src/ruby.rs b/src/ruby.rs index 571f5bf..b2befc9 100644 --- a/src/ruby.rs +++ b/src/ruby.rs @@ -965,7 +965,7 @@ end config.bind "D", config.core.buffer_delete config.bind "Z", config.core.buffer_force_delete config.bind "R", config.core.diff - config.bind "T", config.core.diff.close + config.bind "ctrl+t", config.core.diff.close config.bind "H", config.core.word_wrap.toggle raise "mutable diff actions" unless config.core.frozen? && config.core.diff.frozen? && config.core.diff.close.frozen? && config.core.diff.name.frozen? && @@ -982,8 +982,10 @@ end"#, assert!(!review.editor().word_wrap()); review.handle(Input::Char('R')); review.handle(Input::Escape); + assert!(review.editor().diff().is_none()); + review.handle(Input::Char('R')); assert!(review.editor().diff().is_some()); - review.handle(Input::Char('T')); + review.handle(Input::Ctrl('t')); assert!(review.editor().diff().is_none()); for (key, dirty, quit, saved) in [ ('Q', false, true, false), diff --git a/src/tui.rs b/src/tui.rs index 07bee53..d4c34ac 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -4833,10 +4833,6 @@ mod tests { diff.handle(Input::Enter); render_diff(&mut terminal, &diff, &mut panels); assert!(screen_text(&terminal).contains("Choose After · Before: commit-19")); - diff.handle(Input::Escape); - render_diff(&mut terminal, &diff, &mut panels); - assert!(screen_text(&terminal).contains("> needle")); - assert!(screen_text(&terminal).contains("> commit-19")); diff.handle(Input::Ctrl('u')); diff.handle(Input::Char('\t')); render_diff(&mut terminal, &diff, &mut panels); @@ -5649,7 +5645,6 @@ mod tests { fn floating_inputs_share_workspace_geometry_across_documents_splits_and_diffs() { for context in ["document", "split", "diff", "split-diff", "selector"] { for prefix in [':', '/'] { - // A selector's slash belongs to its revision filter, not native search. if context == "selector" && prefix == '/' { continue; } @@ -5670,7 +5665,14 @@ mod tests { editor.apply_diff_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog()))); } editor.set_message("saved notification"); - editor.handle(Input::Char(prefix)); + if context == "selector" { + editor + .bind("normal", "ctrl+t", Some("core.command".into())) + .unwrap(); + editor.handle(Input::Ctrl('t')); + } else { + editor.handle(Input::Char(prefix)); + } let query = format!("{}TAIL", "界e\u{301}👩‍🚀".repeat(30)); for ch in query.chars() { editor.handle(Input::Char(ch));