alma 0.1.0

A Bevy-native modal text editor with Vim-style navigation.
Documentation
//! Scene setup systems.

use crate::{
    ecs::{
        components::buffer::{EditorView, FocusedEditorView, ViewEntity},
        schedules::EditorStartupSet,
    },
    features::ui::STATUS_BAR_HEIGHT,
    fonts::{EDITOR_FONT_SIZE, editor_font},
    render::ManagedText,
};
use bevy::camera::ScalingMode;
use bevy::prelude::{
    App, AssetServer, Assets, Camera3d, Color, Commands, Entity, IntoScheduleConfigs,
    IsDefaultUiCamera, Justify, Mesh, Mesh3d, MeshMaterial3d, Node, OrthographicProjection,
    Plane3d, Plugin, PositionType, Projection, Query, Res, ResMut, StandardMaterial, Startup, Text,
    TextColor, TextLayout, Transform, Val, Vec2, Vec3, With, default,
};

use crate::features::ui::style::scene_text_color;

/// Half-width and half-depth of the scene plane in world units.
const BACKGROUND_PLANE_HALF_SIZE: f32 = 64.0;

/// Height of the top-down orthographic camera view in world units.
const CAMERA_VIEWPORT_HEIGHT: f32 = 12.0;

/// Height of the camera above the background plane.
const CAMERA_HEIGHT: f32 = 10.0;

/// UI inset for the scene label in logical pixels.
const LABEL_INSET: f32 = 32.0;

/// Plugin that owns initial editor view scene setup.
#[derive(Clone, Copy, Debug, Default)]
pub struct EditorViewPlugin;

impl Plugin for EditorViewPlugin {
    fn build(&self, app: &mut App) {
        let _app = app.add_systems(Startup, setup_scene.in_set(EditorStartupSet::Scene));
    }
}

/// Builds the initial camera, plane, and text specimen stack.
pub fn setup_scene(
    mut commands: Commands,
    asset_server: Res<AssetServer>,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<StandardMaterial>>,
    focused_view: Query<Entity, (With<EditorView>, With<FocusedEditorView>)>,
) {
    let asset_server = asset_server.into_inner();
    let Some(render_target) = focused_view.iter().next() else {
        return;
    };

    let _plane_entity = commands
        .spawn((
            Mesh3d(meshes.add(Plane3d::new(
                Vec3::Y,
                Vec2::splat(BACKGROUND_PLANE_HALF_SIZE),
            ))),
            MeshMaterial3d(materials.add(Color::srgb_u8(0x00, 0x1E, 0x27))),
        ))
        .id();

    let _camera_entity = commands
        .spawn((
            Camera3d::default(),
            Projection::from(OrthographicProjection {
                scaling_mode: ScalingMode::FixedVertical {
                    viewport_height: CAMERA_VIEWPORT_HEIGHT,
                },
                ..OrthographicProjection::default_3d()
            }),
            Transform::from_xyz(0.0, CAMERA_HEIGHT, 0.0).looking_at(Vec3::ZERO, Vec3::Z),
            IsDefaultUiCamera,
        ))
        .id();

    let _text_entity = commands
        .spawn((Node {
            position_type: PositionType::Absolute,
            left: Val::Px(LABEL_INSET),
            top: Val::Px(LABEL_INSET),
            right: Val::Px(LABEL_INSET),
            bottom: Val::Px(STATUS_BAR_HEIGHT + LABEL_INSET),
            ..default()
        },))
        .with_children(|parent| {
            let _managed_text_entity = parent
                .spawn((
                    Text::default(),
                    editor_font(asset_server, EDITOR_FONT_SIZE),
                    TextColor(scene_text_color()),
                    TextLayout::new_with_justify(Justify::Left),
                    ManagedText {
                        target: ViewEntity(render_target),
                    },
                ))
                .id();
        })
        .id();
}

#[cfg(test)]
mod tests {
    use super::{EditorViewPlugin, ManagedText};
    use crate::{
        buffer::BufferFile,
        ecs::{
            buffer::{BufferPlugin, InitialEditorBuffer},
            components::buffer::{EditorView, FocusedEditorView, ViewEntity},
        },
        text_stream::TextByteStream,
    };
    use bevy::asset::AssetApp;
    use bevy::prelude::{
        App, AssetPlugin, Assets, Entity, Font, Mesh, MinimalPlugins, StandardMaterial, With,
    };

    /// Returns the single focused 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>, With<FocusedEditorView>)>();
        query
            .iter(app.world())
            .next()
            .expect("focused editor view should exist")
    }

    #[test]
    fn scene_startup_targets_spawned_focused_buffer() {
        let mut app = App::new();
        let _app = app
            .insert_resource(InitialEditorBuffer {
                stream: TextByteStream::new("scene text"),
                file: BufferFile::scratch(0),
            })
            .add_plugins(MinimalPlugins)
            .add_plugins(AssetPlugin {
                watch_for_changes_override: Some(false),
                ..AssetPlugin::default()
            })
            .init_resource::<Assets<Mesh>>()
            .init_resource::<Assets<StandardMaterial>>()
            .init_asset::<Font>()
            .add_plugins(crate::ecs::EditorCorePlugin)
            .add_plugins(BufferPlugin)
            .add_plugins(EditorViewPlugin);

        app.update();

        let target = editor_view_entity(&mut app);
        let mut managed_text_query = app.world_mut().query::<&ManagedText>();
        let managed_text = managed_text_query
            .single(app.world())
            .expect("scene should spawn one managed text node");
        assert_eq!(managed_text.target, ViewEntity(target));
    }
}