use crate::ecs::World;
use crate::ecs::asset_id::AssetId;
use super::theme;
use super::widget::{self, place_rounded, place_sprite};
pub(crate) const MAX_ROWS: usize = 12;
pub(crate) const ROW_H: f32 = 28.0;
pub(crate) const PAD: f32 = 8.0;
pub(crate) const INDENT: f32 = 16.0;
pub(crate) const SCROLLBAR_W: f32 = 5.0;
pub(crate) const ROW_LABEL_TOP: f32 = ROW_H * 0.5 - theme::TEXT_HALF;
pub(crate) const ROW_TINT: [f32; 4] = [0.13, 0.13, 0.16, 0.0];
pub(crate) const TRACK_TINT: [f32; 4] = [0.12, 0.12, 0.15, 0.9];
pub(crate) const THUMB_TINT: [f32; 4] = [0.40, 0.44, 0.56, 0.95];
pub(crate) const LABEL: [f32; 3] = theme::LABEL;
pub(crate) const HEADER_LABEL: [f32; 3] = [0.58, 0.66, 0.80];
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ListRow {
pub is_header: bool,
pub text: String,
pub entry: Option<usize>,
}
fn entry_name(e: &serde_json::Value) -> Option<&str> {
e.get("name").and_then(|v| v.as_str())
}
fn entry_type(e: &serde_json::Value) -> Option<&str> {
e.get("type").and_then(|v| v.as_str())
}
fn distinct_types(entries: &[serde_json::Value]) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for e in entries {
if let Some(ty) = entry_type(e)
&& !out.iter().any(|s| s == ty)
{
out.push(ty.to_string());
}
}
out.sort();
out
}
pub(crate) fn grouped_rows(
entries: &[serde_json::Value],
type_filter: Option<&str>,
) -> Vec<ListRow> {
let mut rows = Vec::new();
for ty in distinct_types(entries) {
if let Some(f) = type_filter
&& ty != f
{
continue;
}
let mut named: Vec<(usize, String)> = entries
.iter()
.enumerate()
.filter_map(|(i, e)| {
let name = entry_name(e)?;
(entry_type(e) == Some(ty.as_str())).then(|| (i, name.to_string()))
})
.collect();
if named.is_empty() {
continue;
}
named.sort_by(|a, b| a.1.cmp(&b.1));
rows.push(ListRow {
is_header: true,
text: ty.clone(),
entry: None,
});
for (i, name) in named {
rows.push(ListRow {
is_header: false,
text: name,
entry: Some(i),
});
}
}
rows
}
pub(crate) fn place_row(
world: &mut World,
bg_id: AssetId,
label_id: AssetId,
row: &ListRow,
rect: [f32; 4],
tint: [f32; 4],
) {
if row.is_header {
place_sprite(world, bg_id, rect, tint, true);
} else {
place_rounded(
world,
bg_id,
theme::highlight_rect(rect),
tint,
theme::CONTROL_RADIUS,
true,
);
}
let (x_off, color) = if row.is_header {
(PAD, HEADER_LABEL)
} else {
(PAD + INDENT, LABEL)
};
widget::place_message(
world,
label_id,
[
rect[0] + x_off,
rect[1] + ROW_LABEL_TOP,
(rect[2] - x_off - PAD).max(0.0),
widget::LINE_H,
],
&row.text,
color,
true,
);
}
pub(crate) fn layout_scrollbar(
world: &mut World,
ids: (AssetId, AssetId),
total: usize,
scroll: usize,
window: usize,
right_x: f32,
top_y: f32,
) {
let (track_id, thumb_id) = ids;
if total <= window {
return;
}
let x = right_x - SCROLLBAR_W - 2.0;
let track_h = window as f32 * ROW_H;
place_rounded(
world,
track_id,
[x, top_y, SCROLLBAR_W, track_h],
TRACK_TINT,
SCROLLBAR_W * 0.5,
true,
);
let frac_visible = window as f32 / total as f32;
let thumb_h = (track_h * frac_visible).max(20.0);
let max_scroll = (total - window) as f32;
let t = if max_scroll > 0.0 {
scroll.min(total - window) as f32 / max_scroll
} else {
0.0
};
let thumb_y = top_y + t * (track_h - thumb_h);
place_rounded(
world,
thumb_id,
[x, thumb_y, SCROLLBAR_W, thumb_h],
THUMB_TINT,
SCROLLBAR_W * 0.5,
true,
);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::components::{Sprite, TextLabel};
fn entry(name: &str, ty: &str) -> serde_json::Value {
serde_json::json!({"name": name, "type": ty, "args": {}})
}
#[test]
fn grouped_rows_sort_types_and_names_and_keep_indices() {
let entries = vec![
entry("zeta", "PointLight"),
entry("beta", "Decal"),
entry("alpha", "PointLight"),
];
let rows = grouped_rows(&entries, None);
assert!(rows[0].is_header && rows[0].text == "Decal");
assert_eq!((rows[1].text.as_str(), rows[1].entry), ("beta", Some(1)));
assert!(rows[2].is_header && rows[2].text == "PointLight");
assert_eq!((rows[3].text.as_str(), rows[3].entry), ("alpha", Some(2)));
assert_eq!((rows[4].text.as_str(), rows[4].entry), ("zeta", Some(0)));
}
#[test]
fn grouped_rows_filter_keeps_one_group() {
let entries = vec![
entry("a", "PointLight"),
entry("b", "Decal"),
entry("c", "PointLight"),
];
let rows = grouped_rows(&entries, Some("PointLight"));
assert_eq!(rows.len(), 3, "one header + two names");
assert!(rows[0].is_header && rows[0].text == "PointLight");
}
fn world_with(ids: &[AssetId]) -> World {
let mut world = World::new();
for &id in ids {
world.add_component(Sprite {
asset_id: id,
..Default::default()
});
world.add_component(TextLabel {
asset_id: id,
..Default::default()
});
}
world
}
#[test]
fn place_row_indents_names_and_colours_headers() {
let mut world = world_with(&[AssetId(1), AssetId(2)]);
let hdr = ListRow {
is_header: true,
text: "PointLight".into(),
entry: None,
};
place_row(
&mut world,
AssetId(1),
AssetId(1),
&hdr,
[10.0, 20.0, 300.0, ROW_H],
ROW_TINT,
);
let l = world
.query::<TextLabel>()
.find(|l| l.asset_id == AssetId(1))
.unwrap();
assert_eq!(l.x, 10.0 + PAD, "header caption sits at the base pad");
assert_eq!(l.color, HEADER_LABEL);
let name = ListRow {
is_header: false,
text: "lamp".into(),
entry: Some(0),
};
place_row(
&mut world,
AssetId(2),
AssetId(2),
&name,
[10.0, 20.0, 300.0, ROW_H],
ROW_TINT,
);
let l = world
.query::<TextLabel>()
.find(|l| l.asset_id == AssetId(2))
.unwrap();
assert_eq!(l.x, 10.0 + PAD + INDENT, "name caption is indented");
assert_eq!(l.color, LABEL);
}
#[test]
fn scrollbar_shows_only_on_overflow() {
let mut world = world_with(&[AssetId(1), AssetId(2)]);
for s in world.query_mut::<Sprite>() {
s.visible = false;
}
layout_scrollbar(
&mut world,
(AssetId(1), AssetId(2)),
MAX_ROWS,
0,
MAX_ROWS,
300.0,
40.0,
);
assert!(
world.query::<Sprite>().all(|s| !s.visible),
"no bar when the list fits"
);
layout_scrollbar(
&mut world,
(AssetId(1), AssetId(2)),
MAX_ROWS + 5,
0,
MAX_ROWS,
300.0,
40.0,
);
assert!(
world.query::<Sprite>().all(|s| s.visible),
"track + thumb show on overflow"
);
}
}