use crate::{
domain::render_text::{active_search_match_ranges, active_selection_range, render_text_spans},
ecs::{
components::{
buffer::{BufferText, EditorBuffer, EditorView, ViewEntity},
cursor::CursorPosition,
layout::{TextSpanLayout, ViewportState, VisibleTextRange},
vim::VimModalState,
},
events::intent::ViewportIntent,
schedules::EditorSet,
},
vim::ViewportPosition,
};
use bevy::prelude::{
App, DetectChanges, Entity, IntoScheduleConfigs, MessageReader, Mut, Plugin, Query, Ref,
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.add_systems(Update, update_text_span_layouts.in_set(EditorSet::Shape));
}
}
fn apply_viewport_intents(
mut intents: MessageReader<ViewportIntent>,
mut query: Query<(Entity, &mut ViewportState), With<EditorView>>,
) {
for intent in intents.read() {
for (entity, mut viewport_state) in &mut query {
if ViewEntity(entity) != intent.target {
continue;
}
viewport_state.pending_intent = Some(*intent);
}
}
}
#[allow(clippy::type_complexity)]
fn update_visible_text_ranges(
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 {
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::type_complexity)]
fn update_text_span_layouts(
buffers: Query<Ref<BufferText>, With<EditorBuffer>>,
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_range = active_selection_range(
text,
modal_state.mode,
&modal_state.selection,
cursor.byte_index.get(),
);
let search_ranges =
active_search_match_ranges(text, modal_state.search.last_query(), range.clone());
let next_spans = render_text_spans(
visible_text,
range.start,
cursor.byte_index.get(),
selection_range,
&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},
},
text_stream::TextByteStream,
vim::ViewportPosition,
};
use bevy::prelude::{App, Entity, With};
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 {
target: ViewEntity(target),
byte_index: ByteIndex("one\n".len()),
desired_column: 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 {
target: ViewEntity(second),
byte_index: ByteIndex("aa\n".len()),
desired_column: 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());
}
}