alma 0.1.0

A Bevy-native modal text editor with Vim-style navigation.
Documentation
//! Managed text rendering and span synchronization.

use crate::{
    domain::render_text::{RenderedVimTextSpan, VimTextSpanKind},
    ecs::{
        components::{buffer::ViewEntity, layout::TextSpanLayout},
        schedules::EditorSet,
    },
};
use bevy::prelude::{
    App, Changed, Color, Commands, Component, DetectChanges, Entity, IntoScheduleConfigs, Plugin,
    Query, Ref, TextBackgroundColor, TextColor, TextSpan, Update,
};

use crate::features::ui::style::{
    cursor_color, cursor_text_color, scene_text_color, search_match_color, selection_color,
};

/// Text layout filter used by render synchronization.
type TextLayoutFilter = Changed<TextSpanLayout>;

/// Marker for the UI text node rendered from shaped text spans.
#[derive(Clone, Copy, Component, Debug, Eq, PartialEq)]
pub struct ManagedText {
    /// Editor buffer/view entity rendered by this text node.
    pub target: ViewEntity,
}

/// Plugin that synchronizes shaped text spans into Bevy UI entities.
#[derive(Clone, Copy, Debug, Default)]
pub struct TextRenderPlugin;

impl Plugin for TextRenderPlugin {
    fn build(&self, app: &mut App) {
        let _app = app.add_systems(Update, sync_managed_text.in_set(EditorSet::Render));
    }
}

/// Synchronizes the rendered text node with shaped span layout.
pub fn sync_managed_text(
    mut commands: Commands,
    changed_layout_query: Query<&TextSpanLayout, TextLayoutFilter>,
    all_layout_query: Query<&TextSpanLayout>,
    text_query: Query<(Entity, Ref<ManagedText>)>,
) {
    for (entity, managed_text) in &text_query {
        if managed_text.is_changed() {
            match all_layout_query.get(managed_text.target.get()) {
                Ok(span_layout) => sync_text_spans(&mut commands, entity, span_layout),
                Err(_error) => clear_text_spans(&mut commands, entity),
            }
        } else if let Ok(span_layout) = changed_layout_query.get(managed_text.target.get()) {
            sync_text_spans(&mut commands, entity, span_layout);
        } else if !all_layout_query.contains(managed_text.target.get()) {
            clear_text_spans(&mut commands, entity);
        }
    }
}

/// Rebuilds one managed text node from a shaped span layout.
fn sync_text_spans(commands: &mut Commands, entity: Entity, span_layout: &TextSpanLayout) {
    clear_text_spans(commands, entity);
    if span_layout.spans.is_empty() {
        return;
    }

    let _managed_text = commands.entity(entity).with_children(|text| {
        spawn_rendered_text_spans(text, &span_layout.spans);
    });
}

/// Removes all rendered child spans from one managed text node.
fn clear_text_spans(commands: &mut Commands, entity: Entity) {
    let _managed_text = commands
        .entity(entity)
        .despawn_related::<bevy::ecs::hierarchy::Children>();
}

/// Spawns the displayed text with the cursor rendered as a highlighted character cell.
fn spawn_rendered_text_spans(
    text: &mut bevy::ecs::hierarchy::ChildSpawnerCommands,
    spans: &[RenderedVimTextSpan],
) {
    for span in spans {
        let (text_color, background_color) = match span.kind {
            VimTextSpanKind::Normal => (scene_text_color(), Color::NONE),
            VimTextSpanKind::SearchMatch => (scene_text_color(), search_match_color()),
            VimTextSpanKind::Selection => (scene_text_color(), selection_color()),
            VimTextSpanKind::Cursor => (cursor_text_color(), cursor_color()),
        };

        let _span_entity = text.spawn((
            TextSpan::new(span.text.clone()),
            TextColor(text_color),
            TextBackgroundColor(background_color),
        ));
    }
}

#[cfg(test)]
mod tests {
    use super::{ManagedText, TextRenderPlugin};
    use crate::{
        domain::render_text::{RenderedVimTextSpan, VimTextSpanKind},
        ecs::components::{buffer::ViewEntity, layout::TextSpanLayout},
    };
    use bevy::prelude::{App, Entity};

    /// Returns child entities attached to a managed text root.
    fn child_entities(app: &bevy::prelude::App, entity: Entity) -> Vec<Entity> {
        app.world()
            .get::<bevy::ecs::hierarchy::Children>(entity)
            .map(|children| children.iter().copied().collect())
            .unwrap_or_default()
    }

