use concinnity_core::ecs::World;
use concinnity_core::ecs::asset_id::AssetId;
use crate::editor::theme;
use crate::editor::widget::{self, place_rounded, point_in};
pub(crate) const ROW_H: f32 = 28.0;
const BOX_SIZE: f32 = 16.0;
const PAD: f32 = 8.0;
const CHECK_LABEL_INSET: f32 = 32.0;
const VALUE_W: f32 = 64.0;
const LABEL_TOP: f32 = ROW_H * 0.5 - theme::TEXT_HALF;
const BOTTOM_PAD: f32 = 6.0;
const ROW_TINT: [f32; 4] = [0.0, 0.0, 0.0, 0.0];
#[derive(Clone, Copy)]
pub(crate) struct ListIds {
pub panel_bg: AssetId,
pub title: AssetId,
pub close_bg: AssetId,
pub close_label: AssetId,
pub row_bg: fn(usize) -> AssetId,
pub row_label: fn(usize) -> AssetId,
pub check_box: Option<fn(usize) -> AssetId>,
pub value_label: Option<fn(usize) -> AssetId>,
}
pub(crate) fn close_rect(o: [f32; 2], w: f32) -> [f32; 4] {
widget::close_rect(widget::title_rect(o, w))
}
pub(crate) fn row_rect(o: [f32; 2], w: f32, i: usize) -> [f32; 4] {
[o[0], o[1] + widget::TITLE_H + i as f32 * ROW_H, w, ROW_H]
}
pub(crate) fn value_rect(o: [f32; 2], w: f32, i: usize) -> [f32; 4] {
let r = row_rect(o, w, i);
[r[0] + r[2] - VALUE_W, r[1], VALUE_W, ROW_H]
}
pub(crate) fn size(w: f32, rows: usize) -> [f32; 2] {
[w, widget::TITLE_H + rows as f32 * ROW_H + BOTTOM_PAD]
}
pub(crate) fn panel_rect(o: [f32; 2], w: f32, rows: usize) -> [f32; 4] {
widget::outer_rect(o, size(w, rows))
}
pub(crate) struct Row {
pub caption: String,
pub check: Option<bool>,
pub value: Option<String>,
pub selected: bool,
}
impl Row {
pub(crate) fn label(caption: impl Into<String>) -> Self {
Self {
caption: caption.into(),
check: None,
value: None,
selected: false,
}
}
pub(crate) fn checkbox(caption: impl Into<String>, on: bool) -> Self {
Self {
caption: caption.into(),
check: Some(on),
value: None,
selected: false,
}
}
pub(crate) fn with_value(mut self, value: impl Into<String>) -> Self {
self.value = Some(value.into());
self
}
pub(crate) fn select(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
}
pub(crate) fn place(
world: &mut World,
ids: &ListIds,
o: [f32; 2],
s: [f32; 2],
heading: &str,
rows: &[Row],
mouse: [f32; 2],
) {
let w = s[0];
widget::place_panel(world, ids.panel_bg, widget::outer_rect(o, s));
let title = widget::title_rect(o, w);
widget::place_heading(world, ids.title, title, heading);
let close_hover = point_in(mouse[0], mouse[1], close_rect(o, w));
widget::place_close(world, ids.close_bg, ids.close_label, title, close_hover);
for (i, row) in rows.iter().enumerate() {
let r = row_rect(o, w, i);
let hovered = point_in(mouse[0], mouse[1], r);
let tint = if hovered {
theme::HOVER_TINT
} else if row.selected {
theme::SELECTED_TINT
} else {
ROW_TINT
};
place_rounded(
world,
(ids.row_bg)(i),
theme::highlight_rect(r),
tint,
theme::CONTROL_RADIUS,
true,
);
let label_x = match row.check {
Some(on) => {
let box_tint = if on {
theme::CHECK_ON_TINT
} else {
theme::CHECK_OFF_TINT
};
if let Some(check_box) = ids.check_box {
place_rounded(
world,
check_box(i),
[
r[0] + PAD,
r[1] + (ROW_H - BOX_SIZE) * 0.5,
BOX_SIZE,
BOX_SIZE,
],
box_tint,
4.0,
true,
);
}
r[0] + CHECK_LABEL_INSET
}
None => r[0] + PAD,
};
let label_right = match &row.value {
Some(_) => r[0] + r[2] - VALUE_W,
None => r[0] + r[2] - PAD,
};
widget::place_message(
world,
(ids.row_label)(i),
[
label_x,
r[1] + LABEL_TOP,
(label_right - label_x).max(0.0),
widget::LINE_H,
],
&row.caption,
theme::LABEL,
true,
);
let Some(value_label) = ids.value_label else {
continue;
};
match &row.value {
Some(v) => {
let vr = value_rect(o, w, i);
widget::place_message(
world,
value_label(i),
[vr[0], vr[1] + LABEL_TOP, vr[2] - PAD, widget::LINE_H],
v,
theme::LABEL_DIM,
true,
);
}
None => widget::set_label_visible(world, value_label(i), false),
}
}
}
pub(crate) fn hit_row(mx: f32, my: f32, o: [f32; 2], w: f32, rows: usize) -> Option<usize> {
(0..rows).find(|&i| point_in(mx, my, row_rect(o, w, i)))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::editor::hud_ids::hud_ids;
use concinnity_core::components::{Sprite, TextLabel};
const ROWS: usize = 2;
hud_ids! {
base: 0;
sprites: [PANEL_BG, CLOSE_BG, row_bg[ROWS], check_box[ROWS]];
labels: [TITLE_LABEL, CLOSE_LABEL, row_label[ROWS], value_label[ROWS]];
}
const IDS: ListIds = ListIds {
panel_bg: PANEL_BG,
title: TITLE_LABEL,
close_bg: CLOSE_BG,
close_label: CLOSE_LABEL,
row_bg,
row_label,
check_box: Some(check_box),
value_label: Some(value_label),
};
fn injected_world() -> World {
ids().test_world()
}
fn sprite(world: &World, id: AssetId) -> Sprite {
world.get_by_id::<Sprite>(id).cloned().unwrap()
}
#[test]
fn geometry_stacks_rows_below_the_title_bar() {
let o = [40.0, 60.0];
let w = 200.0;
assert_eq!(widget::title_rect(o, w), [40.0, 60.0, w, widget::TITLE_H]);
assert_eq!(row_rect(o, w, 0)[1], 60.0 + widget::TITLE_H);
assert_eq!(row_rect(o, w, 1)[1], 60.0 + widget::TITLE_H + ROW_H);
assert_eq!(size(w, 3)[1], widget::TITLE_H + 3.0 * ROW_H + BOTTOM_PAD);
assert_eq!(
panel_rect(o, w, 2)[3],
widget::TITLE_H + 2.0 * ROW_H + BOTTOM_PAD
);
}
#[test]
fn place_draws_the_rounded_panel_surface() {
let mut world = injected_world();
place(
&mut world,
&IDS,
[20.0, 30.0],
size(200.0, 1),
"Panel",
&[Row::label("a")],
[0.0, 0.0],
);
let bg = sprite(&world, PANEL_BG);
assert!(bg.visible);
assert_eq!((bg.x, bg.y), (20.0, 30.0));
assert_eq!((bg.width, bg.height), (200.0, size(200.0, 1)[1]));
assert_eq!(bg.corner_radius, theme::PANEL_RADIUS);
}
#[test]
fn place_grows_the_surface_to_the_effective_size() {
let mut world = injected_world();
let o = [20.0, 30.0];
let tall_wide = [size(200.0, 1)[0] + 80.0, size(200.0, 1)[1] + 120.0];
place(
&mut world,
&IDS,
o,
tall_wide,
"Panel",
&[Row::label("a")],
[0.0, 0.0],
);
let bg = sprite(&world, PANEL_BG);
assert_eq!((bg.width, bg.height), (tall_wide[0], tall_wide[1]));
assert_eq!(row_rect(o, tall_wide[0], 0)[1], o[1] + widget::TITLE_H);
}
#[test]
fn hit_row_resolves_a_row_or_misses() {
let o = [10.0, 10.0];
let w = 200.0;
let r1 = row_rect(o, w, 1);
assert_eq!(hit_row(r1[0] + 5.0, r1[1] + 5.0, o, w, 3), Some(1));
assert_eq!(hit_row(o[0] + 5.0, o[1] + 5.0, o, w, 3), None);
assert_eq!(hit_row(o[0] + 5.0, 10_000.0, o, w, 3), None);
}
#[test]
fn place_draws_title_close_and_a_checkbox_row() {
let mut world = injected_world();
let o = [20.0, 20.0];
let w = 200.0;
place(
&mut world,
&IDS,
o,
size(w, 1),
"Panel",
&[Row::checkbox("Toggle", true)],
[0.0, 0.0],
);
let title = world.get_by_id::<TextLabel>(TITLE_LABEL).unwrap();
assert!(title.visible && title.content == "Panel");
let close = world.get_by_id::<TextLabel>(CLOSE_LABEL).unwrap();
assert!(close.visible && close.content == "X");
assert_eq!(sprite(&world, check_box(0)).tint, theme::CHECK_ON_TINT);
let label = world.get_by_id::<TextLabel>(row_label(0)).unwrap();
assert_eq!(label.content, "Toggle");
assert_eq!(label.x, o[0] + CHECK_LABEL_INSET);
place(
&mut world,
&IDS,
o,
size(w, 1),
"Panel",
&[Row::checkbox("Toggle", false)],
[0.0, 0.0],
);
assert_eq!(sprite(&world, check_box(0)).tint, theme::CHECK_OFF_TINT);
}
#[test]
fn label_only_row_insets_by_pad_not_a_box() {
let mut world = crate::test_support::injected_world(
&[PANEL_BG, CLOSE_BG, row_bg(0)],
&[TITLE_LABEL, CLOSE_LABEL, row_label(0)],
&[],
);
let labels_only = ListIds {
check_box: None,
value_label: None,
..IDS
};
let o = [20.0, 20.0];
place(
&mut world,
&labels_only,
o,
size(200.0, 1),
"Panel",
&[Row::label("Just text")],
[0.0, 0.0],
);
let label = world.get_by_id::<TextLabel>(row_label(0)).unwrap();
assert_eq!(
label.x,
o[0] + PAD,
"label-only rows inset by PAD, not a box"
);
}
#[test]
fn hovered_and_selected_rows_are_tinted() {
let mut world = injected_world();
let o = [20.0, 20.0];
let w = 200.0;
let r0 = row_rect(o, w, 0);
place(
&mut world,
&IDS,
o,
size(w, 2),
"Panel",
&[Row::label("a"), Row::label("b").select(true)],
[r0[0] + 5.0, r0[1] + 5.0],
);
let hovered = sprite(&world, row_bg(0));
assert_eq!(hovered.tint, theme::HOVER_TINT);
assert!(
hovered.height < ROW_H && hovered.corner_radius > 0.0,
"the highlight is an inset rounded pill, not a full-width band"
);
assert_eq!(
sprite(&world, row_bg(1)).tint,
theme::SELECTED_TINT,
"the selected row is highlighted without a hover"
);
place(
&mut world,
&IDS,
o,
size(w, 2),
"Panel",
&[Row::label("a"), Row::label("b")],
[0.0, 0.0],
);
assert_eq!(sprite(&world, row_bg(0)).tint[3], 0.0);
}
}