mod menu;
use menu::{BADGE_ENTITIES, badge, menu};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use crate::ecs::{
Application, Commands, Component, Entity, Plugin, Res, ResMut, Resource, With, World,
};
use crate::input::{KeyCode, Keys};
use crate::ui::{ButtonIcons, Interaction, MouseInput, PointerCapture, ScreenSize, Submenu};
pub const TOGGLE: KeyCode = KeyCode::F12;
pub const OUTLINER: KeyCode = KeyCode::F11;
pub const FONT: KeyCode = KeyCode::F10;
pub const PROFILER: KeyCode = KeyCode::F9;
#[derive(Resource, Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct DevMode(pub bool);
impl DevMode {
pub fn is_on(self) -> bool {
self.0
}
}
#[derive(Component, Clone, Copy, Debug)]
pub struct DevBadge;
#[derive(Resource, Clone, Default)]
pub struct DevMenu {
open: Arc<AtomicBool>,
outliner_requested: Arc<AtomicBool>,
entities: Vec<Entity>,
badge_button: Option<Entity>,
}
impl DevMenu {
pub fn is_open(&self) -> bool {
self.open.load(Ordering::Relaxed)
}
}
pub fn dev_mode_system(keys: Res<Keys>, dev: Res<DevMode>, mut commands: Commands) {
if !keys.just_pressed(TOGGLE) {
return;
}
let on = !dev.is_on();
commands.queue(move |world: &mut World| set(world, on));
}
pub fn set(world: &mut World, on: bool) {
let was = std::mem::replace(&mut world.resource_mut::<DevMode>().0, on);
if was != on {
log::info!("dev mode {}", if on { "on" } else { "off" });
}
let mut menu = world.remove_resource::<DevMenu>().unwrap_or_default();
for entity in menu.entities.drain(..) {
if let Ok(entity) = world.get_entity_mut(entity) {
entity.despawn();
}
}
let strays: Vec<Entity> = world
.query_filtered::<Entity, With<DevBadge>>()
.iter(world)
.collect();
for entity in strays {
world.entity_mut(entity).despawn();
}
if on {
let (width, height) = {
let screen = world.resource::<ScreenSize>();
(screen.width, screen.height)
};
let open = menu.open.clone();
let layout = badge(world, open, width, height);
menu.badge_button = layout.buttons.first().copied();
menu.entities = layout.entities();
for &entity in &menu.entities {
world.entity_mut(entity).insert(DevBadge);
}
} else {
menu.open.store(false, Ordering::Relaxed);
menu.badge_button = None;
world.resource_mut::<crate::ui::Outliner>().open = false;
}
world.insert_resource(menu);
}
pub fn outliner_key_system(
keys: Res<Keys>,
dev: Res<DevMode>,
mut outliner: ResMut<crate::ui::Outliner>,
) {
if !dev.is_on() || !keys.just_pressed(OUTLINER) {
return;
}
outliner.toggle();
log::info!(
"outliner {}",
match outliner.is_open() {
true => "up",
false => "down",
},
);
}
pub fn profiler_key_system(
keys: Res<Keys>,
dev: Res<DevMode>,
mut profiler: ResMut<crate::ui::Profiler>,
) {
if dev.is_on() && keys.just_pressed(PROFILER) {
profiler.on = !profiler.on;
log::info!("profiler {}", match profiler.on {
true => "on",
false => "off",
});
}
}
pub fn font_key_system(
keys: Res<Keys>,
dev: Res<DevMode>,
mut font: ResMut<crate::ui::Font>,
) {
if dev.is_on() && keys.just_pressed(FONT) {
font.cycle();
}
}
pub fn dev_menu_system(dev: Res<DevMode>, mut commands: Commands) {
if !dev.is_on() {
return;
}
commands.queue(|world: &mut World| {
let mut menu = world.remove_resource::<DevMenu>().unwrap_or_default();
if menu.outliner_requested.swap(false, Ordering::Relaxed) {
world.resource_mut::<crate::ui::Outliner>().toggle();
}
if menu.entities.len() > BADGE_ENTITIES {
let chosen = menu.entities[BADGE_ENTITIES..].iter().any(|&entity| {
world
.get::<Interaction>(entity)
.is_some_and(|interaction| interaction.clicked)
&& world.get::<Submenu>(entity).is_none()
});
let elsewhere = world.resource::<MouseInput>().just_pressed
&& !world.resource::<PointerCapture>().over_panel;
if chosen || elsewhere {
menu.open.store(false, Ordering::Relaxed);
}
}
let wanted = menu.open.load(Ordering::Relaxed);
if let Some(button) = menu.badge_button {
if let Some(mut icons) = world.get_mut::<ButtonIcons>(button) {
icons.trailing = Some(match wanted {
true => crate::ui::icons::path::CARET_DOWN,
false => crate::ui::icons::path::CARET_UP,
});
}
}
let up = menu.entities.len() > BADGE_ENTITIES;
if wanted != up {
if wanted {
let (width, height) = {
let screen = world.resource::<ScreenSize>();
(screen.width, screen.height)
};
let requested = menu.outliner_requested.clone();
let layout = self::menu(world, requested, width, height);
menu.entities.extend(layout.entities());
} else {
for entity in menu.entities.split_off(BADGE_ENTITIES) {
if let Ok(entity) = world.get_entity_mut(entity) {
entity.despawn();
}
}
}
}
world.insert_resource(menu);
});
}
pub struct DevPlugin;
impl Plugin for DevPlugin {
fn build(&self, app: &mut Application) {
app.init_resource::<DevMode>();
app.init_resource::<DevMenu>();
app.init_resource::<ScreenSize>();
app.init_resource::<crate::ui::Outliner>();
app.init_resource::<crate::ui::Font>();
app.add_update_systems(dev_mode_system);
app.add_update_systems(dev_menu_system);
app.add_update_systems(outliner_key_system);
app.add_update_systems(font_key_system);
app.init_resource::<crate::ui::Profiler>();
app.add_update_systems(profiler_key_system);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ecs::Application;
use crate::ui::{CursorPosition, Submenu, UiPlugin, Visibility};
fn app() -> Application {
let mut app = Application::new();
app.add_plugin(UiPlugin);
app.insert_resource(Keys::default());
app.add_plugin(DevPlugin);
app.world.insert_resource(ScreenSize {
width: 1280.0,
height: 800.0,
});
app
}
fn press(app: &mut Application, key: KeyCode) {
app.world.resource_mut::<Keys>().press(key, false);
app.update();
let mut keys = app.world.resource_mut::<Keys>();
keys.end_frame();
keys.release(key);
}
fn press_f12(app: &mut Application) {
press(app, TOGGLE);
}
fn outliner_open(app: &Application) -> bool {
app.world.resource::<crate::ui::Outliner>().is_open()
}
fn badges(app: &mut Application) -> usize {
app.world
.query_filtered::<Entity, With<DevBadge>>()
.iter(&app.world)
.count()
}
fn click(app: &mut Application, entity: Entity) {
let rect = {
let world = &app.world;
let pos = world.get::<crate::ui::Position>(entity).unwrap();
let size = world.get::<crate::ui::Size>(entity).unwrap();
pos.rect(size)
};
app.world.insert_resource(CursorPosition {
x: rect.center_x(),
y: rect.center_y(),
});
app.world.resource_mut::<crate::ui::MouseInput>().left_down = true;
app.world
.resource_mut::<crate::ui::MouseInput>()
.just_pressed = true;
app.update();
app.world.resource_mut::<crate::ui::MouseInput>().left_down = false;
app.update();
}
fn click_at(app: &mut Application, x: f32, y: f32) {
app.world.insert_resource(CursorPosition { x, y });
let mut mouse = app.world.resource_mut::<crate::ui::MouseInput>();
mouse.left_down = true;
mouse.just_pressed = true;
app.update();
app.world.insert_resource(crate::ui::MouseInput::default());
app.update();
}
fn leaf(app: &mut Application) -> Entity {
let menu = app.world.resource::<DevMenu>().clone();
menu.entities[BADGE_ENTITIES..]
.iter()
.copied()
.find(|&entity| {
app.world.get::<crate::ui::Interaction>(entity).is_some()
&& app.world.get::<crate::ui::Submenu>(entity).is_none()
})
.expect("the menu should have an entry that is not a submenu")
}
#[test]
fn choosing_an_entry_closes_the_menu() {
let mut app = app();
press_f12(&mut app);
let badge = badge_button(&mut app);
click(&mut app, badge);
assert!(app.world.resource::<DevMenu>().is_open());
let entry = leaf(&mut app);
click(&mut app, entry);
app.update();
assert!(
!app.world.resource::<DevMenu>().is_open(),
"picking from a menu puts it away",
);
}
#[test]
fn hovering_a_submenu_row_leaves_the_menu_up() {
let mut app = app();
press_f12(&mut app);
let badge = badge_button(&mut app);
click(&mut app, badge);
let parent = {
let mut query = app.world.query::<(Entity, &crate::ui::Submenu)>();
query
.iter(&app.world)
.next()
.map(|(entity, _)| entity)
.expect("the menu has submenus")
};
click(&mut app, parent);
assert!(
app.world.resource::<DevMenu>().is_open(),
"a row that only opens another menu has not been chosen",
);
}
#[test]
fn clicking_away_from_the_menu_closes_it() {
let mut app = app();
press_f12(&mut app);
let badge = badge_button(&mut app);
click(&mut app, badge);
assert!(app.world.resource::<DevMenu>().is_open());
click_at(&mut app, 20.0, 20.0);
assert!(
!app.world.resource::<DevMenu>().is_open(),
"a click on the game is not a click on the menu",
);
}
#[test]
fn the_badge_caret_turns_over_with_the_menu() {
let mut app = app();
press_f12(&mut app);
let badge = badge_button(&mut app);
app.update();
let caret = |app: &Application| {
app.world
.get::<crate::ui::ButtonIcons>(badge)
.and_then(|icons| icons.trailing)
};
assert_eq!(caret(&app), Some(crate::ui::icons::path::CARET_UP));
click(&mut app, badge);
app.update();
assert_eq!(
caret(&app),
Some(crate::ui::icons::path::CARET_DOWN),
"it is up, so the arrow offers to put it back down",
);
}
fn badge_button(app: &mut Application) -> Entity {
app.world
.query_filtered::<Entity, (With<DevBadge>, With<crate::ui::Interaction>)>()
.iter(&app.world)
.next()
.expect("the badge should have a button in it")
}
#[test]
fn f12_turns_it_on_and_off_again() {
let mut app = app();
assert!(
!app.world.resource::<DevMode>().is_on(),
"off to start with"
);
press_f12(&mut app);
assert!(app.world.resource::<DevMode>().is_on());
press_f12(&mut app);
assert!(!app.world.resource::<DevMode>().is_on());
}
#[test]
fn the_badge_is_up_only_while_it_is_on() {
let mut app = app();
assert_eq!(badges(&mut app), 0);
press_f12(&mut app);
assert!(badges(&mut app) > 0, "the badge says dev mode is on");
press_f12(&mut app);
assert_eq!(badges(&mut app), 0, "and goes away with it");
}
#[test]
fn f11_shows_the_outliner_and_hides_it_again() {
let mut app = app();
press_f12(&mut app);
assert!(!outliner_open(&app), "down to start with");
press(&mut app, OUTLINER);
assert!(outliner_open(&app), "one press puts it up");
press(&mut app, OUTLINER);
assert!(!outliner_open(&app), "and the next takes it down");
}
#[test]
fn f10_steps_through_the_faces() {
use crate::ui::{Family, Font};
let mut app = app();
press_f12(&mut app);
assert_eq!(app.world.resource::<Font>().family(), Family::Neon);
press(&mut app, FONT);
assert_eq!(app.world.resource::<Font>().family(), Family::Neon.next());
for _ in 1..Family::ALL.len() {
press(&mut app, FONT);
}
assert_eq!(app.world.resource::<Font>().family(), Family::Neon);
}
#[test]
fn f10_does_nothing_outside_dev_mode() {
use crate::ui::{Family, Font};
let mut app = app();
press(&mut app, FONT);
assert_eq!(app.world.resource::<Font>().family(), Family::Neon);
}
#[test]
fn f11_does_nothing_outside_dev_mode() {
let mut app = app();
assert!(!app.world.resource::<DevMode>().is_on());
press(&mut app, OUTLINER);
assert!(!outliner_open(&app), "no outliner without the working");
}
#[test]
fn leaving_dev_mode_takes_the_outliner_with_it() {
let mut app = app();
press_f12(&mut app);
press(&mut app, OUTLINER);
assert!(outliner_open(&app));
press_f12(&mut app);
assert!(
!outliner_open(&app),
"the outliner cannot outlive the mode it belongs to",
);
}
#[test]
fn nothing_happens_on_the_frames_between() {
let mut app = app();
press_f12(&mut app);
app.update();
app.update();
assert!(app.world.resource::<DevMode>().is_on());
}
#[test]
fn the_badge_opens_the_menu_and_closes_it_again() {
let mut app = app();
press_f12(&mut app);
let badge = badge_button(&mut app);
assert!(!app.world.resource::<DevMenu>().is_open());
click(&mut app, badge);
assert!(app.world.resource::<DevMenu>().is_open(), "one click opens");
click(&mut app, badge);
assert!(
!app.world.resource::<DevMenu>().is_open(),
"and the next one closes",
);
}
#[test]
fn leaving_dev_mode_takes_the_menu_with_it() {
let mut app = app();
press_f12(&mut app);
let badge = badge_button(&mut app);
click(&mut app, badge);
assert!(app.world.resource::<DevMenu>().is_open());
press_f12(&mut app);
assert!(
!app.world.resource::<DevMenu>().is_open(),
"the menu cannot outlive the mode it belongs to",
);
assert_eq!(badges(&mut app), 0);
}
#[test]
fn a_submenu_waits_to_be_hovered_and_opens_to_the_left() {
let mut app = app();
press_f12(&mut app);
let badge = badge_button(&mut app);
click(&mut app, badge);
let (parent, submenu_panel) = {
let world = &mut app.world;
let mut query = world.query::<(Entity, &Submenu)>();
let (parent, submenu) = query.iter(world).next().expect("the menu has submenus");
(parent, submenu.panel)
};
let shown = |app: &Application| {
app.world
.get::<Visibility>(submenu_panel)
.copied()
.unwrap_or_default()
.0
};
assert!(!shown(&app), "closed until the pointer arrives");
let parent_rect = {
let world = &app.world;
let pos = world.get::<crate::ui::Position>(parent).unwrap();
let size = world.get::<crate::ui::Size>(parent).unwrap();
pos.rect(size)
};
app.world.insert_resource(CursorPosition {
x: parent_rect.center_x(),
y: parent_rect.center_y(),
});
app.update();
assert!(shown(&app), "hovering the parent opens it");
let child = app.world.get::<crate::ui::Position>(submenu_panel).unwrap();
assert!(
child.x < parent_rect.x,
"a menu in the bottom-right corner has no room to its right, so \
its submenus go left: child at {} against a parent at {}",
child.x,
parent_rect.x,
);
let (x, y) = {
let world = &app.world;
let pos = world.get::<crate::ui::Position>(submenu_panel).unwrap();
let size = world.get::<crate::ui::Size>(submenu_panel).unwrap();
let rect = pos.rect(size);
(rect.center_x(), rect.center_y())
};
app.world.insert_resource(CursorPosition { x, y });
app.update();
assert!(shown(&app), "and stays open once the pointer is in it");
app.world
.insert_resource(CursorPosition { x: 10.0, y: 10.0 });
app.update();
assert!(!shown(&app), "and closes once the pointer leaves both");
}
}