use crate::{
domain::render_text::{
active_search_match_ranges, active_selection_ranges,
render_text_spans_with_selection_ranges,
},
ecs::{
components::{
buffer::{BufferText, EditorBuffer, EditorView},
cursor::CursorPosition,
layout::{TextSpanLayout, ViewportState, VisibleTextRange},
vim::VimModalState,
},
events::intent::{ViewportIntent, ViewportIntentRejected, ViewportRejectionReason},
schedules::EditorSet,
},
vim::{ViewportPosition, VimConfig},
};
use bevy::prelude::{
App, DetectChanges, IntoScheduleConfigs, MessageReader, MessageWriter, Mut, Plugin, Query, Ref,
Res, Update, With,
};
#[derive(Clone, Copy, Debug, Default)]
pub struct LayoutPlugin;
impl Plugin for LayoutPlugin {
fn build(&self, app: &mut App) {
let _app = app.add_systems(
Update,
(apply_viewport_intents, update_visible_text_ranges)
.chain()
.in_set(EditorSet::Layout),
);
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct TextShapePlugin;
impl Plugin for TextShapePlugin {
fn build(&self, app: &mut App) {
let _app = app
.init_resource::<VimConfig>()
.add_systems(Update, update_text_span_layouts.in_set(EditorSet::Shape));
}
}
fn apply_viewport_intents(
mut intents: MessageReader<ViewportIntent>,
mut rejected: MessageWriter<ViewportIntentRejected>,
mut query: Query<&mut ViewportState, With<EditorView>>,
) {
for intent in intents.read() {
let Ok(mut viewport_state) = query.get_mut(intent.target.get()) else {
let _sent = rejected.write(ViewportIntentRejected {
target: intent.target,
placement: intent.placement,
reason: ViewportRejectionReason::MissingTarget {
target: intent.target,
},
});
continue;
};
viewport_state.pending_intent = Some(*intent);
}
}
#[allow(clippy::type_complexity)]
fn update_visible_text_ranges(
mut rejected: MessageWriter<ViewportIntentRejected>,
buffers: Query<Ref<BufferText>, With<EditorBuffer>>,
mut query: Query<
(
Ref<EditorView>,
Ref<CursorPosition>,
Mut<ViewportState>,
Mut<VisibleTextRange>,
),
With<EditorView>,
>,
) {
for (view, cursor, mut viewport_state, mut visible_range) in &mut query {
let Ok(buffer_text) = buffers.get(view.buffer.get()) else {
if let Some(intent) = viewport_state.pending_intent.take() {
let _sent = rejected.write(ViewportIntentRejected {
target: intent.target,
placement: intent.placement,
reason: ViewportRejectionReason::MissingBuffer {
target: intent.target,
buffer: view.buffer,
},
});
}
continue;
};
let text = buffer_text.stream.as_str();
let was_empty = visible_range.range.is_empty();
let mut changed = false;
if let Some(intent) = viewport_state.pending_intent.take() {
changed |= apply_viewport_intent(
&mut viewport_state.viewport,
text,
cursor.byte_index.get(),
intent,
);
}
if changed || was_empty || buffer_text.is_changed() || cursor.is_changed() {
let _follow_changed = viewport_state
.viewport
.follow_cursor(text, cursor.byte_index.get());
}
debug_assert!(
viewport_state
.viewport
.contains_cursor(cursor.byte_index.get())
|| text.is_empty()
);
let byte_range = viewport_state.viewport.byte_range();
let next_range = byte_range.slice_range();
if visible_range.range != next_range {
visible_range.range = next_range.clone();
}
}
}
fn apply_viewport_intent(
viewport: &mut crate::domain::viewport::TextViewport,
text: &str,
cursor_byte_index: usize,
intent: ViewportIntent,
) -> bool {
match intent {
ViewportIntent {
placement: ViewportPosition::Top,
..
} => viewport.position_cursor_top(text, cursor_byte_index),
ViewportIntent {
placement: ViewportPosition::Center,
..
} => viewport.position_cursor_center(text, cursor_byte_index),
ViewportIntent {
placement: ViewportPosition::Bottom,
..
} => viewport.position_cursor_bottom(text, cursor_byte_index),
}
}
#[allow(clippy::needless_pass_by_value, clippy::type_complexity)]
fn update_text_span_layouts(
buffers: Query<Ref<BufferText>, With<EditorBuffer>>,
vim_config: Res<VimConfig>,
mut query: Query<
(
Ref<EditorView>,
Ref<CursorPosition>,
Ref<VisibleTextRange>,
Ref<VimModalState>,
Mut<TextSpanLayout>,
),
With<EditorView>,
>,
) {
for (view, cursor, visible_range, modal_state, mut span_layout) in &mut query {
let Ok(buffer_text) = buffers.get(view.buffer.get()) else {
if !span_layout.spans.is_empty() {
span_layout.spans.clear();
}
continue;
};
if !view.is_changed()
&& !buffer_text.is_changed()
&& !cursor.is_changed()
&& !visible_range.is_changed()
&& !modal_state.is_changed()
{
continue;
}
let text = buffer_text.stream.as_str();
let range = visible_range.range.clone();
let visible_text = &text[range.clone()];
let selection_ranges = active_selection_ranges(
text,
modal_state.editor.mode,
&modal_state.editor.selection,
cursor.byte_index.get(),
);
let search_ranges = active_search_match_ranges(
text,
modal_state.editor.search.last_query(),
range.clone(),
&vim_config.options,
);
let next_spans = render_text_spans_with_selection_ranges(
visible_text,
range.start,
cursor.byte_index.get(),
&selection_ranges,
&search_ranges,
);
if span_layout.spans != next_spans {
span_layout.spans = next_spans;
}
}
}
#[cfg(test)]
mod tests {
use super::{LayoutPlugin, TextShapePlugin};
use crate::{
buffer::BufferFile,
domain::viewport::TextViewport,
ecs::{
buffer::{BufferPlugin, InitialEditorBuffer},
components::{
buffer::{
BufferEntity, BufferRevision, BufferText, EditorBuffer, EditorView, ViewEntity,
},
cursor::{ByteIndex, CursorPosition},
layout::{TextSpanLayout, ViewportState, VisibleTextRange},
vim::VimModalState,
},
events::{
cursor::CursorMoveRequested,
intent::{ViewportIntent, ViewportIntentRejected, ViewportRejectionReason},
},
},
text_stream::TextByteStream,
vim::ViewportPosition,
};
use bevy::prelude::{App, Entity, MessageReader, Resource, Update, With};
#[derive(Default, Resource)]
struct CapturedRejectedViewportIntents {
events: Vec<ViewportIntentRejected>,
}
fn capture_rejected_viewport_intents(
mut reader: MessageReader<ViewportIntentRejected>,
mut captured: bevy::prelude::ResMut<CapturedRejectedViewportIntents>,
) {
captured.events.extend(reader.read().copied());
}
fn editor_buffer_entity(app: &mut App) -> Entity {
let mut entity_query = app
.world_mut()
.query_filtered::<Entity, With<EditorBuffer>>();
entity_query
.iter(app.world())
.next()
.expect("editor buffer should exist")
}
fn editor_view_entity(app: &mut App) -> Entity {
let mut query = app.world_mut().query_filtered::<Entity, With<EditorView>>();
query
.iter(app.world())
.next()
.expect("editor view should exist")
}
fn spawn_editor_buffer_entity(app: &mut App, text: &str) -> Entity {
let stream = TextByteStream::new(text);
let revision = stream.revision();
let buffer = app
.world_mut()
.spawn((
EditorBuffer,
BufferFile::scratch(revision),
BufferText { stream },
BufferRevision { revision },
))
.id();
app.world_mut()
.spawn((
EditorView {
buffer: BufferEntity(buffer),
},
CursorPosition {
byte_index: ByteIndex(0),
desired_column: None,
},
ViewportState {
viewport: TextViewport::default(),
pending_intent: None,
},
VisibleTextRange::default(),
TextSpanLayout::default(),
VimModalState::default(),
))
.id()
}
#[test]
fn layout_and_shape_plugins_derive_visible_spans() {
let mut app = App::new();
let _app = app
.insert_resource(InitialEditorBuffer {
stream: TextByteStream::new("one\ntwo\nthree"),
file: BufferFile::scratch(0),
})
.add_plugins(crate::ecs::EditorCorePlugin)
.add_plugins(BufferPlugin)
.add_plugins((LayoutPlugin, TextShapePlugin));
app.update();
let target = editor_view_entity(&mut app);
let _message = app
.world_mut()
.write_message(CursorMoveRequested::cursor_cell(
ViewEntity(target),
ByteIndex("one\n".len()),
None,
));
let _message = app.world_mut().write_message(ViewportIntent {
target: ViewEntity(target),
placement: ViewportPosition::Top,
});
app.update();
let mut range_query = app.world_mut().query::<&VisibleTextRange>();
let range = range_query
.iter(app.world())
.next()
.expect("visible range should exist");
assert_eq!(range.range.start, "one\n".len());
let mut span_query = app.world_mut().query::<&TextSpanLayout>();
let spans = span_query
.iter(app.world())
.next()
.expect("text spans should exist");
assert!(!spans.spans.is_empty());
}
#[test]
fn viewport_intents_only_affect_target_entity() {
let mut app = App::new();
let _app = app
.insert_resource(InitialEditorBuffer {
stream: TextByteStream::new("first\nbuffer\ntext"),
file: BufferFile::scratch(0),
})
.add_plugins(crate::ecs::EditorCorePlugin)
.add_plugins(BufferPlugin)
.add_plugins((LayoutPlugin, TextShapePlugin));
app.update();
let _first = editor_buffer_entity(&mut app);
let first_view = editor_view_entity(&mut app);
let second = spawn_editor_buffer_entity(&mut app, "aa\nbb\ncc");
let _message = app
.world_mut()
.write_message(CursorMoveRequested::cursor_cell(
ViewEntity(second),
ByteIndex("aa\n".len()),
None,
));
let _message = app.world_mut().write_message(ViewportIntent {
target: ViewEntity(second),
placement: ViewportPosition::Top,
});
app.update();
let first_range = app
.world()
.get::<VisibleTextRange>(first_view)
.expect("first visible range should exist");
let second_range = app
.world()
.get::<VisibleTextRange>(second)
.expect("second visible range should exist");
assert_eq!(first_range.range.start, 0);
assert_eq!(second_range.range.start, "aa\n".len());
}
#[test]
fn viewport_intent_missing_target_emits_typed_rejection() {
let mut app = App::new();
let _app = app
.insert_resource(InitialEditorBuffer {
stream: TextByteStream::new("first\nbuffer\ntext"),
file: BufferFile::scratch(0),
})
.init_resource::<CapturedRejectedViewportIntents>()
.add_plugins(crate::ecs::EditorCorePlugin)
.add_plugins(BufferPlugin)
.add_plugins((LayoutPlugin, TextShapePlugin))
.add_systems(Update, capture_rejected_viewport_intents);
app.update();
let missing =
ViewEntity(Entity::from_raw_u32(995).expect("test entity index should be valid"));
let _message = app.world_mut().write_message(ViewportIntent {
target: missing,
placement: ViewportPosition::Top,
});
app.update();
app.update();
assert_eq!(
app.world()
.resource::<CapturedRejectedViewportIntents>()
.events,
vec![ViewportIntentRejected {
target: missing,
placement: ViewportPosition::Top,
reason: ViewportRejectionReason::MissingTarget { target: missing },
}]
);
}
#[test]
fn viewport_intent_missing_buffer_emits_typed_rejection() {
let mut app = App::new();
let _app = app
.insert_resource(InitialEditorBuffer {
stream: TextByteStream::new("first\nbuffer\ntext"),
file: BufferFile::scratch(0),
})
.init_resource::<CapturedRejectedViewportIntents>()
.add_plugins(crate::ecs::EditorCorePlugin)
.add_plugins(BufferPlugin)
.add_plugins((LayoutPlugin, TextShapePlugin))
.add_systems(Update, capture_rejected_viewport_intents);
app.update();
let target = editor_view_entity(&mut app);
let missing_buffer =
BufferEntity(Entity::from_raw_u32(994).expect("test entity index should be valid"));
app.world_mut()
.get_mut::<EditorView>(target)
.expect("editor view should exist")
.buffer = missing_buffer;
let _message = app.world_mut().write_message(ViewportIntent {
target: ViewEntity(target),
placement: ViewportPosition::Center,
});
app.update();
app.update();
assert_eq!(
app.world()
.resource::<CapturedRejectedViewportIntents>()
.events,
vec![ViewportIntentRejected {
target: ViewEntity(target),
placement: ViewportPosition::Center,
reason: ViewportRejectionReason::MissingBuffer {
target: ViewEntity(target),
buffer: missing_buffer,
},
}]
);
}
}