use concinnity_core::ecs::World;
use super::hud_ids::{Family, family_base, hud_ids};
use super::panels::assets_panel;
use super::theme;
use super::viewport::billboards;
use super::widget::{self, point_in};
pub(crate) const MAX_ROWS: usize = 24;
hud_ids! {
base: family_base(Family::CreateMenu);
sprites: [pub(crate) MENU_BG, row_bg[MAX_ROWS]];
labels: [HEADING, row_label[MAX_ROWS], row_summary[MAX_ROWS]];
}
const MENU_W: f32 = 420.0;
const CHAR_W: f32 = 8.5;
const NAME_CHARS: usize = 18;
const SUMMARY_X: f32 = NAME_CHARS as f32 * CHAR_W;
const SUMMARY_CHARS: usize = ((MENU_W - PAD * 2.0 - 6.0 - SUMMARY_X) / CHAR_W) as usize;
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 summary(&self) -> &'static str {
match self {
MenuItem::Type(ty) => crate::docs::type_summaries()
.get(*ty)
.map(String::as_str)
.unwrap_or_default(),
MenuItem::PrefabHeader { .. } | MenuItem::Prefab(_) => "",
}
}
fn indented(&self) -> bool {
matches!(self, MenuItem::Prefab(_))
}
}
pub(crate) fn placeable_types() -> Vec<&'static str> {
let mut types: Vec<&'static str> = assets_panel::add_types()
.filter(|ty| billboards::eligible(ty))
.collect();
types.sort_unstable();
types
}
pub(crate) fn position_key(ty: &str) -> &'static str {
if super::panels::form::working_args(ty, None).contains_key("center") {
"center"
} 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 place(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 };
let baseline = r[1] + (ROW_H - widget::LINE_H) * 0.5;
widget::place_left_label(
world,
row_label(slot),
[r[0] + 6.0 + indent, baseline],
&widget::clip_text(&item.caption(), NAME_CHARS),
theme::LABEL,
true,
);
widget::place_left_label(
world,
row_summary(slot),
[r[0] + 6.0 + SUMMARY_X, baseline],
&widget::clip_text(item.summary(), SUMMARY_CHARS),
theme::LABEL_DIM,
true,
);
}
None => {
widget::set_sprite_visible(world, row_bg(slot), false);
widget::set_label_visible(world, row_label(slot), false);
widget::set_label_visible(world, row_summary(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);
widget::set_label_visible(world, row_summary(slot), false);
}
}
#[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::panels::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 a_type_row_carries_its_summary_and_a_prefab_row_none() {
let mut world = ids().test_world();
let rows = vec![
MenuItem::Type("PointLight"),
MenuItem::PrefabHeader { open: true },
MenuItem::Prefab("door".to_string()),
];
place(&mut world, [100.0, 100.0], &rows, [0.0, 0.0]);
let text = |id| {
world
.get_by_id::<concinnity_core::components::TextLabel>(id)
.expect("label present")
.content
.clone()
};
assert_eq!(text(row_label(0)), "PointLight");
assert_eq!(
text(row_summary(0)),
widget::clip_text(MenuItem::Type("PointLight").summary(), SUMMARY_CHARS),
"a type row documents itself"
);
assert!(!text(row_summary(0)).is_empty());
assert!(
text(row_summary(1)).is_empty() && text(row_summary(2)).is_empty(),
"the prefab rows name world assets, which have no summary"
);
}
#[test]
fn the_row_has_room_for_both_columns() {
const {
assert!(SUMMARY_CHARS > 0);
assert!(
SUMMARY_X + SUMMARY_CHARS as f32 * CHAR_W <= MENU_W - PAD * 2.0,
"the summary column stays inside the row"
);
}
for ty in placeable_types() {
assert!(
ty.chars().count() <= NAME_CHARS,
"{ty} is wider than the menu's name column"
);
}
}
#[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);
}
}