alma 0.1.0

A Bevy-native modal text editor with Vim-style navigation.
Documentation
//! Layout and text-shaping systems.

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,
};

/// ECS plugin that derives visible layout from editor state.
#[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),
        );
    }
}

/// ECS plugin that derives render-facing text spans from layout state.
#[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));
    }
}

/// Records cursor-relative viewport placement requests on the buffer view state.
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);
        }
    }
}

/// Updates visible text ranges from buffer, cursor, and viewport state.
#[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();
        }
    }
}

/// Applies a concrete viewport intent to the pure viewport tracker.
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),
    }
}

/// Derives styled text spans from visible text and Vim presentation state.
#[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};

    /// Returns the single editor buffer entity in a test app.
    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")
    }

    /// Returns the first editor view entity in a test app.
    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")
    }

    /// Spawns an additional editor buffer entity for multi-entity layout tests.
    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());
    }
}