use crate::components::{TextAlign, TextLabel};
use crate::ecs::FontHandle;
use crate::gfx::text::{FontSet, measure_label_box};
const HEADER_PX: f32 = 68.0;
const MESSAGE_PX: f32 = 32.0;
const QUIT_PX: f32 = 52.0;
const HEADER_Y: f32 = 0.26;
const MESSAGE_Y: f32 = 0.42;
const QUIT_Y: f32 = 0.64;
const LINE_GAP: f32 = 0.35;
const QUIT_CLEARANCE: f32 = 1.5;
const WRAP_FRACTION: f32 = 0.8;
const MESSAGE_MAX_LINES: u32 = 6;
const QUIT_HIT_PAD: f32 = 24.0;
const HEADER_TEXT: &str = "Error";
const HEADER_COLOR: [f32; 3] = [0.85, 0.09, 0.07];
const TEXT_COLOR: [f32; 3] = [0.95, 0.78, 0.12];
const QUIT_COLOR: [f32; 3] = [0.82, 0.82, 0.82];
const QUIT_HOVER_COLOR: [f32; 3] = [1.0, 0.85, 0.3];
pub(super) struct Layout {
pub(super) labels: Vec<TextLabel>,
pub(super) quit_rect: [f32; 4],
}
impl Layout {
pub(super) fn quit_hit(&self, x: f32, y: f32) -> bool {
let [rx, ry, rw, rh] = self.quit_rect;
x >= rx && x <= rx + rw && y >= ry && y <= ry + rh
}
#[cfg(test)]
fn header_label(&self) -> &TextLabel {
self.labels.first().expect("the header is always present")
}
#[cfg(test)]
fn quit_label(&self) -> &TextLabel {
self.labels.last().expect("the Quit line is always present")
}
#[cfg(test)]
fn message_labels(&self) -> &[TextLabel] {
&self.labels[1..self.labels.len() - 1]
}
}
pub(super) fn build(
message: &str,
win_w: f32,
win_h: f32,
fonts: &FontSet,
handle: FontHandle,
hovered: bool,
) -> Layout {
let size_px = fonts.get(handle).map(|f| f.size_px).unwrap_or(1.0).max(1.0);
let mut labels: Vec<TextLabel> = vec![TextLabel {
font: Some(handle),
content: HEADER_TEXT.to_string(),
x: win_w * 0.5,
y: win_h * HEADER_Y,
color: HEADER_COLOR,
scale: HEADER_PX / size_px,
align: TextAlign::Center,
visible: true,
..TextLabel::default()
}];
let mut y = win_h * MESSAGE_Y;
for line in message.lines() {
let label = TextLabel {
font: Some(handle),
content: line.to_string(),
x: win_w * 0.5,
y,
color: TEXT_COLOR,
scale: MESSAGE_PX / size_px,
align: TextAlign::Center,
wrap_width: win_w * WRAP_FRACTION,
max_lines: MESSAGE_MAX_LINES,
visible: true,
..TextLabel::default()
};
let height = measure_label_box(&label, fonts)
.map(|b| b.h)
.unwrap_or(MESSAGE_PX);
y += height * (1.0 + LINE_GAP);
labels.push(label);
}
let quit_y = (win_h * QUIT_Y).max(y + MESSAGE_PX * QUIT_CLEARANCE);
let quit_label = TextLabel {
font: Some(handle),
content: "Quit".to_string(),
x: win_w * 0.5,
y: quit_y,
color: if hovered {
QUIT_HOVER_COLOR
} else {
QUIT_COLOR
},
scale: QUIT_PX / size_px,
align: TextAlign::Center,
visible: true,
..TextLabel::default()
};
let quit_rect = match measure_label_box(&quit_label, fonts) {
Some(b) => [
quit_label.x - b.w * 0.5 - QUIT_HIT_PAD,
quit_label.y - b.top_inset - QUIT_HIT_PAD,
b.w + QUIT_HIT_PAD * 2.0,
b.h + QUIT_HIT_PAD * 2.0,
],
None => [0.0, 0.0, 0.0, 0.0],
};
labels.push(quit_label);
Layout { labels, quit_rect }
}
#[cfg(test)]
mod tests {
use super::*;
fn fonts() -> (FontSet, FontHandle) {
let handle = super::super::FONT_HANDLE;
let builtin = crate::gfx::builtin_font::load(handle).expect("embedded font decodes");
let mut fonts = FontSet::default();
fonts.insert(handle, builtin.loaded);
(fonts, handle)
}
#[test]
fn every_line_is_horizontally_centered() {
let (fonts, handle) = fonts();
let layout = build("data file missing", 1280.0, 720.0, &fonts, handle, false);
assert_eq!(layout.labels.len(), 3, "header, one message line, Quit");
for label in &layout.labels {
assert_eq!(label.x, 640.0, "centered on the window");
assert_eq!(label.align, TextAlign::Center);
assert!(!label.centered);
}
assert!(
layout.quit_label().scale > layout.message_labels()[0].scale,
"the Quit line draws larger than the message"
);
}
#[test]
fn the_header_leads_in_larger_red_text() {
let (fonts, handle) = fonts();
let layout = build("data file missing", 1280.0, 720.0, &fonts, handle, false);
let header = layout.header_label();
assert_eq!(header.content, "Error");
assert_eq!(header.color, HEADER_COLOR);
assert!(
header.color[0] > header.color[1] && header.color[0] > header.color[2],
"the header reads red: {:?}",
header.color
);
assert!(
header.scale > layout.message_labels()[0].scale,
"the header draws larger than the message"
);
assert!(
header.y < layout.message_labels()[0].y,
"the header sits above the message"
);
}
#[test]
fn the_message_draws_yellow() {
let (fonts, handle) = fonts();
let layout = build("boom\nsecond line", 1280.0, 720.0, &fonts, handle, false);
for line in layout.message_labels() {
assert_eq!(line.color, TEXT_COLOR);
let [r, g, b] = line.color;
assert!(
r > 0.5 && g > 0.5 && b < 0.3,
"the message reads yellow: {:?}",
line.color
);
}
assert_ne!(layout.header_label().color, TEXT_COLOR);
assert_ne!(layout.quit_label().color, TEXT_COLOR);
}
#[test]
fn a_long_message_pushes_quit_below_itself() {
let (fonts, handle) = fonts();
let many_lines = (0..6).map(|i| format!("line {i}")).collect::<Vec<_>>();
let layout = build(&many_lines.join("\n"), 1280.0, 720.0, &fonts, handle, false);
let last = layout.message_labels().last().expect("message lines exist");
let last_box = measure_label_box(last, &fonts).expect("the last line measures");
assert!(
layout.quit_label().y > last.y + last_box.h,
"Quit at {} overlaps the last message line ending at {}",
layout.quit_label().y,
last.y + last_box.h
);
}
#[test]
fn each_message_line_is_centered_independently() {
let (fonts, handle) = fonts();
let layout = build(
"Failed to find the data blob:\nMyGame/data/0",
1280.0,
720.0,
&fonts,
handle,
false,
);
let lines = layout.message_labels();
assert_eq!(lines.len(), 2, "one label per authored line");
assert!(!lines[0].content.contains('\n'));
for line in lines {
assert_eq!(line.x, 640.0, "each line centres on the window");
}
assert!(lines[1].y > lines[0].y, "the second line stacks below");
let first = measure_label_box(&lines[0], &fonts).expect("first line measures");
assert!(lines[1].y >= lines[0].y + first.h);
}
#[test]
fn quit_rect_surrounds_the_quit_label() {
let (fonts, handle) = fonts();
let layout = build("boom", 1280.0, 720.0, &fonts, handle, false);
let quit = layout.quit_label();
let [rx, ry, rw, rh] = layout.quit_rect;
assert!(rw > 0.0 && rh > 0.0, "the button has real bounds");
assert!(layout.quit_hit(quit.x, quit.y), "the label origin hits");
assert!(
layout.quit_hit(rx + 1.0, ry + 1.0),
"the top-left corner hits"
);
assert!(
!layout.quit_hit(rx - 1.0, ry + rh * 0.5),
"left of the button misses"
);
assert!(
!layout.quit_hit(rx + rw * 0.5, ry - 1.0),
"above the button misses"
);
assert!(
!layout.quit_hit(rx + rw + 1.0, ry + rh * 0.5),
"right of it misses"
);
}
#[test]
fn layout_follows_the_window_size() {
let (fonts, handle) = fonts();
let small = build("boom", 800.0, 600.0, &fonts, handle, false);
let large = build("boom", 1920.0, 1080.0, &fonts, handle, false);
assert_eq!(small.message_labels()[0].x, 400.0);
assert_eq!(large.message_labels()[0].x, 960.0);
assert!(
large.quit_rect[1] > small.quit_rect[1],
"the button tracks height"
);
assert!(large.message_labels()[0].wrap_width > small.message_labels()[0].wrap_width);
}
#[test]
fn hovering_retints_only_the_quit_line() {
let (fonts, handle) = fonts();
let plain = build("boom", 1280.0, 720.0, &fonts, handle, false);
let hovered = build("boom", 1280.0, 720.0, &fonts, handle, true);
assert_eq!(
plain.message_labels()[0].color,
hovered.message_labels()[0].color
);
assert_ne!(plain.quit_label().color, hovered.quit_label().color);
assert_eq!(hovered.quit_label().color, QUIT_HOVER_COLOR);
}
#[test]
fn a_long_message_stays_bounded() {
let (fonts, handle) = fonts();
let long = "no compiled world data under ".to_string() + &"very/long/path/".repeat(40);
let layout = build(&long, 1280.0, 720.0, &fonts, handle, false);
let label = &layout.message_labels()[0];
assert!(label.wrap_width > 0.0 && label.wrap_width < 1280.0);
assert_eq!(label.max_lines, MESSAGE_MAX_LINES);
let measured = measure_label_box(label, &fonts).expect("the message measures");
assert!(
measured.w <= label.wrap_width + 1.0,
"wrapped width {} exceeds the wrap bound {}",
measured.w,
label.wrap_width
);
}
}