use bevy_ecs::prelude::*;
use yakui::widgets::{Layer, List, Pad, Reflow};
use yakui::{Alignment, CrossAxisAlignment, Dim2, Pivot, WidgetId};
use crate::ecs::{Application, IntoScheduleConfigs, Plugin};
use crate::input::{KeyCode, Keys};
use crate::ui::icons::path;
use crate::ui::{self, MouseInput, Outliner, PointerCapture, Profiler};
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(Resource, Default)]
pub struct DevMenu {
pub open: bool,
submenu: Option<usize>,
pub(crate) badge: Option<WidgetId>,
pub(crate) tops: Vec<WidgetId>,
pub(crate) entries: Vec<WidgetId>,
}
impl DevMenu {
pub fn is_open(&self) -> bool {
self.open
}
}
pub fn dev_mode_system(keys: Res<Keys>, mut commands: Commands) {
if keys.just_pressed(TOGGLE) {
commands.queue(|world: &mut World| {
let on = !world.resource::<DevMode>().is_on();
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" });
}
if !on {
let mut menu = world.resource_mut::<DevMenu>();
menu.open = false;
menu.submenu = None;
world.resource_mut::<Outliner>().open = false;
}
}
pub fn outliner_key_system(keys: Res<Keys>, dev: Res<DevMode>, mut outliner: ResMut<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<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<ui::Font>) {
if dev.is_on() && keys.just_pressed(FONT) {
font.cycle();
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Action {
Nothing,
Outliner,
Exit,
}
struct Top {
label: &'static str,
entries: &'static [(&'static str, Action)],
}
const MENU: &[Top] = &[
Top {
label: "APP",
entries: &[
("PLAY", Action::Nothing),
("EDIT", Action::Nothing),
("CONNECTIONS", Action::Nothing),
("SETTINGS", Action::Nothing),
("ABOUT", Action::Nothing),
("EXIT", Action::Exit),
],
},
Top {
label: "FILE",
entries: &[
("IMPORT MODEL", Action::Nothing),
("OPEN SCENE", Action::Nothing),
("SAVE SCENE AS", Action::Nothing),
],
},
Top {
label: "VIEW",
entries: &[
("OUTLINER", Action::Outliner),
("INSTANCES", Action::Nothing),
("PROPERTIES", Action::Nothing),
("TIMELINE", Action::Nothing),
("ACTIONS", Action::Nothing),
("SCREENSHOT", Action::Nothing),
("RECENTER VIEW", Action::Nothing),
],
},
];
pub fn dev_ui_system(world: &mut World) {
if !world.resource::<DevMode>().is_on() {
return;
}
let mouse = *world.resource::<MouseInput>();
let over_panel = world.resource::<PointerCapture>().over_panel;
let mut menu = world.remove_resource::<DevMenu>().unwrap_or_default();
if menu.open && mouse.just_pressed && !over_panel {
menu.open = false;
}
let mut chosen: Option<Action> = None;
let mut toggle_badge = false;
let mut hovered_top: Option<usize> = None;
let mut over_submenu = false;
menu.tops.clear();
menu.entries.clear();
Layer::new().show(|| {
ui::screen(|| {
ui::outliner::show(world);
world.resource::<Profiler>().show();
ui::place(1.0, 1.0, Pivot::BOTTOM_RIGHT, || {
yakui::pad(Pad::all(ui::SCREEN_MARGIN), || {
let mut stack = List::column();
stack.main_axis_size = yakui::MainAxisSize::Min;
stack.cross_axis_alignment = CrossAxisAlignment::End;
stack.show(|| {
let caret = match menu.open {
true => path::CARET_DOWN,
false => path::CARET_UP,
};
ui::rows(|| {
let badge = ui::menu_row("DEV", Some(path::WRENCH), Some(caret));
menu.badge = Some(badge.id);
toggle_badge = badge.clicked;
});
Reflow::new(Alignment::TOP_RIGHT, Pivot::BOTTOM_RIGHT, Dim2::ZERO).show(
|| {
if !menu.open {
return;
}
ui::rows(|| {
for (index, top) in MENU.iter().enumerate() {
let mut row = List::row();
row.main_axis_size = yakui::MainAxisSize::Min;
row.show(|| {
let response = ui::menu_row(
top.label,
None,
Some(path::CARET_LEFT),
);
menu.tops.push(response.id);
if response.hovering {
hovered_top = Some(index);
}
if menu.submenu == Some(index) {
submenu(
top,
&mut menu,
&mut chosen,
&mut over_submenu,
);
}
});
}
});
},
);
});
});
});
});
});
menu.submenu = hovered_top.or(match over_submenu {
true => menu.submenu,
false => None,
});
if toggle_badge {
menu.open = !menu.open;
}
if let Some(action) = chosen {
menu.open = false;
menu.submenu = None;
match action {
Action::Nothing => {}
Action::Outliner => world.resource_mut::<Outliner>().toggle(),
Action::Exit => std::process::exit(0),
}
}
if !menu.open {
menu.submenu = None;
}
world.insert_resource(menu);
}
fn submenu(top: &Top, menu: &mut DevMenu, chosen: &mut Option<Action>, over_submenu: &mut bool) {
Reflow::new(
Alignment::BOTTOM_LEFT,
Pivot::BOTTOM_RIGHT,
Dim2::pixels(-ui::PANEL_PADDING, 0.0),
)
.show(|| {
Layer::new().show(|| {
ui::panel(|| {
let region = ui::clickable(None, || {
let mut column = List::column();
column.main_axis_size = yakui::MainAxisSize::Min;
column.show(|| {
for (label, action) in top.entries {
let entry = ui::menu_row(*label, None, None);
menu.entries.push(entry.id);
if entry.clicked {
*chosen = Some(*action);
}
}
});
});
*over_submenu |= region.hovering;
});
});
});
}
pub struct DevPlugin;
impl Plugin for DevPlugin {
fn build(&self, app: &mut Application) {
app.init_resource::<DevMode>();
app.init_resource::<DevMenu>();
app.init_resource::<Outliner>();
app.init_resource::<ui::Font>();
app.init_resource::<Profiler>();
app.add_update_systems(dev_mode_system);
app.add_update_systems(outliner_key_system);
app.add_update_systems(font_key_system);
app.add_update_systems(profiler_key_system);
app.add_update_systems(dev_ui_system.before(ui::clear_input_edge_system));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ui::{CursorPosition, ScreenSize, Ui, UiPlugin};
use yakui::geometry::Vec2;
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 frame(app: &mut Application) {
Ui::begin_frame(&mut app.world);
app.update();
Ui::end_frame(&mut app.world);
}
fn press(app: &mut Application, key: KeyCode) {
app.world.resource_mut::<Keys>().press(key, false);
frame(app);
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::<Outliner>().is_open()
}
fn menu_open(app: &Application) -> bool {
app.world.resource::<DevMenu>().is_open()
}
fn rect_of(app: &Application, id: WidgetId) -> yakui::geometry::Rect {
app.world
.non_send::<Ui>()
.rect_of(id)
.expect("it was drawn last frame")
}
fn badge(app: &Application) -> yakui::geometry::Rect {
let id = app.world.resource::<DevMenu>().badge.expect("a badge");
rect_of(app, id)
}
fn hover(app: &mut Application, at: Vec2) {
app.world
.insert_resource(CursorPosition { x: at.x, y: at.y });
frame(app);
}
fn click(app: &mut Application, at: Vec2) {
app.world
.insert_resource(CursorPosition { x: at.x, y: at.y });
app.world.insert_resource(MouseInput {
left_down: true,
just_pressed: true,
..MouseInput::default()
});
frame(app);
app.world.insert_resource(MouseInput {
just_released: true,
..MouseInput::default()
});
frame(app);
}
fn center(rect: yakui::geometry::Rect) -> Vec2 {
rect.pos() + rect.size() * 0.5
}
#[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_drawn_only_while_it_is_on() {
let mut app = app();
frame(&mut app);
assert!(app.world.resource::<DevMenu>().badge.is_none());
press_f12(&mut app);
frame(&mut app);
let badge = badge(&app);
assert!(
badge.pos().x > 1000.0 && badge.pos().y > 700.0,
"in the bottom-right corner: {badge:?}"
);
}
#[test]
fn the_badge_opens_the_menu_and_closes_it_again() {
let mut app = app();
press_f12(&mut app);
frame(&mut app);
assert!(!menu_open(&app));
let at = center(badge(&app));
click(&mut app, at);
assert!(menu_open(&app), "one click opens");
let at = center(badge(&app));
click(&mut app, at);
assert!(!menu_open(&app), "and the next one closes");
}
#[test]
fn clicking_away_from_the_menu_closes_it() {
let mut app = app();
press_f12(&mut app);
frame(&mut app);
let at = center(badge(&app));
click(&mut app, at);
assert!(menu_open(&app));
click(&mut app, Vec2::new(20.0, 20.0));
assert!(
!menu_open(&app),
"a click on the game is not a click on the menu"
);
}
#[test]
fn a_submenu_opens_on_hover_to_the_left_and_stays_while_the_pointer_is_in_it() {
let mut app = app();
press_f12(&mut app);
frame(&mut app);
let at = center(badge(&app));
click(&mut app, at);
frame(&mut app);
let tops = app.world.resource::<DevMenu>().tops.clone();
assert_eq!(tops.len(), MENU.len(), "a row per top-level entry");
assert!(
app.world.resource::<DevMenu>().entries.is_empty(),
"closed until the pointer arrives"
);
let row = rect_of(&app, tops[2]);
let at = center(row);
hover(&mut app, at);
frame(&mut app);
let entries = app.world.resource::<DevMenu>().entries.clone();
assert_eq!(
entries.len(),
MENU[2].entries.len(),
"hovering the row opens it"
);
let first = rect_of(&app, entries[0]);
assert!(
first.pos().x + first.size().x <= row.pos().x + 1.0,
"a menu in the right-hand corner opens its submenus to the left: \
entry at {first:?} against a row at {row:?}",
);
let at = center(rect_of(&app, entries[3]));
hover(&mut app, at);
frame(&mut app);
assert!(
!app.world.resource::<DevMenu>().entries.is_empty(),
"and stays open once the pointer is in it"
);
hover(&mut app, Vec2::new(10.0, 10.0));
frame(&mut app);
assert!(
app.world.resource::<DevMenu>().entries.is_empty(),
"and closes once the pointer leaves both"
);
assert!(menu_open(&app), "hovering away is not a click away");
}
#[test]
fn choosing_an_entry_acts_on_it_and_closes_the_menu() {
let mut app = app();
press_f12(&mut app);
frame(&mut app);
let at = center(badge(&app));
click(&mut app, at);
frame(&mut app);
let view = app.world.resource::<DevMenu>().tops[2];
let at = center(rect_of(&app, view));
hover(&mut app, at);
frame(&mut app);
let outliner = app.world.resource::<DevMenu>().entries[0];
let at = center(rect_of(&app, outliner));
click(&mut app, at);
assert!(outliner_open(&app), "VIEW > OUTLINER puts it up");
assert!(!menu_open(&app), "picking from a menu puts it away");
}
#[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 f11_does_nothing_outside_dev_mode() {
let mut app = app();
press(&mut app, OUTLINER);
assert!(!outliner_open(&app), "no outliner without the working");
}
#[test]
fn leaving_dev_mode_takes_the_menu_and_the_outliner_with_it() {
let mut app = app();
press_f12(&mut app);
press(&mut app, OUTLINER);
frame(&mut app);
let at = center(badge(&app));
click(&mut app, at);
assert!(menu_open(&app) && outliner_open(&app));
press_f12(&mut app);
assert!(!menu_open(&app), "the menu cannot outlive the mode");
assert!(!outliner_open(&app), "nor can the outliner");
}
#[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 f9_puts_the_profiler_up_while_it_is_on() {
let mut app = app();
press(&mut app, PROFILER);
assert!(!app.world.resource::<Profiler>().on, "not outside dev mode");
press_f12(&mut app);
press(&mut app, PROFILER);
assert!(app.world.resource::<Profiler>().on);
frame(&mut app);
}
#[test]
fn nothing_happens_on_the_frames_between() {
let mut app = app();
press_f12(&mut app);
frame(&mut app);
frame(&mut app);
assert!(app.world.resource::<DevMode>().is_on());
}
}