use super::effects::{VimEffect, VimEffectWriters};
use crate::{
ecs::{
components::{
buffer::{
BufferEntity, BufferText, EditorBuffer, EditorView, FocusedEditorView, ViewEntity,
},
cursor::{ByteIndex, CursorPosition},
layout::ViewportState,
vim::VimModalState,
},
events::{
command::CommandRequested,
cursor::CursorMoveRequested,
input::{EditorInputEvent, KeyInputEvent, TextInputEvent},
intent::ViewportIntent,
status::{StatusMessage, StatusMessageRequested},
},
},
vim::{
ActionContext, ActionDispatcher, KeyToken, LeaderState, NormalCommand, NormalGrammarOutput,
NormalState, VimAction, VimCommandState, VimConfig, VimCursor, VimMode, VimSearchState,
VimSelectionState, VimStatusLine, VimStatusMessage, VisualCommandContext,
VisualGrammarOutput, VisualState, apply_search_outcome,
},
};
use bevy::input::{ButtonState, keyboard::KeyboardInput};
use bevy::prelude::{
ButtonInput, Entity, KeyCode, MessageReader, MessageWriter, Query, Res, ResMut, Resource, Time,
With,
};
type FocusedEditorViewFilter = (With<EditorView>, With<FocusedEditorView>);
type VimInputViewQuery<'world, 'state> = Query<
'world,
'state,
(
Entity,
&'static EditorView,
&'static CursorPosition,
&'static ViewportState,
&'static mut VimModalState,
),
FocusedEditorViewFilter,
>;
const KEY_REPEAT_INITIAL_DELAY_SECONDS: f32 = 0.34;
const KEY_REPEAT_INTERVAL_SECONDS: f32 = 0.045;
#[derive(Clone, Debug, Default, Resource)]
pub struct VimInputState {
repeat: KeyRepeatState,
focused_view: Option<ViewEntity>,
}
#[derive(Clone, Debug, Default)]
struct KeyRepeatState {
command: Option<RepeatableNormalCommand>,
seconds_until_repeat: f32,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum RepeatableNormalCommand {
Left,
Down,
Up,
Right,
NextWord,
PreviousWord,
RepeatSearchForward,
RepeatSearchBackward,
}
pub fn emit_editor_input_events(
mut keyboard_events: MessageReader<KeyboardInput>,
keys: Res<ButtonInput<KeyCode>>,
time: Res<Time>,
mut input_events: MessageWriter<EditorInputEvent>,
mut input_state: ResMut<VimInputState>,
) {
let time = time.into_inner();
let typed_text: Vec<String> = keyboard_events
.read()
.filter(|event| event.state == ButtonState::Pressed)
.filter_map(|event| event.text.as_ref())
.filter(|text| text.chars().all(is_prompt_text_character))
.map(ToString::to_string)
.collect();
let keys = keys.into_inner();
for token in special_key_tokens(keys).chain(control_key_tokens(keys)) {
let _sent = input_events.write(EditorInputEvent::Key(KeyInputEvent {
token,
repeated: false,
}));
}
for text in &typed_text {
let _sent = input_events.write(EditorInputEvent::Text(TextInputEvent {
text: text.clone(),
}));
}
if typed_text.is_empty()
&& let Some(command) = repeatable_normal_command(keys)
&& input_state
.repeat
.tick(command, key_just_pressed(keys, command), time.delta_secs())
&& !key_just_pressed(keys, command)
{
let _sent = input_events.write(EditorInputEvent::Key(KeyInputEvent {
token: repeatable_command_token(command),
repeated: true,
}));
} else if typed_text.is_empty() {
input_state.repeat.cancel();
}
}
pub fn handle_vim_input(
mut input_events: MessageReader<EditorInputEvent>,
time: Res<Time>,
mut effect_writers: VimEffectWriters<'_>,
vim_config: Res<VimConfig>,
mut input_state: ResMut<VimInputState>,
mut view_query: VimInputViewQuery<'_, '_>,
buffers: Query<&BufferText, With<EditorBuffer>>,
) {
let events = input_events.read().cloned().collect::<Vec<_>>();
if events.is_empty() {
return;
}
let Some((target, view, cursor_position, viewport_state, mut modal_state)) =
view_query.iter_mut().next()
else {
return;
};
let Ok(buffer_text) = buffers.get(view.buffer.get()) else {
return;
};
let target = ViewEntity(target);
let buffer_target = view.buffer;
if input_state
.focused_view
.replace(target)
.is_some_and(|last| last != target)
{
input_state.repeat.cancel();
if events.iter().all(EditorInputEvent::is_repeated_key) {
return;
}
}
let vim_config = vim_config.into_inner();
let modal_state = modal_state.as_mut();
let viewport = &viewport_state.viewport;
let delta_millis = u64::try_from(time.into_inner().delta().as_millis()).unwrap_or(u64::MAX);
let text = buffer_text.stream.as_str();
let mut cursor = VimCursor::new();
cursor.set_byte_index(text, cursor_position.byte_index.get());
cursor.set_desired_column(cursor_position.desired_column);
let mut effects = Vec::new();
cursor.clamp_to_text(text);
if modal_state.command.is_active() {
handle_command_input(
target,
buffer_target,
&events,
&mut modal_state.command,
&mut effects,
);
request_cursor_move_from_position(*cursor_position, &cursor, target, &mut effects);
effect_writers.emit(&effects);
return;
}
if modal_state.search.is_active() {
handle_search_input(
text,
&events,
&mut cursor,
&mut modal_state.search,
target,
&mut effects,
);
request_cursor_move_from_position(*cursor_position, &cursor, target, &mut effects);
effect_writers.emit(&effects);
return;
}
if events
.iter()
.any(|event| matches!(event, EditorInputEvent::Key(key) if key.token == KeyToken::Escape))
{
modal_state.mode = VimMode::Normal;
modal_state.selection.clear();
modal_state.command.cancel();
modal_state.leader.cancel();
modal_state.normal.reset_grammar();
modal_state.visual.reset_grammar();
input_state.repeat.cancel();
request_cursor_move_from_position(*cursor_position, &cursor, target, &mut effects);
effect_writers.emit(&effects);
return;
}
let mut status_line = VimStatusLine::default();
handle_normal_input(NormalInputContext {
text,
cursor: &mut cursor,
mode: &mut modal_state.mode,
selection_state: &mut modal_state.selection,
search_state: &mut modal_state.search,
command_state: &mut modal_state.command,
status_line: &mut status_line,
effects: &mut effects,
target,
leader_state: &mut modal_state.leader,
normal_state: &mut modal_state.normal,
visual_state: &mut modal_state.visual,
input_state: &mut input_state,
vim_config,
visible_line_count: viewport.visible_line_count(),
input_events: &events,
delta_millis,
});
request_cursor_move_from_position(*cursor_position, &cursor, target, &mut effects);
effect_writers.emit(&effects);
}
struct NormalInputContext<'state> {
text: &'state str,
cursor: &'state mut VimCursor,
mode: &'state mut VimMode,
selection_state: &'state mut VimSelectionState,
search_state: &'state mut VimSearchState,
command_state: &'state mut VimCommandState,
status_line: &'state mut VimStatusLine,
effects: &'state mut Vec<VimEffect>,
target: ViewEntity,
leader_state: &'state mut LeaderState,
normal_state: &'state mut NormalState,
visual_state: &'state mut VisualState,
input_state: &'state mut VimInputState,
vim_config: &'state VimConfig,
visible_line_count: usize,
input_events: &'state [EditorInputEvent],
delta_millis: u64,
}
fn handle_command_input(
target: ViewEntity,
buffer_target: BufferEntity,
input_events: &[EditorInputEvent],
command_state: &mut VimCommandState,
effects: &mut Vec<VimEffect>,
) {
for event in input_events {
match event {
EditorInputEvent::Key(key) if key.token == KeyToken::Escape => {
command_state.cancel();
effects.push(VimEffect::Status(StatusMessageRequested {
target,
message: StatusMessage::Clear,
}));
return;
}
EditorInputEvent::Key(key) if key.token == KeyToken::Backspace => {
command_state.backspace();
}
EditorInputEvent::Key(key) if key.token == KeyToken::Enter => {
match command_state.submit() {
Ok(command) => {
effects.push(VimEffect::Command(CommandRequested {
target: buffer_target,
source: target,
command,
}));
}
Err(error) => {
effects.push(VimEffect::Status(StatusMessageRequested {
target,
message: StatusMessage::Error(error),
}));
}
}
return;
}
EditorInputEvent::Text(text) => command_state.push_text(&text.text),
EditorInputEvent::Key(_) => {}
}
}
}
fn handle_search_input(
text: &str,
input_events: &[EditorInputEvent],
cursor: &mut VimCursor,
search_state: &mut VimSearchState,
target: ViewEntity,
effects: &mut Vec<VimEffect>,
) {
let mut status_line = VimStatusLine::default();
for event in input_events {
match event {
EditorInputEvent::Key(key) if key.token == KeyToken::Escape => {
search_state.cancel();
effects.push(VimEffect::Status(StatusMessageRequested {
target,
message: StatusMessage::Clear,
}));
return;
}
EditorInputEvent::Key(key) if key.token == KeyToken::Backspace => {
search_state.backspace();
}
EditorInputEvent::Key(key) if key.token == KeyToken::Enter => {
let outcome = search_state.submit(text, cursor.byte_index());
apply_search_outcome(text, cursor, &mut status_line, outcome);
push_status_effect_from_line(target, &status_line, effects);
return;
}
EditorInputEvent::Text(text) => search_state.push_text(&text.text),
EditorInputEvent::Key(_) => {}
}
}
}
fn handle_normal_input(mut context: NormalInputContext<'_>) {
context
.leader_state
.tick(context.delta_millis, context.vim_config.leader.delay_ms);
let tokens = normalized_editor_tokens(context.input_events).collect::<Vec<_>>();
for token in tokens {
if handle_normal_token(&mut context, token).is_done() {
return;
}
}
if context.leader_state.is_pending() || context.leader_state.is_menu_visible() {
context.input_state.repeat.cancel();
return;
}
context.input_state.repeat.cancel();
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum NormalTokenOutcome {
Continue,
Done,
}
impl NormalTokenOutcome {
const fn is_done(self) -> bool {
matches!(self, Self::Done)
}
}
fn handle_normal_token(
context: &mut NormalInputContext<'_>,
token: KeyToken,
) -> NormalTokenOutcome {
if *context.mode == VimMode::Normal {
if token == context.vim_config.leader.key && !context.leader_state.is_pending() {
context.normal_state.reset_grammar();
context.input_state.repeat.cancel();
context.leader_state.start();
return NormalTokenOutcome::Done;
}
if context.leader_state.is_pending() || context.leader_state.is_menu_visible() {
dispatch_leader_token(context, token);
return NormalTokenOutcome::Done;
}
}
if let VimMode::Visual(active_visual_mode) = *context.mode {
match context.visual_state.feed(
token,
VisualCommandContext {
text: context.text,
cursor: context.cursor,
mode: context.mode,
active_visual_mode,
selection_state: context.selection_state,
normal_state: context.normal_state,
},
) {
VisualGrammarOutput::Command(_) | VisualGrammarOutput::Unmatched => {
context.leader_state.cancel();
context.input_state.repeat.cancel();
return NormalTokenOutcome::Done;
}
VisualGrammarOutput::Pending => {
context.leader_state.cancel();
return NormalTokenOutcome::Done;
}
}
}
match context.normal_state.feed_command(token) {
NormalGrammarOutput::Command(command) => {
dispatch_completed_normal_command(
command,
CompletedNormalCommandContext {
text: context.text,
cursor: context.cursor,
mode: context.mode,
selection_state: context.selection_state,
search_state: context.search_state,
command_state: context.command_state,
status_line: context.status_line,
normal_state: context.normal_state,
visible_line_count: context.visible_line_count,
effects: context.effects,
target: context.target,
},
);
context.leader_state.cancel();
context.input_state.repeat.cancel();
NormalTokenOutcome::Done
}
NormalGrammarOutput::Pending => {
context.leader_state.cancel();
NormalTokenOutcome::Done
}
NormalGrammarOutput::Unmatched => {
context.leader_state.cancel();
NormalTokenOutcome::Continue
}
}
}
fn dispatch_leader_token(context: &mut NormalInputContext<'_>, token: KeyToken) {
if let Some(binding) = context.vim_config.leader.binding_for(&[token]) {
ActionDispatcher::dispatch(
&binding.action,
ActionContext {
text: context.text,
cursor: context.cursor,
mode: context.mode,
selection_state: context.selection_state,
search_state: context.search_state,
command_state: context.command_state,
status_line: context.status_line,
normal_state: context.normal_state,
visible_line_count: context.visible_line_count,
},
);
push_status_effect_from_line(context.target, context.status_line, context.effects);
}
context.leader_state.cancel();
context.input_state.repeat.cancel();
}
struct CompletedNormalCommandContext<'state> {
text: &'state str,
cursor: &'state mut VimCursor,
mode: &'state mut VimMode,
selection_state: &'state mut VimSelectionState,
search_state: &'state mut VimSearchState,
command_state: &'state mut VimCommandState,
status_line: &'state mut VimStatusLine,
normal_state: &'state mut NormalState,
visible_line_count: usize,
effects: &'state mut Vec<VimEffect>,
target: ViewEntity,
}
fn dispatch_completed_normal_command(
command: NormalCommand,
context: CompletedNormalCommandContext<'_>,
) {
let CompletedNormalCommandContext {
text,
cursor,
mode,
selection_state,
search_state,
command_state,
status_line,
normal_state,
visible_line_count,
effects,
target,
} = context;
if let NormalCommand::ViewportPosition(position) = command {
effects.push(VimEffect::Viewport(ViewportIntent {
target,
placement: position,
}));
effects.push(VimEffect::Status(StatusMessageRequested {
target,
message: StatusMessage::Clear,
}));
return;
}
ActionDispatcher::dispatch(
&VimAction::NormalCommand(command),
ActionContext {
text,
cursor,
mode,
selection_state,
search_state,
command_state,
status_line,
normal_state,
visible_line_count,
},
);
push_status_effect_from_line(target, status_line, effects);
}
fn repeatable_normal_command(keys: &ButtonInput<KeyCode>) -> Option<RepeatableNormalCommand> {
if keys.pressed(KeyCode::KeyH) {
Some(RepeatableNormalCommand::Left)
} else if keys.pressed(KeyCode::KeyJ) {
Some(RepeatableNormalCommand::Down)
} else if keys.pressed(KeyCode::KeyK) {
Some(RepeatableNormalCommand::Up)
} else if keys.pressed(KeyCode::KeyL) {
Some(RepeatableNormalCommand::Right)
} else if keys.pressed(KeyCode::KeyW) {
Some(RepeatableNormalCommand::NextWord)
} else if keys.pressed(KeyCode::KeyB) {
Some(RepeatableNormalCommand::PreviousWord)
} else if keys.pressed(KeyCode::KeyN) && shift_pressed(keys) {
Some(RepeatableNormalCommand::RepeatSearchBackward)
} else if keys.pressed(KeyCode::KeyN) {
Some(RepeatableNormalCommand::RepeatSearchForward)
} else {
None
}
}
fn key_just_pressed(keys: &ButtonInput<KeyCode>, command: RepeatableNormalCommand) -> bool {
match command {
RepeatableNormalCommand::Left => keys.just_pressed(KeyCode::KeyH),
RepeatableNormalCommand::Down => keys.just_pressed(KeyCode::KeyJ),
RepeatableNormalCommand::Up => keys.just_pressed(KeyCode::KeyK),
RepeatableNormalCommand::Right => keys.just_pressed(KeyCode::KeyL),
RepeatableNormalCommand::NextWord => keys.just_pressed(KeyCode::KeyW),
RepeatableNormalCommand::PreviousWord => keys.just_pressed(KeyCode::KeyB),
RepeatableNormalCommand::RepeatSearchForward
| RepeatableNormalCommand::RepeatSearchBackward => keys.just_pressed(KeyCode::KeyN),
}
}
impl KeyRepeatState {
const fn cancel(&mut self) {
self.command = None;
self.seconds_until_repeat = 0.0;
}
fn tick(
&mut self,
command: RepeatableNormalCommand,
just_pressed: bool,
delta_seconds: f32,
) -> bool {
if just_pressed || self.command != Some(command) {
self.command = Some(command);
self.seconds_until_repeat = KEY_REPEAT_INITIAL_DELAY_SECONDS;
return just_pressed;
}
self.seconds_until_repeat -= delta_seconds;
if self.seconds_until_repeat <= 0.0 {
self.seconds_until_repeat += KEY_REPEAT_INTERVAL_SECONDS;
true
} else {
false
}
}
}
fn control_key_tokens(keys: &ButtonInput<KeyCode>) -> impl Iterator<Item = KeyToken> {
let control_pressed = keys.pressed(KeyCode::ControlLeft) || keys.pressed(KeyCode::ControlRight);
[
(KeyCode::KeyF, KeyToken::Ctrl('f')),
(KeyCode::KeyB, KeyToken::Ctrl('b')),
]
.into_iter()
.filter_map(move |(key_code, token)| {
(control_pressed && keys.just_pressed(key_code)).then_some(token)
})
}
fn special_key_tokens(keys: &ButtonInput<KeyCode>) -> impl Iterator<Item = KeyToken> + '_ {
[
(KeyCode::Escape, KeyToken::Escape),
(KeyCode::Enter, KeyToken::Enter),
(KeyCode::Backspace, KeyToken::Backspace),
]
.into_iter()
.filter_map(|(key_code, token)| keys.just_pressed(key_code).then_some(token))
}
#[cfg(test)]
fn normalized_key_tokens(
keys: &ButtonInput<KeyCode>,
typed_text: &[String],
) -> impl Iterator<Item = KeyToken> {
control_key_tokens(keys).chain(
typed_text
.iter()
.flat_map(|text| text.chars())
.map(KeyToken::Char),
)
}
fn normalized_editor_tokens(
input_events: &[EditorInputEvent],
) -> impl Iterator<Item = KeyToken> + '_ {
input_events.iter().flat_map(|event| match event {
EditorInputEvent::Key(key) => vec![key.token],
EditorInputEvent::Text(text) => text.text.chars().map(KeyToken::Char).collect(),
})
}
const fn repeatable_command_token(command: RepeatableNormalCommand) -> KeyToken {
match command {
RepeatableNormalCommand::Left => KeyToken::Char('h'),
RepeatableNormalCommand::Down => KeyToken::Char('j'),
RepeatableNormalCommand::Up => KeyToken::Char('k'),
RepeatableNormalCommand::Right => KeyToken::Char('l'),
RepeatableNormalCommand::NextWord => KeyToken::Char('w'),
RepeatableNormalCommand::PreviousWord => KeyToken::Char('b'),
RepeatableNormalCommand::RepeatSearchForward => KeyToken::Char('n'),
RepeatableNormalCommand::RepeatSearchBackward => KeyToken::Char('N'),
}
}
fn request_cursor_move_from_position(
previous_position: CursorPosition,
cursor: &VimCursor,
target: ViewEntity,
effects: &mut Vec<VimEffect>,
) {
request_cursor_move_if_changed(
previous_position.byte_index.get(),
cursor.byte_index(),
cursor.desired_column(),
target,
effects,
);
}
fn request_cursor_move_if_changed(
previous_byte_index: usize,
next_byte_index: usize,
desired_column: Option<usize>,
target: ViewEntity,
effects: &mut Vec<VimEffect>,
) {
if previous_byte_index != next_byte_index {
effects.push(VimEffect::CursorMove(CursorMoveRequested {
target,
byte_index: ByteIndex(next_byte_index),
desired_column,
}));
}
}
fn push_status_effect_from_line(
target: ViewEntity,
status_line: &VimStatusLine,
effects: &mut Vec<VimEffect>,
) {
let message = match status_line.message() {
Some(VimStatusMessage::Error(error)) => StatusMessage::Error(*error),
Some(VimStatusMessage::Info(message)) => StatusMessage::Info(message.clone()),
None => StatusMessage::Clear,
};
effects.push(VimEffect::Status(StatusMessageRequested {
target,
message,
}));
}
fn shift_pressed(keys: &ButtonInput<KeyCode>) -> bool {
keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight)
}
fn is_prompt_text_character(character: char) -> bool {
!character.is_control()
}
#[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::{
ecs::{
components::{
buffer::{
BufferEntity, BufferText, EditorBuffer, EditorView, FocusedEditorView,
ViewEntity,
},
cursor::{ByteIndex, CursorPosition},
layout::ViewportState,
vim::VimModalState,
},
events::{
command::CommandRequested,
cursor::CursorMoveRequested,
input::{EditorInputEvent, KeyInputEvent, TextInputEvent},
intent::ViewportIntent,
status::{StatusMessage, StatusMessageRequested},
},
},
text_stream::TextByteStream,
vim::{
KeyToken, SearchDirection, ViewportPosition, VimCommand, VimConfig, VimMode,
VimStatusMessage, VisualMode,
},
};
use bevy::prelude::{
App, ButtonInput, Entity, IntoScheduleConfigs, KeyCode, MessageReader, Resource, Update,
};
use proptest::prelude::*;
#[derive(Default, Resource)]
struct CapturedAdapterEffects {
cursor_moves: Vec<CursorMoveRequested>,
viewport_intents: Vec<ViewportIntent>,
status_requests: Vec<StatusMessageRequested>,
command_requests: Vec<CommandRequested>,
}
fn capture_adapter_effects(
mut cursor_reader: MessageReader<CursorMoveRequested>,
mut viewport_reader: MessageReader<ViewportIntent>,
mut status_reader: MessageReader<StatusMessageRequested>,
mut command_reader: MessageReader<CommandRequested>,
mut captured: bevy::prelude::ResMut<CapturedAdapterEffects>,
) {
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());
}
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::<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::<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);
}
}
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
}
#[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 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);
effect_writers.emit(&[
super::VimEffect::Viewport(ViewportIntent {
target,
placement: ViewportPosition::Center,
}),
super::VimEffect::Status(StatusMessageRequested {
target,
message: StatusMessage::Clear,
}),
]);
}
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 collected_vim_effects_emit_typed_ecs_messages() {
let mut app = App::new();
let _app = app
.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 {
target: ViewEntity(target),
byte_index: ByteIndex(0),
desired_column: None,
}]
);
assert_eq!(
captured.status_requests,
vec![StatusMessageRequested {
target: ViewEntity(target),
message: StatusMessage::Clear,
}]
);
}
#[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")
.status
.set_info("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.status.message(),
Some(&VimStatusMessage::Info(String::from("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")
.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.search.start(SearchDirection::Forward);
modal_state.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 {
target: ViewEntity(target),
byte_index: ByteIndex(1),
desired_column: 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.search.start(SearchDirection::Forward);
modal_state.search.push_text("two");
let outcome = modal_state.search.submit(text_value, 0);
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 {
target: ViewEntity(target),
byte_index: ByteIndex("one two one ".len()),
desired_column: 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.search.start(SearchDirection::Backward);
modal_state.search.push_text("two");
let outcome = modal_state.search.submit(text_value, text_value.len());
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 {
target: ViewEntity(target),
byte_index: ByteIndex("one ".len()),
desired_column: 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.mode = VimMode::Visual(VisualMode::Characterwise);
modal_state.selection.start("abc", 0);
modal_state.status.set_info("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.mode, VimMode::Normal);
assert!(modal_state.selection.selection().is_none());
assert_eq!(
modal_state.status.message(),
Some(&VimStatusMessage::Info(String::from("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")
.status
.set_info("first modal");
app.world_mut()
.get_mut::<VimModalState>(second)
.expect("second modal state should exist")
.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")
.status
.message()
.cloned();
if focus_second {
prop_assert_eq!(
unfocused_status,
Some(VimStatusMessage::Info(String::from("first modal")))
);
} else {
prop_assert_eq!(
unfocused_status,
Some(VimStatusMessage::Error(crate::vim::VimError::NoFileName))
);
}
}
}
}