use super::{
dispatch::{NormalInputContext, handle_normal_input},
effects::{VimEffect, VimEffectBatch, VimEffectWriters},
insert::{InsertInputContext, handle_insert_input},
keyboard::VimInputState,
prompts::{handle_command_input, handle_search_input},
};
use crate::{
ecs::{
components::{
buffer::{
BufferEntity, BufferText, EditorBuffer, EditorView, FocusedEditorView, ViewEntity,
},
cursor::{ByteIndex, CursorPosition},
layout::ViewportState,
vim::VimModalState,
},
events::{
cursor::CursorMoveRequested,
input::{EditorInputEvent, VimInputRejected, VimInputRejectionReason},
},
},
text_stream::TextByteStream,
vim::{KeyToken, VimConfig, VimCursor, VimMode, VimStatusLine},
};
use bevy::{
ecs::system::SystemParam,
prelude::{Entity, MessageReader, MessageWriter, Query, Res, ResMut, Time, With},
};
#[cfg(test)]
use super::keyboard::{
KEY_REPEAT_INITIAL_DELAY_SECONDS, KEY_REPEAT_INTERVAL_SECONDS, KeyRepeatState,
RepeatableNormalCommand, emit_editor_input_events, is_prompt_text_character,
normalized_editor_tokens, normalized_key_tokens,
};
type FocusedEditorViewFilter = (With<EditorView>, With<FocusedEditorView>);
type VimInputViewQuery<'world, 'state> = Query<
'world,
'state,
(
Entity,
&'static EditorView,
&'static CursorPosition,
&'static ViewportState,
&'static mut VimModalState,
),
FocusedEditorViewFilter,
>;
#[derive(SystemParam)]
pub struct VimInputSystemParams<'w, 's> {
input_events: MessageReader<'w, 's, EditorInputEvent>,
time: Res<'w, Time>,
effect_writers: VimEffectWriters<'w>,
rejected: MessageWriter<'w, VimInputRejected>,
vim_config: Res<'w, VimConfig>,
input_state: ResMut<'w, VimInputState>,
view_query: VimInputViewQuery<'w, 's>,
buffers: Query<'w, 's, &'static BufferText, With<EditorBuffer>>,
}
pub fn handle_vim_input(mut params: VimInputSystemParams<'_, '_>) {
let events = params.input_events.read().cloned().collect::<Vec<_>>();
if events.is_empty() {
return;
}
let Some((target, view, cursor_position, viewport_state, mut modal_state)) =
params.view_query.iter_mut().next()
else {
return;
};
let target = ViewEntity(target);
let Ok(buffer_text) = params.buffers.get(view.buffer.get()) else {
reject_missing_buffer(target, view.buffer, &mut params.rejected);
return;
};
let buffer_target = view.buffer;
if should_drop_repeated_focus_change(target, &events, &mut params.input_state) {
return;
}
let vim_config = params.vim_config.into_inner();
let modal_state = modal_state.as_mut();
let delta_millis =
u64::try_from(params.time.into_inner().delta().as_millis()).unwrap_or(u64::MAX);
let stream = &buffer_text.stream;
let text = stream.as_str();
let mut cursor = cursor_from_position(text, *cursor_position);
let mut effects = VimEffectBatch::default();
cursor.clamp_to_text(text);
if dispatch_active_mode_input(ActiveModeDispatchContext {
text,
target,
buffer_target,
previous_position: *cursor_position,
cursor: &mut cursor,
events: &events,
modal_state,
vim_config,
effects: &mut effects,
writers: &mut params.effect_writers,
rejected: &mut params.rejected,
input_state: &mut params.input_state,
}) {
return;
}
dispatch_normal_mode_input(NormalModeDispatchContext {
stream,
text,
cursor: &mut cursor,
modal_state,
target,
buffer_target,
input_state: &mut params.input_state,
rejected: &mut params.rejected,
vim_config,
visible_line_count: viewport_state.viewport.visible_line_count(),
input_events: &events,
delta_millis,
effects: &mut effects,
});
request_cursor_move_from_position(*cursor_position, &cursor, target, &mut effects);
params.effect_writers.emit(&effects);
}
struct ActiveModeDispatchContext<'input, 'writer> {
text: &'input str,
target: ViewEntity,
buffer_target: BufferEntity,
previous_position: CursorPosition,
cursor: &'input mut VimCursor,
events: &'input [EditorInputEvent],
modal_state: &'input mut VimModalState,
vim_config: &'input VimConfig,
effects: &'input mut VimEffectBatch,
writers: &'input mut VimEffectWriters<'writer>,
rejected: &'input mut MessageWriter<'writer, VimInputRejected>,
input_state: &'input mut VimInputState,
}
fn dispatch_active_mode_input(context: ActiveModeDispatchContext<'_, '_>) -> bool {
let ActiveModeDispatchContext {
text,
target,
buffer_target,
previous_position,
cursor,
events,
modal_state,
vim_config,
effects,
writers,
rejected,
input_state,
} = context;
handle_active_command_prompt(
modal_state,
PromptDispatchContext {
target,
buffer_target,
previous_position,
cursor,
events,
effects,
writers,
},
) || handle_active_search_prompt(
modal_state,
SearchDispatchContext {
text,
target,
previous_position,
cursor,
events,
vim_config,
effects,
writers,
},
) || handle_active_insert_mode(
modal_state,
InsertDispatchContext {
text,
target,
buffer_target,
previous_position,
events,
effects,
writers,
rejected,
},
) || handle_escape(
modal_state,
EscapeDispatchContext {
target,
previous_position,
cursor,
events,
effects,
writers,
input_state,
},
)
}
fn cursor_from_position(text: &str, position: CursorPosition) -> VimCursor {
let mut cursor = VimCursor::new();
cursor.set_byte_index(text, position.byte_index.get());
cursor.set_desired_column(position.desired_column);
cursor
}
fn reject_missing_buffer(
target: ViewEntity,
buffer: BufferEntity,
rejected: &mut MessageWriter<VimInputRejected>,
) {
let _sent = rejected.write(VimInputRejected {
target,
reason: VimInputRejectionReason::MissingBuffer { target, buffer },
});
}
fn should_drop_repeated_focus_change(
target: ViewEntity,
events: &[EditorInputEvent],
input_state: &mut VimInputState,
) -> bool {
if input_state
.focused_view
.replace(target)
.is_none_or(|last| last == target)
{
return false;
}
input_state.repeat.cancel();
events.iter().all(EditorInputEvent::is_repeated_key)
}
struct NormalModeDispatchContext<'input, 'writer> {
stream: &'input TextByteStream,
text: &'input str,
cursor: &'input mut VimCursor,
modal_state: &'input mut VimModalState,
target: ViewEntity,
buffer_target: BufferEntity,
input_state: &'input mut VimInputState,
rejected: &'input mut MessageWriter<'writer, VimInputRejected>,
vim_config: &'input VimConfig,
visible_line_count: usize,
input_events: &'input [EditorInputEvent],
delta_millis: u64,
effects: &'input mut VimEffectBatch,
}
fn dispatch_normal_mode_input(context: NormalModeDispatchContext<'_, '_>) {
let NormalModeDispatchContext {
stream,
text,
cursor,
modal_state,
target,
buffer_target,
input_state,
rejected,
vim_config,
visible_line_count,
input_events,
delta_millis,
effects,
} = context;
let mut status_line = VimStatusLine::default();
handle_normal_input(NormalInputContext {
stream,
text,
cursor,
mode: &mut modal_state.editor.mode,
selection_state: &mut modal_state.editor.selection,
search_state: &mut modal_state.editor.search,
command_state: &mut modal_state.editor.command,
status_line: &mut status_line,
effects,
target,
buffer_target,
registers: &mut modal_state.editor.registers,
repeat_state: &mut modal_state.editor.repeat,
leader_state: &mut modal_state.editor.leader,
normal_state: &mut modal_state.grammar.normal,
visual_state: &mut modal_state.grammar.visual,
input_state,
rejected,
vim_config,
visible_line_count,
input_events,
delta_millis,
});
}
struct PromptDispatchContext<'input, 'writer> {
target: ViewEntity,
buffer_target: BufferEntity,
previous_position: CursorPosition,
cursor: &'input VimCursor,
events: &'input [EditorInputEvent],
effects: &'input mut VimEffectBatch,
writers: &'input mut VimEffectWriters<'writer>,
}
fn handle_active_command_prompt(
modal_state: &mut VimModalState,
context: PromptDispatchContext<'_, '_>,
) -> bool {
if !modal_state.editor.command.is_active() {
return false;
}
let PromptDispatchContext {
target,
buffer_target,
previous_position,
cursor,
events,
effects,
writers,
} = context;
handle_command_input(
target,
buffer_target,
events,
&mut modal_state.editor.command,
effects,
);
request_cursor_move_from_position(previous_position, cursor, target, effects);
writers.emit(effects);
true
}
struct SearchDispatchContext<'input, 'writer> {
text: &'input str,
target: ViewEntity,
previous_position: CursorPosition,
cursor: &'input mut VimCursor,
events: &'input [EditorInputEvent],
vim_config: &'input VimConfig,
effects: &'input mut VimEffectBatch,
writers: &'input mut VimEffectWriters<'writer>,
}
fn handle_active_search_prompt(
modal_state: &mut VimModalState,
context: SearchDispatchContext<'_, '_>,
) -> bool {
if !modal_state.editor.search.is_active() {
return false;
}
let SearchDispatchContext {
text,
target,
previous_position,
cursor,
events,
vim_config,
effects,
writers,
} = context;
handle_search_input(
text,
events,
cursor,
&mut modal_state.editor.search,
vim_config,
target,
effects,
);
request_cursor_move_from_position(previous_position, cursor, target, effects);
writers.emit(effects);
true
}
struct InsertDispatchContext<'input, 'writer> {
text: &'input str,
target: ViewEntity,
buffer_target: BufferEntity,
previous_position: CursorPosition,
events: &'input [EditorInputEvent],
effects: &'input mut VimEffectBatch,
writers: &'input mut VimEffectWriters<'writer>,
rejected: &'input mut MessageWriter<'writer, VimInputRejected>,
}
fn handle_active_insert_mode(
modal_state: &mut VimModalState,
context: InsertDispatchContext<'_, '_>,
) -> bool {
let VimMode::Insert(insert_state) = modal_state.editor.mode else {
return false;
};
let InsertDispatchContext {
text,
target,
buffer_target,
previous_position,
events,
effects,
writers,
rejected,
} = context;
let handled = handle_insert_input(
&mut modal_state.editor.mode,
InsertInputContext {
text,
target,
buffer_target,
cursor_byte_index: previous_position.byte_index.get(),
insert_state,
events,
effects,
rejected,
},
);
writers.emit(effects);
handled
}
struct EscapeDispatchContext<'input, 'writer> {
target: ViewEntity,
previous_position: CursorPosition,
cursor: &'input VimCursor,
events: &'input [EditorInputEvent],
effects: &'input mut VimEffectBatch,
writers: &'input mut VimEffectWriters<'writer>,
input_state: &'input mut VimInputState,
}
fn handle_escape(modal_state: &mut VimModalState, context: EscapeDispatchContext<'_, '_>) -> bool {
let EscapeDispatchContext {
target,
previous_position,
cursor,
events,
effects,
writers,
input_state,
} = context;
if !events
.iter()
.any(|event| matches!(event, EditorInputEvent::Key(key) if key.token == KeyToken::Escape))
{
return false;
}
modal_state.editor.mode = VimMode::Normal;
modal_state.editor.selection.clear();
modal_state.editor.command.cancel();
modal_state.editor.leader.cancel();
modal_state.grammar.normal.reset_grammar();
modal_state.grammar.visual.reset_grammar();
input_state.repeat.cancel();
request_cursor_move_from_position(previous_position, cursor, target, effects);
writers.emit(effects);
true
}
fn request_cursor_move_from_position(
previous_position: CursorPosition,
cursor: &VimCursor,
target: ViewEntity,
effects: &mut VimEffectBatch,
) {
request_cursor_move_if_changed(
previous_position.byte_index.get(),
previous_position.desired_column,
cursor.byte_index(),
cursor.desired_column(),
target,
effects,
);
}
fn request_cursor_move_if_changed(
previous_byte_index: usize,
previous_desired_column: Option<usize>,
next_byte_index: usize,
desired_column: Option<usize>,
target: ViewEntity,
effects: &mut VimEffectBatch,
) {
if previous_byte_index != next_byte_index || previous_desired_column != desired_column {
effects.push(VimEffect::CursorMove(CursorMoveRequested::cursor_cell(
target,
ByteIndex(next_byte_index),
desired_column,
)));
}
}
#[cfg(test)]
mod tests {
use super::{
KEY_REPEAT_INITIAL_DELAY_SECONDS, KEY_REPEAT_INTERVAL_SECONDS, KeyRepeatState,
RepeatableNormalCommand, emit_editor_input_events, is_prompt_text_character,
normalized_editor_tokens, normalized_key_tokens,
};
use crate::{
StatusInfoText,
buffer::BufferEdit,
ecs::{
components::{
buffer::{
BufferEntity, BufferText, EditorBuffer, EditorView, FocusedEditorView,
ViewEntity,
},
cursor::{ByteIndex, CursorPosition},
layout::ViewportState,
vim::VimModalState,
},
events::{
command::CommandRequested,
cursor::CursorMoveRequested,
edit::{BufferEditRequested, EditHistoryRequested},
input::{
EditorInputEvent, KeyInputEvent, TextInputEvent, VimInputRejected,
VimInputRejectionReason,
},
intent::ViewportIntent,
status::{StatusMessage, StatusMessageRequested},
},
},
text_stream::TextByteStream,
vim::{
InsertState, KeyToken, OperatorTarget, RegisterKind, RegisterName, SearchDirection,
ViewportPosition, VimCommand, VimConfig, VimMode, VimStatusMessage, VisualMode,
},
};
use bevy::{
input::{
ButtonState,
keyboard::{Key, KeyboardInput},
},
prelude::{
App, ButtonInput, Entity, IntoScheduleConfigs, KeyCode, MessageReader, Resource,
Update, With,
},
};
use proptest::prelude::*;
#[derive(Default, Resource)]
struct CapturedAdapterEffects {
buffer_edits: Vec<BufferEditRequested>,
edit_history: Vec<EditHistoryRequested>,
cursor_moves: Vec<CursorMoveRequested>,
viewport_intents: Vec<ViewportIntent>,
status_requests: Vec<StatusMessageRequested>,
command_requests: Vec<CommandRequested>,
input_rejections: Vec<VimInputRejected>,
}
type CapturedEffectReaders<'w, 's> = (
MessageReader<'w, 's, BufferEditRequested>,
MessageReader<'w, 's, EditHistoryRequested>,
MessageReader<'w, 's, CursorMoveRequested>,
MessageReader<'w, 's, ViewportIntent>,
MessageReader<'w, 's, StatusMessageRequested>,
MessageReader<'w, 's, CommandRequested>,
MessageReader<'w, 's, VimInputRejected>,
);
fn capture_adapter_effects(
(
mut buffer_edit_reader,
mut edit_history_reader,
mut cursor_reader,
mut viewport_reader,
mut status_reader,
mut command_reader,
mut input_rejection_reader,
): CapturedEffectReaders<'_, '_>,
mut captured: bevy::prelude::ResMut<CapturedAdapterEffects>,
) {
captured
.buffer_edits
.extend(buffer_edit_reader.read().cloned());
captured
.edit_history
.extend(edit_history_reader.read().copied());
captured.cursor_moves.extend(cursor_reader.read().copied());
captured
.viewport_intents
.extend(viewport_reader.read().copied());
captured
.status_requests
.extend(status_reader.read().cloned());
captured
.command_requests
.extend(command_reader.read().copied());
captured
.input_rejections
.extend(input_rejection_reader.read().copied());
}
fn vim_adapter_app(text: &str, cursor: usize) -> (App, Entity) {
let mut app = App::new();
let stream = TextByteStream::new(text);
let buffer = app
.world_mut()
.spawn((EditorBuffer, BufferText { stream }))
.id();
let target = app
.world_mut()
.spawn((
EditorView {
buffer: BufferEntity(buffer),
},
FocusedEditorView,
CursorPosition {
byte_index: ByteIndex(cursor),
desired_column: None,
},
ViewportState::default(),
VimModalState::default(),
))
.id();
let _app = app
.add_message::<EditorInputEvent>()
.add_message::<VimInputRejected>()
.add_message::<BufferEditRequested>()
.add_message::<EditHistoryRequested>()
.add_message::<CursorMoveRequested>()
.add_message::<CommandRequested>()
.add_message::<ViewportIntent>()
.add_message::<StatusMessageRequested>()
.insert_resource(VimConfig::default())
.init_resource::<bevy::prelude::Time>()
.init_resource::<super::VimInputState>()
.init_resource::<CapturedAdapterEffects>()
.add_systems(
Update,
(super::handle_vim_input, capture_adapter_effects).chain(),
);
(app, target)
}
fn two_buffer_vim_adapter_app(focus_second: bool) -> (App, Entity, Entity) {
let mut app = App::new();
let first_buffer = app
.world_mut()
.spawn((
EditorBuffer,
BufferText {
stream: TextByteStream::new("first"),
},
))
.id();
let first = app
.world_mut()
.spawn((
EditorView {
buffer: BufferEntity(first_buffer),
},
CursorPosition {
byte_index: ByteIndex(2),
desired_column: None,
},
ViewportState::default(),
VimModalState::default(),
))
.id();
let second_buffer = app
.world_mut()
.spawn((
EditorBuffer,
BufferText {
stream: TextByteStream::new("second"),
},
))
.id();
let second = app
.world_mut()
.spawn((
EditorView {
buffer: BufferEntity(second_buffer),
},
CursorPosition {
byte_index: ByteIndex(3),
desired_column: None,
},
ViewportState::default(),
VimModalState::default(),
))
.id();
let focused = if focus_second { second } else { first };
let _focused = app
.world_mut()
.entity_mut(focused)
.insert(FocusedEditorView);
let _app = app
.add_message::<EditorInputEvent>()
.add_message::<VimInputRejected>()
.add_message::<BufferEditRequested>()
.add_message::<EditHistoryRequested>()
.add_message::<CursorMoveRequested>()
.add_message::<CommandRequested>()
.add_message::<ViewportIntent>()
.add_message::<StatusMessageRequested>()
.insert_resource(VimConfig::default())
.init_resource::<bevy::prelude::Time>()
.init_resource::<super::VimInputState>()
.init_resource::<CapturedAdapterEffects>()
.add_systems(
Update,
(super::handle_vim_input, capture_adapter_effects).chain(),
);
(app, first, second)
}
fn write_input_events(app: &mut App, events: impl IntoIterator<Item = EditorInputEvent>) {
for event in events {
let _message = app.world_mut().write_message(event);
}
}
fn feed_text_frames(app: &mut App, keys: &str) {
for character in keys.chars() {
write_input_events(app, [text(&character.to_string())]);
app.update();
}
}
const fn key(token: KeyToken) -> EditorInputEvent {
EditorInputEvent::Key(KeyInputEvent {
token,
repeated: false,
})
}
const fn repeated_key(token: KeyToken) -> EditorInputEvent {
EditorInputEvent::Key(KeyInputEvent {
token,
repeated: true,
})
}
fn text(value: &str) -> EditorInputEvent {
EditorInputEvent::Text(TextInputEvent {
text: value.to_owned(),
})
}
fn captured_effects(app: &App) -> &CapturedAdapterEffects {
app.world().resource::<CapturedAdapterEffects>()
}
fn buffer_for_view(app: &App, view: Entity) -> BufferEntity {
app.world()
.get::<EditorView>(view)
.expect("view should have an editor view component")
.buffer
}
fn validated_delete_edit(
app: &App,
buffer: BufferEntity,
range: std::ops::Range<usize>,
) -> BufferEdit {
let stream = &app
.world()
.get::<BufferText>(buffer.get())
.expect("buffer should have text")
.stream;
BufferEdit::delete_validated(stream.validate_range(range).unwrap())
}
#[test]
fn vim_input_missing_buffer_emits_typed_rejection() {
let (mut app, target) = vim_adapter_app("abc", 0);
let missing =
BufferEntity(Entity::from_raw_u32(995).expect("test entity index should be valid"));
app.world_mut()
.get_mut::<EditorView>(target)
.expect("view should have an editor view component")
.buffer = missing;
write_input_events(&mut app, [key(KeyToken::Char('l'))]);
app.update();
assert_eq!(
captured_effects(&app).input_rejections,
vec![VimInputRejected {
target: ViewEntity(target),
reason: VimInputRejectionReason::MissingBuffer {
target: ViewEntity(target),
buffer: missing,
},
}]
);
assert!(captured_effects(&app).cursor_moves.is_empty());
}
#[test]
fn prompt_input_rejects_control_characters() {
assert!(is_prompt_text_character('a'));
assert!(!is_prompt_text_character('\n'));
assert!(!is_prompt_text_character('\u{1b}'));
}
#[test]
fn key_repeat_pauses_before_repeating() {
let mut repeat = KeyRepeatState::default();
assert!(repeat.tick(RepeatableNormalCommand::Down, true, 0.0));
assert!(!repeat.tick(
RepeatableNormalCommand::Down,
false,
KEY_REPEAT_INITIAL_DELAY_SECONDS - 0.01
));
assert!(repeat.tick(RepeatableNormalCommand::Down, false, 0.02));
assert!(!repeat.tick(
RepeatableNormalCommand::Down,
false,
KEY_REPEAT_INTERVAL_SECONDS - 0.02
));
assert!(repeat.tick(RepeatableNormalCommand::Down, false, 0.02));
}
#[test]
fn held_key_repeat_arms_without_immediate_second_motion() {
let mut repeat = KeyRepeatState::default();
assert!(!repeat.tick(RepeatableNormalCommand::Down, false, 0.0));
assert!(!repeat.tick(
RepeatableNormalCommand::Down,
false,
KEY_REPEAT_INITIAL_DELAY_SECONDS - 0.01
));
assert!(repeat.tick(RepeatableNormalCommand::Down, false, 0.02));
}
#[test]
fn normalized_keys_emit_control_chords_before_text() {
let mut keys = ButtonInput::<KeyCode>::default();
keys.press(KeyCode::ControlLeft);
keys.press(KeyCode::KeyF);
let typed_text = vec![String::from("f")];
assert_eq!(
normalized_key_tokens(&keys, &typed_text).collect::<Vec<_>>(),
vec![KeyToken::Ctrl('f'), KeyToken::Char('f')]
);
}
#[test]
fn normalized_editor_events_emit_tokens_in_order() {
let events = vec![
EditorInputEvent::Key(crate::ecs::events::input::KeyInputEvent {
token: KeyToken::Ctrl('f'),
repeated: false,
}),
EditorInputEvent::Text(crate::ecs::events::input::TextInputEvent {
text: String::from("ab"),
}),
];
assert_eq!(
normalized_editor_tokens(&events).collect::<Vec<_>>(),
vec![
KeyToken::Ctrl('f'),
KeyToken::Char('a'),
KeyToken::Char('b')
]
);
}
#[derive(Default, Resource)]
struct CapturedInputEvents {
events: Vec<EditorInputEvent>,
}
#[derive(Default, Resource)]
struct CapturedVimEffects {
viewport_intents: Vec<ViewportIntent>,
status_requests: Vec<StatusMessageRequested>,
}
fn capture_input_events(
mut reader: MessageReader<EditorInputEvent>,
mut captured: bevy::prelude::ResMut<CapturedInputEvents>,
) {
captured.events.extend(reader.read().cloned());
}
fn input_stage_app(mode: VimMode) -> (App, Entity) {
let mut app = App::new();
let buffer = Entity::from_raw_u32(42).expect("valid test entity index");
let target = app
.world_mut()
.spawn((
EditorView {
buffer: BufferEntity(buffer),
},
FocusedEditorView,
VimModalState {
editor: crate::ecs::components::vim::VimEditorState {
mode,
..Default::default()
},
..Default::default()
},
))
.id();
let _app = app
.add_message::<KeyboardInput>()
.add_message::<EditorInputEvent>()
.init_resource::<ButtonInput<KeyCode>>()
.init_resource::<bevy::prelude::Time>()
.init_resource::<super::VimInputState>()
.init_resource::<CapturedInputEvents>()
.add_systems(
Update,
(emit_editor_input_events, capture_input_events).chain(),
);
(app, target)
}
fn keyboard_input(key_code: KeyCode, text: Option<&str>, repeat: bool) -> KeyboardInput {
KeyboardInput {
key_code,
logical_key: text.map_or(
Key::Unidentified(bevy::input::keyboard::NativeKey::Unidentified),
|text| Key::Character(text.into()),
),
state: ButtonState::Pressed,
text: text.map(Into::into),
repeat,
window: Entity::from_raw_u32(7).expect("valid test entity index"),
}
}
fn emit_sample_vim_effects(mut effect_writers: super::VimEffectWriters<'_>) {
let target = bevy::prelude::Entity::from_raw_u32(0).expect("valid entity index");
let target = ViewEntity(target);
let mut effects = super::VimEffectBatch::default();
effects.push(super::VimEffect::Viewport(ViewportIntent {
target,
placement: ViewportPosition::Center,
}));
effects.push(super::VimEffect::Status(StatusMessageRequested {
target,
message: StatusMessage::Clear,
}));
effect_writers.emit(&effects);
}
fn capture_vim_effects(
mut viewport_reader: MessageReader<ViewportIntent>,
mut status_reader: MessageReader<StatusMessageRequested>,
mut captured: bevy::prelude::ResMut<CapturedVimEffects>,
) {
captured
.viewport_intents
.extend(viewport_reader.read().copied());
captured
.status_requests
.extend(status_reader.read().cloned());
}
#[test]
fn input_stage_emits_editor_events_without_editor_state() {
let mut app = App::new();
let _app = app
.add_message::<bevy::input::keyboard::KeyboardInput>()
.add_message::<EditorInputEvent>()
.init_resource::<ButtonInput<KeyCode>>()
.init_resource::<bevy::prelude::Time>()
.init_resource::<super::VimInputState>()
.init_resource::<CapturedInputEvents>()
.add_systems(
Update,
(emit_editor_input_events, capture_input_events).chain(),
);
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::Escape);
app.update();
assert_eq!(
app.world().resource::<CapturedInputEvents>().events,
vec![EditorInputEvent::Key(
crate::ecs::events::input::KeyInputEvent {
token: KeyToken::Escape,
repeated: false,
}
)]
);
}
#[test]
fn input_stage_does_not_synthesize_normal_repeats_in_insert_mode() {
let (mut app, _target) = input_stage_app(VimMode::Insert(InsertState::default()));
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::KeyL);
app.update();
assert!(
app.world()
.resource::<CapturedInputEvents>()
.events
.is_empty()
);
}
#[test]
fn input_stage_forwards_insert_repeated_text_payloads() {
let (mut app, _target) = input_stage_app(VimMode::Insert(InsertState::default()));
let _message =
app.world_mut()
.write_message(keyboard_input(KeyCode::KeyA, Some("a"), true));
app.update();
assert_eq!(
app.world().resource::<CapturedInputEvents>().events,
vec![EditorInputEvent::Text(TextInputEvent {
text: String::from("a"),
})]
);
}
#[test]
fn collected_vim_effects_emit_typed_ecs_messages() {
let mut app = App::new();
let _app = app
.add_message::<BufferEditRequested>()
.add_message::<EditHistoryRequested>()
.add_message::<crate::ecs::events::cursor::CursorMoveRequested>()
.add_message::<crate::ecs::events::command::CommandRequested>()
.add_message::<ViewportIntent>()
.add_message::<StatusMessageRequested>()
.init_resource::<CapturedVimEffects>()
.add_systems(
Update,
(emit_sample_vim_effects, capture_vim_effects).chain(),
);
app.update();
let captured = app.world().resource::<CapturedVimEffects>();
assert_eq!(
captured.viewport_intents,
vec![ViewportIntent {
target: ViewEntity(
bevy::prelude::Entity::from_raw_u32(0).expect("valid entity index")
),
placement: ViewportPosition::Center,
}]
);
assert_eq!(
captured.status_requests,
vec![StatusMessageRequested {
target: ViewEntity(
bevy::prelude::Entity::from_raw_u32(0).expect("valid entity index")
),
message: StatusMessage::Clear,
}]
);
}
#[test]
fn normal_input_emits_targeted_cursor_and_status_effects() {
let (mut app, target) = vim_adapter_app("abc", 1);
write_input_events(&mut app, [text("h")]);
app.update();
let captured = captured_effects(&app);
assert_eq!(
captured.cursor_moves,
vec![CursorMoveRequested::cursor_cell(
ViewEntity(target),
ByteIndex(0),
None
)]
);
assert_eq!(
captured.status_requests,
vec![StatusMessageRequested {
target: ViewEntity(target),
message: StatusMessage::Clear,
}]
);
}
#[test]
fn normal_input_emits_undo_and_redo_requests() {
let (mut app, _target) = vim_adapter_app("abc", 1);
write_input_events(&mut app, [text("u")]);
app.update();
write_input_events(&mut app, [key(KeyToken::Ctrl('r'))]);
app.update();
let mut query = app
.world_mut()
.query_filtered::<Entity, With<EditorBuffer>>();
let buffer = query
.iter(app.world())
.next()
.expect("editor buffer should exist");
assert_eq!(
captured_effects(&app).edit_history,
vec![
EditHistoryRequested::undo(BufferEntity(buffer)),
EditHistoryRequested::redo(BufferEntity(buffer)),
]
);
}
#[test]
fn normal_delete_emits_buffer_edit_and_updates_unnamed_register() {
let (mut app, target) = vim_adapter_app("abc\ndef", 0);
feed_text_frames(&mut app, "dd");
let buffer = buffer_for_view(&app, target);
assert_eq!(
captured_effects(&app).buffer_edits,
vec![BufferEditRequested {
target: buffer,
edit: validated_delete_edit(&app, buffer, 0..4),
}]
);
let modal_state = app
.world()
.get::<VimModalState>(target)
.expect("modal state should exist");
let register = modal_state
.editor
.registers
.get(RegisterName::Unnamed)
.expect("delete should write unnamed register");
assert_eq!(register.as_str(), "abc\n");
assert_eq!(register.kind(), RegisterKind::Linewise);
assert!(
modal_state
.editor
.registers
.get(RegisterName::Yank0)
.is_none()
);
}
#[test]
fn normal_delete_word_emits_validated_motion_range() {
let (mut app, target) = vim_adapter_app("one two three", 0);
feed_text_frames(&mut app, "dw");
let buffer = buffer_for_view(&app, target);
assert_eq!(
captured_effects(&app).buffer_edits,
vec![BufferEditRequested {
target: buffer,
edit: validated_delete_edit(&app, buffer, 0.."one ".len()),
}]
);
let modal_state = app
.world()
.get::<VimModalState>(target)
.expect("modal state should exist");
let register = modal_state
.editor
.registers
.get(RegisterName::Unnamed)
.expect("delete should write unnamed register");
assert_eq!(register.as_str(), "one ");
assert_eq!(register.kind(), RegisterKind::Characterwise);
assert!(
modal_state
.editor
.registers
.get(RegisterName::Yank0)
.is_none()
);
}
#[test]
fn normal_delete_to_line_end_includes_destination_cell() {
let (mut app, target) = vim_adapter_app("abc\ndef", 1);
feed_text_frames(&mut app, "d$");
let buffer = buffer_for_view(&app, target);
assert_eq!(
captured_effects(&app).buffer_edits,
vec![BufferEditRequested {
target: buffer,
edit: validated_delete_edit(&app, buffer, 1..3),
}]
);
let modal_state = app
.world()
.get::<VimModalState>(target)
.expect("modal state should exist");
let register = modal_state
.editor
.registers
.get(RegisterName::Unnamed)
.expect("delete should write unnamed register");
assert_eq!(register.as_str(), "bc");
assert_eq!(register.kind(), RegisterKind::Characterwise);
}
#[test]
fn normal_delete_combines_operator_and_motion_counts() {
let (mut app, target) = vim_adapter_app("one two three four five", 0);
feed_text_frames(&mut app, "2d2w");
let expected = "one two three four ";
let buffer = buffer_for_view(&app, target);
assert_eq!(
captured_effects(&app).buffer_edits,
vec![BufferEditRequested {
target: buffer,
edit: validated_delete_edit(&app, buffer, 0..expected.len()),
}]
);
let modal_state = app
.world()
.get::<VimModalState>(target)
.expect("modal state should exist");
let register = modal_state
.editor
.registers
.get(RegisterName::Unnamed)
.expect("delete should write unnamed register");
assert_eq!(register.as_str(), expected);
assert_eq!(register.kind(), RegisterKind::Characterwise);
}
#[test]
fn normal_delete_word_preserves_utf8_boundaries() {
let (mut app, target) = vim_adapter_app("αβ γδ", 0);
feed_text_frames(&mut app, "dw");
let expected = "αβ ";
let buffer = buffer_for_view(&app, target);
assert_eq!(
captured_effects(&app).buffer_edits,
vec![BufferEditRequested {
target: buffer,
edit: validated_delete_edit(&app, buffer, 0..expected.len()),
}]
);
let modal_state = app
.world()
.get::<VimModalState>(target)
.expect("modal state should exist");
let register = modal_state
.editor
.registers
.get(RegisterName::Unnamed)
.expect("delete should write unnamed register");
assert_eq!(register.as_str(), expected);
assert_eq!(register.kind(), RegisterKind::Characterwise);
}
#[test]
fn normal_delete_inner_word_uses_text_object_target() {
let (mut app, target) = vim_adapter_app("one αβ three", "one ".len());
feed_text_frames(&mut app, "diw");
let expected = "αβ";
let buffer = buffer_for_view(&app, target);
assert_eq!(
captured_effects(&app).buffer_edits,
vec![BufferEditRequested {
target: buffer,
edit: validated_delete_edit(&app, buffer, "one ".len().."one αβ".len()),
}]
);
let modal_state = app
.world()
.get::<VimModalState>(target)
.expect("modal state should exist");
let register = modal_state
.editor
.registers
.get(RegisterName::Unnamed)
.expect("delete should write unnamed register");
assert_eq!(register.as_str(), expected);
assert_eq!(register.kind(), RegisterKind::Characterwise);
}
#[test]
fn normal_yank_updates_registers_without_buffer_edit() {
let (mut app, target) = vim_adapter_app("abc\ndef", 0);
feed_text_frames(&mut app, "yy");
assert!(captured_effects(&app).buffer_edits.is_empty());
let modal_state = app
.world()
.get::<VimModalState>(target)
.expect("modal state should exist");
let unnamed = modal_state
.editor
.registers
.get(RegisterName::Unnamed)
.expect("yank should write unnamed register");
let yank0 = modal_state
.editor
.registers
.get(RegisterName::Yank0)
.expect("yank should write register zero");
assert_eq!(unnamed.as_str(), "abc\n");
assert_eq!(unnamed.kind(), RegisterKind::Linewise);
assert_eq!(yank0.as_str(), "abc\n");
assert_eq!(yank0.kind(), RegisterKind::Linewise);
}
#[test]
fn normal_yank_motion_updates_unnamed_and_yank0_without_editing() {
let (mut app, target) = vim_adapter_app("one two", 0);
feed_text_frames(&mut app, "yw");
assert!(captured_effects(&app).buffer_edits.is_empty());
let modal_state = app
.world()
.get::<VimModalState>(target)
.expect("modal state should exist");
let unnamed = modal_state
.editor
.registers
.get(RegisterName::Unnamed)
.expect("yank should write unnamed register");
let yank0 = modal_state
.editor
.registers
.get(RegisterName::Yank0)
.expect("yank should write register zero");
assert_eq!(unnamed.as_str(), "one ");
assert_eq!(unnamed.kind(), RegisterKind::Characterwise);
assert_eq!(yank0.as_str(), "one ");
assert_eq!(yank0.kind(), RegisterKind::Characterwise);
}
#[test]
fn normal_yank_counted_utf8_motion_updates_registers_without_editing() {
let (mut app, target) = vim_adapter_app("αβ γδ ζη", 0);
feed_text_frames(&mut app, "y2w");
let expected = "αβ γδ ";
assert!(captured_effects(&app).buffer_edits.is_empty());
let modal_state = app
.world()
.get::<VimModalState>(target)
.expect("modal state should exist");
let unnamed = modal_state
.editor
.registers
.get(RegisterName::Unnamed)
.expect("yank should write unnamed register");
let yank0 = modal_state
.editor
.registers
.get(RegisterName::Yank0)
.expect("yank should write register zero");
assert_eq!(unnamed.as_str(), expected);
assert_eq!(unnamed.kind(), RegisterKind::Characterwise);
assert_eq!(yank0.as_str(), expected);
assert_eq!(yank0.kind(), RegisterKind::Characterwise);
}
#[test]
fn normal_put_after_pastes_characterwise_unnamed_register() {
let (mut app, target) = vim_adapter_app("aλc", "a".len());
let register_stream = TextByteStream::new("β");
let register_target =
OperatorTarget::characterwise(®ister_stream, 0.."β".len()).unwrap();
app.world_mut()
.get_mut::<VimModalState>(target)
.expect("modal state should exist")
.editor
.registers
.write_yank(®ister_stream, register_target)
.unwrap();
write_input_events(&mut app, [text("p")]);
app.update();
let buffer = buffer_for_view(&app, target);
assert_eq!(
captured_effects(&app).buffer_edits,
vec![BufferEditRequested {
target: buffer,
edit: BufferEdit::insert("aλ".len(), "β"),
}]
);
assert_eq!(
captured_effects(&app).cursor_moves,
vec![CursorMoveRequested::cursor_cell(
ViewEntity(target),
ByteIndex("aλ".len()),
None,
)]
);
}
#[test]
fn normal_put_before_pastes_characterwise_unnamed_register() {
let (mut app, target) = vim_adapter_app("abc", 1);
let register_stream = TextByteStream::new("x");
let register_target = OperatorTarget::characterwise(®ister_stream, 0..1).unwrap();
app.world_mut()
.get_mut::<VimModalState>(target)
.expect("modal state should exist")
.editor
.registers
.write_yank(®ister_stream, register_target)
.unwrap();
write_input_events(&mut app, [text("P")]);
app.update();
let buffer = buffer_for_view(&app, target);
assert_eq!(
captured_effects(&app).buffer_edits,
vec![BufferEditRequested {
target: buffer,
edit: BufferEdit::insert(1, "x"),
}]
);
assert!(captured_effects(&app).cursor_moves.is_empty());
}
#[test]
fn normal_put_linewise_below_inserts_full_line() {
let (mut app, target) = vim_adapter_app("one", 0);
let register_stream = TextByteStream::new("two");
let register_target = OperatorTarget::linewise(®ister_stream, 0.."two".len()).unwrap();
app.world_mut()
.get_mut::<VimModalState>(target)
.expect("modal state should exist")
.editor
.registers
.write_yank(®ister_stream, register_target)
.unwrap();
write_input_events(&mut app, [text("p")]);
app.update();
let buffer = buffer_for_view(&app, target);
assert_eq!(
captured_effects(&app).buffer_edits,
vec![BufferEditRequested {
target: buffer,
edit: BufferEdit::insert("one".len(), "\ntwo\n"),
}]
);
assert_eq!(
captured_effects(&app).cursor_moves,
vec![CursorMoveRequested::cursor_cell(
ViewEntity(target),
ByteIndex("one\n".len()),
None,
)]
);
}
#[test]
fn normal_change_word_deletes_to_unnamed_register_and_enters_insert() {
let (mut app, target) = vim_adapter_app("one two", 0);
feed_text_frames(&mut app, "cw");
let buffer = buffer_for_view(&app, target);
assert_eq!(
captured_effects(&app).buffer_edits,
vec![BufferEditRequested {
target: buffer,
edit: validated_delete_edit(&app, buffer, 0.."one ".len()),
}]
);
assert!(captured_effects(&app).cursor_moves.is_empty());
let modal_state = app
.world()
.get::<VimModalState>(target)
.expect("modal state should exist");
assert!(modal_state.editor.mode.is_insert());
let register = modal_state
.editor
.registers
.get(RegisterName::Unnamed)
.expect("change should write unnamed register");
assert_eq!(register.as_str(), "one ");
assert_eq!(register.kind(), RegisterKind::Characterwise);
assert!(
modal_state
.editor
.registers
.get(RegisterName::Yank0)
.is_none()
);
}
#[test]
fn normal_dot_repeats_last_delete_operation() {
let (mut app, target) = vim_adapter_app("one two", 0);
feed_text_frames(&mut app, "dw");
write_input_events(&mut app, [text(".")]);
app.update();
let buffer = buffer_for_view(&app, target);
assert_eq!(
captured_effects(&app).buffer_edits,
vec![
BufferEditRequested {
target: buffer,
edit: validated_delete_edit(&app, buffer, 0.."one ".len()),
},
BufferEditRequested {
target: buffer,
edit: validated_delete_edit(&app, buffer, 0.."one ".len()),
},
]
);
}
#[test]
fn normal_dot_repeats_last_paste_operation() {
let (mut app, target) = vim_adapter_app("abc", 1);
let register_stream = TextByteStream::new("x");
let register_target = OperatorTarget::characterwise(®ister_stream, 0..1).unwrap();
app.world_mut()
.get_mut::<VimModalState>(target)
.expect("modal state should exist")
.editor
.registers
.write_yank(®ister_stream, register_target)
.unwrap();
write_input_events(&mut app, [text("p")]);
app.update();
write_input_events(&mut app, [text(".")]);
app.update();
let buffer = buffer_for_view(&app, target);
assert_eq!(
captured_effects(&app).buffer_edits,
vec![
BufferEditRequested {
target: buffer,
edit: BufferEdit::insert(2, "x"),
},
BufferEditRequested {
target: buffer,
edit: BufferEdit::insert(2, "x"),
},
]
);
}
#[test]
fn normal_insert_entry_switches_mode_without_mutating_buffer() {
let (mut app, target) = vim_adapter_app("aλc", "a".len());
write_input_events(&mut app, [text("a")]);
app.update();
let modal_state = app
.world()
.get::<VimModalState>(target)
.expect("modal state should exist");
assert!(modal_state.editor.mode.is_insert());
assert!(captured_effects(&app).buffer_edits.is_empty());
assert_eq!(
captured_effects(&app).cursor_moves,
vec![CursorMoveRequested::insert_caret(
ViewEntity(target),
ByteIndex("aλ".len()),
None
)]
);
}
#[test]
fn insert_mode_text_emits_buffer_edit_and_insert_caret_move() {
let (mut app, target) = vim_adapter_app("abc", 1);
app.world_mut()
.get_mut::<VimModalState>(target)
.expect("modal state should exist")
.editor
.mode = VimMode::Insert(InsertState::default());
write_input_events(&mut app, [text("λx")]);
app.update();
let buffer = buffer_for_view(&app, target);
assert_eq!(
captured_effects(&app).buffer_edits,
vec![BufferEditRequested {
target: buffer,
edit: BufferEdit::insert(1, "λx"),
}]
);
assert_eq!(
captured_effects(&app).cursor_moves,
vec![CursorMoveRequested::insert_caret(
ViewEntity(target),
ByteIndex("aλx".len()),
None
)]
);
}
#[test]
fn insert_mode_escape_returns_to_normal_with_vim_exit_cursor() {
let (mut app, target) = vim_adapter_app("abc", "abc".len());
app.world_mut()
.get_mut::<VimModalState>(target)
.expect("modal state should exist")
.editor
.mode = VimMode::Insert(InsertState::default());
write_input_events(&mut app, [text("x"), key(KeyToken::Escape)]);
app.update();
let modal_state = app
.world()
.get::<VimModalState>(target)
.expect("modal state should exist");
assert_eq!(modal_state.editor.mode, VimMode::Normal);
assert_eq!(
captured_effects(&app).cursor_moves,
vec![CursorMoveRequested::cursor_cell(
ViewEntity(target),
ByteIndex("abc".len()),
None
)]
);
}
#[test]
fn open_line_insert_entry_emits_newline_edit_before_insert_mode() {
let (mut app, target) = vim_adapter_app("abc\ndef", 1);
write_input_events(&mut app, [text("o")]);
app.update();
let buffer = buffer_for_view(&app, target);
assert_eq!(
captured_effects(&app).buffer_edits,
vec![BufferEditRequested {
target: buffer,
edit: BufferEdit::insert("abc".len(), "\n"),
}]
);
assert_eq!(
captured_effects(&app).cursor_moves,
vec![CursorMoveRequested::insert_caret(
ViewEntity(target),
ByteIndex("abc\n".len()),
None
)]
);
}
#[test]
fn insert_mode_rejects_oversized_text_without_payload_disclosure() {
let (mut app, target) = vim_adapter_app("abc", 0);
app.world_mut()
.get_mut::<VimModalState>(target)
.expect("modal state should exist")
.editor
.mode = VimMode::Insert(InsertState::default());
let oversized = "x".repeat(super::super::insert::MAX_INSERT_TEXT_BYTES_PER_FRAME + 1);
write_input_events(&mut app, [text(&oversized)]);
app.update();
assert!(captured_effects(&app).buffer_edits.is_empty());
assert_eq!(
captured_effects(&app).input_rejections,
vec![VimInputRejected {
target: ViewEntity(target),
reason: VimInputRejectionReason::InsertPayloadTooLarge {
target: ViewEntity(target),
rejected_byte_len: oversized.len(),
max_byte_len: super::super::insert::MAX_INSERT_TEXT_BYTES_PER_FRAME,
},
}]
);
let debug = format!("{:?}", captured_effects(&app).input_rejections);
assert!(!debug.contains(&oversized));
}
#[test]
fn visual_delete_emits_buffer_edit_clears_selection_and_returns_to_normal() {
let (mut app, target) = vim_adapter_app("one two", "one".len());
{
let mut modal_state = app
.world_mut()
.get_mut::<VimModalState>(target)
.expect("modal state should exist");
modal_state.editor.mode = VimMode::Visual(VisualMode::Characterwise);
modal_state.editor.selection.start("one two", 0);
}
write_input_events(&mut app, [text("d")]);
app.update();
let buffer = buffer_for_view(&app, target);
assert_eq!(
captured_effects(&app).buffer_edits,
vec![BufferEditRequested {
target: buffer,
edit: validated_delete_edit(&app, buffer, 0.."one ".len()),
}]
);
let modal_state = app
.world()
.get::<VimModalState>(target)
.expect("modal state should exist");
assert_eq!(modal_state.editor.mode, VimMode::Normal);
assert!(modal_state.editor.selection.selection().is_none());
let register = modal_state
.editor
.registers
.get(RegisterName::Unnamed)
.expect("delete should write unnamed register");
assert_eq!(register.as_str(), "one ");
assert_eq!(register.kind(), RegisterKind::Characterwise);
assert!(
modal_state
.editor
.registers
.get(RegisterName::Yank0)
.is_none()
);
}
#[test]
fn visual_yank_linewise_updates_registers_without_editing_and_returns_to_normal() {
let (mut app, target) = vim_adapter_app("one\ntwo\nthree", "one\ntwo".len());
{
let mut modal_state = app
.world_mut()
.get_mut::<VimModalState>(target)
.expect("modal state should exist");
modal_state.editor.mode = VimMode::Visual(VisualMode::Linewise);
modal_state
.editor
.selection
.start("one\ntwo\nthree", "one\nt".len());
}
write_input_events(&mut app, [text("y")]);
app.update();
assert!(captured_effects(&app).buffer_edits.is_empty());
let modal_state = app
.world()
.get::<VimModalState>(target)
.expect("modal state should exist");
assert_eq!(modal_state.editor.mode, VimMode::Normal);
assert!(modal_state.editor.selection.selection().is_none());
let unnamed = modal_state
.editor
.registers
.get(RegisterName::Unnamed)
.expect("yank should write unnamed register");
let yank0 = modal_state
.editor
.registers
.get(RegisterName::Yank0)
.expect("yank should write register zero");
assert_eq!(unnamed.as_str(), "two\n");
assert_eq!(unnamed.kind(), RegisterKind::Linewise);
assert_eq!(yank0.as_str(), "two\n");
assert_eq!(yank0.kind(), RegisterKind::Linewise);
}
#[test]
fn visual_delete_reverse_utf8_selection_preserves_boundaries() {
let (mut app, target) = vim_adapter_app("aλβz", "aλ".len());
{
let mut modal_state = app
.world_mut()
.get_mut::<VimModalState>(target)
.expect("modal state should exist");
modal_state.editor.mode = VimMode::Visual(VisualMode::Characterwise);
modal_state.editor.selection.start("aλβz", "aλ".len());
}
app.world_mut()
.get_mut::<CursorPosition>(target)
.expect("cursor position should exist")
.byte_index = ByteIndex("a".len());
write_input_events(&mut app, [text("d")]);
app.update();
let expected = "λβ";
let buffer = buffer_for_view(&app, target);
assert_eq!(
captured_effects(&app).buffer_edits,
vec![BufferEditRequested {
target: buffer,
edit: validated_delete_edit(&app, buffer, "a".len()..("a".len() + expected.len())),
}]
);
let modal_state = app
.world()
.get::<VimModalState>(target)
.expect("modal state should exist");
assert_eq!(modal_state.editor.mode, VimMode::Normal);
assert!(modal_state.editor.selection.selection().is_none());
let register = modal_state
.editor
.registers
.get(RegisterName::Unnamed)
.expect("delete should write unnamed register");
assert_eq!(register.as_str(), expected);
assert_eq!(register.kind(), RegisterKind::Characterwise);
}
#[test]
fn visual_change_deletes_selection_clears_selection_and_enters_insert() {
let (mut app, target) = vim_adapter_app("one two", "one".len());
{
let mut modal_state = app
.world_mut()
.get_mut::<VimModalState>(target)
.expect("modal state should exist");
modal_state.editor.mode = VimMode::Visual(VisualMode::Characterwise);
modal_state.editor.selection.start("one two", 0);
}
write_input_events(&mut app, [text("c")]);
app.update();
let buffer = buffer_for_view(&app, target);
assert_eq!(
captured_effects(&app).buffer_edits,
vec![BufferEditRequested {
target: buffer,
edit: validated_delete_edit(&app, buffer, 0.."one ".len()),
}]
);
assert_eq!(
captured_effects(&app).cursor_moves,
vec![CursorMoveRequested::cursor_cell(
ViewEntity(target),
ByteIndex(0),
None
)]
);
let modal_state = app
.world()
.get::<VimModalState>(target)
.expect("modal state should exist");
assert!(modal_state.editor.mode.is_insert());
assert!(modal_state.editor.selection.selection().is_none());
let register = modal_state
.editor
.registers
.get(RegisterName::Unnamed)
.expect("change should write unnamed register");
assert_eq!(register.as_str(), "one ");
assert_eq!(register.kind(), RegisterKind::Characterwise);
assert!(
modal_state
.editor
.registers
.get(RegisterName::Yank0)
.is_none()
);
}
#[test]
fn normal_input_emits_cursor_update_when_only_desired_column_changes() {
let (mut app, target) = vim_adapter_app("abc", 0);
app.world_mut()
.get_mut::<CursorPosition>(target)
.expect("cursor position should exist")
.desired_column = Some(2);
write_input_events(&mut app, [text("h")]);
app.update();
assert_eq!(
captured_effects(&app).cursor_moves,
vec![CursorMoveRequested::cursor_cell(
ViewEntity(target),
ByteIndex(0),
None
)]
);
}
#[test]
fn normal_input_leaves_authoritative_status_to_owner_boundary() {
let (mut app, target) = vim_adapter_app("abc", 1);
app.world_mut()
.get_mut::<VimModalState>(target)
.expect("modal state should exist")
.editor
.status
.set_info(StatusInfoText::trusted_static("owner-owned"));
write_input_events(&mut app, [text("h")]);
app.update();
let modal_state = app
.world()
.get::<VimModalState>(target)
.expect("modal state should exist");
assert_eq!(
modal_state.editor.status.message(),
Some(&VimStatusMessage::Info(StatusInfoText::trusted_static(
"owner-owned"
)))
);
assert_eq!(
captured_effects(&app).status_requests,
vec![StatusMessageRequested {
target: ViewEntity(target),
message: StatusMessage::Clear,
}]
);
}
#[test]
fn viewport_input_emits_targeted_viewport_effect() {
let (mut app, target) = vim_adapter_app("abc\ndef", 4);
write_input_events(&mut app, [text("z")]);
app.update();
write_input_events(&mut app, [text("t")]);
app.update();
let captured = captured_effects(&app);
assert_eq!(
captured.viewport_intents,
vec![ViewportIntent {
target: ViewEntity(target),
placement: ViewportPosition::Top,
}]
);
assert_eq!(
captured.status_requests,
vec![StatusMessageRequested {
target: ViewEntity(target),
message: StatusMessage::Clear,
}]
);
}
#[test]
fn command_prompt_enter_emits_targeted_command_effect() {
let (mut app, target) = vim_adapter_app("abc", 0);
app.world_mut()
.get_mut::<VimModalState>(target)
.expect("modal state should exist")
.editor
.command
.start_with("w");
write_input_events(&mut app, [key(KeyToken::Enter)]);
app.update();
assert_eq!(
captured_effects(&app).command_requests,
vec![CommandRequested {
target: buffer_for_view(&app, target),
source: ViewEntity(target),
command: VimCommand::Write,
}]
);
}
#[test]
fn repeated_key_does_not_leak_after_focus_changes() {
let (mut app, first, second) = two_buffer_vim_adapter_app(false);
write_input_events(&mut app, [text("l")]);
app.update();
{
let mut captured = app.world_mut().resource_mut::<CapturedAdapterEffects>();
captured.cursor_moves.clear();
captured.status_requests.clear();
captured.viewport_intents.clear();
captured.command_requests.clear();
}
let _first = app
.world_mut()
.entity_mut(first)
.remove::<FocusedEditorView>();
let _second = app.world_mut().entity_mut(second).insert(FocusedEditorView);
write_input_events(&mut app, [repeated_key(KeyToken::Char('l'))]);
app.update();
let captured = captured_effects(&app);
assert!(captured.cursor_moves.is_empty());
assert!(captured.status_requests.is_empty());
}
#[test]
fn search_prompt_enter_emits_targeted_cursor_and_status_effects() {
let (mut app, target) = vim_adapter_app("abc abc", 0);
{
let mut modal_state = app
.world_mut()
.get_mut::<VimModalState>(target)
.expect("modal state should exist");
modal_state.editor.search.start(SearchDirection::Forward);
modal_state.editor.search.push_text("bc");
}
write_input_events(&mut app, [key(KeyToken::Enter)]);
app.update();
let captured = captured_effects(&app);
assert_eq!(
captured.cursor_moves,
vec![CursorMoveRequested::cursor_cell(
ViewEntity(target),
ByteIndex(1),
None
)]
);
assert_eq!(
captured.status_requests,
vec![StatusMessageRequested {
target: ViewEntity(target),
message: StatusMessage::Clear,
}]
);
}
#[test]
fn normal_search_repeat_emits_targeted_cursor_and_status_effects() {
let text_value = "one two one two";
let (mut app, target) = vim_adapter_app(text_value, "one ".len());
{
let mut modal_state = app
.world_mut()
.get_mut::<VimModalState>(target)
.expect("modal state should exist");
modal_state.editor.search.start(SearchDirection::Forward);
modal_state.editor.search.push_text("two");
let outcome =
modal_state
.editor
.search
.submit(text_value, 0, &crate::vim::VimOptions::default());
assert_eq!(
outcome,
crate::vim::SearchOutcome::Match {
byte_index: "one ".len(),
}
);
}
write_input_events(&mut app, [text("n")]);
app.update();
let captured = captured_effects(&app);
assert_eq!(
captured.cursor_moves,
vec![CursorMoveRequested::cursor_cell(
ViewEntity(target),
ByteIndex("one two one ".len()),
None
)]
);
assert_eq!(
captured.status_requests,
vec![StatusMessageRequested {
target: ViewEntity(target),
message: StatusMessage::Clear,
}]
);
}
#[test]
fn normal_search_repeat_uses_last_search_direction() {
let text_value = "one two one two";
let (mut app, target) = vim_adapter_app(text_value, "one two one ".len());
{
let mut modal_state = app
.world_mut()
.get_mut::<VimModalState>(target)
.expect("modal state should exist");
modal_state.editor.search.start(SearchDirection::Backward);
modal_state.editor.search.push_text("two");
let outcome = modal_state.editor.search.submit(
text_value,
text_value.len(),
&crate::vim::VimOptions::default(),
);
assert_eq!(
outcome,
crate::vim::SearchOutcome::Match {
byte_index: "one two one ".len(),
}
);
}
write_input_events(&mut app, [text("n")]);
app.update();
assert_eq!(
captured_effects(&app).cursor_moves,
vec![CursorMoveRequested::cursor_cell(
ViewEntity(target),
ByteIndex("one ".len()),
None
)]
);
}
#[test]
fn escape_cancels_visual_state_without_owner_mutation() {
let (mut app, target) = vim_adapter_app("abc", 1);
{
let mut modal_state = app
.world_mut()
.get_mut::<VimModalState>(target)
.expect("modal state should exist");
modal_state.editor.mode = VimMode::Visual(VisualMode::Characterwise);
modal_state.editor.selection.start("abc", 0);
modal_state
.editor
.status
.set_info(StatusInfoText::trusted_static("pending"));
}
write_input_events(&mut app, [key(KeyToken::Escape)]);
app.update();
let modal_state = app
.world()
.get::<VimModalState>(target)
.expect("modal state should exist");
assert_eq!(modal_state.editor.mode, VimMode::Normal);
assert!(modal_state.editor.selection.selection().is_none());
assert_eq!(
modal_state.editor.status.message(),
Some(&VimStatusMessage::Info(StatusInfoText::trusted_static(
"pending"
)))
);
assert!(captured_effects(&app).cursor_moves.is_empty());
}
proptest! {
#[test]
fn vim_input_routes_only_to_focused_modal_state(
focus_second in any::<bool>(),
input in "[hl]{0,16}",
) {
let (mut app, first, second) = two_buffer_vim_adapter_app(focus_second);
app.world_mut()
.get_mut::<VimModalState>(first)
.expect("first modal state should exist")
.editor.status
.set_info(StatusInfoText::trusted_static("first modal"));
app.world_mut()
.get_mut::<VimModalState>(second)
.expect("second modal state should exist")
.editor.status
.set_error(crate::vim::VimError::NoFileName);
if !input.is_empty() {
write_input_events(&mut app, [text(&input)]);
}
app.update();
let focused = if focus_second { second } else { first };
let unfocused = if focus_second { first } else { second };
let focused_view = ViewEntity(focused);
let focused_buffer = buffer_for_view(&app, focused);
let captured = captured_effects(&app);
prop_assert!(captured.cursor_moves.iter().all(|request| request.target == focused_view));
prop_assert!(captured.status_requests.iter().all(|request| request.target == focused_view));
prop_assert!(captured.viewport_intents.iter().all(|request| request.target == focused_view));
prop_assert!(captured.command_requests.iter().all(|request| request.source == focused_view && request.target == focused_buffer));
let unfocused_status = app
.world()
.get::<VimModalState>(unfocused)
.expect("unfocused modal state should exist")
.editor.status
.message()
.cloned();
if focus_second {
prop_assert_eq!(
unfocused_status,
Some(VimStatusMessage::Info(StatusInfoText::trusted_static(
"first modal"
)))
);
} else {
prop_assert_eq!(
unfocused_status,
Some(VimStatusMessage::Error(crate::vim::VimError::NoFileName))
);
}
}
}
}