use super::registry::{self, PanelKey};
use super::theme;
use super::widget::{self, point_in};
use crate::ecs::World;
use crate::ecs::asset_id::AssetId;
const BASE: u32 = registry::base(PanelKey::Palette);
pub(crate) const PANEL_BG: AssetId = AssetId(BASE);
pub(crate) const TITLE_LABEL: AssetId = AssetId(BASE + 2);
pub(crate) const CLOSE_BG: AssetId = AssetId(BASE + 3);
pub(crate) const CLOSE_LABEL: AssetId = AssetId(BASE + 4);
pub(crate) const INPUT: AssetId = AssetId(BASE + 5);
fn row_bg(i: usize) -> AssetId {
AssetId(BASE + 0x10 + i as u32)
}
fn row_label(i: usize) -> AssetId {
AssetId(BASE + 0x40 + i as u32)
}
fn row_hint(i: usize) -> AssetId {
AssetId(BASE + 0x70 + i as u32)
}
fn row_tag(i: usize) -> AssetId {
AssetId(BASE + 0xA0 + i as u32)
}
pub(crate) const PALETTE_W: f32 = 560.0;
const PAD: f32 = 10.0;
const INPUT_H: f32 = 28.0;
const ROW_H: f32 = 24.0;
pub(crate) const ROW_POOL: usize = 10;
const CAPTION_CHARS: usize = 26;
const HINT_CHARS: usize = 24;
const HINT_X: f32 = 240.0;
const TAG_W: f32 = 70.0;
pub(crate) struct PaletteRow<'a> {
pub caption: &'a str,
pub hint: &'a str,
pub tag: &'a str,
}
pub(crate) struct PaletteView<'a> {
pub rows: Vec<PaletteRow<'a>>,
pub selected: usize,
pub scroll: usize,
pub total: usize,
pub focus: bool,
pub mouse: [f32; 2],
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PaletteHit {
FocusInput,
Row(usize),
Consume,
}
pub(crate) fn default_origin(vp: [f32; 2]) -> [f32; 2] {
let s = size();
[
((vp[0] - s[0]) * 0.5).max(0.0),
(vp[1] * 0.18).max(super::hud::body_top()),
]
}
pub(crate) fn size() -> [f32; 2] {
[
PALETTE_W,
widget::TITLE_H + INPUT_H + ROW_POOL as f32 * ROW_H + 2.0 * PAD,
]
}
pub(crate) fn input_rect(o: [f32; 2]) -> [f32; 4] {
[
o[0] + PAD,
o[1] + widget::TITLE_H + PAD * 0.5,
PALETTE_W - 2.0 * PAD,
INPUT_H,
]
}
fn rows_top(o: [f32; 2]) -> f32 {
o[1] + widget::TITLE_H + INPUT_H + PAD
}
pub(crate) fn row_rect(o: [f32; 2], slot: usize) -> [f32; 4] {
[
o[0] + PAD * 0.5,
rows_top(o) + slot as f32 * ROW_H,
PALETTE_W - PAD,
ROW_H,
]
}
pub(crate) fn cursor_over_rows(mx: f32, my: f32, o: [f32; 2]) -> bool {
let top = rows_top(o);
mx >= o[0] && mx < o[0] + PALETTE_W && my >= top && my < top + ROW_POOL as f32 * ROW_H
}
pub(crate) fn hit_test(view: &PaletteView, mx: f32, my: f32, o: [f32; 2]) -> Option<PaletteHit> {
if point_in(mx, my, input_rect(o)) {
return Some(PaletteHit::FocusInput);
}
for slot in 0..view.rows.len() {
if point_in(mx, my, row_rect(o, slot)) {
return Some(PaletteHit::Row(slot));
}
}
point_in(mx, my, widget::outer_rect(o, size())).then_some(PaletteHit::Consume)
}
pub(crate) fn apply(world: &mut World, view: Option<&PaletteView>, o: [f32; 2]) {
let Some(view) = view else {
hide_all(world);
return;
};
widget::place_panel(world, PANEL_BG, widget::outer_rect(o, size()));
let title = widget::title_rect(o, PALETTE_W);
widget::place_heading(world, TITLE_LABEL, title, "Palette");
let close_hover = point_in(view.mouse[0], view.mouse[1], widget::close_rect(title));
widget::place_close(world, CLOSE_BG, CLOSE_LABEL, title, close_hover);
widget::show_field(world, INPUT, input_rect(o), view.focus);
for slot in 0..ROW_POOL {
let Some(row) = view.rows.get(slot) else {
widget::set_sprite_visible(world, row_bg(slot), false);
widget::set_label_visible(world, row_hint(slot), false);
widget::set_label_visible(world, row_tag(slot), false);
if slot == 0 && view.total == 0 {
empty_row(world, o);
} else {
widget::set_label_visible(world, row_label(slot), false);
}
continue;
};
let r = row_rect(o, slot);
let tint = if view.scroll + slot == view.selected {
theme::SELECTED_TINT
} else if point_in(view.mouse[0], view.mouse[1], r) {
theme::HOVER_TINT
} else {
[0.0; 4]
};
widget::place_rounded(
world,
row_bg(slot),
theme::highlight_rect(r),
tint,
theme::CONTROL_RADIUS,
tint[3] > 0.0,
);
let text_y = r[1] + ROW_H * 0.5 - theme::TEXT_HALF;
widget::place_left_label(
world,
row_label(slot),
[r[0] + 8.0, text_y],
&widget::clip_text(row.caption, CAPTION_CHARS),
theme::LABEL,
true,
);
widget::place_left_label(
world,
row_hint(slot),
[r[0] + HINT_X, text_y],
&widget::clip_text(row.hint, HINT_CHARS),
theme::LABEL_DIM,
true,
);
widget::place_left_label(
world,
row_tag(slot),
[r[0] + r[2] - TAG_W, text_y],
row.tag,
theme::LABEL_DIM,
true,
);
}
}
fn empty_row(world: &mut World, o: [f32; 2]) {
let r = row_rect(o, 0);
widget::place_left_label(
world,
row_label(0),
[r[0] + 8.0, r[1] + ROW_H * 0.5 - theme::TEXT_HALF],
"no match",
theme::LABEL_DIM,
true,
);
}
pub(crate) fn hide_all(world: &mut World) {
widget::hide_all(world, &all_sprite_ids(), &all_label_ids(), &all_field_ids());
}
pub(crate) fn all_sprite_ids() -> Vec<AssetId> {
let mut ids = vec![PANEL_BG, CLOSE_BG];
ids.extend((0..ROW_POOL).map(row_bg));
ids
}
pub(crate) fn all_label_ids() -> Vec<AssetId> {
let mut ids = vec![TITLE_LABEL, CLOSE_LABEL];
for i in 0..ROW_POOL {
ids.push(row_label(i));
ids.push(row_hint(i));
ids.push(row_tag(i));
}
ids
}
pub(crate) fn all_field_ids() -> Vec<AssetId> {
vec![INPUT]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::components::{Sprite, TextInput, TextLabel};
fn injected_world() -> World {
let mut world = World::new();
for id in all_sprite_ids() {
world.add_component(Sprite {
asset_id: id,
..Default::default()
});
}
for id in all_label_ids() {
world.add_component(TextLabel {
asset_id: id,
..Default::default()
});
}
for id in all_field_ids() {
world.add_component(TextInput {
asset_id: id,
..Default::default()
});
}
world
}
fn rows(n: usize) -> Vec<(String, String)> {
(0..n)
.map(|i| (format!("item {i}"), format!("hint {i}")))
.collect()
}
fn view<'a>(backing: &'a [(String, String)], selected: usize) -> PaletteView<'a> {
PaletteView {
rows: backing
.iter()
.map(|(c, h)| PaletteRow {
caption: c,
hint: h,
tag: "entity",
})
.collect(),
selected,
scroll: 0,
total: backing.len(),
focus: true,
mouse: [0.0, 0.0],
}
}
#[test]
fn hit_test_resolves_rows_input_or_swallows() {
let o = [40.0, 40.0];
let backing = rows(3);
let v = view(&backing, 0);
let i = input_rect(o);
assert_eq!(
hit_test(&v, i[0] + 5.0, i[1] + 5.0, o),
Some(PaletteHit::FocusInput)
);
let r1 = row_rect(o, 1);
assert_eq!(
hit_test(&v, r1[0] + 5.0, r1[1] + 5.0, o),
Some(PaletteHit::Row(1))
);
let r5 = row_rect(o, 5);
assert_eq!(
hit_test(&v, r5[0] + 5.0, r5[1] + 5.0, o),
Some(PaletteHit::Consume)
);
assert_eq!(hit_test(&v, 5000.0, 5000.0, o), None);
}
#[test]
fn rows_stack_under_the_input_inside_the_panel() {
let o = [0.0, 0.0];
let i = input_rect(o);
let first = row_rect(o, 0);
assert!(first[1] >= i[1] + i[3], "rows start below the input");
let last = row_rect(o, ROW_POOL - 1);
let p = widget::outer_rect(o, size());
assert!(
last[1] + last[3] <= p[1] + p[3],
"rows stay inside the panel"
);
assert!(cursor_over_rows(10.0, first[1] + 5.0, o));
assert!(!cursor_over_rows(10.0, i[1] + 5.0, o));
}
#[test]
fn apply_draws_the_window_and_highlights_the_selection() {
let mut world = injected_world();
let backing = rows(3);
apply(&mut world, Some(&view(&backing, 1)), [20.0, 20.0]);
let caption = world
.query::<TextLabel>()
.find(|l| l.asset_id == row_label(0))
.unwrap();
assert!(caption.visible && caption.content == "item 0");
let tag = world
.query::<TextLabel>()
.find(|l| l.asset_id == row_tag(0))
.unwrap();
assert!(tag.visible && tag.content == "entity");
let lit: Vec<usize> = (0..ROW_POOL)
.filter(|&i| {
world
.query::<Sprite>()
.find(|s| s.asset_id == row_bg(i))
.is_some_and(|s| s.visible)
})
.collect();
assert_eq!(lit, vec![1]);
assert!(
!world
.query::<TextLabel>()
.find(|l| l.asset_id == row_label(3))
.unwrap()
.visible
);
let input = world.query::<TextInput>().next().unwrap();
assert!(input.visible && input.focused);
}
#[test]
fn an_empty_window_says_no_match() {
let mut world = injected_world();
let backing = rows(0);
apply(&mut world, Some(&view(&backing, 0)), [20.0, 20.0]);
let first = world
.query::<TextLabel>()
.find(|l| l.asset_id == row_label(0))
.unwrap();
assert!(first.visible && first.content == "no match");
}
#[test]
fn hide_all_blanks_every_element() {
let mut world = injected_world();
let backing = rows(ROW_POOL);
apply(&mut world, Some(&view(&backing, 0)), [20.0, 20.0]);
apply(&mut world, None, [0.0, 0.0]);
assert!(world.query::<Sprite>().all(|s| !s.visible));
assert!(world.query::<TextLabel>().all(|l| !l.visible));
let input = world.query::<TextInput>().next().unwrap();
assert!(!input.visible && !input.focused);
}
}