alma 0.1.0

A Bevy-native modal text editor with Vim-style navigation.
Documentation
//! Editor focus ownership systems.

use crate::ecs::{
    components::buffer::{EditorView, FocusedEditorView, ViewEntity},
    events::focus::FocusRequested,
    schedules::EditorSet,
};
use bevy::prelude::{
    App, Commands, Entity, IntoScheduleConfigs, MessageReader, Plugin, Query, Update, With, Without,
};

/// ECS plugin that owns editor view focus markers.
#[derive(Clone, Copy, Debug, Default)]
pub struct FocusPlugin;

impl Plugin for FocusPlugin {
    fn build(&self, app: &mut App) {
        let _app = app.add_systems(
            Update,
            (apply_focus_requests, enforce_single_focused_editor_view)
                .chain()
                .in_set(EditorSet::Input),
        );
    }
}

/// Applies explicit focus requests to editor view entities.
fn apply_focus_requests(
    mut commands: Commands,
    mut requests: MessageReader<FocusRequested>,
    editor_views: Query<Entity, With<EditorView>>,
    focused_views: Query<Entity, (With<EditorView>, With<FocusedEditorView>)>,
) {
    let Some(request) = requests.read().last().copied() else {
        return;
    };
    if !editor_views.contains(request.target.get()) {
        return;
    }

    for focused in &focused_views {
        if ViewEntity(focused) != request.target {
            let _entity = commands.entity(focused).remove::<FocusedEditorView>();
        }
    }
    let _entity = commands
        .entity(request.target.get())
        .insert(FocusedEditorView);
}

/// Deterministically collapses accidental multi-focus states to one editor view.
fn enforce_single_focused_editor_view(
    mut commands: Commands,
    focused_buffers: Query<Entity, (With<EditorView>, With<FocusedEditorView>)>,
    unfocused_buffers: Query<Entity, (With<EditorView>, Without<FocusedEditorView>)>,
) {
    let mut focused = focused_buffers.iter().collect::<Vec<_>>();
    if focused.len() > 1 {
        focused.sort_unstable();
        for entity in focused.into_iter().skip(1) {
            let _entity = commands.entity(entity).remove::<FocusedEditorView>();
        }
        return;
    }

    if focused.is_empty() {
        let mut candidates = unfocused_buffers.iter().collect::<Vec<_>>();
        candidates.sort_unstable();
        if let Some(entity) = candidates.first().copied() {
            let _entity = commands.entity(entity).insert(FocusedEditorView);
        }
    }
}

#[cfg(test)]
mod tests {
    use super::FocusPlugin;
    use crate::ecs::{
        components::buffer::{EditorBuffer, EditorView, FocusedEditorView, ViewEntity},
        events::focus::FocusRequested,
    };
    use bevy::prelude::{App, Entity, With};
    use proptest::prelude::*;

    /// Spawns an editor view entity with optional focus.
    fn spawn_view(app: &mut App, focused: bool) -> Entity {
        let buffer = app.world_mut().spawn(EditorBuffer).id();
        let mut entity = app.world_mut().spawn(EditorView {
            buffer: crate::ecs::components::buffer::BufferEntity(buffer),
        });
        if focused {
            let _entity = entity.insert(FocusedEditorView);
        }
        entity.id()
    }

    /// Returns currently focused editor views in deterministic order.
    fn focused_views(app: &mut App) -> Vec<Entity> {
        let mut query = app
            .world_mut()
            .query_filtered::<Entity, (With<EditorView>, With<FocusedEditorView>)>();
        let mut entities = query.iter(app.world()).collect::<Vec<_>>();
        entities.sort_unstable();
        entities
    }

    #[test]
    fn focus_request_moves_focus_to_target_view() {
        let mut app = App::new();
        let _app = app
            .add_plugins(crate::ecs::EditorCorePlugin)
            .add_plugins(FocusPlugin);
        let first = spawn_view(&mut app, true);
        let second = spawn_view(&mut app, false);

        let _message = app.world_mut().write_message(FocusRequested {
            target: ViewEntity(second),
        });
        app.update();

        assert_eq!(focused_views(&mut app), vec![second]);
        assert!(!app.world().entity(first).contains::<FocusedEditorView>());
    }

    #[test]
    fn invalid_focus_request_keeps_existing_focus() {
        let mut app = App::new();
        let _app = app
            .add_plugins(crate::ecs::EditorCorePlugin)
            .add_plugins(FocusPlugin);
        let focused = spawn_view(&mut app, true);
        let unrelated = app.world_mut().spawn_empty().id();

        let _message = app.world_mut().write_message(FocusRequested {
            target: ViewEntity(unrelated),
        });
        app.update();

        assert_eq!(focused_views(&mut app), vec![focused]);
    }

    proptest! {
        #[test]
        fn focus_invariant_collapses_zero_or_many_focused_buffers(focused_flags in proptest::collection::vec(any::<bool>(), 1..24)) {
            let mut app = App::new();
            let _app = app
                .add_plugins(crate::ecs::EditorCorePlugin)
                .add_plugins(FocusPlugin);

            for focused in focused_flags {
                let _entity = spawn_view(&mut app, focused);
            }

            app.update();

            prop_assert_eq!(focused_views(&mut app).len(), 1);
        }
    }
}