use crate::app::cycle::Turn;
use crate::app::palette;
use bevy::color::Mix;
use bevy::prelude::*;
use bevy::ui::widget::NodeImageMode;
pub fn display_font(px: f32) -> TextFont {
TextFont {
font: FontSource::Handle(crate::app::boot::DISPLAY_FONT),
font_size: FontSize::Px(px),
..default()
}
}
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 spend(keys: &mut ButtonInput<KeyCode>, codes: &[KeyCode]) {
for &code in codes {
keys.clear_just_pressed(code);
}
}
pub const CLOSERS: [KeyCode; 3] = [KeyCode::Enter, KeyCode::NumpadEnter, KeyCode::Escape];
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: f32 = FRAME_PX + 12.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::all(Val::Px(CARD_PAD)),
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()),
display_font(type_scale::BODY),
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;
const ROW_PAD_Y: f32 = 3.0;
pub fn row_pitch(font: f32) -> f32 {
(font * LINE_HEIGHT + 2.0 * ROW_PAD_Y).max(ROW_H)
}
pub const HEADING_H: f32 = HEADING_FONT * LINE_HEIGHT + HEADING_MARGIN;
const HEADING_FONT: f32 = 15.0;
const HEADING_MARGIN: f32 = 6.0;
pub const LINE_HEIGHT: f32 = 1.2;
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(ROW_PAD_Y)),
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,
) {
parent
.spawn(Node {
flex_direction: FlexDirection::Column,
align_items: AlignItems::FlexStart,
row_gap: Val::Px(ROW_GAP),
..default()
})
.with_children(|column| {
for row in 0..count {
column.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;
#[derive(Component)]
pub struct CardFrame;
#[derive(Component)]
pub struct Framed;
const FRAMED_RADIUS: f32 = 16.0;
const FRAME_BAND: f32 = 32.0;
const FRAME_SLICE: f32 = 56.0;
const _: () = assert!(
FRAME_SLICE > FRAME_BAND,
"the cut must hold each corner whole"
);
const FRAME_PX: f32 = 13.0;
fn frame_corner_scale(ui_scale: f32, scale_factor: f32) -> f32 {
FRAME_PX / FRAME_BAND * ui_scale * scale_factor
}
fn frame_bundle(art: &crate::app::art::Art, edge: Option<Color>, corner: f32) -> impl Bundle {
let tint = frame_tint(edge);
(
CardFrame,
ImageNode {
image: art.card_frame.clone(),
color: tint,
image_mode: NodeImageMode::Sliced(TextureSlicer {
border: BorderRect::all(FRAME_SLICE),
max_corner_scale: corner,
..default()
}),
..default()
},
Node {
position_type: PositionType::Absolute,
left: Val::Px(-1.0),
right: Val::Px(-1.0),
top: Val::Px(-1.0),
bottom: Val::Px(-1.0),
..default()
},
Pickable::IGNORE,
)
}
fn frame_tint(edge: Option<Color>) -> Color {
match edge {
Some(color) if color != palette::CARD_EDGE => {
Color::WHITE.mix(&color.with_alpha(1.0), 0.55)
}
Some(_) | None => Color::WHITE,
}
}
pub fn scale_card_frames(
ui_scale: Res<UiScale>,
windows: Query<&Window>,
mut frames: Query<&mut ImageNode, With<CardFrame>>,
added: Query<(), Added<CardFrame>>,
) {
if !ui_scale.is_changed() && added.is_empty() {
return;
}
let factor = windows.iter().next().map_or(1.0, Window::scale_factor);
let corner = frame_corner_scale(ui_scale.0, factor);
for mut frame in &mut frames {
if let NodeImageMode::Sliced(slicer) = &mut frame.image_mode
&& (slicer.max_corner_scale - corner).abs() > f32::EPSILON
{
slicer.max_corner_scale = corner;
}
}
}
#[derive(bevy::ecs::system::SystemParam)]
pub struct Viewport<'w, 's> {
windows: Query<'w, 's, &'static Window>,
ui_scale: Res<'w, UiScale>,
}
impl Viewport<'_, '_> {
pub fn size(&self) -> Option<Vec2> {
let window = self.windows.single().ok()?;
Some(Vec2::new(window.width(), window.height()) / self.ui_scale.0.max(f32::EPSILON))
}
pub fn ui_scale(&self) -> f32 {
self.ui_scale.0
}
pub fn scale_factor(&self) -> f32 {
self.windows.iter().next().map_or(1.0, Window::scale_factor)
}
}
#[derive(bevy::ecs::system::SystemParam)]
pub struct Buttons<'w, 's> {
pub keys: Res<'w, ButtonInput<KeyCode>>,
pub pads: Query<'w, 's, &'static Gamepad>,
}
impl Buttons<'_, '_> {
pub fn pad(&self, button: GamepadButton) -> bool {
self.pads.iter().any(|pad| pad.just_pressed(button))
}
}
type NewCard = (Entity, &'static mut Node, Option<&'static BorderColor>);
pub fn dress_cards(
mut commands: Commands,
art: Res<crate::app::art::Art>,
viewport: Viewport,
mut cards: Query<NewCard, Added<ShoreCard>>,
mut framed: Query<NewCard, (Added<Framed>, Without<ShoreCard>)>,
) {
let corner = frame_corner_scale(viewport.ui_scale(), viewport.scale_factor());
for (card, mut node, edge) in &mut framed {
node.border_radius = BorderRadius::all(Val::Px(FRAMED_RADIUS));
clear_the_wood(&mut node);
let frame = commands
.spawn(frame_bundle(&art, edge.map(|edge| edge.top), corner))
.id();
commands.entity(card).insert_children(0, &[frame]);
}
for (card, mut node, edge) in &mut cards {
node.border_radius = BorderRadius::all(Val::Px(FRAMED_RADIUS));
clear_the_wood(&mut node);
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();
let frame = commands
.spawn(frame_bundle(&art, edge.map(|edge| edge.top), corner))
.id();
commands.entity(card).insert_children(0, &[tide, frame]);
}
}
fn clear_the_wood(node: &mut Node) {
for side in [
&mut node.padding.top,
&mut node.padding.left,
&mut node.padding.right,
] {
if px_of(*side).is_some_and(|px| px < CARD_PAD) {
*side = Val::Px(CARD_PAD);
}
}
}
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(_)
| Val::Em(_)
| Val::Rem(_) => 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(HEADING_MARGIN)),
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),
display_font(HEADING_FONT),
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 a_frame_takes_the_cards_own_colour_and_only_that() {
assert_eq!(frame_tint(None), Color::WHITE);
assert_eq!(frame_tint(Some(palette::CARD_EDGE)), Color::WHITE);
let red = frame_tint(Some(palette::INK_RAID.with_alpha(0.6)));
assert_ne!(red, Color::WHITE, "stained");
assert_eq!(red.alpha(), 1.0, "at full strength");
assert_eq!(
red,
frame_tint(Some(palette::INK_RAID)),
"whatever the edge's alpha"
);
}
#[test]
fn a_frame_keeps_its_thickness_at_every_scale() {
let at = |ui: f32, factor: f32| FRAME_BAND * frame_corner_scale(ui, factor) / (ui * factor);
for (ui, factor) in [(1.0, 1.0), (1.5, 1.0), (0.8, 1.0), (1.5, 2.0)] {
assert!((at(ui, factor) - FRAME_PX).abs() < 1e-4, "{ui} x {factor}");
}
}
#[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");
}
}