use super::registry::ID_BASE;
use super::widget::{self, point_in};
use super::{billboards, panel, theme};
use crate::ecs::World;
use crate::ecs::asset_id::AssetId;
const BASE: u32 = ID_BASE + 0x6000;
pub(crate) const MENU_BG: AssetId = AssetId(BASE);
const HEADING: AssetId = AssetId(BASE + 1);
const fn row_bg(i: usize) -> AssetId {
AssetId(BASE + 0x20 + i as u32)
}
const fn row_label(i: usize) -> AssetId {
AssetId(BASE + 0x60 + i as u32)
}
pub(crate) const MAX_ROWS: usize = 24;
const MENU_W: f32 = 190.0;
const ROW_H: f32 = 24.0;
const PAD: f32 = 4.0;
const HEADING_H: f32 = 22.0;
const INDENT: f32 = 14.0;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum MenuItem {
Type(&'static str),
PrefabHeader { open: bool },
Prefab(String),
}
impl MenuItem {
fn caption(&self) -> String {
match self {
MenuItem::Type(ty) => (*ty).to_string(),
MenuItem::PrefabHeader { open: false } => "Prefab instance >".to_string(),
MenuItem::PrefabHeader { open: true } => "Prefab instance v".to_string(),
MenuItem::Prefab(name) => name.clone(),
}
}
fn indented(&self) -> bool {
matches!(self, MenuItem::Prefab(_))
}
}
pub(crate) fn placeable_types() -> Vec<&'static str> {
let mut types: Vec<&'static str> = panel::add_types()
.filter(|ty| billboards::eligible(ty))
.collect();
types.sort_unstable();
types
}
pub(crate) fn position_key(ty: &str) -> &'static str {
if super::form::working_args(ty, None).contains_key("centre") {
"centre"
} else {
"position"
}
}
pub(crate) fn items(prefabs: &[String], prefabs_open: bool) -> Vec<MenuItem> {
let mut out: Vec<MenuItem> = placeable_types().into_iter().map(MenuItem::Type).collect();
if !prefabs.is_empty() {
out.push(MenuItem::PrefabHeader { open: prefabs_open });
if prefabs_open {
out.extend(prefabs.iter().cloned().map(MenuItem::Prefab));
}
}
out.truncate(MAX_ROWS);
out
}
pub(crate) fn size(rows: usize) -> [f32; 2] {
[MENU_W, HEADING_H + rows as f32 * ROW_H + PAD * 2.0]
}
pub(crate) fn origin(anchor: [f32; 2], rows: usize, vp: [f32; 2]) -> [f32; 2] {
widget::clamp_origin(anchor, size(rows), vp, super::hud::BAR_H)
}
pub(crate) fn menu_rect(o: [f32; 2], rows: usize) -> [f32; 4] {
widget::outer_rect(o, size(rows))
}
pub(crate) fn row_rect(o: [f32; 2], i: usize) -> [f32; 4] {
[
o[0] + PAD,
o[1] + HEADING_H + PAD + i as f32 * ROW_H,
MENU_W - PAD * 2.0,
ROW_H,
]
}
pub(crate) fn hit_row(mx: f32, my: f32, o: [f32; 2], rows: usize) -> Option<usize> {
(0..rows).find(|&i| point_in(mx, my, row_rect(o, i)))
}
pub(crate) fn over(mx: f32, my: f32, o: [f32; 2], rows: usize) -> bool {
point_in(mx, my, menu_rect(o, rows))
}
pub(crate) fn apply(world: &mut World, o: [f32; 2], items: &[MenuItem], mouse: [f32; 2]) {
widget::place_panel(world, MENU_BG, menu_rect(o, items.len()));
widget::place_left_label(
world,
HEADING,
[o[0] + PAD + 4.0, o[1] + 3.0],
"Create here",
theme::HEADING,
true,
);
for slot in 0..MAX_ROWS {
match items.get(slot) {
Some(item) => {
let r = row_rect(o, slot);
let hovered = point_in(mouse[0], mouse[1], r);
widget::place_rounded(
world,
row_bg(slot),
r,
theme::HOVER_TINT,
theme::CONTROL_RADIUS,
hovered,
);
let indent = if item.indented() { INDENT } else { 0.0 };
widget::place_left_label(
world,
row_label(slot),
[r[0] + 6.0 + indent, r[1] + (ROW_H - widget::LINE_H) * 0.5],
&widget::clip_text(&item.caption(), 24),
theme::LABEL,
true,
);
}
None => {
widget::set_sprite_visible(world, row_bg(slot), false);
widget::set_label_visible(world, row_label(slot), false);
}
}
}
}
pub(crate) fn hide(world: &mut World) {
widget::set_sprite_visible(world, MENU_BG, false);
widget::set_label_visible(world, HEADING, false);
for slot in 0..MAX_ROWS {
widget::set_sprite_visible(world, row_bg(slot), false);
widget::set_label_visible(world, row_label(slot), false);
}
}
pub(crate) fn all_sprite_ids() -> Vec<AssetId> {
std::iter::once(MENU_BG)
.chain((0..MAX_ROWS).map(row_bg))
.collect()
}
pub(crate) fn all_label_ids() -> Vec<AssetId> {
std::iter::once(HEADING)
.chain((0..MAX_ROWS).map(row_label))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn placeable_types_are_the_positioned_blank_useful_set() {
let types = placeable_types();
for expected in [
"PointLight",
"SpotLight",
"TriggerVolume",
"ReflectionProbe",
] {
assert!(types.contains(&expected), "{expected} missing: {types:?}");
}
for excluded in ["Prop", "Sprite", "GraphicsConfig", "Window", "Material"] {
assert!(!types.contains(&excluded), "{excluded} offered: {types:?}");
}
let mut sorted = types.clone();
sorted.sort_unstable();
assert_eq!(types, sorted, "alphabetized");
}
#[test]
fn every_placeable_type_has_its_position_key() {
for ty in placeable_types() {
let key = position_key(ty);
let args = crate::editor::form::working_args(ty, None);
assert!(args.contains_key(key), "{ty} lacks {key}");
}
}
#[test]
fn items_list_types_then_a_prefab_section() {
let none = items(&[], false);
assert!(none.iter().all(|i| matches!(i, MenuItem::Type(_))));
let prefabs = vec!["door".to_string(), "crate".to_string()];
let closed = items(&prefabs, false);
assert_eq!(
closed.last(),
Some(&MenuItem::PrefabHeader { open: false }),
"closed section is one header row"
);
assert!(!closed.iter().any(|i| matches!(i, MenuItem::Prefab(_))));
let open = items(&prefabs, true);
assert_eq!(
open.iter()
.filter(|i| matches!(i, MenuItem::Prefab(_)))
.count(),
2,
"open section lists the world's prefabs"
);
}
#[test]
fn hit_row_resolves_rows_and_misses_outside() {
let o = [100.0, 100.0];
let rows = 5;
let r1 = row_rect(o, 1);
assert_eq!(hit_row(r1[0] + 2.0, r1[1] + 2.0, o, rows), Some(1));
assert_eq!(hit_row(0.0, 0.0, o, rows), None);
assert!(over(r1[0], r1[1], o, rows));
assert!(!over(0.0, 0.0, o, rows));
let m = menu_rect(o, rows);
for i in 0..rows {
let r = row_rect(o, i);
assert!(r[1] + r[3] <= m[1] + m[3], "row {i} inside the menu");
}
}
#[test]
fn origin_clamps_fully_on_screen() {
let vp = [800.0, 600.0];
let o = origin([790.0, 590.0], 6, vp);
let s = size(6);
assert!(o[0] + s[0] <= vp[0]);
assert!(o[1] + s[1] <= vp[1]);
assert!(o[1] >= super::super::hud::BAR_H);
}
}