codecraft 0.1.2

A minimalist 3D game engine built on parts of Bevy (ECS, color) with wgpu and winit: OpenPBR materials, clustered lighting, a yakui-drawn UI, audio and gamepad haptics; its binary maps any folder, and the symbols of its Rust files, as a 3D wall of boxes
Documentation
//! The outliner: a live list of what is in the world.
use bevy_ecs::observer::Observer;
use bevy_ecs::prelude::*;
use bevy_ecs::resource::IsResource;
use bevy_ecs::system::SystemIdMarker;
use bevy_ecs::world::EntityRef;
use yakui::CrossAxisAlignment;
use yakui::geometry::{Constraints, Vec2};
use yakui::widgets::List;

use super::color::yakui_color;
use super::resources::ScreenSize;
use super::widgets::{self, SCREEN_MARGIN, icon, text};
use crate::sceneobjects::{Category, SceneObject};

const WIDTH: f32 = 200.0;
/// Starts below the top-left corner, which a game may use itself (chessrs has a MENU button there).
const TOP: f32 = 64.0;
const ICON: f32 = 13.0;
const PX: f32 = 14.0;

/// Whether the outliner is up.
#[derive(Resource, Default)]
pub struct Outliner {
    pub open: bool,
}

impl Outliner {
    pub fn is_open(&self) -> bool {
        self.open
    }

    pub fn toggle(&mut self) {
        self.open = !self.open;
    }
}

/// One line of the list.
#[derive(Clone, Debug, PartialEq)]
pub struct Row {
    pub entity: Entity,
    pub name: String,
    pub icon: &'static str,
    pub category: Category,
    /// Whether the entity had a [`SceneObject`].
    pub labelled: bool,
}

impl Row {
    fn of(entity: EntityRef) -> Self {
        match entity.get::<SceneObject>() {
            Some(object) => Self {
                entity: entity.id(),
                name: object.name.clone(),
                icon: object.icon,
                category: object.category,
                labelled: true,
            },
            None => Self {
                entity: entity.id(),
                name: format!("Entity {}", entity.id()),
                icon: super::icons::path::CIRCLE,
                category: Category::Scene,
                labelled: false,
            },
        }
    }
}

/// Every scene entity in the world, in list order.
pub fn rows(world: &World) -> Vec<Row> {
    let mut rows: Vec<Row> = world
        .iter_entities()
        // Resources, observers and one-shot systems are entities too.
        .filter(|entity| {
            !(entity.contains::<IsResource>()
                || entity.contains::<Observer>()
                || entity.contains::<SystemIdMarker>())
        })
        .map(Row::of)
        .collect();
    // Spawn order is not stable across rebuilds, so sort: unlabelled last, then category, then name.
    rows.sort_by(|a, b| {
        (!a.labelled, a.category as u8, a.name.as_str()).cmp(&(
            !b.labelled,
            b.category as u8,
            b.name.as_str(),
        ))
    });
    rows
}

/// Draws the list if open, closing it on its X; call inside a [`widgets::screen`].
pub fn show(world: &mut World) {
    if !world.resource::<Outliner>().open {
        return;
    }
    let screen = *world.resource::<ScreenSize>();
    let rows = rows(world);

    let response = widgets::window(
        "OUTLINER",
        Vec2::new(SCREEN_MARGIN, TOP),
        WIDTH + widgets::PANEL_PADDING * 2.0,
        true,
        || {
            let room = (screen.height - TOP - SCREEN_MARGIN - widgets::TITLE_HEIGHT).max(50.0);
            yakui::constrained(
                Constraints {
                    min: Vec2::new(WIDTH, 0.0),
                    max: Vec2::new(WIDTH, room),
                },
                || {
                    yakui::scroll_vertical(|| {
                        let mut column = List::column();
                        column.main_axis_size = yakui::MainAxisSize::Min;
                        column.show(|| {
                            for row in &rows {
                                let mut line = List::row();
                                line.item_spacing = 5.0;
                                line.cross_axis_alignment = CrossAxisAlignment::Center;
                                line.show(|| {
                                    icon(row.icon, ICON, yakui_color(row.category.color()));
                                    text(PX, row.name.clone());
                                });
                            }
                        });
                    });
                },
            );
        },
    );
    if response.closed {
        world.resource_mut::<Outliner>().open = false;
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::ecs::Application;
    use crate::ui::icons::path;
    use crate::ui::plugin::UiPlugin;

    fn lamp(name: impl Into<String>) -> SceneObject {
        SceneObject::new(name, path::LAMP, Category::Light)
    }

    fn block(name: impl Into<String>) -> SceneObject {
        SceneObject::new(name, path::CUBE, Category::Mesh)
    }

    fn names(world: &World) -> Vec<String> {
        rows(world).into_iter().map(|row| row.name).collect()
    }

    #[test]
    fn rows_are_grouped_by_category_then_named_in_order() {
        let mut world = World::new();
        world.spawn(block("Zebra"));
        world.spawn(lamp("Key"));
        world.spawn(block("Apple"));
        world.spawn(lamp("Fill"));

        assert_eq!(names(&world), ["Fill", "Key", "Apple", "Zebra"]);
    }

    #[test]
    fn a_row_carries_its_object_s_icon_tinted_by_its_category() {
        let mut world = World::new();
        world.spawn(lamp("Lamp 1"));
        let rows = rows(&world);
        assert_eq!(rows.len(), 1);
        assert_eq!(rows[0].icon, path::LAMP);
        assert_eq!(rows[0].category, Category::Light);
    }

    #[test]
    fn an_entity_with_nothing_to_say_for_itself_is_still_listed() {
        let mut world = World::new();
        let bare = world.spawn(()).id();
        world.spawn(block("Block 1"));
        assert_eq!(names(&world), ["Block 1", &format!("Entity {bare}")]);
    }

    #[test]
    fn the_list_does_not_list_the_world_s_bookkeeping() {
        let mut app = Application::new();
        app.add_plugin(UiPlugin);
        app.world.spawn(lamp("Lamp 1"));
        assert_eq!(names(&app.world), ["Lamp 1"]);
    }

    #[test]
    fn it_draws_while_open_and_the_x_closes_it() {
        use crate::ui::resources::{CursorPosition, MouseInput};
        use crate::ui::state::Ui;

        let mut app = Application::new();
        app.add_plugin(UiPlugin);
        app.world.insert_resource(ScreenSize {
            width: 1280.0,
            height: 800.0,
        });
        for i in 0..50 {
            app.world.spawn(block(format!("Block {i:02}")));
        }
        app.world.resource_mut::<Outliner>().toggle();

        let frame = |app: &mut Application| {
            Ui::begin_frame(&mut app.world);
            widgets::screen(|| show(&mut app.world));
            app.update();
            Ui::end_frame(&mut app.world);
        };
        frame(&mut app);
        frame(&mut app);
        assert!(app.world.resource::<Outliner>().is_open());

        let x = (
            SCREEN_MARGIN + WIDTH + widgets::PANEL_PADDING * 2.0 - widgets::TITLE_HEIGHT * 0.5,
            TOP + widgets::TITLE_HEIGHT * 0.5,
        );
        app.world.insert_resource(CursorPosition { x: x.0, y: x.1 });
        app.world.insert_resource(MouseInput {
            left_down: true,
            just_pressed: true,
            ..MouseInput::default()
        });
        frame(&mut app);
        app.world.insert_resource(MouseInput::default());
        frame(&mut app);
        assert!(
            !app.world.resource::<Outliner>().is_open(),
            "the X closed it"
        );
    }
}