Add Vim search motions to the composer (#41586)

## What changed

- Add draft-local forward and backward literal search with `/` and `?`, plus
  wrapped repeat navigation with `n` and `N`.
- Support search motions after delete, change, and yank operators, while keeping
  queries separate from the draft and skipping atomic elements and partial
  grapheme matches.
- Render and edit the query in the composer footer, highlight matches, and add
  configurable `tui.keymap.vim_search` bindings to the keymap picker and schema.

## Testing

- Cover navigation, wrapping, operator composition, repeat behavior, Unicode
  graphemes, atomic elements, query editing, paste handling, and rendering.

GitOrigin-RevId: 087b080ff8fd74e48456c6b899c07ea5eed38e0c
This commit is contained in:
Benjamin Carlsson
2026-08-30 00:42:00 +00:00
committed by copyberry
parent b8c86376a2
commit 63d213884d
27 changed files with 745 additions and 42 deletions
+17
View File
@@ -324,6 +324,21 @@ pub struct TuiVimOperatorKeymap {
pub cancel: Option<KeybindingsSpec>,
}
/// Search motions shared by Vim normal and operator-pending input.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(deny_unknown_fields)]
pub struct TuiVimSearchKeymap {
/// Search forward in the active buffer (`/`).
pub forward: Option<KeybindingsSpec>,
/// Search backward in the active buffer (`?`).
pub backward: Option<KeybindingsSpec>,
/// Repeat the accepted search (`n`).
pub next: Option<KeybindingsSpec>,
/// Repeat in the opposite direction (`N`).
pub previous: Option<KeybindingsSpec>,
}
/// Vim text-object keybindings for modal editing inside text areas.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
#[serde(deny_unknown_fields)]
@@ -470,6 +485,8 @@ pub struct TuiKeymap {
#[serde(default)]
pub vim_operator: TuiVimOperatorKeymap,
#[serde(default)]
pub vim_search: TuiVimSearchKeymap,
#[serde(default)]
pub vim_text_object: TuiVimTextObjectKeymap,
#[serde(default)]
pub pager: TuiPagerKeymap,
+1
View File
@@ -42,6 +42,7 @@ pub use crate::tui_keymap::TuiListKeymap;
pub use crate::tui_keymap::TuiPagerKeymap;
pub use crate::tui_keymap::TuiVimNormalKeymap;
pub use crate::tui_keymap::TuiVimOperatorKeymap;
pub use crate::tui_keymap::TuiVimSearchKeymap;
pub const DEFAULT_OTEL_ENVIRONMENT: &str = "dev";
pub const DEFAULT_MEMORIES_MAX_ROLLOUTS_PER_STARTUP: usize = 2;
+58
View File
@@ -3926,6 +3926,12 @@
"select_inner_text_object": null,
"yank_line": null
},
"vim_search": {
"backward": null,
"forward": null,
"next": null,
"previous": null
},
"vim_text_object": {
"backtick": null,
"big_word": null,
@@ -4754,6 +4760,19 @@
"yank_line": null
}
},
"vim_search": {
"allOf": [
{
"$ref": "#/definitions/TuiVimSearchKeymap"
}
],
"default": {
"backward": null,
"forward": null,
"next": null,
"previous": null
}
},
"vim_text_object": {
"allOf": [
{
@@ -5405,6 +5424,45 @@
},
"type": "object"
},
"TuiVimSearchKeymap": {
"additionalProperties": false,
"description": "Search motions shared by Vim normal and operator-pending input.",
"properties": {
"backward": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Search backward in the active buffer (`?`)."
},
"forward": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Search forward in the active buffer (`/`)."
},
"next": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Repeat the accepted search (`n`)."
},
"previous": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Repeat in the opposite direction (`N`)."
}
},
"type": "object"
},
"TuiVimTextObjectKeymap": {
"additionalProperties": false,
"description": "Vim text-object keybindings for modal editing inside text areas.",
+39 -11
View File
@@ -59,6 +59,7 @@
//! `Ctrl+R` opens a reverse incremental search mode. The footer becomes the search input; once the
//! query is non-empty, the composer body previews the current match. `Enter` accepts the preview as
//! an editable draft and `Esc` restores the draft that was active when search started.
//! Vim queries stay draft-local.
//!
//! Slash commands are staged for local history instead of being recorded immediately. Command
//! recall is a two-phase handoff: stage the submitted slash text here, then record it after
@@ -292,6 +293,7 @@ mod footer_state;
mod history_search;
mod popup_state;
mod slash_input;
mod vim_search;
use self::attachment_state::AttachmentState;
use self::draft_state::ComposerMentionBinding;
@@ -937,13 +939,17 @@ impl ChatComposer {
/// Return the contexts whose handlers can consume the next composer key.
pub(crate) fn keymap_contexts(&self) -> KeymapContextSet {
if self.draft.textarea.vim_query().is_some() {
return KeymapContextSet::new(KeymapContext::Editor);
}
if self.history_search.is_some() {
return KeymapContextSet::new(KeymapContext::Composer);
}
if !self.draft.input_enabled || self.popups.active() {
return KeymapContextSet::default();
}
KeymapContextSet::new(KeymapContext::Composer).with(self.draft.textarea.keymap_context())
let contexts = self.draft.textarea.keymap_contexts();
contexts.with(KeymapContext::Composer)
}
pub fn set_collaboration_mode_indicator(
@@ -1065,7 +1071,10 @@ impl ChatComposer {
return None;
}
if let Some(pos) = self.history_search_cursor_pos(area) {
if let Some(pos) = self
.vim_search_cursor_pos(area)
.or_else(|| self.history_search_cursor_pos(area))
{
return Some(pos);
}
@@ -1187,6 +1196,10 @@ impl ChatComposer {
pub fn handle_paste(&mut self, pasted: String) -> bool {
let pasted = pasted.replace("\r\n", "\n").replace('\r', "\n");
let pasted = sanitize_user_text(pasted.into());
if let Some(query) = self.draft.textarea.vim_query_mut() {
query.editor.insert_str(&pasted);
return true;
}
let char_count = pasted.chars().count();
if char_count > LARGE_PASTE_CHAR_THRESHOLD {
let placeholder = self.next_large_paste_placeholder(char_count);
@@ -1348,6 +1361,7 @@ impl ChatComposer {
/// commands, not as candidate literal paste text. It also resets transient
/// footer mode so the visible hints match the new editing surface.
pub(crate) fn set_vim_enabled(&mut self, enabled: bool) {
self.draft.textarea.enable_vim_search();
self.draft.textarea.set_vim_enabled(enabled);
self.draft.paste_burst.clear_after_explicit_paste();
self.footer.mode = reset_mode_after_activity(self.footer.mode);
@@ -1949,6 +1963,13 @@ impl ChatComposer {
return (InputResult::None, false);
}
if self.history_search.is_none()
&& !self.popups.active()
&& self.draft.textarea.wants_vim_search_key(key_event)
{
return self.handle_input_basic(key_event);
}
if self.history_search.is_some() {
return self.handle_history_search_key(key_event);
}
@@ -1970,9 +1991,11 @@ impl ChatComposer {
result
}
/// Return true if any popup or history search is active.
/// Whether a popup or query owns input.
pub(crate) fn popup_active(&self) -> bool {
self.history_search.is_some() || self.popups.active()
self.history_search.is_some()
|| self.draft.textarea.vim_query().is_some()
|| self.popups.active()
}
#[inline]
@@ -3724,6 +3747,7 @@ impl ChatComposer {
};
if self.draft.is_bash_mode
&& self.draft.textarea.vim_query().is_none()
&& matches!(input.code, KeyCode::Backspace)
&& self.draft.textarea.cursor() == 0
{
@@ -3854,7 +3878,7 @@ impl ChatComposer {
/// modes (Esc hint, overlay, quit reminder) can override that base when
/// their conditions are active.
fn footer_mode(&self) -> FooterMode {
if self.history_search.is_some() {
if self.history_search.is_some() || self.draft.textarea.vim_query().is_some() {
return FooterMode::HistorySearch;
}
@@ -3882,7 +3906,7 @@ impl ChatComposer {
}
fn custom_footer_height(&self) -> Option<u16> {
if self.footer.flash_visible() {
if self.draft.textarea.vim_query().is_some() || self.footer.flash_visible() {
return Some(1);
}
self.footer
@@ -3893,7 +3917,7 @@ impl ChatComposer {
pub(crate) fn sync_popups(&mut self) {
self.sync_slash_command_elements();
if self.history_search.is_some() {
if self.history_search.is_some() || self.draft.textarea.vim_query().is_some() {
if self.popups.current_file_query.is_some() {
self.app_event_tx
.send(AppEvent::StartFileSearch(String::new()));
@@ -4645,7 +4669,9 @@ impl ChatComposer {
} else {
popup_rect
};
if let Some(line) = self.history_search_footer_line() {
if let Some(input) = self.draft.textarea.vim_query() {
input.render(inset_footer_hint_area(hint_rect), buf);
} else if let Some(line) = self.history_search_footer_line() {
render_footer_line(hint_rect, buf, line);
} else {
let available_width =
@@ -4903,6 +4929,7 @@ impl ChatComposer {
highlights.extend(
self.history_search_highlight_ranges()
.into_iter()
.chain(self.draft.textarea.vim_search_highlights())
.map(|range| (range, search_highlight_style)),
);
if highlights.is_empty() {
@@ -6037,7 +6064,7 @@ mod tests {
}
#[test]
fn slash_opens_command_popup_in_vim_normal_mode() {
fn slash_opens_command_popup_in_vim_insert_mode() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
@@ -6052,6 +6079,7 @@ mod tests {
/*disable_paste_burst*/ true,
);
composer.set_vim_enabled(/*enabled*/ true);
composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
let (result, needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE));
@@ -6068,7 +6096,7 @@ mod tests {
}
#[test]
fn slash_command_can_be_typed_and_dispatched_after_vim_normal_slash() {
fn slash_command_dispatches_after_vim_insert_slash() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
@@ -6084,7 +6112,7 @@ mod tests {
);
composer.set_vim_enabled(/*enabled*/ true);
for ch in ['/', 'd', 'i', 'f', 'f'] {
for ch in ['i', '/', 'd', 'i', 'f', 'f'] {
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
}
assert_eq!(composer.draft.textarea.text(), "/diff");
@@ -0,0 +1,23 @@
//! Composer UI bridge for textarea-owned Vim queries, independent of prompt-history recall.
use super::ChatComposer;
use super::inset_footer_hint_area;
use ratatui::layout::Rect;
impl ChatComposer {
pub(crate) fn cancel_vim_search(&mut self) -> bool {
self.draft.textarea.cancel_vim_search()
}
pub(super) fn vim_search_cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
let query = self.draft.textarea.vim_query()?;
let [_, _, _, mut footer] = self.layout_areas(area);
footer.y = footer.bottom().saturating_sub(1);
footer.height = footer.height.min(1);
query.cursor_pos(inset_footer_hint_area(footer))
}
}
#[cfg(test)]
#[path = "vim_search_tests.rs"]
mod tests;
@@ -0,0 +1,58 @@
//! Search input goes through real composer routing and rendering without changing the draft.
use super::super::tests::snapshot_composer_state_with_width;
use crate::keymap::KeyChordMatch;
use crate::keymap::KeyChordMatcher;
use crate::keymap::RuntimeKeymap;
use codex_config::types::TuiKeymap;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use pretty_assertions::assert_eq;
use tokio::time::Instant;
#[test]
fn vim_search_query_edits_and_paste_preserve_the_draft() {
snapshot_composer_state_with_width(
"vim_composer_search",
/*width*/ 40,
/*enhanced_keys_supported*/ false,
|composer| {
composer.set_text_content("a g\u{301}g b हि gg".into(), Vec::new(), Vec::new());
composer.set_vim_enabled(/*enabled*/ true);
composer.draft.textarea.set_cursor(/*pos*/ 0);
let config: TuiKeymap = toml::from_str("[vim_search]\nforward='g s'").unwrap();
let keymap = RuntimeKeymap::from_config(&config).unwrap();
composer.set_keymap_bindings(&keymap);
let mut matcher = KeyChordMatcher::default();
for ch in "dgse\u{301}\u{8}gg".chars() {
let code = if ch == '\u{8}' {
KeyCode::Backspace
} else {
KeyCode::Char(ch)
};
let event = KeyEvent::new(code, KeyModifiers::NONE);
let event = match matcher.advance(
event,
&keymap.chords,
composer.keymap_contexts(),
Instant::now(),
) {
KeyChordMatch::PassThrough => event,
KeyChordMatch::Completed(event) => event,
KeyChordMatch::Pending(_) => continue,
other => panic!("unexpected chord result: {other:?}"),
};
composer.handle_key_event(event);
}
composer.handle_paste("photo.png".into());
let input = composer.draft.textarea.vim_query().unwrap();
assert_eq!(input.editor.text(), "ggphoto.png");
assert_eq!(composer.draft.textarea.text(), "a g\u{301}g b हि gg");
assert!(composer.attachments.local_image_paths().is_empty());
composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
composer.handle_paste("ह".into());
},
);
}
+1 -1
View File
@@ -161,7 +161,7 @@ impl CollaborationModeIndicator {
/// (for example, showing `QuitShortcutReminder` only while its timer is active).
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum FooterMode {
/// Single-line incremental history search prompt shown while Ctrl+R search is active.
/// Single-line query prompt for history recall or Vim buffer search.
HistorySearch,
/// Transient "press again to quit" reminder (Ctrl+C/Ctrl+D).
QuitShortcutReminder,
+6 -1
View File
@@ -758,7 +758,7 @@ impl BottomPane {
self.request_redraw();
}
event
} else if self.composer.cancel_history_search() {
} else if self.composer.cancel_vim_search() || self.composer.cancel_history_search() {
self.request_redraw();
CancellationEvent::Handled
} else if self.composer_is_empty() {
@@ -2141,6 +2141,11 @@ mod tests {
assert_eq!(pane.composer_text(), "draft");
assert!(!pane.composer.popup_active());
assert!(!pane.quit_shortcut_hint_visible());
pane.composer.set_vim_enabled(/*enabled*/ true);
pane.handle_key_event(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE));
assert_eq!(CancellationEvent::Handled, pane.on_ctrl_c());
assert_eq!(pane.composer_text(), "draft");
assert!(!pane.composer.popup_active());
}
// live ring removed; related tests deleted.
@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› a ǵg b हि gg " Hidden by multi-width symbols: [(10, " ")]
" "
" "
" "
" "
" "
" "
" ?ह "
+20 -3
View File
@@ -23,6 +23,7 @@ use crate::keymap::KeymapContext;
use crate::keymap::RuntimeKeymap;
use crate::keymap::VimNormalKeymap;
use crate::keymap::VimOperatorKeymap;
use crate::keymap::VimSearchKeymap;
use crate::keymap::VimTextObjectKeymap;
use crate::width::display_width;
use codex_protocol::user_input::ByteRange;
@@ -50,6 +51,7 @@ use unicode_segmentation::UnicodeSegmentation;
mod hyperlinks;
mod vim;
mod vim_commands;
mod vim_search;
mod wrapping;
use self::vim::VimMode;
use self::vim::VimMotion;
@@ -141,9 +143,12 @@ pub(crate) struct TextArea {
vim_mode: VimMode,
vim_pending: VimPending,
vim_commands: VimCommandState,
vim_search: vim_search::VimSearch,
vim_search_enabled: bool,
editor_keymap: Arc<EditorKeymap>,
vim_normal_keymap: VimNormalKeymap,
vim_operator_keymap: VimOperatorKeymap,
vim_search_keymap: VimSearchKeymap,
vim_text_object_keymap: VimTextObjectKeymap,
}
@@ -184,9 +189,12 @@ impl TextArea {
vim_mode: VimMode::Insert,
vim_pending: VimPending::None,
vim_commands: VimCommandState::default(),
vim_search: vim_search::VimSearch::default(),
vim_search_enabled: false,
editor_keymap: defaults.editor,
vim_normal_keymap: defaults.vim_normal,
vim_operator_keymap: defaults.vim_operator,
vim_search_keymap: defaults.vim_search,
vim_text_object_keymap: defaults.vim_text_object,
}
}
@@ -201,6 +209,7 @@ impl TextArea {
self.editor_keymap = Arc::clone(&keymap.editor);
self.vim_normal_keymap = keymap.vim_normal.clone();
self.vim_operator_keymap = keymap.vim_operator.clone();
self.vim_search_keymap = keymap.vim_search.clone();
self.vim_text_object_keymap = keymap.vim_text_object.clone();
}
@@ -253,6 +262,7 @@ impl TextArea {
self.wrap_cache.replace(None);
self.preferred_col = None;
self.vim_pending = VimPending::None;
self.vim_search = vim_search::VimSearch::default();
self.vim_commands = VimCommandState::default();
}
@@ -265,6 +275,7 @@ impl TextArea {
pub(crate) fn set_vim_enabled(&mut self, enabled: bool) {
self.vim_enabled = enabled;
self.vim_pending = VimPending::None;
self.vim_search = vim_search::VimSearch::default();
self.vim_commands = VimCommandState::default();
self.vim_mode = if enabled {
VimMode::Normal
@@ -301,12 +312,12 @@ impl TextArea {
/// This is observable so the composer can avoid stealing the second key of
/// `d{motion}` or `y{motion}` for higher-level shortcuts.
pub(crate) fn is_vim_operator_pending(&self) -> bool {
!matches!(self.vim_pending, VimPending::None)
self.vim_query().is_some() || !matches!(self.vim_pending, VimPending::None)
}
/// Return the keymap context that owns the next editing key.
pub(crate) fn keymap_context(&self) -> KeymapContext {
if !self.vim_enabled || self.vim_mode == VimMode::Insert {
if !self.vim_enabled || self.vim_mode == VimMode::Insert || self.vim_query().is_some() {
return KeymapContext::Editor;
}
match self.vim_pending {
@@ -326,6 +337,7 @@ impl TextArea {
if self.vim_enabled {
self.vim_mode = VimMode::Insert;
self.vim_pending = VimPending::None;
self.cancel_vim_search();
}
}
@@ -339,6 +351,7 @@ impl TextArea {
if self.vim_enabled {
self.vim_mode = VimMode::Normal;
self.vim_pending = VimPending::None;
self.cancel_vim_search();
self.preferred_col = None;
}
}
@@ -363,7 +376,7 @@ impl TextArea {
/// transition rather than a popup cancel/backtrack or turn-interrupt shortcut.
pub(crate) fn should_handle_vim_insert_escape(&self, event: KeyEvent) -> bool {
self.vim_enabled
&& (self.vim_mode == VimMode::Insert || !matches!(self.vim_pending, VimPending::None))
&& (self.vim_mode == VimMode::Insert || self.is_vim_operator_pending())
&& event.code == KeyCode::Esc
&& event.modifiers == KeyModifiers::NONE
&& matches!(event.kind, KeyEventKind::Press | KeyEventKind::Repeat)
@@ -679,6 +692,9 @@ impl TextArea {
}
fn handle_vim_input(&mut self, event: KeyEvent) {
if self.handle_vim_search_key(event) {
return;
}
let prior_mode = self.vim_mode;
match self.vim_mode {
VimMode::Insert => self.handle_vim_insert(event),
@@ -830,6 +846,7 @@ impl TextArea {
}
if self.vim_normal_keymap.cancel_operator.is_pressed(event) {
self.vim_pending = VimPending::None;
self.cancel_vim_search();
return;
}
self.handle_vim_extra_command(event);
@@ -13,6 +13,7 @@ use super::VimTextObject;
use super::VimTextObjectScope;
use super::vim::VimFindMotion;
use crate::key_hint::KeyBindingListExt;
use crate::vim_search::SearchQuery;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
@@ -38,12 +39,13 @@ pub(super) enum VimInsertPosition {
OpenBelow,
}
#[derive(Clone, Copy, Debug)]
#[derive(Clone, Debug)]
pub(super) enum VimEditTarget {
Character,
Line,
LineEnd,
Motion(VimMotion),
Search(SearchQuery),
TextObject {
scope: VimTextObjectScope,
object: VimTextObject,
@@ -57,7 +59,7 @@ pub(super) enum VimEditTarget {
},
}
#[derive(Clone, Copy, Debug)]
#[derive(Clone, Debug)]
pub(super) enum VimAction {
Insert(VimInsertPosition),
Delete(VimEditTarget),
@@ -119,7 +121,7 @@ impl TextArea {
&& !self.vim_commands.replaying
&& !self.vim_commands.pending_change.is_empty();
let prior_len = self.text.len();
self.apply_vim_editor_action(action);
self.apply_vim_editor_action(action.clone());
let changed = self.text.len() != prior_len;
let deletion = matches!(
action,
@@ -141,9 +143,9 @@ impl TextArea {
pub(super) fn start_vim_edit(&mut self, action: VimAction) -> bool {
let prior_len = self.text.len();
self.vim_commands.pending_change = vec![VimEdit::Editor(VimEditorEdit(action))];
self.vim_commands.pending_change = vec![VimEdit::Editor(VimEditorEdit(action.clone()))];
self.vim_commands.changed = false;
if !self.apply_vim_editor_action(action) {
if !self.apply_vim_editor_action(action.clone()) {
self.vim_commands.pending_change.clear();
return false;
}
@@ -180,7 +182,7 @@ impl TextArea {
pub(crate) fn apply_vim_edit(&mut self, edit: &VimEdit) -> bool {
match edit {
VimEdit::Editor(VimEditorEdit(action)) => self.apply_vim_editor_action(*action),
VimEdit::Editor(VimEditorEdit(action)) => self.apply_vim_editor_action(action.clone()),
VimEdit::Text(text) => {
if self.vim_mode != VimMode::Insert {
return false;
@@ -193,6 +195,7 @@ impl TextArea {
fn apply_vim_editor_action(&mut self, action: VimAction) -> bool {
let prior_len = self.text.len();
let is_change = matches!(action, VimAction::Change(_));
match action {
VimAction::Insert(position) => {
match position {
@@ -223,7 +226,7 @@ impl TextArea {
self.vim_mode = VimMode::Insert;
}
VimAction::Delete(target) | VimAction::Change(target) => {
let operator = if matches!(action, VimAction::Delete(_)) {
let operator = if !is_change {
VimOperator::Delete
} else {
VimOperator::Change
@@ -254,6 +257,11 @@ impl TextArea {
}
}
VimEditTarget::Motion(motion) => self.apply_vim_operator(operator, motion),
VimEditTarget::Search(query) => {
if !self.apply_vim_search(&query, Some(operator)) {
return false;
}
}
VimEditTarget::TextObject { scope, object } => {
let Some(range) = self.text_object_range(object, scope) else {
return false;
@@ -531,7 +539,7 @@ impl TextArea {
self.set_cursor(self.first_non_blank_of_current_line());
}
fn is_vim_command_target(&self, position: usize) -> bool {
pub(super) fn is_vim_command_target(&self, position: usize) -> bool {
!self
.elements
.iter()
@@ -2,7 +2,6 @@ use super::super::TextArea;
use super::VimAction;
use crate::keymap::KeyChordMatch;
use crate::keymap::KeyChordMatcher;
use crate::keymap::KeymapContextSet;
use crate::keymap::RuntimeKeymap;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
@@ -15,6 +14,7 @@ fn vim_textarea(text: &str, cursor: usize) -> TextArea {
textarea.insert_str(text);
textarea.set_cursor(cursor);
textarea.set_vim_enabled(/*enabled*/ true);
textarea.enable_vim_search();
textarea
}
@@ -22,16 +22,16 @@ fn keys(textarea: &mut TextArea, keys: &str) {
let keymap = RuntimeKeymap::defaults();
let mut matcher = KeyChordMatcher::default();
for key in keys.chars() {
let code = if key == '\n' {
KeyCode::Enter
} else {
KeyCode::Char(key)
let code = match key {
'\n' => KeyCode::Enter,
'\x1b' => KeyCode::Esc,
ch => KeyCode::Char(ch),
};
let event = KeyEvent::new(code, KeyModifiers::NONE);
match matcher.advance(
event,
&keymap.chords,
KeymapContextSet::new(textarea.keymap_context()),
textarea.keymap_contexts(),
Instant::now(),
) {
KeyChordMatch::PassThrough => textarea.input(event),
@@ -167,7 +167,7 @@ fn repeat_omits_ineffective_deletions_before_inserted_text() {
] {
let mut textarea = vim_textarea("one two\n", cursor);
keys(&mut textarea, "i");
textarea.apply_vim_insert_action(action);
textarea.apply_vim_insert_action(action.clone());
keys(&mut textarea, "X");
escape(&mut textarea);
textarea.set_cursor(repeat_cursor);
@@ -620,3 +620,57 @@ fn find_and_navigation_have_visual_snapshot_coverage() {
^
"###);
}
#[test]
fn search_motions_compose_with_operators_and_repeat() {
for (commands, text, cursor, mode) in [
("/b\n", "a b c b d b", 2, "Normal"),
("/b\nn", "a b c b d b", 6, "Normal"),
("/b\nnN", "a b c b d b", 2, "Normal"),
("/b\nnnn", "a b c b d b", 2, "Normal"),
("?b\n", "a b c b d b", 10, "Normal"),
("?b\nn", "a b c b d b", 6, "Normal"),
("?b\nN", "a b c b d b", 2, "Normal"),
("d/b\n", "b c b d b", 0, "Normal"),
("c/b\n", "b c b d b", 0, "Insert"),
("y/b\np", "aa b c b d b", 3, "Normal"),
("/b\ndn", "a b d b", 2, "Normal"),
("?b\ndN", "a b", 2, "Normal"),
("?b\nd?b\n", "a b c b", 6, "Normal"),
("?b\ny?b\n", "a b c b d b", 6, "Normal"),
("d/missing\n", "a b c b d b", 0, "Normal"),
("/b\nc?missing\x1bn", "a b c b d b", 6, "Normal"),
("c/b\nX\x1bl.", "XXb d b", 1, "Normal"),
] {
let mut area = vim_textarea("a b c b d b", /*cursor*/ 0);
keys(&mut area, commands);
assert_eq!(
(area.text(), area.cursor(), area.vim_mode_label()),
(text, cursor, Some(mode)),
"{commands:?}"
);
}
for (cursor, commands, expected) in [
(1, "d/xyz\n", "a\nxyz"),
(0, "d/xyz\n", "xyz"),
(0, "c/xyz\nQ\x1b", "Q\nxyz"),
(1, "c/xyz\nQ\x1b", "aQ\nxyz"),
(0, "y/xyz\np", "abc\nabc\nxyz"),
] {
let mut area = vim_textarea("abc\nxyz", cursor);
keys(&mut area, commands);
assert_eq!(area.text(), expected, "{commands:?}");
}
}
#[test]
fn search_skips_atomic_elements_and_partial_graphemes() {
let mut area = vim_textarea("é e\u{301}\n", /*cursor*/ 0);
area.insert_element("[photo.png]");
keys(&mut area, "/photo\n");
assert_eq!(area.cursor(), "[photo.png]".len());
keys(&mut area, "/\u{301}\n");
assert_eq!(area.cursor(), "[photo.png]".len());
keys(&mut area, "/e\u{301}\n");
assert_eq!(area.cursor(), "[photo.png]é ".len());
}
@@ -0,0 +1,247 @@
//! Draft-local search motions and operator transactions. Query entry never edits the buffer;
//! accepted searches wrap, skip atomic elements, and record patterns rather than byte offsets.
use super::TextArea;
use super::TextAreaState;
use super::VimAction;
use super::VimEditTarget;
use super::VimOperator;
use super::VimPending;
use crate::key_hint::KeyBindingListExt;
use crate::keymap::KeymapContext;
use crate::keymap::KeymapContextSet;
use crate::vim_search::SearchDirection;
use crate::vim_search::SearchQuery;
use crate::vim_search::matching_ranges;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::widgets::StatefulWidgetRef;
use ratatui::widgets::Widget;
use std::ops::Range;
#[derive(Debug, Default)]
pub(super) struct VimSearch {
input: Option<Box<SearchInput>>,
last: SearchQuery,
}
/// Footer query editor, boxed by its owning textarea to keep the draft and query independent.
#[derive(Debug)]
pub(crate) struct SearchInput {
pub(crate) editor: TextArea,
direction: SearchDirection,
}
impl SearchInput {
pub(crate) fn render(&self, area: Rect, buf: &mut Buffer) {
let prefix = match self.direction {
SearchDirection::Forward => "/",
SearchDirection::Backward => "?",
};
prefix.cyan().render(area, buf);
StatefulWidgetRef::render_ref(
&&self.editor,
Self::query_area(area),
buf,
&mut TextAreaState::default(),
);
}
pub(crate) fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
self.editor.cursor_pos(Self::query_area(area))
}
fn query_area(area: Rect) -> Rect {
Rect {
x: area.x.saturating_add(1),
width: area.width.saturating_sub(1),
..area
}
}
}
enum SearchCommand {
Start(SearchDirection),
Next,
Previous,
}
impl TextArea {
pub(crate) fn keymap_contexts(&self) -> KeymapContextSet {
let context = self.keymap_context();
let contexts = KeymapContextSet::new(context);
if self.vim_search_enabled
&& matches!(
context,
KeymapContext::VimNormal | KeymapContext::VimOperator
)
{
contexts.with(KeymapContext::VimSearch)
} else {
contexts
}
}
pub(crate) fn enable_vim_search(&mut self) {
self.vim_search_enabled = true;
}
pub(crate) fn vim_query(&self) -> Option<&SearchInput> {
self.vim_search.input.as_deref()
}
pub(crate) fn vim_query_mut(&mut self) -> Option<&mut SearchInput> {
self.vim_search.input.as_deref_mut()
}
pub(crate) fn cancel_vim_search(&mut self) -> bool {
self.vim_search.input.take().is_some_and(|_| {
self.vim_pending = VimPending::None;
true
})
}
pub(crate) fn wants_vim_search_key(&self, event: KeyEvent) -> bool {
self.vim_search.input.is_some() || self.vim_search_command(event).is_some()
}
fn vim_search_command(&self, event: KeyEvent) -> Option<SearchCommand> {
use SearchCommand::Start;
if !self.vim_search_enabled
|| !self.is_vim_normal_mode()
|| !matches!(self.vim_pending, VimPending::None | VimPending::Operator(_))
{
return None;
}
let keys = &self.vim_search_keymap;
[
(&keys.forward, Start(SearchDirection::Forward)),
(&keys.backward, Start(SearchDirection::Backward)),
(&keys.next, SearchCommand::Next),
(&keys.previous, SearchCommand::Previous),
]
.into_iter()
.find_map(|(bindings, command)| bindings.is_pressed(event).then_some(command))
}
pub(super) fn handle_vim_search_key(&mut self, event: KeyEvent) -> bool {
let query = if let Some(mut input) = self.vim_search.input.take() {
match (event.code, event.modifiers) {
(KeyCode::Esc, _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => {
self.vim_pending = VimPending::None;
return true;
}
(KeyCode::Enter, KeyModifiers::NONE) => {}
_ => {
input.editor.input_with_keymap(event, &self.editor_keymap);
self.vim_search.input = Some(input);
return true;
}
}
let text = if input.editor.is_empty() {
self.vim_search.last.text.clone()
} else {
input.editor.text().to_owned()
};
let query = SearchQuery {
text,
direction: input.direction,
};
self.vim_search.last = query.clone();
query
} else {
let Some(command) = self.vim_search_command(event) else {
return false;
};
if let SearchCommand::Start(direction) = command {
self.vim_search.input = Some(Box::new(SearchInput {
editor: TextArea::new(),
direction,
}));
return true;
}
let mut query = self.vim_search.last.clone();
if matches!(command, SearchCommand::Previous) {
query.direction = query.direction.reversed();
}
query
};
match std::mem::replace(&mut self.vim_pending, VimPending::None) {
VimPending::Operator(VimOperator::Delete) => {
self.start_vim_edit(VimAction::Delete(VimEditTarget::Search(query)));
}
VimPending::Operator(VimOperator::Change) => {
self.start_vim_edit(VimAction::Change(VimEditTarget::Search(query)));
}
VimPending::Operator(VimOperator::Yank) => {
self.apply_vim_search(&query, Some(VimOperator::Yank));
}
_ => {
self.apply_vim_search(&query, /*operator*/ None);
}
}
true
}
pub(super) fn apply_vim_search(
&mut self,
query: &SearchQuery,
operator: Option<VimOperator>,
) -> bool {
let origin = self.cursor_pos;
// Prefer matches before wrapping; order backward matches from the end.
let target = matching_ranges(&self.text, &query.text)
.filter(|range| self.is_vim_command_target(range.start))
.map(|range| range.start)
.min_by_key(|&pos| match query.direction {
SearchDirection::Forward => (pos <= origin, pos),
SearchDirection::Backward => (pos >= origin, usize::MAX - pos),
});
let Some(target) = target else {
return false;
};
if let Some(operator) = operator {
if origin == target {
return false;
}
let mut range = origin.min(target)..origin.max(target);
let line_start = self.beginning_of_line(range.start);
let ends_at_line_start = range.end == self.beginning_of_line(range.end);
// Exclusive motions ending at column zero may become linewise.
let linewise =
ends_at_line_start && self.text[line_start..range.start].trim().is_empty();
if linewise {
range.start = line_start;
} else if ends_at_line_start {
range.end -= 1;
}
let start = range.start;
if operator == VimOperator::Yank {
self.set_cursor(start);
}
self.apply_vim_operator_to_range(operator, range);
if linewise {
self.kill_buffer_kind = super::KillBufferKind::Linewise;
if operator == VimOperator::Change {
self.insert_str_at(start, "\n");
self.set_cursor(start);
}
}
} else {
self.set_cursor(target);
}
true
}
pub(crate) fn vim_search_highlights(&self) -> Vec<Range<usize>> {
let query = match self.vim_query() {
Some(input) => input.editor.text(),
None => &self.vim_search.last.text,
};
matching_ranges(&self.text, query)
.filter(|range| self.is_vim_command_target(range.start))
.collect()
}
}
+27
View File
@@ -34,6 +34,8 @@ use std::sync::Arc;
mod bindings;
mod chords;
mod vim_search;
pub(crate) use vim_search::VimSearchKeymap;
#[cfg(test)]
#[path = "keymap/conflict_tests.rs"]
@@ -70,6 +72,7 @@ pub(crate) struct RuntimeKeymap {
pub(crate) editor: Arc<EditorKeymap>,
pub(crate) vim_normal: VimNormalKeymap,
pub(crate) vim_operator: VimOperatorKeymap,
pub(crate) vim_search: VimSearchKeymap,
pub(crate) vim_text_object: VimTextObjectKeymap,
pub(crate) pager: PagerKeymap,
pub(crate) list: ListKeymap,
@@ -1360,6 +1363,7 @@ impl RuntimeKeymap {
editor,
vim_normal,
vim_operator,
vim_search: VimSearchKeymap::default(),
vim_text_object,
pager,
list,
@@ -1367,6 +1371,7 @@ impl RuntimeKeymap {
approval,
};
resolved.configure_vim_search(keymap)?;
resolved.validate_conflicts()?;
chords::validate_chord_conflicts(&resolved)?;
chords::install_dispatch_bindings(&mut resolved)?;
@@ -1544,6 +1549,7 @@ impl RuntimeKeymap {
start_change_operator: default_bindings![plain(KeyCode::Char('c'))],
cancel_operator: default_bindings![plain(KeyCode::Esc)],
},
vim_search: VimSearchKeymap::default(),
vim_operator: VimOperatorKeymap {
delete_line: default_bindings![plain(KeyCode::Char('d'))],
yank_line: default_bindings![plain(KeyCode::Char('y'))],
@@ -2927,6 +2933,27 @@ mod tests {
}
}
#[test]
fn vim_search_preserves_custom_motion_bindings() {
for operator in [false, true] {
let mut config = TuiKeymap::default();
if operator {
config.vim_operator.motion_left = Some(one("n"));
} else {
config.vim_normal.move_left = Some(one("n"));
}
assert!(
RuntimeKeymap::from_config(&config)
.unwrap()
.vim_search
.next
.is_empty()
);
config.vim_search.next = Some(one("n"));
assert!(RuntimeKeymap::from_config(&config).is_err());
}
}
#[test]
fn configured_vim_normal_chord_prefix_prunes_new_repeat_default() {
let mut keymap = TuiKeymap::default();
+12 -3
View File
@@ -19,6 +19,7 @@ pub(crate) enum KeymapContext {
Editor,
VimNormal,
VimOperator,
VimSearch,
VimTextObject,
Pager,
List,
@@ -35,6 +36,7 @@ impl KeymapContext {
Self::Editor => "editor",
Self::VimNormal => "vim_normal",
Self::VimOperator => "vim_operator",
Self::VimSearch => "vim_search",
Self::VimTextObject => "vim_text_object",
Self::Pager => "pager",
Self::List => "list",
@@ -46,7 +48,7 @@ impl KeymapContext {
pub(crate) const fn allows_plain_chord_prefix(self) -> bool {
matches!(
self,
Self::VimNormal | Self::VimOperator | Self::VimTextObject
Self::VimNormal | Self::VimOperator | Self::VimSearch | Self::VimTextObject
)
}
@@ -57,7 +59,9 @@ impl KeymapContext {
matches!(
(self, other),
(Self::List, Self::Approval)
(Self::VimSearch, Self::VimNormal | Self::VimOperator)
| (Self::VimNormal | Self::VimOperator, Self::VimSearch)
| (Self::List, Self::Approval)
| (Self::Approval, Self::List)
| (Self::List, Self::Agents)
| (Self::Agents, Self::List)
@@ -75,7 +79,11 @@ impl KeymapContext {
const fn is_main_editor(self) -> bool {
matches!(
self,
Self::Editor | Self::VimNormal | Self::VimOperator | Self::VimTextObject
Self::Editor
| Self::VimNormal
| Self::VimOperator
| Self::VimSearch
| Self::VimTextObject
)
}
}
@@ -304,6 +312,7 @@ define_runtime_action_bindings! {
start_change_operator,
cancel_operator,
],
"vim_search" => VimSearch, vim_search, vim_search [forward, backward, next, previous],
"vim_operator" => VimOperator, vim_operator, vim_operator [
delete_line,
yank_line,
+2
View File
@@ -68,6 +68,7 @@ const fn context_bit(context: KeymapContext) -> u16 {
KeymapContext::List => 8,
KeymapContext::Approval => 9,
KeymapContext::Agents => 10,
KeymapContext::VimSearch => 11,
}
}
@@ -514,6 +515,7 @@ Choose a different chord and retry.",
| KeymapContext::Editor
| KeymapContext::VimNormal
| KeymapContext::VimOperator
| KeymapContext::VimSearch
| KeymapContext::VimTextObject => MAIN_RESERVED_BINDINGS,
KeymapContext::List => LIST_RESERVED_BINDINGS,
KeymapContext::Agents | KeymapContext::Approval => &LIST_RESERVED_BINDINGS[..1],
+78
View File
@@ -0,0 +1,78 @@
//! Search bindings shared by normal and operator-pending input. Existing explicit Vim
//! bindings win over new defaults; explicit search conflicts remain configuration errors.
use super::bindings::configured_binding_for_action;
use super::*;
#[derive(Clone, Debug)]
pub(crate) struct VimSearchKeymap {
pub(crate) forward: Vec<KeyBinding>,
pub(crate) backward: Vec<KeyBinding>,
pub(crate) next: Vec<KeyBinding>,
pub(crate) previous: Vec<KeyBinding>,
}
impl Default for VimSearchKeymap {
fn default() -> Self {
use key_hint::plain;
use key_hint::shift;
Self {
forward: vec![plain(KeyCode::Char('/'))],
backward: vec![plain(KeyCode::Char('?')), shift(KeyCode::Char('?'))],
next: vec![plain(KeyCode::Char('n'))],
previous: vec![plain(KeyCode::Char('N')), shift(KeyCode::Char('n'))],
}
}
}
impl RuntimeKeymap {
pub(super) fn configure_vim_search(&mut self, config: &TuiKeymap) -> Result<(), String> {
let others: Vec<_> = runtime_action_bindings(self)
.filter(|binding| {
matches!(
binding.id.context,
KeymapContext::VimNormal | KeymapContext::VimOperator
)
})
.collect();
let mut search = self.vim_search.clone();
let config_search = &config.vim_search;
let mut actions = [
("forward", &config_search.forward, &mut search.forward),
("backward", &config_search.backward, &mut search.backward),
("next", &config_search.next, &mut search.next),
("previous", &config_search.previous, &mut search.previous),
];
for (action, configured, bindings) in &mut actions {
let path = format!("tui.keymap.vim_search.{action}");
**bindings = resolve_bindings(configured.as_ref(), bindings, &path)?;
if configured.is_none() {
bindings.retain(|key| {
!others.iter().any(|other| {
other.bindings.contains(key)
&& configured_binding_for_action(config, other.id)
.is_some_and(Option::is_some)
}) && !self.chords.bindings.iter().any(|chord| {
chord.action.context.overlaps(KeymapContext::VimSearch)
&& chord.chord.prefix.parts() == key.parts()
})
});
}
if let Some(other) = others
.iter()
.find(|other| other.bindings.iter().any(|key| bindings.contains(key)))
{
return Err(format!(
"Conflicting `{path}` and `{}` bindings",
other.id.config_path()
));
}
}
validate_unique(
"vim_search",
actions.map(|(action, _, bindings)| (action, bindings.as_slice())),
)?;
self.vim_search = search;
Ok(())
}
}
+8
View File
@@ -154,6 +154,10 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[
action("vim_normal", "Vim normal", "start_yank_operator", "Begin a yank operator and wait for a motion."),
action("vim_normal", "Vim normal", "start_change_operator", "Begin a change operator and wait for a motion or text object."),
action("vim_normal", "Vim normal", "cancel_operator", "Cancel a pending Vim operator."),
action("vim_search", "Vim search", "forward", "Search forward in the active buffer."),
action("vim_search", "Vim search", "backward", "Search backward in the active buffer."),
action("vim_search", "Vim search", "next", "Repeat the accepted search."),
action("vim_search", "Vim search", "previous", "Repeat the search in the opposite direction."),
action("vim_operator", "Vim operator", "delete_line", "Repeat delete operator to delete the whole line."),
action("vim_operator", "Vim operator", "yank_line", "Repeat yank operator to yank the whole line."),
action("vim_operator", "Vim operator", "motion_left", "Operator motion left."),
@@ -320,6 +324,10 @@ pub(super) fn binding_slot<'a>(
("vim_normal", "start_yank_operator") => Some(&mut keymap.vim_normal.start_yank_operator),
("vim_normal", "start_change_operator") => Some(&mut keymap.vim_normal.start_change_operator),
("vim_normal", "cancel_operator") => Some(&mut keymap.vim_normal.cancel_operator),
("vim_search", "forward") => Some(&mut keymap.vim_search.forward),
("vim_search", "backward") => Some(&mut keymap.vim_search.backward),
("vim_search", "next") => Some(&mut keymap.vim_search.next),
("vim_search", "previous") => Some(&mut keymap.vim_search.previous),
("vim_operator", "delete_line") => Some(&mut keymap.vim_operator.delete_line),
("vim_operator", "yank_line") => Some(&mut keymap.vim_operator.yank_line),
("vim_operator", "motion_left") => Some(&mut keymap.vim_operator.motion_left),
+6 -1
View File
@@ -104,7 +104,12 @@ const KEYMAP_CONTEXT_TABS: &[KeymapContextTab] = &[
id: "vim-shortcuts",
label: "Vim",
description: "Vim normal-mode and operator shortcuts.",
contexts: &["vim_normal", "vim_operator", "vim_text_object"],
contexts: &[
"vim_normal",
"vim_operator",
"vim_search",
"vim_text_object",
],
},
KeymapContextTab {
id: "navigation-shortcuts",
+1
View File
@@ -215,6 +215,7 @@ mod updates;
#[cfg(any(not(debug_assertions), test))]
mod updates_cache;
mod version;
mod vim_search;
mod width;
#[cfg(any(target_os = "windows", test))]
mod windows_sandbox;
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap
All configurable shortcuts.
131 actions, 1 customized, 4 unbound.
135 actions, 1 customized, 4 unbound.
[All] Common Customized (1) Unbound (4) App Composer Editor Vim Navigation Agents Approval Debug
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap
All configurable shortcuts.
132 actions, 0 customized, 5 unbound.
136 actions, 0 customized, 5 unbound.
[All] Common Customized (0) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug
@@ -2,14 +2,14 @@
source: tui/src/keymap_setup.rs
expression: snapshot
---
tab: All (131 selectable)
tab: All (135 selectable)
tab: Common (20 selectable)
tab: Customized (0) (0 selectable)
tab: Unbound (4) (4 selectable)
tab: App (14 selectable)
tab: Composer (5 selectable)
tab: Editor (17 selectable)
tab: Vim (62 selectable)
tab: Vim (66 selectable)
tab: Navigation (20 selectable)
tab: Agents (5 selectable)
tab: Approval (8 selectable)
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap
All configurable shortcuts.
131 actions, 1 customized, 4 unbound.
135 actions, 1 customized, 4 unbound.
[All] Common Customized (1) Unbound (4) App Composer Editor Vim Navigation Agents Approval Debug
@@ -5,7 +5,7 @@ expression: "render_picker(params, 78)"
Keymap
All configurable shortcuts.
131 actions, 0 customized, 4 unbound.
135 actions, 0 customized, 4 unbound.
[All] Common Customized (0) Unbound (4) App Composer Editor Vim
Navigation Agents Approval Debug
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap
All configurable shortcuts.
131 actions, 0 customized, 4 unbound.
135 actions, 0 customized, 4 unbound.
[All] Common Customized (0) Unbound (4) App Composer Editor Vim Navigation Agents Approval Debug
+44
View File
@@ -0,0 +1,44 @@
//! Surface-independent literal Vim search queries. Matches span complete graphemes;
//! callers own navigation, operator ranges, and the lifetime of accepted versus pending input.
use std::ops::Range;
use unicode_segmentation::UnicodeSegmentation;
#[derive(Clone, Copy, Debug, Default)]
pub(crate) enum SearchDirection {
#[default]
Forward,
Backward,
}
impl SearchDirection {
pub(crate) fn reversed(self) -> Self {
match self {
Self::Forward => Self::Backward,
Self::Backward => Self::Forward,
}
}
}
#[derive(Clone, Debug, Default)]
pub(crate) struct SearchQuery {
pub(crate) text: String,
pub(crate) direction: SearchDirection,
}
pub(crate) fn matching_ranges<'a>(
text: &'a str,
query: &'a str,
) -> impl Iterator<Item = Range<usize>> + 'a {
let text = if query.is_empty() { "" } else { text };
text.grapheme_indices(/*is_extended*/ true)
.filter(move |&(start, _)| text[start..].starts_with(query))
.map(move |(start, _)| {
let end = text[start..]
.grapheme_indices(/*is_extended*/ true)
.map(|(offset, grapheme)| start + offset + grapheme.len())
.find(|&end| end >= start + query.len())
.unwrap_or(text.len());
start..end
})
}