use concinnity_core::components::TextAlign;
use concinnity_core::ecs::World;
use super::asset_list::{self, ListRow, MAX_ROWS, ROW_H};
use super::registry::PanelKey;
use crate::editor::hud_ids::{hud_ids, panel_base};
use crate::editor::theme;
use crate::editor::widget::{self, place_rounded, point_in};
pub(crate) const TPL_W: f32 = 420.0;
const POOL_MAX: usize = 28;
hud_ids! {
base: panel_base(PanelKey::TemplateDetail);
sprites: [
pub(crate) PANEL_BG,
pub(crate) CLOSE_BG,
pub(crate) APPLY_BG,
pub(crate) row_bg[POOL_MAX],
pub(crate) LIST_TRACK,
pub(crate) LIST_THUMB,
];
labels: [
pub(crate) TITLE_LABEL,
pub(crate) CLOSE_LABEL,
pub(crate) APPLY_LABEL,
pub(crate) DESC_LABEL,
pub(crate) row_label[POOL_MAX],
];
}
const PAD: f32 = 10.0;
const GAP: f32 = 8.0;
const HEADER_H: f32 = 34.0;
const BTN_W: f32 = 84.0;
const DESC_SCALE: f32 = 0.8;
const BTN_TINT: [f32; 4] = [0.22, 0.40, 0.56, 1.0];
const BTN_TINT_HOVER: [f32; 4] = [0.28, 0.48, 0.66, 1.0];
const DESC_COLOR: [f32; 3] = [0.72, 0.76, 0.84];
const LABEL_WHITE: [f32; 3] = [1.0, 1.0, 1.0];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TemplateAction {
Apply,
Close,
Consume,
}
pub(crate) struct TemplateView<'a> {
pub title: &'a str,
pub description: &'a str,
pub rows: &'a [ListRow],
pub scroll: usize,
pub mouse: [f32; 2],
}
fn visible_rows(n_rows: usize) -> usize {
n_rows.clamp(1, MAX_ROWS)
}
const CHROME_H: f32 = widget::TITLE_H + PAD + HEADER_H + PAD + PAD;
fn panel_height(rows: usize) -> f32 {
CHROME_H + rows as f32 * ROW_H
}
pub(crate) fn rows_for_height(h: f32) -> usize {
(((h - CHROME_H) / ROW_H).floor() as usize).clamp(1, POOL_MAX)
}
pub(crate) fn max_size(n_rows: usize) -> [f32; 2] {
[f32::INFINITY, panel_height(n_rows.clamp(1, POOL_MAX))]
}
pub(crate) fn default_origin(vw: f32) -> [f32; 2] {
[(vw - TPL_W) * 0.5, crate::editor::hud::body_top() + 30.0]
}
pub(crate) fn size(n_rows: usize) -> [f32; 2] {
[TPL_W, panel_height(visible_rows(n_rows))]
}
pub(crate) fn close_rect(o: [f32; 2], w: f32) -> [f32; 4] {
widget::close_rect(widget::title_rect(o, w))
}
pub(crate) fn apply_rect(o: [f32; 2], w: f32) -> [f32; 4] {
[
o[0] + w - PAD - BTN_W,
widget::header_y(o, PAD),
BTN_W,
HEADER_H,
]
}
fn desc_rect(o: [f32; 2], w: f32) -> [f32; 4] {
[
o[0] + PAD,
widget::header_y(o, PAD),
w - 2.0 * PAD - (BTN_W + GAP),
HEADER_H,
]
}
fn body_top(o: [f32; 2]) -> f32 {
widget::header_y(o, PAD) + HEADER_H + PAD
}
fn list_row_rect(o: [f32; 2], w: f32, r: usize) -> [f32; 4] {
[o[0], body_top(o) + r as f32 * ROW_H, w, ROW_H]
}
pub(crate) fn cursor_over(mx: f32, my: f32, o: [f32; 2], s: [f32; 2]) -> bool {
point_in(mx, my, widget::outer_rect(o, s))
}
pub(crate) fn hit_test(mx: f32, my: f32, o: [f32; 2], s: [f32; 2]) -> Option<TemplateAction> {
let w = s[0];
if !point_in(mx, my, widget::outer_rect(o, s)) {
return None;
}
if point_in(mx, my, close_rect(o, w)) {
return Some(TemplateAction::Close);
}
if point_in(mx, my, apply_rect(o, w)) {
return Some(TemplateAction::Apply);
}
Some(TemplateAction::Consume)
}
pub(crate) fn place(world: &mut World, view: &TemplateView, o: [f32; 2], s: [f32; 2]) {
ids().hide(world);
let w = s[0];
let window = rows_for_height(s[1]);
widget::place_panel(world, PANEL_BG, widget::outer_rect(o, s));
widget::place_heading(world, TITLE_LABEL, widget::title_rect(o, w), view.title);
let close_hover = point_in(view.mouse[0], view.mouse[1], close_rect(o, w));
widget::place_close(
world,
CLOSE_BG,
CLOSE_LABEL,
widget::title_rect(o, w),
close_hover,
);
let desc = desc_rect(o, w);
if let Some(l) = widget::label_mut(world, DESC_LABEL) {
l.x = desc[0];
l.y = desc[1] + HEADER_H * 0.5 - 10.0 * DESC_SCALE;
l.align = TextAlign::Left;
l.color = DESC_COLOR;
l.scale = DESC_SCALE;
l.visible = true;
l.wrap_width = desc[2].max(0.0);
l.max_lines = 1;
l.content = view.description.to_string();
}
let apply_btn = apply_rect(o, w);
let hover = point_in(view.mouse[0], view.mouse[1], apply_btn);
place_rounded(
world,
APPLY_BG,
apply_btn,
if hover { BTN_TINT_HOVER } else { BTN_TINT },
theme::CONTROL_RADIUS,
true,
);
if let Some(l) = widget::label_mut(world, APPLY_LABEL) {
l.x = apply_btn[0] + apply_btn[2] * 0.5;
l.y = apply_btn[1] + apply_btn[3] * 0.5 - theme::TEXT_HALF;
l.align = TextAlign::Center;
l.color = LABEL_WHITE;
l.visible = true;
l.content = "Apply".to_string();
}
let total = view.rows.len();
let scroll = view.scroll.min(total.saturating_sub(1));
for r in 0..window {
let idx = scroll + r;
if idx >= total {
break;
}
asset_list::place_row(
world,
row_bg(r),
row_label(r),
&view.rows[idx],
list_row_rect(o, w, r),
asset_list::ROW_TINT,
);
}
asset_list::layout_scrollbar(
world,
(LIST_TRACK, LIST_THUMB),
total,
scroll,
window,
o[0] + w,
body_top(o),
);
}
#[cfg(test)]
mod tests {
use super::*;
use concinnity_core::components::{Sprite, TextLabel};
use concinnity_core::ecs::asset_id::AssetId;
fn test_origin() -> [f32; 2] {
default_origin(1280.0)
}
fn rows() -> Vec<ListRow> {
vec![
ListRow {
is_header: true,
text: "Camera3D".into(),
entry: None,
},
ListRow {
is_header: false,
text: "world_camera".into(),
entry: Some(0),
},
ListRow {
is_header: true,
text: "Room".into(),
entry: None,
},
ListRow {
is_header: false,
text: "world_room".into(),
entry: Some(1),
},
]
}
fn view(rows: &[ListRow]) -> TemplateView<'_> {
TemplateView {
title: "Template Minimal 3D World",
description: "A lit room with a camera and sky.",
rows,
scroll: 0,
mouse: [0.0, 0.0],
}
}
fn injected_world() -> World {
ids().test_world()
}
fn sprite(world: &World, id: AssetId) -> Sprite {
world
.get_by_id::<Sprite>(id)
.cloned()
.expect("sprite present")
}
fn label(world: &World, id: AssetId) -> TextLabel {
world
.get_by_id::<TextLabel>(id)
.cloned()
.expect("label present")
}
#[test]
fn header_pins_description_and_apply_under_the_title_bar() {
let o = test_origin();
let title = widget::title_rect(o, TPL_W);
assert_eq!(title, [o[0], o[1], TPL_W, widget::TITLE_H]);
let close = close_rect(o, TPL_W);
assert_eq!(close[1], o[1], "the X sits in the title bar");
assert_eq!(
close[0] + close[2],
o[0] + TPL_W,
"flush to the panel right"
);
let desc = desc_rect(o, TPL_W);
let apply_btn = apply_rect(o, TPL_W);
assert_eq!(desc[1], o[1] + widget::TITLE_H + PAD, "under the title bar");
assert_eq!(
desc[1], apply_btn[1],
"description shares Apply's header row"
);
assert!(
desc[0] + desc[2] <= apply_btn[0],
"description ends left of Apply"
);
assert_eq!(
apply_btn[0] + apply_btn[2],
o[0] + TPL_W - PAD,
"Apply flush to the panel's right pad"
);
let moved = widget::outer_rect([40.0, 60.0], size(0));
assert_eq!((moved[0], moved[1]), (40.0, 60.0));
assert_eq!(
size(0),
[moved[2], moved[3]],
"drag-clamp footprint matches"
);
}
#[test]
fn panel_height_tracks_rows_and_caps_at_the_window() {
let short = size(3);
let tall = size(MAX_ROWS + 10);
assert!(short[1] < tall[1]);
assert_eq!(
tall[1],
size(MAX_ROWS)[1],
"a list past the window scrolls instead of growing"
);
}
#[test]
fn apply_and_close_hit_test() {
let s = size(rows().len());
let o = test_origin();
let a = apply_rect(o, TPL_W);
let x = close_rect(o, TPL_W);
assert_eq!(
hit_test(a[0] + 5.0, a[1] + 5.0, o, s),
Some(TemplateAction::Apply)
);
assert_eq!(
hit_test(x[0] + 5.0, x[1] + 5.0, o, s),
Some(TemplateAction::Close)
);
let t = widget::title_rect(o, TPL_W);
assert_eq!(
hit_test(t[0] + 5.0, t[1] + 5.0, o, s),
Some(TemplateAction::Consume)
);
let row = list_row_rect(o, TPL_W, 1);
assert_eq!(
hit_test(row[0] + 5.0, row[1] + 5.0, o, s),
Some(TemplateAction::Consume)
);
assert_eq!(hit_test(5.0, 5.0, o, s), None);
}
#[test]
fn place_renders_title_description_and_rows() {
let rs = rows();
let mut world = injected_world();
let o = test_origin();
place(&mut world, &view(&rs), o, size(rs.len()));
assert_eq!(
label(&world, TITLE_LABEL).content,
"Template Minimal 3D World"
);
let desc = label(&world, DESC_LABEL);
assert!(desc.visible && desc.content == "A lit room with a camera and sky.");
assert!(desc.scale < 1.0, "description draws a touch smaller");
assert_eq!(label(&world, APPLY_LABEL).content, "Apply");
assert_eq!(label(&world, row_label(0)).content, "Camera3D");
let name = label(&world, row_label(1));
assert_eq!(name.content, "world_camera");
assert!(
name.x > label(&world, row_label(0)).x,
"the name is indented under its type header"
);
assert!(sprite(&world, PANEL_BG).visible);
}
#[test]
fn scrollbar_only_when_the_list_overflows() {
let short = rows();
let mut world = injected_world();
place(&mut world, &view(&short), test_origin(), size(short.len()));
assert!(!sprite(&world, LIST_THUMB).visible, "short list, no bar");
let long: Vec<ListRow> = (0..MAX_ROWS + 6)
.map(|i| ListRow {
is_header: false,
text: format!("asset_{i}"),
entry: Some(i),
})
.collect();
place(&mut world, &view(&long), test_origin(), size(long.len()));
assert!(sprite(&world, LIST_THUMB).visible, "overflow shows the bar");
}
#[test]
fn taller_size_reveals_more_rows_up_to_the_pool() {
let n = MAX_ROWS + 8;
assert_eq!(
rows_for_height(size(n)[1]),
MAX_ROWS,
"default shows the window"
);
assert_eq!(
rows_for_height(max_size(n)[1]),
n,
"max reveals all the rows"
);
assert_eq!(rows_for_height(10_000.0), POOL_MAX, "capped at the pool");
assert_eq!(max_size(3)[1], size(3)[1]);
}
}