use concinnity_core::ecs::World;
use super::hud_ids::{Family, family_base, hud_ids};
use super::widget::{self, point_in};
use super::widget_check::{self, Check, CheckIds};
use super::{hud, theme};
pub(crate) const MAX_BUTTONS: usize = 3;
hud_ids! {
base: family_base(Family::Modal);
sprites: [DIM, PANEL_BG, CHECK_BOX, button_bg[MAX_BUTTONS]];
labels: [MESSAGE, CHECK_CAPTION, CHECK_NOTE, button_label[MAX_BUTTONS]];
fields: [pub(crate) NAME_INPUT = "world name"];
}
const CHECK: CheckIds = CheckIds {
box_bg: CHECK_BOX,
caption: CHECK_CAPTION,
note: CHECK_NOTE,
};
const PANEL_W: f32 = 380.0;
const PAD: f32 = 14.0;
const MSG_LINES: u32 = 3;
const BTN_W: f32 = 100.0;
const BTN_H: f32 = 28.0;
const BTN_GAP: f32 = 10.0;
const FIELD_H: f32 = 28.0;
const DIM_TINT: [f32; 4] = [0.02, 0.02, 0.03, 0.55];
const DANGER_TINT: [f32; 4] = [0.68, 0.26, 0.28, 1.0];
const DANGER_HOVER_TINT: [f32; 4] = [0.80, 0.32, 0.34, 1.0];
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Button {
pub(crate) label: String,
pub(crate) danger: bool,
pub(crate) action: Action,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Action {
Dismiss,
Worlds(super::worlds::WorldsConfirm),
NameWorld,
LeaveShaderSource {
save: bool,
then: super::panels::shader_source::Leave,
},
DeleteShader(String),
DeleteSdfField(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Dialog {
pub(crate) message: String,
pub(crate) buttons: Vec<Button>,
pub(crate) field: bool,
pub(crate) check: Option<Check>,
}
impl Dialog {
pub(crate) fn controls(&self) -> Controls {
Controls {
field: self.field,
check: self.check.is_some(),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct Controls {
pub(crate) field: bool,
pub(crate) check: bool,
}
impl Controls {
fn field_h(self) -> f32 {
if self.field { FIELD_H + PAD } else { 0.0 }
}
fn check_h(self) -> f32 {
if self.check {
widget_check::HEIGHT + PAD
} else {
0.0
}
}
}
pub(crate) fn size(c: Controls) -> [f32; 2] {
[
PANEL_W,
PAD + MSG_LINES as f32 * widget::LINE_H + c.field_h() + c.check_h() + PAD + BTN_H + PAD,
]
}
pub(crate) fn panel_rect(vp: [f32; 2], c: Controls) -> [f32; 4] {
let s = size(c);
let centered = [(vp[0] - s[0]) * 0.5, (vp[1] - s[1]) * 0.5];
widget::outer_rect(widget::clamp_origin(centered, s, vp, hud::BAR_H), s)
}
fn message_rect(p: [f32; 4]) -> [f32; 4] {
[
p[0] + PAD,
p[1] + PAD,
p[2] - 2.0 * PAD,
MSG_LINES as f32 * widget::LINE_H,
]
}
fn field_rect(p: [f32; 4]) -> [f32; 4] {
let m = message_rect(p);
[m[0], m[1] + m[3] + PAD, m[2], FIELD_H]
}
fn check_rect(p: [f32; 4], c: Controls) -> [f32; 4] {
let m = message_rect(p);
[
m[0],
m[1] + m[3] + PAD + c.field_h(),
m[2],
widget_check::HEIGHT,
]
}
pub(crate) fn button_rect(p: [f32; 4], count: usize, i: usize) -> [f32; 4] {
let right = p[0] + p[2] - PAD;
let x = right - (count - i) as f32 * BTN_W - (count - 1 - i) as f32 * BTN_GAP;
[x, p[1] + p[3] - PAD - BTN_H, BTN_W, BTN_H]
}
pub(crate) fn hit_button(
mx: f32,
my: f32,
vp: [f32; 2],
count: usize,
c: Controls,
) -> Option<usize> {
let p = panel_rect(vp, c);
(0..count.min(MAX_BUTTONS)).find(|&i| point_in(mx, my, button_rect(p, count, i)))
}
pub(crate) fn hit_check(mx: f32, my: f32, vp: [f32; 2], c: Controls) -> bool {
c.check && widget_check::hit(mx, my, check_rect(panel_rect(vp, c), c))
}
fn button_tint(danger: bool, hovered: bool) -> [f32; 4] {
match (danger, hovered) {
(true, true) => DANGER_HOVER_TINT,
(true, false) => DANGER_TINT,
(false, true) => theme::HOVER_TINT,
(false, false) => theme::BUTTON_TINT,
}
}
pub(crate) fn place(world: &mut World, vp: [f32; 2], dialog: &Dialog, mouse: [f32; 2]) {
let c = dialog.controls();
let buttons = &dialog.buttons;
widget::place_sprite(world, DIM, [0.0, 0.0, vp[0], vp[1]], DIM_TINT, true);
let p = panel_rect(vp, c);
widget::place_panel(world, PANEL_BG, p);
widget::place_message(
world,
MESSAGE,
message_rect(p),
&dialog.message,
theme::LABEL,
true,
);
match dialog.field {
true => widget::show_field(world, NAME_INPUT, field_rect(p), true),
false => widget::hide_field(world, NAME_INPUT),
}
match &dialog.check {
Some(check) => widget_check::place(world, CHECK, check_rect(p, c), check, mouse),
None => widget_check::hide(world, CHECK),
}
for slot in 0..MAX_BUTTONS {
match buttons.get(slot) {
Some(b) => {
let r = button_rect(p, buttons.len(), slot);
let hovered = point_in(mouse[0], mouse[1], r);
widget::place_rounded(
world,
button_bg(slot),
r,
button_tint(b.danger, hovered),
theme::CONTROL_RADIUS,
true,
);
widget::place_center_label(
world,
button_label(slot),
[r[0] + r[2] * 0.5, r[1] + r[3] * 0.5 - theme::TEXT_HALF],
&widget::clip_text(&b.label, 12),
theme::LABEL,
true,
);
}
None => {
widget::set_sprite_visible(world, button_bg(slot), false);
widget::set_label_visible(world, button_label(slot), false);
}
}
}
}
pub(crate) fn hide(world: &mut World) {
ids().hide(world);
}
#[cfg(test)]
mod tests {
use super::*;
use concinnity_core::components::TextInput;
use concinnity_core::components::{Sprite, TextAlign, TextLabel};
use concinnity_core::ecs::asset_id::AssetId;
const VP: [f32; 2] = [1280.0, 720.0];
const PLAIN: Controls = Controls {
field: false,
check: false,
};
const PROMPT: Controls = Controls {
field: true,
check: false,
};
fn dialog(message: &str, buttons: &[Button], field: bool) -> Dialog {
Dialog {
message: message.to_string(),
buttons: buttons.to_vec(),
field,
check: None,
}
}
fn world_with_elements() -> World {
ids().test_world()
}
fn plain(label: &str) -> Button {
Button {
label: label.to_string(),
danger: false,
action: Action::Dismiss,
}
}
fn danger(label: &str) -> Button {
Button {
label: label.to_string(),
danger: true,
action: Action::Dismiss,
}
}
fn sprite(world: &World, id: AssetId) -> Sprite {
world.get_by_id::<Sprite>(id).cloned().unwrap()
}
fn label(world: &World, id: AssetId) -> TextLabel {
world.get_by_id::<TextLabel>(id).cloned().unwrap()
}
#[test]
fn panel_centers_and_clamps_below_the_top_bar() {
let p = panel_rect(VP, PLAIN);
let s = size(PLAIN);
assert_eq!(p[0], (VP[0] - s[0]) * 0.5);
assert_eq!(p[1], (VP[1] - s[1]) * 0.5);
assert_eq!((p[2], p[3]), (s[0], s[1]));
let tiny = panel_rect([500.0, 120.0], PLAIN);
assert!(tiny[1] >= hud::BAR_H);
assert!(tiny[0] >= 0.0);
}
#[test]
fn buttons_lay_out_left_to_right_inside_the_dialog() {
let p = panel_rect(VP, PLAIN);
for count in [2, 3] {
let rects: Vec<[f32; 4]> = (0..count).map(|i| button_rect(p, count, i)).collect();
for w in rects.windows(2) {
assert!(w[0][0] + w[0][2] < w[1][0], "no overlap, caller order");
}
let last = rects[count - 1];
assert_eq!(
last[0] + last[2],
p[0] + p[2] - PAD,
"the last button is flush with the right padding"
);
for r in &rects {
assert!(
r[0] >= p[0] && r[0] + r[2] <= p[0] + p[2],
"inside the dialog"
);
assert!(r[1] + r[3] <= p[1] + p[3], "inside the dialog");
let m = message_rect(p);
assert!(r[1] >= m[1] + m[3], "below the message area");
}
}
}
#[test]
fn hit_button_resolves_each_button_and_misses_the_rest() {
let p = panel_rect(VP, PLAIN);
for count in [2, 3] {
for i in 0..count {
let r = button_rect(p, count, i);
assert_eq!(
hit_button(r[0] + 2.0, r[1] + 2.0, VP, count, PLAIN),
Some(i)
);
}
assert_eq!(hit_button(p[0] + 2.0, p[1] + 2.0, VP, count, PLAIN), None);
assert_eq!(hit_button(5.0, 5.0, VP, count, PLAIN), None);
let r1 = button_rect(p, count, 1);
assert_eq!(hit_button(r1[0] - 1.0, r1[1] + 2.0, VP, count, PLAIN), None);
}
}
#[test]
fn place_dims_the_whole_screen_behind_the_dialog() {
let mut world = world_with_elements();
place(
&mut world,
VP,
&dialog("Delete?", &[plain("Cancel"), danger("Delete")], false),
[0.0, 0.0],
);
let dim = sprite(&world, DIM);
assert!(dim.visible);
assert_eq!(
(dim.x, dim.y, dim.width, dim.height),
(0.0, 0.0, VP[0], VP[1])
);
assert!(dim.tint[3] < 1.0, "translucent, not a blackout");
assert!(sprite(&world, PANEL_BG).visible);
}
#[test]
fn danger_styling_marks_the_flagged_button_only() {
let mut world = world_with_elements();
place(
&mut world,
VP,
&dialog(
"Discard changes?",
&[plain("Cancel"), danger("Discard")],
false,
),
[0.0, 0.0],
);
assert_eq!(sprite(&world, button_bg(0)).tint, theme::BUTTON_TINT);
assert_eq!(sprite(&world, button_bg(1)).tint, DANGER_TINT);
let l = label(&world, button_label(1));
assert!(l.visible && l.content == "Discard");
assert_eq!(l.align, TextAlign::Center);
}
#[test]
fn hover_highlights_the_button_under_the_cursor() {
let p = panel_rect(VP, PLAIN);
let buttons = [plain("Cancel"), danger("Discard")];
let r0 = button_rect(p, 2, 0);
let r1 = button_rect(p, 2, 1);
let mut world = world_with_elements();
place(
&mut world,
VP,
&dialog("m", &buttons, false),
[r0[0] + 2.0, r0[1] + 2.0],
);
assert_eq!(sprite(&world, button_bg(0)).tint, theme::HOVER_TINT);
assert_eq!(sprite(&world, button_bg(1)).tint, DANGER_TINT);
place(
&mut world,
VP,
&dialog("m", &buttons, false),
[r1[0] + 2.0, r1[1] + 2.0],
);
assert_eq!(sprite(&world, button_bg(0)).tint, theme::BUTTON_TINT);
assert_eq!(sprite(&world, button_bg(1)).tint, DANGER_HOVER_TINT);
}
#[test]
fn a_two_button_dialog_hides_the_third_slot_and_bounds_the_message() {
let mut world = world_with_elements();
place(
&mut world,
VP,
&dialog(
"a long message that must wrap inside the dialog",
&[plain("No"), plain("Yes")],
false,
),
[0.0, 0.0],
);
assert!(!sprite(&world, button_bg(2)).visible);
assert!(!label(&world, button_label(2)).visible);
let m = label(&world, MESSAGE);
assert!(m.visible);
assert_eq!(m.max_lines, MSG_LINES);
assert_eq!(m.wrap_width, PANEL_W - 2.0 * PAD);
}
#[test]
fn hide_blanks_every_element() {
let mut world = world_with_elements();
place(
&mut world,
VP,
&dialog("m", &[plain("No"), plain("Yes")], false),
[0.0, 0.0],
);
hide(&mut world);
assert!(world.query::<Sprite>().all(|s| !s.visible));
assert!(world.query::<TextLabel>().all(|l| !l.visible));
assert!(world.query::<TextInput>().all(|t| !t.visible && !t.focused));
}
#[test]
fn a_prompt_shows_a_focused_name_field_and_a_plain_dialog_does_not() {
let mut world = world_with_elements();
place(
&mut world,
VP,
&dialog("Name this world", &[plain("Save")], true),
[0.0, 0.0],
);
let field = world.get_by_id::<TextInput>(NAME_INPUT).cloned().unwrap();
assert!(field.visible && field.focused);
let p = panel_rect(VP, PROMPT);
let m = label(&world, MESSAGE);
assert!(field.y > m.y, "the field sits under the message");
let button = button_rect(p, 1, 0);
assert!(field.y + field.height <= button[1], "and above the buttons");
assert!(size(PROMPT)[1] > size(PLAIN)[1], "the prompt is taller");
place(
&mut world,
VP,
&dialog("Delete?", &[plain("No")], false),
[0.0, 0.0],
);
assert!(world.query::<TextInput>().all(|t| !t.visible && !t.focused));
}
#[test]
fn a_checkbox_sits_above_the_buttons() {
let mut d = dialog("Delete?", &[plain("Cancel"), danger("Delete")], false);
d.check = Some(Check {
caption: "Also delete its files".to_string(),
on: false,
enabled: true,
note: None,
});
let c = d.controls();
assert!(size(c)[1] > size(PLAIN)[1]);
let mut world = world_with_elements();
place(&mut world, VP, &d, [0.0, 0.0]);
let caption = label(&world, CHECK.caption);
assert!(caption.visible && caption.content == "Also delete its files");
let p = panel_rect(VP, c);
let r = check_rect(p, c);
assert!(r[1] + r[3] <= button_rect(p, 2, 0)[1]);
assert!(hit_check(r[0] + 2.0, r[1] + 2.0, VP, c));
assert!(!hit_check(r[0] + 2.0, r[1] + 2.0, VP, PLAIN));
assert_eq!(hit_button(r[0] + 2.0, r[1] + 2.0, VP, 2, c), None);
place(
&mut world,
VP,
&dialog("m", &[plain("No")], false),
[0.0, 0.0],
);
assert!(!label(&world, CHECK.caption).visible);
}
}