use super::rect::Rect;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Anchor {
#[default]
Center,
TopLeft,
TopRight,
BottomLeft,
BottomRight,
}
impl Anchor {
pub fn expands(self) -> Expand {
match self {
Anchor::TopRight | Anchor::BottomRight => Expand::Left,
Anchor::Center | Anchor::TopLeft | Anchor::BottomLeft => Expand::Right,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Expand {
#[default]
Automatic,
Left,
Right,
}
pub const BUTTON_WIDTH: f32 = 200.0;
pub const BUTTON_HEIGHT: f32 = 56.0;
pub const BUTTON_SPACING: f32 = 20.0;
pub const PANEL_PADDING: f32 = 5.0;
pub const LABEL_PIXEL_SIZE: f32 = 3.0;
pub const TITLE_HEIGHT: f32 = 26.0;
pub const TITLE_LABEL_SIZE: f32 = 2.0;
pub const TITLE_INSET: f32 = 5.0;
pub const SCREEN_MARGIN: f32 = 16.0;
pub const LABEL_MARGIN: f32 = 24.0;
pub const MENU_ROW_HEIGHT: f32 = 26.0;
pub const MENU_LABEL_SIZE: f32 = 2.0;
pub const MENU_ROW_MARGIN: f32 = 10.0;
pub const MENU_ICON: f32 = 12.0;
pub const MENU_ICON_GAP: f32 = 6.0;
pub const POINTER_SIZE: f32 = 9.0;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Metrics {
pub button_width: f32,
pub button_height: f32,
pub spacing: f32,
pub label_size: f32,
}
impl Default for Metrics {
fn default() -> Self {
Self::scaled(1.0)
}
}
impl Metrics {
pub fn scaled(scale: f32) -> Self {
Self {
button_width: BUTTON_WIDTH * scale,
button_height: BUTTON_HEIGHT * scale,
spacing: BUTTON_SPACING * scale,
label_size: LABEL_PIXEL_SIZE * scale,
}
}
pub fn menu(scale: f32) -> Self {
Self {
button_width: 0.0,
button_height: MENU_ROW_HEIGHT * scale,
spacing: 0.0,
label_size: MENU_LABEL_SIZE * scale,
}
}
pub fn snug(
mut self,
rows: impl IntoIterator<Item = (impl AsRef<str>, bool, bool)>,
scale: f32,
) -> Self {
let margin = MENU_ROW_MARGIN * scale;
let icon = (MENU_ICON + MENU_ICON_GAP) * scale;
self.button_width = rows
.into_iter()
.map(|(label, leading, trailing)| {
super::font::text_width(label.as_ref(), self.label_size)
+ margin * 2.0
+ if leading { icon } else { 0.0 }
+ if trailing { icon } else { 0.0 }
})
.fold(0.0f32, f32::max);
self
}
pub fn fitting(mut self, labels: impl IntoIterator<Item = impl AsRef<str>>) -> Self {
self.button_width = labels
.into_iter()
.map(|label| {
super::font::text_width(label.as_ref(), self.label_size)
+ LABEL_MARGIN * self.label_size / LABEL_PIXEL_SIZE * 2.0
})
.fold(self.button_width, f32::max);
self
}
}
pub fn button_width_for(labels: impl IntoIterator<Item = impl AsRef<str>>) -> f32 {
Metrics::default().fitting(labels).button_width
}
pub fn panel_size(button_count: usize, metrics: Metrics) -> (f32, f32) {
let content_height = if button_count == 0 {
0.0
} else {
metrics.button_height * button_count as f32 + metrics.spacing * (button_count - 1) as f32
};
(
metrics.button_width + PANEL_PADDING * 2.0,
content_height + PANEL_PADDING * 2.0,
)
}
pub fn stack_at(x: f32, y: f32, button_count: usize, metrics: Metrics) -> (Rect, Vec<Rect>) {
let (panel_width, panel_height) = panel_size(button_count, metrics);
let panel = Rect::new(x, y, panel_width, panel_height);
let x = panel.center_x() - metrics.button_width * 0.5;
let mut y = panel.y + PANEL_PADDING;
let mut buttons = Vec::with_capacity(button_count);
for _ in 0..button_count {
buttons.push(Rect::new(x, y, metrics.button_width, metrics.button_height));
y += metrics.button_height + metrics.spacing;
}
(panel, buttons)
}
pub fn submenu_rect(
parent_panel: Rect,
row: Rect,
button_count: usize,
metrics: Metrics,
expand: Expand,
preferred: Expand,
screen_width: f32,
screen_height: f32,
) -> (Rect, Vec<Rect>) {
let (width, height) = panel_size(button_count, metrics);
let want = match expand {
Expand::Automatic => preferred,
side => side,
};
let left = parent_panel.x - width;
let right = parent_panel.x + parent_panel.width;
let fits = |x: f32| x >= 0.0 && x + width <= screen_width;
let x = match want {
Expand::Left if fits(left) => left,
Expand::Left if fits(right) => right,
Expand::Left => left,
_ if fits(right) => right,
_ if fits(left) => left,
_ => right,
};
let top = row.y + row.height * 0.5 - metrics.button_height * 0.5 - PANEL_PADDING;
let bottom = row.y + row.height * 0.5 + metrics.button_height * 0.5 + PANEL_PADDING - height;
let floor = screen_height - SCREEN_MARGIN;
let y = match top + height <= floor {
true => top,
false => bottom,
};
let y = y.min(floor - height).max(SCREEN_MARGIN);
stack_at(
x.clamp(0.0, (screen_width - width).max(0.0)),
y,
button_count,
metrics,
)
}
pub fn vertical_menu(
screen_width: f32,
screen_height: f32,
button_count: usize,
anchor: Anchor,
metrics: Metrics,
) -> (Rect, Vec<Rect>) {
vertical_menu_offset(
screen_width,
screen_height,
button_count,
anchor,
metrics,
(0.0, 0.0),
)
}
pub fn vertical_menu_offset(
screen_width: f32,
screen_height: f32,
button_count: usize,
anchor: Anchor,
metrics: Metrics,
offset: (f32, f32),
) -> (Rect, Vec<Rect>) {
let (panel_width, panel_height) = panel_size(button_count, metrics);
let right = screen_width - panel_width - SCREEN_MARGIN;
let bottom = screen_height - panel_height - SCREEN_MARGIN;
let (x, y) = match anchor {
Anchor::Center => (
screen_width * 0.5 - panel_width * 0.5,
screen_height * 0.5 - panel_height * 0.5,
),
Anchor::TopLeft => (SCREEN_MARGIN, SCREEN_MARGIN),
Anchor::TopRight => (right, SCREEN_MARGIN),
Anchor::BottomLeft => (SCREEN_MARGIN, bottom),
Anchor::BottomRight => (right, bottom),
};
stack_at(x + offset.0, y + offset.1, button_count, metrics)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_short_label_leaves_the_button_at_its_default_width() {
assert_eq!(button_width_for(["QUIT"]), BUTTON_WIDTH);
assert_eq!(button_width_for(["BACK", "QUIT"]), BUTTON_WIDTH);
}
#[test]
fn the_default_width_is_a_floor_rather_than_a_size() {
assert!(button_width_for(["A REALLY RATHER LONG LABEL"]) > BUTTON_WIDTH);
}
#[test]
fn a_long_label_widens_the_button_to_fit() {
let width = button_width_for(["PLAY ONLINE"]);
assert!(
width > BUTTON_WIDTH,
"the button should have grown: {width}"
);
assert!(
width
>= super::super::font::text_width("PLAY ONLINE", LABEL_PIXEL_SIZE)
+ LABEL_MARGIN * 2.0,
"the label plus its margins must fit",
);
}
#[test]
fn a_panel_is_as_wide_as_its_widest_label() {
let mixed = button_width_for(["QUIT", "QUICK PAIR 5+0", "BACK"]);
assert_eq!(mixed, button_width_for(["QUICK PAIR 5+0"]));
}
#[test]
fn a_scaled_panel_shrinks_its_buttons_and_their_labels() {
let half = Metrics::scaled(0.5);
assert_eq!(half.button_width, BUTTON_WIDTH * 0.5);
assert_eq!(half.button_height, BUTTON_HEIGHT * 0.5);
assert_eq!(half.label_size, LABEL_PIXEL_SIZE * 0.5);
let fitted = half.fitting(["MENU"]);
assert!(
fitted.button_width >= super::super::font::text_width("MENU", half.label_size),
"{fitted:?}",
);
assert!(
fitted.button_width < BUTTON_WIDTH,
"half size should be smaller than a full button: {fitted:?}",
);
}
#[test]
fn a_scaled_panel_is_half_as_tall_as_a_full_one() {
let metrics = Metrics::scaled(1.0).fitting(["MENU"]);
let (full, _) = vertical_menu(800.0, 600.0, 1, Anchor::TopLeft, metrics);
let (half, _) = vertical_menu(
800.0,
600.0,
1,
Anchor::TopLeft,
Metrics::scaled(0.5).fitting(["MENU"]),
);
let content = |rect: Rect| rect.height - PANEL_PADDING * 2.0;
assert!(
(content(half) - content(full) * 0.5).abs() < 0.01,
"{} vs {}",
content(half),
content(full),
);
}
#[test]
fn every_button_gets_the_width_the_panel_was_given() {
let metrics = Metrics {
button_width: 320.0,
..Metrics::default()
};
let (panel, buttons) = vertical_menu(800.0, 600.0, 3, Anchor::Center, metrics);
assert_eq!(buttons.len(), 3);
for button in &buttons {
assert_eq!(button.width, 320.0);
assert!(button.x >= panel.x, "buttons sit inside the panel");
assert!(button.x + button.width <= panel.x + panel.width);
}
assert_eq!(panel.width, 320.0 + PANEL_PADDING * 2.0);
}
#[test]
fn a_submenu_with_no_room_below_grows_upward() {
let metrics = Metrics::menu(1.0).snug([("RECENTER VIEW", false, false)], 1.0);
let (_, height) = panel_size(7, metrics);
let screen = (320.0, 240.0);
let panel = Rect::new(200.0, 200.0, 110.0, 30.0);
let row = Rect::new(205.0, 205.0, 100.0, MENU_ROW_HEIGHT);
let (child, rows) = submenu_rect(
panel,
row,
7,
metrics,
Expand::Left,
Expand::Left,
screen.0,
screen.1,
);
assert!(
child.y + height <= screen.1,
"it should fit on the screen: {child:?} in a {screen:?} window",
);
assert!(child.y >= 0.0, "and not run off the top: {child:?}");
assert_eq!(rows.len(), 7, "with every entry laid out");
assert!(
rows.last().unwrap().y + rows.last().unwrap().height <= screen.1,
"including the last one: {:?}",
rows.last(),
);
assert!(
child.y < row.y,
"it grew up from the row rather than down: {child:?} against {row:?}",
);
}
#[test]
fn a_submenu_with_room_below_lines_up_with_its_row() {
let metrics = Metrics::menu(1.0).snug([("ONE", false, false)], 1.0);
let panel = Rect::new(200.0, 100.0, 110.0, 200.0);
let row = Rect::new(205.0, 120.0, 100.0, MENU_ROW_HEIGHT);
let (child, rows) =
submenu_rect(panel, row, 3, metrics, Expand::Right, Expand::Right, 1920.0, 1080.0);
assert!(
(rows[0].y - row.y).abs() < 1.0,
"the first entry should sit level with its row: {:?} against {row:?}",
rows[0],
);
assert!(child.y <= row.y);
}
#[test]
fn a_submenu_never_covers_the_panel_it_came_from() {
let metrics = Metrics::menu(1.0).snug([("RECENTER VIEW", false, false)], 1.0);
let panel = Rect::new(1000.0, 400.0, 120.0, 200.0);
for (row, side) in [
(Rect::new(1005.0, 420.0, 110.0, MENU_ROW_HEIGHT), Expand::Left),
(Rect::new(1005.0, 420.0, 110.0, MENU_ROW_HEIGHT), Expand::Right),
] {
let (child, _) = submenu_rect(panel, row, 7, metrics, side, side, 1920.0, 1080.0);
let overlaps = child.x < panel.x + panel.width && panel.x < child.x + child.width;
assert!(
!overlaps,
"{side:?}: submenu {child:?} overlaps its parent panel {panel:?}",
);
}
}
#[test]
fn a_submenu_lines_up_with_its_row() {
let metrics = Metrics::menu(1.0).snug([("OPEN SCENE", false, false)], 1.0);
let panel = Rect::new(400.0, 300.0, 120.0, 200.0);
let top = Rect::new(405.0, 320.0, 110.0, MENU_ROW_HEIGHT);
let lower = Rect::new(405.0, 420.0, 110.0, MENU_ROW_HEIGHT);
let at = |row| {
submenu_rect(panel, row, 3, metrics, Expand::Right, Expand::Right, 1920.0, 1080.0)
.0
.y
};
assert!(at(lower) > at(top), "a lower row opens a lower menu");
}
#[test]
fn a_menu_shrinks_to_what_is_in_it() {
let wide = Metrics::menu(1.0).snug([("RECENTER VIEW", false, false)], 1.0);
let narrow = Metrics::menu(1.0).snug([("APP", false, false)], 1.0);
assert!(narrow.button_width < wide.button_width);
assert!(
narrow.button_width < BUTTON_WIDTH,
"three letters should not need a game menu's width: {}",
narrow.button_width,
);
}
#[test]
fn a_row_makes_room_for_its_icons() {
let plain = Metrics::menu(1.0).snug([("VIEW", false, false)], 1.0);
let both = Metrics::menu(1.0).snug([("VIEW", true, true)], 1.0);
assert!(both.button_width > plain.button_width);
}
#[test]
fn menu_rows_touch() {
let metrics = Metrics::menu(1.0).snug([("A", false, false)], 1.0);
let (_, rows) = stack_at(0.0, 0.0, 3, metrics);
assert_eq!(rows[0].y + rows[0].height, rows[1].y);
assert_eq!(rows[1].y + rows[1].height, rows[2].y);
}
#[test]
fn a_corner_menu_opens_its_submenus_away_from_its_edge() {
assert_eq!(Anchor::BottomRight.expands(), Expand::Left);
assert_eq!(Anchor::TopRight.expands(), Expand::Left);
assert_eq!(Anchor::BottomLeft.expands(), Expand::Right);
assert_eq!(Anchor::TopLeft.expands(), Expand::Right);
assert_eq!(Anchor::Center.expands(), Expand::Right);
}
#[test]
fn a_submenu_takes_the_side_it_was_asked_for() {
let metrics = Metrics::default();
let parent = Rect::new(400.0, 300.0, 200.0, BUTTON_HEIGHT);
let go = |expand, preferred| {
submenu_rect(parent, parent, 2, metrics, expand, preferred, 1280.0, 800.0)
.0
.x
};
assert!(go(Expand::Left, Expand::Right) < parent.x);
assert!(go(Expand::Right, Expand::Left) >= parent.x + parent.width);
assert!(go(Expand::Automatic, Expand::Left) < parent.x);
assert!(go(Expand::Automatic, Expand::Right) >= parent.x + parent.width);
}
#[test]
fn a_submenu_with_no_room_on_its_side_opens_on_the_other() {
let metrics = Metrics::default();
let (width, height) = panel_size(2, metrics);
let hugging_left = Rect::new(0.0, 300.0, 120.0, BUTTON_HEIGHT);
let (panel, _) = submenu_rect(
hugging_left,
hugging_left,
2,
metrics,
Expand::Left,
Expand::Left,
1280.0,
800.0,
);
assert_eq!(
panel.x, 120.0,
"no room on the left, so it should have flipped to the right",
);
let hugging_right = Rect::new(1280.0 - 120.0, 300.0, 120.0, BUTTON_HEIGHT);
let (panel, _) = submenu_rect(
hugging_right,
hugging_right,
2,
metrics,
Expand::Right,
Expand::Right,
1280.0,
800.0,
);
assert!(
panel.x + width <= 1280.0,
"no room on the right, so it should have flipped: {panel:?}",
);
assert!(panel.y + height <= 800.0);
}
#[test]
fn a_submenu_near_an_edge_is_pushed_back_onto_the_screen() {
let metrics = Metrics::default();
let (_, height) = panel_size(4, metrics);
let low = Rect::new(1000.0, 780.0, 200.0, BUTTON_HEIGHT);
let (panel, buttons) = submenu_rect(
low,
low,
4,
metrics,
Expand::Automatic,
Expand::Left,
1280.0,
800.0,
);
assert!(panel.y >= 0.0 && panel.y + height <= 800.0, "{panel:?}");
for button in buttons {
assert!(button.y >= panel.y);
assert!(button.y + button.height <= panel.y + panel.height);
}
}
#[test]
fn a_full_size_panel_never_shrinks_below_the_default_width() {
let metrics = Metrics::default().fitting(["OK"]);
let (_, buttons) = vertical_menu(800.0, 600.0, 1, Anchor::Center, metrics);
assert_eq!(buttons[0].width, BUTTON_WIDTH);
}
}