use super::theme;
use crate::components::{Sprite, TextAlign, TextInput, TextLabel};
use crate::ecs::World;
use crate::ecs::asset_id::AssetId;
pub(crate) const TITLE_H: f32 = 28.0;
const CLOSE_TINT_HOVER: [f32; 4] = [0.46, 0.26, 0.28, 1.0];
const CLOSE_GLYPH_COLOR: [f32; 3] = [0.88, 0.89, 0.92];
const CLOSE_INSET: f32 = 4.0;
pub(crate) fn place_panel(world: &mut World, id: AssetId, rect: [f32; 4]) {
if let Some(s) = sprite_mut(world, id) {
s.x = rect[0];
s.y = rect[1];
s.width = rect[2];
s.height = rect[3];
s.tint = theme::CHROME_TINT;
s.corner_radius = theme::PANEL_RADIUS;
s.border_width = theme::PANEL_BORDER_WIDTH;
s.border_color = theme::PANEL_BORDER_TINT;
s.visible = true;
}
}
pub(crate) fn place_heading(world: &mut World, label: AssetId, rect: [f32; 4], heading: &str) {
if let Some(l) = label_mut(world, label) {
l.x = rect[0] + 10.0;
l.y = rect[1] + rect[3] * 0.5 - theme::TEXT_HALF;
l.align = TextAlign::Left;
l.color = theme::HEADING;
l.visible = true;
l.content = heading.to_string();
}
}
pub(crate) fn outer_rect(o: [f32; 2], s: [f32; 2]) -> [f32; 4] {
[o[0], o[1], s[0], s[1]]
}
pub(crate) fn title_rect(o: [f32; 2], w: f32) -> [f32; 4] {
[o[0], o[1], w, TITLE_H]
}
pub(crate) fn header_y(o: [f32; 2], gap: f32) -> f32 {
o[1] + TITLE_H + gap
}
pub(crate) fn place_left_label(
world: &mut World,
id: AssetId,
pos: [f32; 2],
content: &str,
color: [f32; 3],
visible: bool,
) {
if let Some(l) = label_mut(world, id) {
l.x = pos[0];
l.y = pos[1];
l.align = TextAlign::Left;
l.color = color;
l.visible = visible;
l.content = content.to_string();
}
}
pub(crate) const LINE_H: f32 = theme::TEXT_HALF * 2.0;
pub(crate) fn lines_in(h: f32) -> u32 {
((h / LINE_H).floor() as u32).max(1)
}
pub(crate) fn place_message(
world: &mut World,
id: AssetId,
rect: [f32; 4],
content: &str,
color: [f32; 3],
visible: bool,
) {
let lines = lines_in(rect[3]);
if let Some(l) = label_mut(world, id) {
l.x = rect[0];
l.y = rect[1];
l.align = TextAlign::Left;
l.color = color;
l.visible = visible;
l.wrap_width = rect[2].max(0.0);
l.max_lines = lines;
l.content = content.to_string();
}
}
pub(crate) fn close_rect(title: [f32; 4]) -> [f32; 4] {
[title[0] + title[2] - TITLE_H, title[1], TITLE_H, TITLE_H]
}
pub(crate) fn place_close(
world: &mut World,
bg: AssetId,
label: AssetId,
title: [f32; 4],
hovered: bool,
) {
let rect = close_rect(title);
if hovered {
let pill = [
rect[0] + CLOSE_INSET,
rect[1] + CLOSE_INSET,
rect[2] - 2.0 * CLOSE_INSET,
rect[3] - 2.0 * CLOSE_INSET,
];
place_rounded(
world,
bg,
pill,
CLOSE_TINT_HOVER,
theme::CONTROL_RADIUS,
true,
);
} else {
set_sprite_visible(world, bg, false);
}
if let Some(l) = label_mut(world, label) {
l.x = rect[0] + rect[2] * 0.5;
l.y = rect[1] + rect[3] * 0.5 - theme::TEXT_HALF;
l.align = TextAlign::Center;
l.color = if hovered {
[1.0, 1.0, 1.0]
} else {
CLOSE_GLYPH_COLOR
};
l.visible = true;
l.content = "X".to_string();
}
}
pub(crate) fn clamp_origin(
pos: [f32; 2],
size: [f32; 2],
viewport: [f32; 2],
top: f32,
) -> [f32; 2] {
[
pos[0].clamp(0.0, (viewport[0] - size[0]).max(0.0)),
pos[1].clamp(top, (viewport[1] - size[1]).max(top)),
]
}
pub(crate) fn sprite_mut(world: &mut World, id: AssetId) -> Option<&mut Sprite> {
world.query_mut::<Sprite>().find(|s| s.asset_id == id)
}
pub(crate) fn label_mut(world: &mut World, id: AssetId) -> Option<&mut TextLabel> {
world.query_mut::<TextLabel>().find(|l| l.asset_id == id)
}
pub(crate) fn input_mut(world: &mut World, id: AssetId) -> Option<&mut TextInput> {
world.query_mut::<TextInput>().find(|t| t.asset_id == id)
}
pub(crate) fn input(world: &World, id: AssetId) -> Option<&TextInput> {
world.query::<TextInput>().find(|t| t.asset_id == id)
}
pub(crate) fn place_sprite(
world: &mut World,
id: AssetId,
rect: [f32; 4],
tint: [f32; 4],
visible: bool,
) {
place_rounded(world, id, rect, tint, 0.0, visible);
}
pub(crate) fn place_rounded(
world: &mut World,
id: AssetId,
rect: [f32; 4],
tint: [f32; 4],
radius: f32,
visible: bool,
) {
if let Some(s) = sprite_mut(world, id) {
s.x = rect[0];
s.y = rect[1];
s.width = rect[2];
s.height = rect[3];
s.tint = tint;
s.corner_radius = radius;
s.border_width = 0.0;
s.visible = visible;
}
}
pub(crate) fn place_bordered(
world: &mut World,
id: AssetId,
rect: [f32; 4],
tint: [f32; 4],
border: [f32; 4],
width: f32,
) {
place_rounded(world, id, rect, tint, theme::CONTROL_RADIUS, true);
if let Some(s) = sprite_mut(world, id) {
s.border_color = border;
s.border_width = width;
}
}
pub(crate) fn set_sprite_visible(world: &mut World, id: AssetId, visible: bool) {
if let Some(s) = sprite_mut(world, id) {
s.visible = visible;
}
}
pub(crate) fn set_label_visible(world: &mut World, id: AssetId, visible: bool) {
if let Some(l) = label_mut(world, id) {
l.visible = visible;
}
}
pub(crate) fn show_field(world: &mut World, id: AssetId, rect: [f32; 4], focused: bool) {
if let Some(t) = input_mut(world, id) {
t.x = rect[0];
t.y = rect[1];
t.width = rect[2];
t.height = rect[3];
t.visible = true;
t.focused = focused;
}
}
pub(crate) fn hide_field(world: &mut World, id: AssetId) {
if let Some(t) = input_mut(world, id) {
t.visible = false;
t.focused = false;
}
}
pub(crate) fn hide_all(
world: &mut World,
sprites: &[AssetId],
labels: &[AssetId],
fields: &[AssetId],
) {
for &id in sprites {
set_sprite_visible(world, id, false);
}
for &id in labels {
set_label_visible(world, id, false);
}
for &id in fields {
hide_field(world, id);
}
}
pub(crate) fn focus_field_with(world: &mut World, id: AssetId, content: &str) {
if let Some(t) = input_mut(world, id) {
t.content = content.to_string();
t.caret = content.chars().count();
t.focused = true;
t.visible = true;
}
}
pub(crate) fn seed_field(world: &mut World, id: AssetId, content: &str) {
if let Some(t) = input_mut(world, id) {
t.content = content.to_string();
t.caret = content.chars().count();
}
}
pub(crate) fn field_text(world: &World, id: AssetId) -> String {
input(world, id)
.map(|t| t.content.clone())
.unwrap_or_default()
}
pub(crate) fn point_in(x: f32, y: f32, rect: [f32; 4]) -> bool {
x >= rect[0] && x < rect[0] + rect[2] && y >= rect[1] && y < rect[1] + rect[3]
}
pub(crate) fn clip_text(text: &str, max: usize) -> String {
if text.chars().count() <= max {
return text.to_string();
}
let clipped: String = text.chars().take(max.saturating_sub(3)).collect();
format!("{clipped}...")
}
#[cfg(test)]
mod tests {
use super::*;
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.add_component(TextInput {
asset_id: id,
..Default::default()
});
}
world
}
#[test]
fn place_message_bounds_text_to_the_box_that_holds_it() {
let mut world = World::new();
let id = AssetId(1);
world.add_component(TextLabel {
asset_id: id,
..Default::default()
});
place_message(
&mut world,
id,
[10.0, 20.0, 300.0, 2.0 * LINE_H],
"a long message",
[1.0; 3],
true,
);
let l = world
.query::<TextLabel>()
.find(|l| l.asset_id == id)
.unwrap();
assert_eq!((l.x, l.y), (10.0, 20.0));
assert_eq!(l.wrap_width, 300.0);
assert_eq!(l.max_lines, 2);
}
#[test]
fn a_box_always_holds_at_least_one_line() {
assert_eq!(lines_in(0.0), 1);
assert_eq!(lines_in(LINE_H), 1);
assert_eq!(lines_in(3.0 * LINE_H), 3);
}
#[test]
fn clip_text_shortens_long_strings() {
assert_eq!(clip_text("short", 10), "short");
assert_eq!(clip_text("a very long line indeed", 10), "a very ...");
}
#[test]
fn accessors_find_by_id_or_return_none() {
let mut world = world_with(&[AssetId(1)]);
assert!(sprite_mut(&mut world, AssetId(1)).is_some());
assert!(label_mut(&mut world, AssetId(1)).is_some());
assert!(input_mut(&mut world, AssetId(1)).is_some());
assert!(input(&world, AssetId(1)).is_some());
assert!(sprite_mut(&mut world, AssetId(999)).is_none());
assert!(input(&world, AssetId(999)).is_none());
}
#[test]
fn place_sprite_sets_geometry_and_is_a_noop_when_absent() {
let mut world = world_with(&[AssetId(1)]);
place_sprite(
&mut world,
AssetId(1),
[5.0, 6.0, 7.0, 8.0],
[1.0, 0.0, 0.0, 1.0],
true,
);
let s = world
.query::<Sprite>()
.find(|s| s.asset_id == AssetId(1))
.unwrap();
assert_eq!((s.x, s.y, s.width, s.height), (5.0, 6.0, 7.0, 8.0));
assert_eq!(s.tint, [1.0, 0.0, 0.0, 1.0]);
assert!(s.visible);
place_sprite(&mut world, AssetId(2), [0.0; 4], [0.0; 4], true);
assert_eq!(world.query::<Sprite>().count(), 1);
}
#[test]
fn visibility_setters_toggle_the_right_element() {
let mut world = world_with(&[AssetId(1)]);
set_sprite_visible(&mut world, AssetId(1), false);
set_label_visible(&mut world, AssetId(1), false);
assert!(!world.query::<Sprite>().next().unwrap().visible);
assert!(!world.query::<TextLabel>().next().unwrap().visible);
}
#[test]
fn place_panel_draws_a_rounded_chrome_surface() {
let mut world = world_with(&[AssetId(1)]);
place_panel(&mut world, AssetId(1), [40.0, 60.0, 320.0, 400.0]);
let bg = world
.query::<Sprite>()
.find(|s| s.asset_id == AssetId(1))
.unwrap();
assert!(bg.visible);
assert_eq!(
(bg.x, bg.y, bg.width, bg.height),
(40.0, 60.0, 320.0, 400.0)
);
assert_eq!(bg.tint, theme::CHROME_TINT);
assert_eq!(bg.corner_radius, theme::PANEL_RADIUS);
assert_eq!(bg.border_width, theme::PANEL_BORDER_WIDTH);
assert_eq!(bg.border_color, theme::PANEL_BORDER_TINT);
}
#[test]
fn place_heading_positions_the_title_text() {
let mut world = world_with(&[AssetId(2)]);
place_heading(
&mut world,
AssetId(2),
[40.0, 60.0, 320.0, TITLE_H],
"Assets",
);
let l = world
.query::<TextLabel>()
.find(|l| l.asset_id == AssetId(2))
.unwrap();
assert!(l.visible);
assert_eq!(l.content, "Assets");
assert_eq!(l.align, TextAlign::Left);
assert_eq!(l.x, 50.0, "heading is inset from the panel's left edge");
}
#[test]
fn close_button_shows_its_square_only_on_hover() {
let mut world = world_with(&[AssetId(1), AssetId(2)]);
let title = [40.0, 60.0, 320.0, TITLE_H];
let r = close_rect(title);
assert_eq!(r[2], TITLE_H, "square");
assert_eq!(
r[0] + r[2],
title[0] + title[2],
"flush to the title bar's right edge"
);
place_close(&mut world, AssetId(1), AssetId(2), title, false);
let bg = |w: &World| {
w.query::<Sprite>()
.find(|s| s.asset_id == AssetId(1))
.cloned()
.unwrap()
};
assert!(!bg(&world).visible, "no square while idle");
let glyph = world
.query::<TextLabel>()
.find(|l| l.asset_id == AssetId(2))
.unwrap();
assert!(
glyph.visible && glyph.content == "X",
"the X glyph always shows"
);
place_close(&mut world, AssetId(1), AssetId(2), title, true);
let hovered = bg(&world);
assert!(hovered.visible, "the square shows on hover");
assert!(hovered.corner_radius > 0.0, "and it is rounded");
}
#[test]
fn clamp_origin_keeps_the_whole_panel_on_screen() {
let size = [320.0, 400.0];
let vp = [1280.0, 720.0];
let top = 40.0;
assert_eq!(
clamp_origin([100.0, 50.0], size, vp, top),
[100.0, 50.0],
"an in-bounds origin is untouched"
);
assert_eq!(
clamp_origin([-40.0, -9000.0], size, vp, top),
[0.0, top],
"stops at the left edge and the bar's lower edge"
);
assert_eq!(
clamp_origin([2000.0, 700.0], size, vp, top),
[vp[0] - size[0], vp[1] - size[1]],
"stops with the panel's far edges on the window's"
);
assert_eq!(
clamp_origin([10.0, 300.0], [320.0, 900.0], vp, top),
[10.0, top]
);
}
#[test]
fn outer_rect_is_origin_plus_size() {
assert_eq!(
outer_rect([40.0, 60.0], [320.0, 400.0]),
[40.0, 60.0, 320.0, 400.0]
);
}
#[test]
fn title_rect_spans_the_width_at_the_bar_height() {
let t = title_rect([40.0, 60.0], 320.0);
assert_eq!(t, [40.0, 60.0, 320.0, TITLE_H]);
assert_eq!(close_rect(t)[0] + TITLE_H, t[0] + t[2]);
}
#[test]
fn header_y_sits_below_the_title_bar_plus_gap() {
assert_eq!(header_y([40.0, 60.0], 0.0), 60.0 + TITLE_H);
assert_eq!(header_y([40.0, 60.0], 10.0), 60.0 + TITLE_H + 10.0);
}
#[test]
fn place_left_label_positions_and_no_ops_when_absent() {
let mut world = world_with(&[AssetId(1)]);
place_left_label(
&mut world,
AssetId(1),
[12.0, 34.0],
"hi",
[0.1, 0.2, 0.3],
true,
);
let l = label_mut(&mut world, AssetId(1)).unwrap();
assert_eq!((l.x, l.y), (12.0, 34.0));
assert_eq!(l.align, TextAlign::Left);
assert_eq!(l.color, [0.1, 0.2, 0.3]);
assert!(l.visible && l.content == "hi");
place_left_label(&mut world, AssetId(999), [0.0, 0.0], "x", [0.0; 3], true);
assert_eq!(world.query::<TextLabel>().count(), 1);
}
#[test]
fn point_in_includes_top_left_excludes_bottom_right() {
let r = [10.0, 20.0, 100.0, 40.0];
assert!(point_in(10.0, 20.0, r), "top-left corner is inside");
assert!(point_in(50.0, 40.0, r), "interior is inside");
assert!(!point_in(110.0, 40.0, r), "right edge is outside");
assert!(!point_in(50.0, 60.0, r), "bottom edge is outside");
assert!(!point_in(9.9, 40.0, r), "left of the rect is outside");
}
}