use crate::app::cycle::Turn;
use crate::app::palette;
use bevy::prelude::*;
pub mod type_scale {
pub const TITLE: f32 = 54.0;
pub const DISPLAY: f32 = 34.0;
pub const HEADING: f32 = 24.0;
pub const ROW: f32 = 19.0;
pub const BODY: f32 = 17.0;
pub const FINE: f32 = 15.0;
}
pub const NUMBER_KEYS: [KeyCode; 9] = [
KeyCode::Digit1,
KeyCode::Digit2,
KeyCode::Digit3,
KeyCode::Digit4,
KeyCode::Digit5,
KeyCode::Digit6,
KeyCode::Digit7,
KeyCode::Digit8,
KeyCode::Digit9,
];
pub fn number_pressed(keys: &ButtonInput<KeyCode>, count: usize) -> Option<usize> {
NUMBER_KEYS
.iter()
.take(count)
.position(|&key| keys.just_pressed(key))
}
pub fn nav(keys: &ButtonInput<KeyCode>, selected: usize, len: usize) -> usize {
let mut at = selected;
if keys.just_pressed(KeyCode::KeyW) || keys.just_pressed(KeyCode::ArrowUp) {
at = (at + len - 1) % len;
}
if keys.just_pressed(KeyCode::KeyS) || keys.just_pressed(KeyCode::ArrowDown) {
at = (at + 1) % len;
}
at
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Nav {
Up,
Down,
Stay,
}
pub fn nav_live(keys: &ButtonInput<KeyCode>, selected: usize, live: &[bool]) -> usize {
let nav = if keys.just_pressed(KeyCode::KeyW) || keys.just_pressed(KeyCode::ArrowUp) {
Nav::Up
} else if keys.just_pressed(KeyCode::KeyS) || keys.just_pressed(KeyCode::ArrowDown) {
Nav::Down
} else {
Nav::Stay
};
nav_live_steps(nav, selected, live)
}
pub fn step(nav: Nav, selected: usize, len: usize) -> usize {
match nav {
Nav::Up => (selected + len - 1) % len,
Nav::Down => (selected + 1) % len,
Nav::Stay => selected,
}
}
fn nav_live_steps(nav: Nav, selected: usize, live: &[bool]) -> usize {
let len = live.len();
let step = match nav {
Nav::Up => len - 1, Nav::Down => 1,
Nav::Stay => return first_live(selected, live).unwrap_or(selected),
};
let mut at = selected;
for _ in 0..len {
at = (at + step) % len;
if live[at] {
return at;
}
}
selected
}
fn first_live(from: usize, live: &[bool]) -> Option<usize> {
(0..live.len())
.map(|offset| (from + offset) % live.len())
.find(|&row| live[row])
}
pub fn enter(keys: &ButtonInput<KeyCode>) -> bool {
keys.just_pressed(KeyCode::Enter) || keys.just_pressed(KeyCode::NumpadEnter)
}
pub fn left_right(keys: &ButtonInput<KeyCode>) -> Option<Turn> {
if keys.just_pressed(KeyCode::KeyD) || keys.just_pressed(KeyCode::ArrowRight) {
Some(Turn::Right)
} else if keys.just_pressed(KeyCode::KeyA) || keys.just_pressed(KeyCode::ArrowLeft) {
Some(Turn::Left)
} else {
None
}
}
pub mod layer {
pub const RESULTS: i32 = 10;
pub const BANNER: i32 = 15;
pub const CARD: i32 = 20;
pub const TOAST: i32 = 30;
const _: () = assert!(RESULTS < BANNER && BANNER < CARD && CARD < TOAST);
}
pub fn centred_overlay() -> Node {
Node {
position_type: PositionType::Absolute,
width: Val::Percent(100.0),
height: Val::Percent(100.0),
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
..default()
}
}
pub const CARD_PAD_Y: f32 = 16.0;
pub fn screen_card() -> (ShoreCard, Node, BackgroundColor, BorderColor, BoxShadow) {
(
ShoreCard,
Node {
flex_direction: FlexDirection::Column,
align_items: AlignItems::Center,
row_gap: Val::Px(ROW_GAP),
min_height: Val::Px(0.0),
padding: UiRect::axes(Val::Px(22.0), Val::Px(CARD_PAD_Y)),
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(16.0)),
..default()
},
BackgroundColor(palette::CARD_FILL),
BorderColor::all(palette::CARD_EDGE),
card_shadow(),
)
}
pub const HEADER_H: f32 = 42.0;
pub const UNDER_HEADER: f32 = HEADER_H + 4.0;
const BAR_AIR: f32 = 10.0;
pub const BAR_H: f32 = HEADER_H + BAR_AIR;
pub fn between_bars() -> Node {
Node {
position_type: PositionType::Absolute,
top: Val::Px(BAR_H),
bottom: Val::Px(BAR_H),
left: Val::Px(0.0),
right: Val::Px(0.0),
flex_direction: FlexDirection::Column,
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
row_gap: Val::Px(10.0),
..default()
}
}
pub fn heading(text: &str, first: bool) -> impl Bundle {
(
Text::new(text.to_string()),
TextFont {
font_size: FontSize::Px(type_scale::BODY),
..default()
},
TextColor(palette::GOLD.with_alpha(0.55)),
Node {
margin: UiRect::top(Val::Px(if first { 0.0 } else { 10.0 }))
.with_bottom(Val::Px(3.0))
.with_left(Val::Px(10.0)),
align_self: AlignSelf::FlexStart,
..default()
},
)
}
pub fn band(picked: bool) -> Color {
if picked {
palette::PICKED_WASH
} else {
Color::NONE
}
}
pub const ROW_H: f32 = 25.0;
pub const ROW_GAP: f32 = 2.0;
pub const HEADING_H: f32 = 20.0;
pub fn card_row() -> (Node, BackgroundColor) {
(
Node {
align_items: AlignItems::Center,
min_height: Val::Px(ROW_H),
padding: UiRect::axes(Val::Px(10.0), Val::Px(3.0)),
border_radius: BorderRadius::all(Val::Px(7.0)),
..default()
},
BackgroundColor(Color::NONE),
)
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Half {
Label,
Value,
}
pub fn cell_ink(half: Half, picked: bool) -> Color {
match (half, picked) {
(Half::Label, true) => Color::WHITE,
(Half::Label, false) => palette::PARCHMENT.with_alpha(0.62),
(Half::Value, true) => palette::GOLD,
(Half::Value, false) => palette::PARCHMENT.with_alpha(0.92),
}
}
pub fn cell(width: f32, font_px: f32) -> (Node, Text, TextFont, TextLayout, TextColor) {
(
Node {
width: Val::Px(width),
flex_shrink: 0.0,
overflow: Overflow::clip_x(),
..default()
},
Text::new(""),
TextFont {
font_size: FontSize::Px(font_px),
..default()
},
TextLayout::no_wrap(),
TextColor(palette::IDLE_ROW),
)
}
pub fn card_shadow() -> BoxShadow {
BoxShadow::from(ShadowStyle {
color: palette::CARD_SHADOW,
x_offset: Val::Px(0.0),
y_offset: Val::Px(6.0),
spread_radius: Val::Px(0.0),
blur_radius: Val::Px(18.0),
})
}
pub fn despawn_marked<M: Component>(mut commands: Commands, ui: Query<Entity, With<M>>) {
for entity in &ui {
commands.entity(entity).despawn();
}
}
pub fn save_and_despawn<M: Component>(
mut commands: Commands,
settings: Res<crate::app::settings::GameSettings>,
caps: Res<crate::app::keycaps::KeyCaps>,
ui: Query<Entity, With<M>>,
) {
settings.save(&caps);
for entity in &ui {
commands.entity(entity).despawn();
}
}
pub fn spawn_rows<M: Component>(
parent: &mut ChildSpawnerCommands,
count: usize,
font_px: f32,
make: impl Fn(usize) -> M,
) {
for row in 0..count {
parent.spawn((
make(row),
Text::new(""),
TextFont {
font_size: FontSize::Px(font_px),
..default()
},
TextColor(Color::WHITE),
));
}
}
pub fn set_text<T: std::ops::DerefMut<Target = String>>(text: &mut Mut<T>, value: &str) {
if text.as_str() != value {
value.clone_into(&mut ***text);
}
}
pub fn set_color(color: &mut Mut<TextColor>, target: Color) {
if color.0 != target {
color.0 = target;
}
}
pub fn set_bg(bg: &mut Mut<BackgroundColor>, target: Color) {
if bg.0 != target {
bg.0 = target;
}
}
pub fn set_shown(node: &mut Mut<Node>, shown: bool) {
let want = if shown { Display::Flex } else { Display::None };
if node.display != want {
node.display = want;
}
}
#[derive(Component)]
pub struct ShoreCard;
pub fn dress_cards(
mut commands: Commands,
art: Res<crate::app::art::Art>,
mut cards: Query<(Entity, &mut Node), Added<ShoreCard>>,
) {
for (card, mut node) in &mut cards {
let dry = Val::Px(FOAM_DEPTH + 4.0);
if px_of(node.padding.bottom).unwrap_or(0.0) < FOAM_DEPTH {
node.padding.bottom = dry;
}
let tide = commands.spawn(tide_bundle(&art.foam)).id();
commands.entity(card).insert_children(0, &[tide]);
}
}
fn px_of(v: Val) -> Option<f32> {
match v {
Val::Px(px) => Some(px),
Val::Auto | Val::Percent(_) | Val::Vw(_) | Val::Vh(_) | Val::VMin(_) | Val::VMax(_) => None,
}
}
pub const FOAM_DEPTH: f32 = 20.0;
pub fn tide_line(
panel: &mut bevy::ecs::relationship::RelatedSpawnerCommands<ChildOf>,
foam: &Handle<Image>,
) {
panel.spawn(tide_bundle(foam));
}
fn tide_bundle(foam: &Handle<Image>) -> (ImageNode, Node) {
(
ImageNode {
image: foam.clone(),
color: palette::FOAM_LINE,
flip_y: true,
image_mode: NodeImageMode::Stretch,
..default()
},
Node {
position_type: PositionType::Absolute,
left: Val::Px(0.0),
right: Val::Px(0.0),
bottom: Val::Px(0.0),
height: Val::Px(FOAM_DEPTH),
border_radius: BorderRadius::bottom(Val::Px(10.0)),
..default()
},
)
}
pub fn heading_row(
panel: &mut bevy::ecs::relationship::RelatedSpawnerCommands<ChildOf>,
heading: &str,
icon: Option<&Handle<Image>>,
) {
panel
.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: Val::Px(8.0),
margin: UiRect::bottom(Val::Px(6.0)),
width: Val::Percent(100.0),
..default()
})
.with_children(|head| {
if let Some(icon) = icon {
head.spawn((
ImageNode::new(icon.clone()).with_color(palette::HEADING_ICON),
Node {
width: Val::Px(22.0),
height: Val::Px(22.0),
..default()
},
));
}
head.spawn((
Text::new(heading),
TextFont {
font_size: FontSize::Px(15.0),
..default()
},
TextColor(palette::IDLE_ROW),
));
head.spawn((
Node {
flex_grow: 1.0,
height: Val::Px(1.0),
margin: UiRect::left(Val::Px(8.0)),
..default()
},
BackgroundColor(palette::CARD_EDGE),
));
});
}
pub fn paint_row(selected: bool, line: &str, text: &mut Mut<Text>, color: &mut Mut<TextColor>) {
set_text(
text,
&format!("{} {line}", if selected { ">" } else { " " }),
);
let target = if selected {
palette::SELECTED_ROW
} else {
palette::IDLE_ROW
};
set_color(color, target);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rewriting_the_same_text_flags_nothing() {
let mut world = World::new();
let id = world
.spawn((Text::new("same"), TextColor(palette::IDLE_ROW)))
.id();
let flagged = |world: &mut World, write: &dyn Fn(&mut World)| {
world.clear_trackers();
let before = world.change_tick();
world.increment_change_tick();
write(world);
world
.entity(id)
.get_ref::<Text>()
.expect("spawned with text")
.last_changed()
.is_newer_than(before, world.change_tick())
};
assert!(
!flagged(&mut world, &|world| {
let mut text = world.get_mut::<Text>(id).expect("spawned with text");
set_text(&mut text, "same");
}),
"an identical string must not mark the text changed"
);
assert!(
flagged(&mut world, &|world| {
let mut text = world.get_mut::<Text>(id).expect("spawned with text");
set_text(&mut text, "different");
}),
"a real edit still has to mark it changed"
);
assert_eq!(world.entity(id).get::<Text>().expect("text").0, "different");
}
#[test]
fn nav_live_skips_hidden_rows() {
let live = [true, false, false, true, false];
let mut down = ButtonInput::<KeyCode>::default();
down.press(KeyCode::KeyS);
assert_eq!(nav_live(&down, 0, &live), 3);
assert_eq!(nav_live(&down, 3, &live), 0, "wraps past the dark tail");
let mut up = ButtonInput::<KeyCode>::default();
up.press(KeyCode::KeyW);
assert_eq!(nav_live(&up, 3, &live), 0);
assert_eq!(nav_live(&up, 0, &live), 3);
let idle = ButtonInput::<KeyCode>::default();
assert_eq!(nav_live(&idle, 1, &live), 3);
assert_eq!(nav_live(&idle, 3, &live), 3);
assert_eq!(nav_live(&down, 2, &[false, false, false]), 2);
}
#[test]
fn nav_wraps_both_ways() {
let mut keys = ButtonInput::<KeyCode>::default();
keys.press(KeyCode::KeyW);
assert_eq!(nav(&keys, 0, 5), 4, "up from the top wraps to the bottom");
let mut keys = ButtonInput::<KeyCode>::default();
keys.press(KeyCode::KeyS);
assert_eq!(nav(&keys, 4, 5), 0, "down from the bottom wraps to the top");
}
}
#[cfg(test)]
mod nav_tests {
use super::*;
#[test]
fn the_cursor_steps_over_a_row_that_is_not_there_to_pick() {
let live = [true, false, false, true, true];
assert_eq!(
nav_live_steps(Nav::Down, 0, &live),
3,
"over the two dark ones"
);
assert_eq!(nav_live_steps(Nav::Up, 3, &live), 0, "and back over them");
assert_eq!(nav_live_steps(Nav::Down, 3, &live), 4, "the next one along");
}
#[test]
fn the_ends_of_the_list_join_up() {
let all = [true, true, true];
assert_eq!(
nav_live_steps(Nav::Up, 0, &all),
2,
"off the top to the bottom"
);
assert_eq!(
nav_live_steps(Nav::Down, 2, &all),
0,
"and off the bottom to the top"
);
let live = [true, false, false];
assert_eq!(
nav_live_steps(Nav::Down, 0, &live),
0,
"the only row there is"
);
assert_eq!(nav_live_steps(Nav::Up, 0, &live), 0);
}
#[test]
fn a_list_with_nothing_to_pick_leaves_the_cursor_alone() {
let none = [false, false, false];
for nav in [Nav::Up, Nav::Down, Nav::Stay] {
assert_eq!(
nav_live_steps(nav, 1, &none),
1,
"the cursor moved to nowhere"
);
}
}
#[test]
fn standing_still_comes_off_a_row_that_has_just_gone_dark() {
let live = [false, false, true, true];
assert_eq!(
nav_live_steps(Nav::Stay, 0, &live),
2,
"the next one that is there"
);
assert_eq!(
nav_live_steps(Nav::Stay, 2, &live),
2,
"and a live row stays put"
);
let live = [true, false, false];
assert_eq!(nav_live_steps(Nav::Stay, 2, &live), 0);
}
#[test]
fn the_plain_walk_wraps_at_both_ends() {
assert_eq!(step(Nav::Down, 0, 3), 1);
assert_eq!(step(Nav::Down, 2, 3), 0, "off the bottom");
assert_eq!(step(Nav::Up, 0, 3), 2, "off the top");
assert_eq!(step(Nav::Stay, 1, 3), 1);
assert_eq!(step(Nav::Down, 0, 1), 0, "a list of one goes nowhere");
}
}