    #[test]
    fn render_sync_only_rebuilds_when_span_layout_changes() {
        let mut app = App::new();
        let _app = app.add_plugins(TextRenderPlugin);
        let buffer = app
            .world_mut()
            .spawn((TextSpanLayout {
                spans: vec![RenderedVimTextSpan {
                    text: String::from("hello"),
                    kind: VimTextSpanKind::Normal,
                }],
            },))
            .id();
        let managed_text = app
            .world_mut()
            .spawn(ManagedText {
                target: ViewEntity(buffer),
            })
            .id();

        app.update();
        let first_children = child_entities(&app, managed_text);
        assert_eq!(first_children.len(), 1);

        app.update();
        assert_eq!(child_entities(&app, managed_text), first_children);

        app.world_mut()
            .query::<&mut TextSpanLayout>()
            .single_mut(app.world_mut())
            .expect("one span layout should exist")
            .spans
            .push(RenderedVimTextSpan {
                text: String::from("!"),
                kind: VimTextSpanKind::Cursor,
            });
        app.update();
        let changed_children = child_entities(&app, managed_text);
        assert_eq!(changed_children.len(), 2);
        assert_ne!(changed_children, first_children);
    }

    #[test]
    fn render_sync_clears_children_when_layout_becomes_empty() {
        let mut app = App::new();
        let _app = app.add_plugins(TextRenderPlugin);
        let buffer = app
            .world_mut()
            .spawn((TextSpanLayout {
                spans: vec![RenderedVimTextSpan {
                    text: String::from("stale"),
                    kind: VimTextSpanKind::Normal,
                }],
            },))
            .id();
        let managed_text = app
            .world_mut()
            .spawn(ManagedText {
                target: ViewEntity(buffer),
            })
            .id();

        app.update();
        assert_eq!(child_entities(&app, managed_text).len(), 1);

        app.world_mut()
            .get_mut::<TextSpanLayout>(buffer)
            .expect("span layout should exist")
            .spans
            .clear();
        app.update();

        assert!(child_entities(&app, managed_text).is_empty());
    }

    #[test]
    fn render_sync_clears_children_when_target_layout_is_missing() {
        let mut app = App::new();
        let _app = app.add_plugins(TextRenderPlugin);
        let buffer = app
            .world_mut()
            .spawn((TextSpanLayout {
                spans: vec![RenderedVimTextSpan {
                    text: String::from("target"),
                    kind: VimTextSpanKind::Normal,
                }],
            },))
            .id();
        let missing_target = app.world_mut().spawn_empty().id();
        let managed_text = app
            .world_mut()
            .spawn(ManagedText {
                target: ViewEntity(buffer),
            })
            .id();

        app.update();
        assert_eq!(child_entities(&app, managed_text).len(), 1);

        app.world_mut()
            .get_mut::<ManagedText>(managed_text)
            .expect("managed text should exist")
            .target = ViewEntity(missing_target);
        app.update();

        assert!(child_entities(&app, managed_text).is_empty());
    }

    #[test]
    fn render_sync_uses_each_managed_text_target() {
        let mut app = App::new();
        let _app = app.add_plugins(TextRenderPlugin);
        let first_buffer = app
            .world_mut()
            .spawn((TextSpanLayout {
                spans: vec![RenderedVimTextSpan {
                    text: String::from("first"),
                    kind: VimTextSpanKind::Normal,
                }],
            },))
            .id();
        let second_buffer = app
            .world_mut()
            .spawn((TextSpanLayout {
                spans: vec![
                    RenderedVimTextSpan {
                        text: String::from("sec"),
                        kind: VimTextSpanKind::Normal,
                    },
                    RenderedVimTextSpan {
                        text: String::from("ond"),
                        kind: VimTextSpanKind::Cursor,
                    },
                ],
            },))
            .id();
        let first_text = app
            .world_mut()
            .spawn(ManagedText {
                target: ViewEntity(first_buffer),
            })
            .id();
        let second_text = app
            .world_mut()
            .spawn(ManagedText {
                target: ViewEntity(second_buffer),
            })
            .id();

        app.update();

        assert_eq!(child_entities(&app, first_text).len(), 1);
        assert_eq!(child_entities(&app, second_text).len(), 2);
    }

    #[test]
    fn render_sync_populates_new_managed_text_for_stable_layout() {
        let mut app = App::new();
        let _app = app.add_plugins(TextRenderPlugin);
        let buffer = app
            .world_mut()
            .spawn((TextSpanLayout {
                spans: vec![RenderedVimTextSpan {
                    text: String::from("stable"),
                    kind: VimTextSpanKind::Normal,
                }],
            },))
            .id();

        app.update();
        let managed_text = app
            .world_mut()
            .spawn(ManagedText {
                target: ViewEntity(buffer),
            })
            .id();
        app.update();

        assert_eq!(child_entities(&app, managed_text).len(), 1);
    }
}