use crate::app::editor::EditorState;
use crate::app::lobby::LobbyState;
use crate::app::settings::GameSettings;
use crate::app::{Campaign, Phase, Screen, Sim, VersusPhase};
use crate::app::{menu_ui, palette};
use bevy::prelude::*;
mod clock;
mod field_guide;
mod text;
use crate::app::palette::CLOCK_CALM;
pub(crate) use clock::{Blink, clock_color, clock_into, urgency_band};
pub use clock::{TideClock, update_tide_clock};
use field_guide::spawn_field_guide;
pub use field_guide::{field_guide_visibility, update_field_guide};
pub(crate) use text::{event_name, puzzle_status};
#[derive(Component)]
pub struct LevelLabel;
#[derive(Component)]
pub struct PostsLabel;
#[derive(Component)]
pub struct PromptLabel;
const PROMPT_SHARE: f32 = 0.88;
const PROMPT_PAD_X: f32 = 12.0;
const PROMPT_MIN_PX: f32 = menu_ui::type_scale::FINE;
fn prompt_room(width: f32) -> f32 {
width * PROMPT_SHARE - 2.0 * PROMPT_PAD_X
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum PromptBand {
Shrinks,
OneRow,
TwoRows,
}
pub(crate) fn prompt_band(screen: Screen, replaying: bool) -> PromptBand {
if field_guide::field_guide_shown(screen, replaying) {
return PromptBand::Shrinks;
}
match screen {
Screen::Lobby | Screen::Editor => PromptBand::TwoRows,
Screen::Menu
| Screen::Puzzle
| Screen::Versus
| Screen::Settings
| Screen::Controls
| Screen::MatchSetup
| Screen::Achievements
| Screen::StageSelect
| Screen::Replays
| Screen::Interlude
| Screen::Language
| Screen::NewVersion => PromptBand::OneRow,
}
}
pub(crate) fn banded(band: PromptBand, line: &str) -> String {
use crate::app::i18n::WORD_JOINER;
const GLUE: &str = "\u{a0}";
let glued = |words: &str| {
words
.replace(' ', GLUE)
.replace('/', &format!("/{WORD_JOINER}"))
.replace('-', &format!("-{WORD_JOINER}"))
};
match band {
PromptBand::TwoRows => line
.split(" | ")
.map(|part| part.split(" ").map(glued).collect::<Vec<_>>().join(" "))
.collect::<Vec<_>>()
.join(&format!("{GLUE}| ")),
PromptBand::Shrinks | PromptBand::OneRow => line.to_string(),
}
}
fn prompt_fit(natural: f32, room: f32) -> Option<f32> {
if natural <= room {
return Some(HEADER_PX);
}
let size = HEADER_PX * room / natural;
(size >= PROMPT_MIN_PX).then_some(size)
}
#[cfg(test)]
fn prompt_lines(line: &str, width: f32) -> Option<Vec<String>> {
use crate::app::i18n::metrics::display_px;
let room = prompt_room(width);
let mut pieces: Vec<(&str, String)> = Vec::new();
let (mut piece, mut joiner) = (String::new(), "");
let mut chars = line.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
' ' => {
pieces.push((joiner, std::mem::take(&mut piece)));
joiner = " ";
}
'/' | '-' if chars.peek().is_some_and(|next| next.is_alphabetic()) => {
piece.push(ch);
pieces.push((joiner, std::mem::take(&mut piece)));
joiner = "";
}
_ => piece.push(ch),
}
}
pieces.push((joiner, piece));
let mut rows = vec![String::new()];
for (joiner, piece) in pieces {
let row = rows.last_mut().expect("a row");
let longer = match row.is_empty() {
true => piece.clone(),
false => format!("{row}{joiner}{piece}"),
};
if row.is_empty() || display_px(&longer, HEADER_PX) <= room {
*row = longer;
} else {
rows.push(piece);
}
if display_px(rows.last().expect("a row"), HEADER_PX) > room {
return None;
}
}
Some(rows)
}
#[cfg(test)]
fn prompt_rows(line: &str, width: f32) -> usize {
prompt_lines(line, width).map_or(usize::MAX, |rows| rows.len())
}
#[cfg(test)]
pub(crate) fn prompt_fits(band: PromptBand, line: &str) -> Result<(), String> {
use crate::app::i18n::metrics::display_px;
let width = crate::app::settings::DESIGN_W;
let room = prompt_room(width);
let line = &banded(band, line);
let natural = display_px(line, HEADER_PX);
let fits = match band {
PromptBand::Shrinks => prompt_fit(natural, room).is_some(),
PromptBand::OneRow => natural <= room,
PromptBand::TwoRows => prompt_rows(line, width) <= 2,
};
match fits {
true => Ok(()),
false => Err(format!(
"{natural:.0}px in {band:?} ({room:.0}px a row{})",
match band {
PromptBand::Shrinks => format!(
", {:.0}px at the fine print",
room * HEADER_PX / PROMPT_MIN_PX
),
PromptBand::OneRow | PromptBand::TwoRows => String::new(),
}
)),
}
}
type PromptText = (
&'static Text,
&'static mut TextFont,
&'static mut TextLayout,
&'static bevy::text::TextLayoutInfo,
);
pub fn fit_prompt(
viewport: menu_ui::Viewport,
screen: Res<State<Screen>>,
playback: Res<crate::app::Playback>,
mut prompts: Query<PromptText, With<PromptLabel>>,
mut too_long: Local<Option<String>>,
) {
let legend_up = prompt_band(*screen.get(), playback.0.is_some()) == PromptBand::Shrinks;
let Some(size) = viewport.size() else {
return;
};
let room = prompt_room(size.x);
for (text, mut font, mut layout, info) in &mut prompts {
let FontSize::Px(now) = font.font_size else {
continue;
};
let given_up = too_long.as_deref() == Some(text.0.as_str());
let (size, wrap) = if legend_up && given_up {
(PROMPT_MIN_PX, true)
} else if legend_up && info.size.x > 0.0 {
let drawn = info.size.x / info.scale_factor.max(f32::EPSILON);
let natural = drawn * HEADER_PX / now;
let one_line = layout.linebreak == LineBreak::NoWrap;
match prompt_fit(natural, room) {
Some(size) if one_line => (size, false),
Some(_) => (HEADER_PX, false),
None => {
*too_long = Some(text.0.clone());
(PROMPT_MIN_PX, true)
}
}
} else {
(HEADER_PX, true)
};
let size = (size * 2.0).floor() / 2.0;
if (now - size).abs() > 0.01 {
font.font_size = FontSize::Px(size);
}
let linebreak = if wrap {
LineBreak::WordBoundary
} else {
LineBreak::NoWrap
};
if layout.linebreak != linebreak {
layout.linebreak = linebreak;
}
}
}
#[derive(Component)]
pub struct HeaderBar;
const HEADER_PX: f32 = 22.0;
pub fn spawn_hud(
mut commands: Commands,
settings: Res<GameSettings>,
art: Res<crate::app::art::Art>,
sim: Res<Sim>,
) {
let font = menu_ui::display_font(HEADER_PX);
commands
.spawn((
HeaderBar,
Node {
position_type: PositionType::Absolute,
top: Val::Px(0.0),
left: Val::Px(0.0),
width: Val::Percent(100.0),
height: Val::Px(menu_ui::HEADER_H),
padding: UiRect::horizontal(Val::Px(12.0)),
align_items: AlignItems::Center,
justify_content: JustifyContent::SpaceBetween,
column_gap: Val::Px(18.0),
..default()
},
BackgroundGradient::default(),
))
.with_children(|bar| {
bar.spawn((
TitleBox,
Node {
overflow: Overflow::clip_x(),
flex_shrink: 0.0,
..default()
},
))
.with_children(|box_| {
box_.spawn((
LevelLabel,
Text::new(""),
font.clone(),
TextColor(palette::HUD_INK),
TextLayout::no_wrap(),
));
});
bar.spawn(Node {
overflow: Overflow::clip_x(),
flex_shrink: 1.0,
min_width: Val::Px(0.0),
..default()
})
.with_children(|box_| {
box_.spawn((
PostsLabel,
Text::new(""),
font.clone(),
TextColor(palette::HUD_INK),
TextLayout::no_wrap(),
));
});
});
commands.spawn((
PromptLabel,
Text::new(""),
font,
TextColor(palette::SELECTED_ROW),
Node {
position_type: PositionType::Absolute,
bottom: Val::Px(8.0),
left: Val::Px(12.0),
max_width: Val::Vw(PROMPT_SHARE * 100.0),
padding: UiRect::axes(Val::Px(PROMPT_PAD_X), Val::Px(5.0)),
border_radius: BorderRadius::all(Val::Px(11.0)),
..default()
},
BackgroundColor(palette::PILL_FILL),
));
commands
.spawn((Node {
position_type: PositionType::Absolute,
top: Val::Px(menu_ui::UNDER_HEADER),
left: Val::Px(0.0),
width: Val::Percent(100.0),
justify_content: JustifyContent::Center,
..default()
},))
.with_children(|wrap| {
wrap.spawn((
HintLabel,
Text::new(""),
TextFont {
font_size: FontSize::Px(menu_ui::type_scale::ROW),
..default()
},
TextColor(palette::SELECTED_ROW.with_alpha(0.85)),
Node {
padding: UiRect::axes(Val::Px(12.0), Val::Px(4.0)),
border_radius: BorderRadius::all(Val::Px(11.0)),
display: Display::None,
..default()
},
BackgroundColor(palette::PILL_FILL),
));
});
commands
.spawn((
Node {
position_type: PositionType::Absolute,
top: Val::Px(2.0),
left: Val::Px(0.0),
width: Val::Percent(100.0),
justify_content: JustifyContent::Center,
..default()
},
ZIndex(1),
))
.with_children(|wrap| {
wrap.spawn((
TideClock,
Text::new(""),
menu_ui::display_font(menu_ui::type_scale::DISPLAY),
TextColor(CLOCK_CALM),
));
});
spawn_field_guide(&mut commands, settings.tr(), &art, &sim.0);
}
#[derive(Component)]
pub struct TitleBox;
const TITLE_SHARE_WITH_CLOCK: f32 = 44.0;
fn title_width(screen: Screen) -> Val {
match screen {
Screen::Puzzle => Val::Percent(TITLE_SHARE_WITH_CLOCK),
Screen::Menu
| Screen::Versus
| Screen::Editor
| Screen::Lobby
| Screen::Settings
| Screen::Controls
| Screen::MatchSetup
| Screen::Achievements
| Screen::StageSelect
| Screen::Replays
| Screen::Interlude
| Screen::Language
| Screen::NewVersion => Val::Auto,
}
}
#[derive(Component)]
pub struct HintLabel;
#[derive(bevy::ecs::system::SystemParam)]
pub struct HintNotes<'w> {
stuck: Res<'w, crate::app::hint::Hints>,
denied: Res<'w, crate::app::hint::DeniedNote>,
}
#[derive(bevy::ecs::system::SystemParam)]
pub struct HintLesson<'w> {
campaign: Res<'w, Campaign>,
coop: Res<'w, crate::app::Coop>,
settings: Res<'w, GameSettings>,
caps: Res<'w, crate::app::keycaps::KeyCaps>,
device: Res<'w, crate::app::gamepad::LastDevice>,
face: Res<'w, crate::app::gamepad::PadFace>,
}
impl HintLesson<'_> {
fn shown(&self) -> Option<String> {
let campaign = &self.campaign;
let hint = campaign.shown_hint(campaign.index, &self.settings, *self.coop, *self.device)?;
Some(self.caps.legend(&hint))
}
}
pub fn update_hint(
screen: Res<State<Screen>>,
phase: Res<State<Phase>>,
lesson: HintLesson,
notes: HintNotes,
mut hints: Query<(&mut Text, &mut Node), With<HintLabel>>,
) {
let HintNotes { stuck, denied } = notes;
let line = if *screen.get() != Screen::Puzzle {
String::new()
} else {
let tr = lesson.settings.tr();
let setup = *phase.get() == Phase::Setup;
let button = match *lesson.device {
crate::app::gamepad::LastDevice::Pad => crate::app::gamepad::scheme::button_name(
&lesson.face.0,
crate::app::gamepad::scheme::Does::Hint,
tr,
),
crate::app::gamepad::LastDevice::Keyboard => None,
};
crate::app::hint::hint_line(tr, &stuck, &denied, phase.get(), button.as_deref())
.or_else(|| setup.then(|| lesson.shown()).flatten())
.unwrap_or_default()
};
for (mut text, mut node) in &mut hints {
menu_ui::set_text(&mut text, &line);
menu_ui::set_shown(&mut node, !line.is_empty());
}
}
#[derive(bevy::ecs::system::SystemParam)]
pub struct Modes<'w> {
screen: Res<'w, State<Screen>>,
phase: Res<'w, State<Phase>>,
vphase: Res<'w, State<VersusPhase>>,
editor: Res<'w, EditorState>,
lobby: Res<'w, LobbyState>,
match_menu: Res<'w, crate::app::match_setup::MatchMenu>,
pause_menu: Res<'w, crate::app::pause::PauseMenu>,
coop: Res<'w, crate::app::Coop>,
table: Res<'w, crate::app::rematch::LocalTable>,
keep: Res<'w, crate::app::replays::RoundToKeep>,
config: Res<'w, crate::app::match_setup::MatchConfig>,
beaches: Res<'w, crate::app::match_setup::CustomBeaches>,
}
#[derive(bevy::ecs::system::SystemParam)]
pub struct HudSources<'w> {
modes: Modes<'w>,
seating: crate::app::seating::Seating<'w>,
sim: Res<'w, Sim>,
campaign: Res<'w, Campaign>,
settings: Res<'w, GameSettings>,
keycaps: Res<'w, crate::app::keycaps::KeyCaps>,
library: Res<'w, crate::app::replays::Library>,
notice: Res<'w, crate::app::RoundNotice>,
speed: Res<'w, crate::app::replays::PlaybackSpeed>,
tournament: Res<'w, crate::app::tournament::Tournament>,
spectators: Res<'w, crate::app::spectators::SpectatorChat>,
daily: Res<'w, crate::app::Daily>,
pad: PadSources<'w>,
}
#[derive(bevy::ecs::system::SystemParam)]
pub struct PadSources<'w> {
face: Res<'w, crate::app::gamepad::PadFace>,
device: Res<'w, crate::app::gamepad::LastDevice>,
pick: Res<'w, crate::app::gamepad::PadPick>,
}
impl HudSources<'_> {
fn readout(&self) -> text::Readout<'_> {
let Self {
modes,
seating,
sim,
campaign,
settings,
keycaps,
library,
notice,
speed,
tournament,
spectators,
daily,
pad,
} = self;
text::Readout {
tr: settings.tr(),
lang: settings.language,
sim,
campaign,
coop: modes.coop.0,
phase: &modes.phase,
vphase: &modes.vphase,
editor: &modes.editor,
online: &seating.online,
playback: &seating.playback,
daily,
lobby: &modes.lobby,
tournament,
seats: &seating.seats,
settings,
keycaps,
names: &seating.names,
controllers: &seating.controllers,
library,
notice,
match_menu: &modes.match_menu,
paused: modes.pause_menu.is_open(),
spectator_typing: spectators.0.as_deref(),
crowd: seating
.online
.0
.as_ref()
.map_or_else(Default::default, |session| session.stands.tide.crowd),
speed: speed.0,
face: &pad.face.0,
device: *pad.device,
pick: *pad.pick,
rematch: crate::app::rematch::rematch(&modes.table, &seating.online, tournament),
keep: modes.keep.offer(),
map_pick: crate::app::match_setup::card_to_pick(
&modes.match_menu,
&modes.config,
&modes.beaches,
)
.is_some(),
}
}
}
type HudLabels<'w, 's> = ParamSet<
'w,
's,
(
Query<'static, 'static, &'static mut Text, With<LevelLabel>>,
Query<'static, 'static, &'static mut Text, With<PostsLabel>>,
Query<'static, 'static, (&'static mut Text, &'static mut Node), With<PromptLabel>>,
Query<'static, 'static, &'static mut Node, With<TitleBox>>,
),
>;
pub fn update_hud(sources: HudSources, mut labels: HudLabels) {
let screen = sources.modes.screen.get();
let said = text::screen_text(*screen, &sources.readout());
if let Ok(mut text) = labels.p0().single_mut() {
menu_ui::set_text(&mut text, &said.title);
}
if let Ok(mut node) = labels.p3().single_mut() {
let wanted = title_width(*screen);
if node.width != wanted {
node.width = wanted;
}
}
if let Ok(mut text) = labels.p1().single_mut() {
menu_ui::set_text(&mut text, &said.status);
}
if let Ok((mut text, mut node)) = labels.p2().single_mut() {
let band = prompt_band(*screen, sources.seating.playback.0.is_some());
menu_ui::set_text(&mut text, &banded(band, &said.prompt));
menu_ui::set_shown(&mut node, !said.prompt.is_empty());
}
}
pub fn header_backdrop(
screen: Res<State<Screen>>,
mut bars: Query<&mut BackgroundGradient, With<HeaderBar>>,
) {
let shaded = *screen.get() != Screen::Menu;
for mut bg in &mut bars {
if bg.0.is_empty() == shaded {
*bg = if shaded {
header_shade()
} else {
BackgroundGradient::default()
};
}
}
}
fn header_shade() -> BackgroundGradient {
BackgroundGradient::from(LinearGradient::to_bottom(vec![
ColorStop::percent(palette::HEADER_FILL.with_alpha(0.92), 0.0),
ColorStop::percent(palette::HEADER_FILL.with_alpha(0.88), 80.0),
ColorStop::percent(palette::HEADER_FILL.with_alpha(0.0), 100.0),
]))
}
const PROMPT_LINGER: f32 = 6.0;
const PROMPT_FADE: f32 = 0.8;
#[derive(bevy::ecs::system::SystemParam)]
pub struct PromptRound<'w> {
round: crate::app::round_state::RoundState<'w>,
pause_menu: Res<'w, crate::app::pause::PauseMenu>,
online: Res<'w, crate::app::net::Online>,
playback: Res<'w, crate::app::Playback>,
}
impl PromptRound<'_> {
fn running(&self) -> bool {
let seated = matches!(
text::header_viewer(&self.online, &self.playback),
crate::app::seating::Viewer::Peer(_) | crate::app::seating::Viewer::Table
);
self.round.versus_running() && !self.round.held() && !self.pause_menu.is_open() && seated
}
}
pub fn fade_prompt(
time: Res<Time>,
round: PromptRound,
mut stretch: Local<(f32, String)>,
mut prompts: Query<(&Text, &mut TextColor, &mut BackgroundColor), With<PromptLabel>>,
) {
let running = round.running();
let (shown_for, said) = &mut *stretch;
for (text, mut color, mut bg) in &mut prompts {
if !running || text.0 != *said {
*shown_for = 0.0;
said.clone_from(&text.0);
} else {
*shown_for += time.delta_secs();
}
let shown = prompt_shown(*shown_for);
menu_ui::set_color(&mut color, palette::SELECTED_ROW.with_alpha(shown));
menu_ui::set_bg(
&mut bg,
palette::PILL_FILL.with_alpha(palette::PILL_FILL.alpha() * shown),
);
}
}
fn prompt_shown(secs: f32) -> f32 {
let t = ((secs - PROMPT_LINGER) / PROMPT_FADE).clamp(0.0, 1.0);
1.0 - t * t * (3.0 - 2.0 * t)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_controls_line_lingers_then_fades_out() {
assert_eq!(prompt_shown(0.0), 1.0);
assert_eq!(prompt_shown(PROMPT_LINGER), 1.0);
let midway = prompt_shown(PROMPT_LINGER + PROMPT_FADE / 2.0);
assert!((0.1..0.9).contains(&midway), "midway through: {midway}");
assert_eq!(prompt_shown(PROMPT_LINGER + PROMPT_FADE), 0.0);
assert_eq!(prompt_shown(600.0), 0.0);
}
#[test]
fn the_controls_line_holds_under_a_peers_pause() {
use crate::app::net::{Online, OnlineSession};
use bevy::ecs::system::RunSystemOnce;
let mut app = App::new();
app.add_plugins(bevy::state::app::StatesPlugin);
app.insert_state(Screen::Versus);
app.insert_state(VersusPhase::Running);
app.init_resource::<crate::app::Paused>();
app.init_resource::<crate::app::pause::PauseMenu>();
app.init_resource::<crate::app::Playback>();
let session = OnlineSession::new(
crate::transport::UdpTransport::host(0).expect("ephemeral port"),
crate::sim::Lockstep::new(0, vec![0, 1], crate::sim::DEFAULT_DELAY),
2,
crate::transport::MatchTerms::default(),
);
app.insert_resource(Online(Some(session)));
app.update();
let running = |app: &mut App| {
app.world_mut()
.run_system_once(|round: PromptRound| round.running())
.expect("runs")
};
assert!(running(&mut app), "a seat's running round fades the line");
app.world_mut()
.resource_mut::<Online>()
.0
.as_mut()
.expect("a session")
.session
.receive_pause(5);
assert!(!running(&mut app), "a peer's pause holds it");
}
#[test]
fn a_prompt_shrinks_to_its_room_and_no_further() {
assert_eq!(prompt_fit(900.0, 1000.0), Some(HEADER_PX));
let size = prompt_fit(1100.0, 1000.0).expect("a tenth too wide fits smaller");
assert!((size - HEADER_PX * 1000.0 / 1100.0).abs() < 0.01, "{size}");
assert_eq!(
prompt_fit(2000.0, 1000.0),
None,
"under the fine print: wrap"
);
}
#[test]
fn a_line_past_the_room_takes_a_second_row() {
use crate::app::i18n::metrics::display_px;
let room = prompt_room(crate::app::settings::DESIGN_W);
let mut line = "Esc".to_string();
while display_px(&format!("{line} | Esc"), HEADER_PX) <= room {
line.push_str(" | Esc");
}
assert_eq!(prompt_fits(PromptBand::OneRow, &line), Ok(()));
let over = format!("{line} | Esc");
assert!(prompt_fits(PromptBand::OneRow, &over).is_err(), "{over}");
assert_eq!(prompt_rows(&over, crate::app::settings::DESIGN_W), 2);
assert_eq!(prompt_fits(PromptBand::TwoRows, &over), Ok(()));
assert_eq!(prompt_fits(PromptBand::Shrinks, &over), Ok(()));
let three = format!("{line} | {line} | {line}");
assert!(prompt_fits(PromptBand::TwoRows, &three).is_err(), "{three}");
}
#[test]
fn two_rows_break_only_between_keys() {
let line = "WASD: bewegen | K: Mรถwen setzen | Esc: Menรผ";
let set = banded(PromptBand::TwoRows, line);
let plain = |set: &str| set.replace('\u{a0}', " ").replace('\u{2060}', "");
assert_eq!(plain(&set), line, "the same words");
let breaks: Vec<usize> = set.match_indices(' ').map(|(at, _)| at).collect();
assert_eq!(breaks.len(), 2, "{set:?}");
for at in breaks {
assert!(set[..at].ends_with('|'), "a break inside a key: {set:?}");
}
let picks = banded(PromptBand::TwoRows, "Esc: zurรผck | 7. Bo Li 8. Anna");
assert_eq!(
picks,
"Esc:\u{a0}zurรผck\u{a0}| 7.\u{a0}Bo\u{a0}Li 8.\u{a0}Anna"
);
assert_eq!(banded(PromptBand::OneRow, line), line);
assert_eq!(banded(PromptBand::Shrinks, line), line);
let width = crate::app::settings::DESIGN_W;
let room = prompt_room(width);
let px = |text: &str| crate::app::i18n::metrics::display_px(text, HEADER_PX);
for (last, cut) in [("K: Mรถwen", "K:"), ("LB/RB: Seite", "LB/")] {
let mut keys = "Esc: Menรผ".to_string();
while px(&format!("{keys} | Esc: Menรผ | {cut}")) <= room {
keys.push_str(" | Esc: Menรผ");
}
while px(&format!("{keys}m | {cut}")) <= room {
keys.push('m');
}
let split = format!("{keys} | {last}");
assert!(px(&format!("{keys} | {cut}")) <= room, "room for {cut:?}");
assert!(px(&split) > room, "no room for {last:?}");
let naive = prompt_lines(&split, width).expect("fits two rows");
assert!(
naive[0].ends_with(cut),
"set as written, {cut:?} ends the first row: {naive:?}"
);
let set =
prompt_lines(&banded(PromptBand::TwoRows, &split), width).expect("fits two rows");
assert_eq!(set.len(), 2, "{set:?}");
assert_eq!(plain(&set[1]), last, "the whole key moves down: {set:?}");
}
}
#[test]
fn the_prompt_wraps_only_where_a_second_row_is_kept() {
assert_eq!(prompt_band(Screen::Versus, false), PromptBand::Shrinks);
assert_eq!(prompt_band(Screen::Puzzle, false), PromptBand::Shrinks);
assert_eq!(prompt_band(Screen::Versus, true), PromptBand::OneRow);
assert_eq!(prompt_band(Screen::MatchSetup, false), PromptBand::OneRow);
assert_eq!(prompt_band(Screen::Replays, false), PromptBand::OneRow);
assert_eq!(prompt_band(Screen::Lobby, false), PromptBand::TwoRows);
assert_eq!(prompt_band(Screen::Editor, false), PromptBand::TwoRows);
}
#[test]
fn the_coop_lines_fit_beside_their_titles() {
use crate::app::i18n::fill;
use crate::app::i18n::metrics::display_px;
use crate::app::settings::DESIGN_W;
let room = DESIGN_W - 2.0 * 12.0 - 18.0;
let mut over = Vec::new();
for lang in crate::app::i18n::ALL_LANGS {
let tr = lang.tr();
let pair = |line: &str| crate::app::name_the_pair(tr, line);
let shelf = crate::sim::coop_levels();
let longest = shelf
.iter()
.map(|level| lang.level_name(&level.name).to_string())
.max_by(|a, b| display_px(a, HEADER_PX).total_cmp(&display_px(b, HEADER_PX)))
.unwrap_or_default();
let of = shelf.len();
let stage = format!("{} {of}/{of} - {longest}", tr.stage_coop);
let count = fill(
&pair(tr.hands_count),
&[("a", "9"), ("b", "9"), ("c", "9"), ("d", "9")],
);
let saved = fill(tr.saved_count, &[("a", "12"), ("b", "12")]);
let list = format!("{} - {}", tr.title_tide_pool, tr.title_stages);
let editor = format!(
"{} [{}] - {} 2",
tr.title_editor, tr.ed_kind_puzzle, tr.ed_default_name
);
for (title, msg) in [
(list.clone(), pair(tr.stage_needs_two)),
(list, pair(tr.stage_coop_on)),
(stage, format!("{count} | {saved}")),
(editor.clone(), format!("{} - ", tr.ed_needs_both)),
(editor, format!("{} - ", tr.ed_one_alone)),
] {
let used = display_px(&title, HEADER_PX) + display_px(&msg, HEADER_PX);
if used > room {
over.push(format!("{lang:?}: {used:.0}px: {title} | {msg}"));
}
}
let open = fill(&pair(tr.stage_open_hands), &[("a", "9"), ("b", "9")]);
let before = (of - 1).to_string();
let width = DESIGN_W - 2.0 * 12.0;
let mut lines = vec![(pair(tr.coop_rule), 17.0)];
for level in &shelf {
let name = lang.level_name(&level.name);
lines.push((format!("{} {of}. {name} - {open}", tr.stage_coop), 20.0));
lines.push((
format!(
"{} {of}. {name} - {}",
tr.stage_coop,
fill(tr.stage_locked_coop, &[("n", before.as_str())])
),
20.0,
));
}
for (line, px) in lines {
let used = crate::app::i18n::metrics::text_px(&line, px);
if used > width {
over.push(format!("{lang:?}: {used:.0}px: {line}"));
}
}
}
assert!(over.is_empty(), "too long:\n{}", over.join("\n"));
}
#[test]
fn the_editors_status_fits_beside_its_title() {
use crate::app::i18n::fill;
use crate::app::i18n::metrics::display_px;
use crate::app::settings::DESIGN_W;
let room = DESIGN_W - 2.0 * 12.0 - 18.0;
let stem = "the-long-gull-alley-at-dusk2";
assert_eq!(stem.chars().count(), 28);
let mut over = Vec::new();
for lang in crate::app::i18n::ALL_LANGS {
let tr = lang.tr();
let title = format!(
"{} [{}] - {} 2",
tr.title_editor, tr.ed_kind_puzzle, tr.ed_default_name
);
let saved = fill(tr.ed_saved_to, &[("file", &format!("{stem}.txt"))]);
let solved = fill(
tr.ed_solvable,
&[
("n", "4"),
("g", "9"),
("placements", "(10,8) (11,2) (3,12) (19,12)"),
],
);
let fresh = format!("{} 2", tr.ed_default_name);
let asked = fill(tr.ed_save_taken, &[("name", &fresh)]);
let save = crate::app::gamepad::scheme::button_name(
&crate::app::gamepad::Face::Editor { layer: true },
crate::app::gamepad::scheme::Does::Save,
tr,
)
.expect("a button saves");
let pad = fill(tr.pad.save_taken, &[("name", &fresh), ("b", &save)]);
let warned = [
tr.ed_not_solvable_needs,
tr.ed_puzzle_no_castle,
tr.ed_puzzle_no_crabs,
tr.ed_arena_no_crabs,
tr.ed_arena_skips_seat,
tr.ed_arena_needs_seats,
]
.map(|warning| fill(warning, &[("n", "6"), ("m", "9")]));
let seat = |n| crate::app::seat_label(tr, n);
let weighed = [
fill(
tr.ed_fair_uneven,
&[("p", &seat(5)), ("n", "188"), ("q", &seat(4)), ("m", "199")],
),
fill(tr.ed_fair_cut_off, &[("p", &seat(5))]),
tr.ed_mirror_line.to_string(),
tr.ed_mirror_across.to_string(),
fill(tr.ed_mirror_seats, &[("p", &seat(4)), ("q", &seat(5))]),
format!(
"{} - {}",
tr.ed_mirror_moved,
fill(tr.ed_resized, &[("w", "12"), ("h", "9")])
),
];
for msg in [saved, solved, asked, pad]
.into_iter()
.chain(warned)
.chain(weighed)
{
let used = display_px(&title, HEADER_PX) + display_px(&msg, HEADER_PX);
if used > room {
over.push(format!("{lang:?}: {used:.0}px: {title} | {msg}"));
}
}
}
assert!(over.is_empty(), "past {room:.0}px:\n{}", over.join("\n"));
}
#[test]
fn a_long_level_name_stops_before_the_clock() {
use crate::app::i18n::metrics::display_px;
use crate::app::settings::DESIGN_W;
let clock = display_px("10:00", menu_ui::type_scale::DISPLAY);
let clock_left = DESIGN_W / 2.0 - clock / 2.0;
let Val::Percent(share) = title_width(Screen::Puzzle) else {
panic!("the puzzle title is held to a share of the bar");
};
let title_right = DESIGN_W * share / 100.0;
assert!(
title_right < clock_left,
"the title reaches {title_right:.0}px and the clock starts at {clock_left:.0}px"
);
let name = "W".repeat(28);
let worst = crate::app::i18n::ALL_LANGS
.into_iter()
.map(|lang| {
let tr = lang.tr();
[tr.title_tide_pool, tr.stage_custom]
.into_iter()
.map(|section| display_px(&format!("{section} 100/100 - {name}"), HEADER_PX))
.fold(0.0f32, f32::max)
})
.fold(0.0f32, f32::max);
assert!(
worst > title_right,
"nothing to clip: the longest header is {worst:.0}px into {title_right:.0}px"
);
assert_eq!(title_width(Screen::Editor), Val::Auto);
assert_eq!(title_width(Screen::Versus), Val::Auto);
}
}