mirror of
https://github.com/openai/codex.git
synced 2026-10-28 18:58:46 +00:00
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:
committed by
copyberry
parent
b8c86376a2
commit
63d213884d
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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());
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
+13
@@ -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, " ")]
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" ?ह "
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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
-2
@@ -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)
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user