refactor(engine): split hud_plugin runtime code into submodules
Continues #118 after settings_plugin (PR #127): the 2,725-line mod.rs becomes five focused submodules along existing system boundaries — mod.rs 553 markers, resources, popover enums, plugin build spawn.rs 552 band / columns / avatar / action-bar construction interaction.rs 616 button handlers, Modes/Menu popovers, tap gesture fx.rs 430 action fades, score pulses/floaters, streak flourish updates.rs 612 HUD text / typography / visibility updaters Moved items are pub(super) (fx re-exported pub for HudActionFade and format_time_limit consumers). Code moved verbatim; no behaviour change; all 44 hud tests pass unchanged. Refs #118 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,612 @@
|
||||
//! Per-frame HUD text/typography/visibility updater systems.
|
||||
|
||||
use super::*;
|
||||
|
||||
use bevy::window::WindowResized;
|
||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||
use solitaire_core::Suit;
|
||||
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
||||
|
||||
use crate::auto_complete_plugin::AutoCompleteState;
|
||||
|
||||
/// Formats a time-limit value in seconds as `"mm:ss"` for HUD display.
|
||||
///
|
||||
/// For example `format_time_limit(300)` returns `"5:00"`.
|
||||
pub fn format_time_limit(secs: u64) -> String {
|
||||
let m = secs / 60;
|
||||
let s = secs % 60;
|
||||
format!("{m}:{s:02}")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Score-change feedback (G2)
|
||||
//
|
||||
// The flow for each Update tick:
|
||||
// 1. `detect_score_change` diffs `GameStateResource.score` against
|
||||
// `PreviousScore`. On any positive delta it inserts/refreshes
|
||||
// `ScorePulse` on the score readout; on a delta ≥
|
||||
// `SCORE_FLOATER_THRESHOLD` it also spawns a floating "+N" UI text
|
||||
// anchored just below the score.
|
||||
// 2. `advance_score_pulse` ticks the pulse component, applies the
|
||||
// triangular 1.0 → 1.1 → 1.0 scale curve, and removes the
|
||||
// component on completion.
|
||||
// 3. `advance_score_floater` drifts each floater upward, fades it to
|
||||
// transparent, and despawns it when its lifetime expires.
|
||||
//
|
||||
// The threshold of 50 (a foundation promotion's typical bonus) keeps
|
||||
// floaters rare and meaningful — see `SCORE_FLOATER_THRESHOLD`.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Sets the [`HudWonPreviously`] text to "✓ Won before" whenever the
|
||||
/// current deal's seed + draw_mode + mode triple matches an entry in
|
||||
/// the rolling [`ReplayHistory`]. Cleared while the active game is won
|
||||
/// (the on-screen "Game won!" cue already conveys victory) and on
|
||||
/// fresh deals the player hasn't won before.
|
||||
///
|
||||
/// Lives in its own system rather than `update_hud` to keep this
|
||||
/// orthogonal: `update_hud`'s query disambiguation is already busy
|
||||
/// enough; threading another marker through every Without filter
|
||||
/// would touch ~10 unrelated queries for no benefit.
|
||||
pub(super) fn update_won_previously(
|
||||
game: Res<GameStateResource>,
|
||||
// Optional because the HUD plugin's headless tests run without
|
||||
// `StatsPlugin` and therefore without this resource. With the
|
||||
// resource absent there's no history to compare against; the
|
||||
// indicator just stays empty.
|
||||
history: Option<Res<crate::stats_plugin::ReplayHistoryResource>>,
|
||||
mut q: Query<&mut Text, With<HudWonPreviously>>,
|
||||
) {
|
||||
let Ok(mut text) = q.single_mut() else {
|
||||
return;
|
||||
};
|
||||
let won_before = !game.0.is_won()
|
||||
&& history.as_ref().is_some_and(|h| {
|
||||
h.0.replays.iter().any(|r| {
|
||||
r.seed == game.0.seed && r.draw_mode == game.0.draw_mode() && r.mode == game.0.mode
|
||||
})
|
||||
});
|
||||
let next = if won_before {
|
||||
"\u{2713} Won before"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
if text.0 != next {
|
||||
text.0 = next.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
||||
pub(super) fn update_hud(
|
||||
game: Res<GameStateResource>,
|
||||
time_attack: Option<Res<TimeAttackResource>>,
|
||||
daily: Option<Res<DailyChallengeResource>>,
|
||||
auto_complete: Option<Res<AutoCompleteState>>,
|
||||
mut score_q: Query<
|
||||
&mut Text,
|
||||
(
|
||||
With<HudScore>,
|
||||
Without<HudMoves>,
|
||||
Without<HudTime>,
|
||||
Without<HudMode>,
|
||||
Without<HudChallenge>,
|
||||
Without<HudUndos>,
|
||||
Without<HudAutoComplete>,
|
||||
Without<HudRecycles>,
|
||||
Without<HudDrawCycle>,
|
||||
Without<HudSelection>,
|
||||
),
|
||||
>,
|
||||
mut moves_q: Query<
|
||||
&mut Text,
|
||||
(
|
||||
With<HudMoves>,
|
||||
Without<HudScore>,
|
||||
Without<HudTime>,
|
||||
Without<HudMode>,
|
||||
Without<HudChallenge>,
|
||||
Without<HudUndos>,
|
||||
Without<HudAutoComplete>,
|
||||
Without<HudRecycles>,
|
||||
Without<HudDrawCycle>,
|
||||
Without<HudSelection>,
|
||||
),
|
||||
>,
|
||||
mut time_q: Query<
|
||||
&mut Text,
|
||||
(
|
||||
With<HudTime>,
|
||||
Without<HudScore>,
|
||||
Without<HudMoves>,
|
||||
Without<HudMode>,
|
||||
Without<HudChallenge>,
|
||||
Without<HudUndos>,
|
||||
Without<HudAutoComplete>,
|
||||
Without<HudRecycles>,
|
||||
Without<HudDrawCycle>,
|
||||
Without<HudSelection>,
|
||||
),
|
||||
>,
|
||||
mut mode_q: Query<
|
||||
&mut Text,
|
||||
(
|
||||
With<HudMode>,
|
||||
Without<HudScore>,
|
||||
Without<HudMoves>,
|
||||
Without<HudTime>,
|
||||
Without<HudChallenge>,
|
||||
Without<HudUndos>,
|
||||
Without<HudAutoComplete>,
|
||||
Without<HudRecycles>,
|
||||
Without<HudDrawCycle>,
|
||||
Without<HudSelection>,
|
||||
),
|
||||
>,
|
||||
mut challenge_q: Query<
|
||||
(&mut Text, &mut TextColor),
|
||||
(
|
||||
With<HudChallenge>,
|
||||
Without<HudScore>,
|
||||
Without<HudMoves>,
|
||||
Without<HudTime>,
|
||||
Without<HudMode>,
|
||||
Without<HudUndos>,
|
||||
Without<HudAutoComplete>,
|
||||
Without<HudRecycles>,
|
||||
Without<HudDrawCycle>,
|
||||
Without<HudSelection>,
|
||||
),
|
||||
>,
|
||||
mut undos_q: Query<
|
||||
(&mut Text, &mut TextColor),
|
||||
(
|
||||
With<HudUndos>,
|
||||
Without<HudScore>,
|
||||
Without<HudMoves>,
|
||||
Without<HudTime>,
|
||||
Without<HudMode>,
|
||||
Without<HudChallenge>,
|
||||
Without<HudAutoComplete>,
|
||||
Without<HudRecycles>,
|
||||
Without<HudDrawCycle>,
|
||||
Without<HudSelection>,
|
||||
),
|
||||
>,
|
||||
mut auto_q: Query<
|
||||
&mut Text,
|
||||
(
|
||||
With<HudAutoComplete>,
|
||||
Without<HudScore>,
|
||||
Without<HudMoves>,
|
||||
Without<HudTime>,
|
||||
Without<HudMode>,
|
||||
Without<HudChallenge>,
|
||||
Without<HudUndos>,
|
||||
Without<HudRecycles>,
|
||||
Without<HudDrawCycle>,
|
||||
Without<HudSelection>,
|
||||
),
|
||||
>,
|
||||
mut recycles_q: Query<
|
||||
&mut Text,
|
||||
(
|
||||
With<HudRecycles>,
|
||||
Without<HudScore>,
|
||||
Without<HudMoves>,
|
||||
Without<HudTime>,
|
||||
Without<HudMode>,
|
||||
Without<HudChallenge>,
|
||||
Without<HudUndos>,
|
||||
Without<HudAutoComplete>,
|
||||
Without<HudDrawCycle>,
|
||||
Without<HudSelection>,
|
||||
),
|
||||
>,
|
||||
mut draw_cycle_q: Query<
|
||||
&mut Text,
|
||||
(
|
||||
With<HudDrawCycle>,
|
||||
Without<HudScore>,
|
||||
Without<HudMoves>,
|
||||
Without<HudTime>,
|
||||
Without<HudMode>,
|
||||
Without<HudChallenge>,
|
||||
Without<HudUndos>,
|
||||
Without<HudAutoComplete>,
|
||||
Without<HudRecycles>,
|
||||
Without<HudSelection>,
|
||||
),
|
||||
>,
|
||||
) {
|
||||
let ta_active = time_attack.as_ref().is_some_and(|ta| ta.active);
|
||||
|
||||
// Score, moves, mode, challenge, and undos only need updating when game state changes.
|
||||
if game.is_changed() {
|
||||
let g = &game.0;
|
||||
let is_zen = g.mode == GameMode::Zen;
|
||||
if let Ok(mut t) = score_q.single_mut() {
|
||||
// Zen mode suppresses score display per spec ("No score display").
|
||||
**t = if is_zen {
|
||||
String::new()
|
||||
} else {
|
||||
format!("Score: {}", g.score())
|
||||
};
|
||||
}
|
||||
if let Ok(mut t) = moves_q.single_mut() {
|
||||
**t = format!("Moves: {}", g.move_count());
|
||||
}
|
||||
if let Ok(mut t) = mode_q.single_mut() {
|
||||
**t = match g.mode {
|
||||
GameMode::Classic => match g.draw_mode() {
|
||||
DrawStockConfig::DrawOne => String::new(),
|
||||
DrawStockConfig::DrawThree => "Draw 3".to_string(),
|
||||
},
|
||||
GameMode::Zen => "ZEN".to_string(),
|
||||
GameMode::Challenge => "CHALLENGE".to_string(),
|
||||
GameMode::TimeAttack => "TIME ATTACK".to_string(),
|
||||
GameMode::Difficulty(level) => level.label().to_uppercase(),
|
||||
};
|
||||
}
|
||||
|
||||
// --- Daily challenge constraint (with time-low colour warning) ---
|
||||
if let Ok((mut t, mut color)) = challenge_q.single_mut() {
|
||||
if g.is_won() {
|
||||
**t = String::new();
|
||||
} else if let Some(dc) = daily.as_deref() {
|
||||
**t = challenge_hud_text(dc);
|
||||
if let Some(max_secs) = dc.max_time_secs {
|
||||
let remaining = max_secs.saturating_sub(g.elapsed_seconds);
|
||||
*color = TextColor(challenge_time_color(remaining));
|
||||
}
|
||||
} else {
|
||||
**t = String::new();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Undo count ---
|
||||
if let Ok((mut t, mut color)) = undos_q.single_mut() {
|
||||
let count = g.undo_count();
|
||||
if count == 0 {
|
||||
**t = String::new();
|
||||
*color = TextColor(TEXT_PRIMARY);
|
||||
} else {
|
||||
**t = format!("Undos: {count}");
|
||||
// STATE_WARNING signals "you took a penalty" — same hue
|
||||
// as the Recycles counter so they read as one category.
|
||||
*color = TextColor(STATE_WARNING);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Recycle counter (both modes, hidden until first recycle) ---
|
||||
if let Ok(mut t) = recycles_q.single_mut() {
|
||||
**t = if g.recycle_count() > 0 {
|
||||
format!("Recycles: {}", g.recycle_count())
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
}
|
||||
|
||||
// --- Draw-cycle indicator (Draw-Three mode only) ---
|
||||
if let Ok(mut t) = draw_cycle_q.single_mut() {
|
||||
**t = if g.is_won() || g.draw_mode() != DrawStockConfig::DrawThree {
|
||||
// Hide when not in Draw-Three or after the game is won.
|
||||
String::new()
|
||||
} else {
|
||||
let stock_len = g.stock_cards().len();
|
||||
let next_draw = stock_len.min(3);
|
||||
format!("Cycle: {next_draw}/3")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Time display: show Time Attack countdown every frame when active;
|
||||
// Zen mode suppresses the timer per spec ("No timer") — cleared unconditionally
|
||||
// every frame so it disappears immediately on the frame Z is pressed.
|
||||
// Otherwise show game elapsed time (updates once per second via game.is_changed()).
|
||||
let is_zen = game.0.mode == GameMode::Zen;
|
||||
let update_time = (ta_active || game.is_changed()) && !is_zen;
|
||||
if update_time {
|
||||
if let Ok(mut t) = time_q.single_mut() {
|
||||
if let Some(ta) = time_attack.as_ref().filter(|ta| ta.active) {
|
||||
let remaining = ta.remaining_secs.max(0.0) as u64;
|
||||
let m = remaining / 60;
|
||||
let s = remaining % 60;
|
||||
**t = format!("{m}:{s:02}");
|
||||
} else {
|
||||
let secs = game.0.elapsed_seconds;
|
||||
let m = secs / 60;
|
||||
let s = secs % 60;
|
||||
**t = format!("{m}:{s:02}");
|
||||
}
|
||||
}
|
||||
} else if is_zen {
|
||||
// Clear the time display immediately whenever Zen mode is active —
|
||||
// do not guard on game.is_changed() so it clears on the same frame
|
||||
// the player presses Z, before any move is made.
|
||||
if let Ok(mut t) = time_q.single_mut() {
|
||||
**t = String::new();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Auto-complete badge ---
|
||||
// Reflects the AutoCompleteState resource; update whenever it changes or game changes.
|
||||
let ac_active = auto_complete.as_ref().is_some_and(|ac| ac.active);
|
||||
let ac_changed = auto_complete.as_ref().is_some_and(|ac| ac.is_changed());
|
||||
if (ac_changed || game.is_changed())
|
||||
&& let Ok(mut t) = auto_q.single_mut()
|
||||
{
|
||||
**t = if ac_active {
|
||||
"AUTO".to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the `HudSelection` text node to show which pile is Tab-selected.
|
||||
///
|
||||
/// Displays `"▶ {pile_name}"` while `SelectionState::selected_pile` is `Some`,
|
||||
/// or an empty string when no pile is selected. Runs every frame so the
|
||||
/// indicator stays in sync with the selection resource.
|
||||
pub(super) fn update_selection_hud(
|
||||
selection: Option<Res<SelectionState>>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
mut q: Query<&mut Text, With<HudSelection>>,
|
||||
) {
|
||||
let Ok(mut t) = q.single_mut() else { return };
|
||||
let label = match selection.as_deref().and_then(|s| s.selected_pile.as_ref()) {
|
||||
None => String::new(),
|
||||
Some(KlondikePile::Stock) => "▶ Waste".to_string(),
|
||||
Some(KlondikePile::Foundation(slot)) => match game.as_deref() {
|
||||
Some(g) => foundation_selection_label(*slot, &g.0),
|
||||
// No game resource means we can't probe claimed_suit; show the
|
||||
// slot-based placeholder so the HUD still surfaces the selection.
|
||||
None => format!("▶ Foundation {}", foundation_number(*slot)),
|
||||
},
|
||||
Some(KlondikePile::Tableau(idx)) => format!("▶ Column {}", tableau_number(*idx)),
|
||||
};
|
||||
**t = label;
|
||||
}
|
||||
|
||||
/// Returns the HUD selection label for a foundation slot.
|
||||
///
|
||||
/// When the slot has a claimed suit (any card has landed) the announcement is
|
||||
/// "▶ {Suit} Foundation"; while the slot is empty it falls back to a
|
||||
/// "▶ Foundation N" placeholder labelled by the 1-based slot index.
|
||||
pub(super) fn foundation_selection_label(
|
||||
slot: Foundation,
|
||||
game: &solitaire_core::game_state::GameState,
|
||||
) -> String {
|
||||
let claimed = game
|
||||
.pile(KlondikePile::Foundation(slot))
|
||||
.first()
|
||||
.map(|c| c.0.suit());
|
||||
match claimed {
|
||||
Some(suit) => {
|
||||
let s = match suit {
|
||||
Suit::Clubs => "Clubs",
|
||||
Suit::Diamonds => "Diamonds",
|
||||
Suit::Hearts => "Hearts",
|
||||
Suit::Spades => "Spades",
|
||||
};
|
||||
format!("▶ {s} Foundation")
|
||||
}
|
||||
None => format!("▶ Foundation {}", foundation_number(slot)),
|
||||
}
|
||||
}
|
||||
|
||||
const fn foundation_number(foundation: Foundation) -> u8 {
|
||||
match foundation {
|
||||
Foundation::Foundation1 => 1,
|
||||
Foundation::Foundation2 => 2,
|
||||
Foundation::Foundation3 => 3,
|
||||
Foundation::Foundation4 => 4,
|
||||
}
|
||||
}
|
||||
|
||||
const fn tableau_number(tableau: Tableau) -> u8 {
|
||||
match tableau {
|
||||
Tableau::Tableau1 => 1,
|
||||
Tableau::Tableau2 => 2,
|
||||
Tableau::Tableau3 => 3,
|
||||
Tableau::Tableau4 => 4,
|
||||
Tableau::Tableau5 => 5,
|
||||
Tableau::Tableau6 => 6,
|
||||
Tableau::Tableau7 => 7,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fires `InfoToastEvent("Auto-completing...")` exactly once each time
|
||||
/// `AutoCompleteState` transitions from inactive to active. Uses a `Local<bool>`
|
||||
/// to debounce so the toast only appears on the leading edge.
|
||||
pub(super) fn announce_auto_complete(
|
||||
auto_complete: Option<Res<AutoCompleteState>>,
|
||||
mut toast: MessageWriter<InfoToastEvent>,
|
||||
mut was_active: Local<bool>,
|
||||
) {
|
||||
let now_active = auto_complete.as_ref().is_some_and(|ac| ac.active);
|
||||
if now_active && !*was_active {
|
||||
toast.write(InfoToastEvent("Auto-completing...".to_string()));
|
||||
}
|
||||
*was_active = now_active;
|
||||
}
|
||||
|
||||
/// Builds the HUD text for the active daily challenge constraints.
|
||||
///
|
||||
/// Returns `"Limit: mm:ss"` when a time limit is set, `"Goal: N pts"` when a
|
||||
/// score target is set, or an empty string when the challenge has no extra
|
||||
/// constraints.
|
||||
pub(super) fn challenge_hud_text(dc: &DailyChallengeResource) -> String {
|
||||
if let Some(secs) = dc.max_time_secs {
|
||||
format!("Limit: {}", format_time_limit(secs))
|
||||
} else if let Some(score) = dc.target_score {
|
||||
format!("Goal: {score} pts")
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the colour for the challenge time-limit HUD label based on
|
||||
/// seconds remaining. Uses theme tokens so the urgency ramp picks up
|
||||
/// palette changes for free.
|
||||
///
|
||||
/// | Remaining | Token |
|
||||
/// |-------------|------------------|
|
||||
/// | ≥ 60 s | `STATE_INFO` |
|
||||
/// | 30 – 59 s | `STATE_WARNING` |
|
||||
/// | < 30 s | `STATE_DANGER` |
|
||||
pub fn challenge_time_color(remaining: u64) -> Color {
|
||||
if remaining < 30 {
|
||||
STATE_DANGER
|
||||
} else if remaining < 60 {
|
||||
STATE_WARNING
|
||||
} else {
|
||||
STATE_INFO
|
||||
}
|
||||
}
|
||||
|
||||
/// Scales HUD Tier-1 font sizes to fit a narrow viewport.
|
||||
///
|
||||
/// Fires on every `WindowResized` event. Below 480 logical pixels wide the
|
||||
/// score drops from `TYPE_HEADLINE` (26 px) to `TYPE_BODY_LG` (18 px) and the
|
||||
/// Moves/Timer labels drop from `TYPE_BODY_LG` to `TYPE_CAPTION` (11 px), so
|
||||
/// all three items remain on one row inside the 50 %-wide HUD column
|
||||
/// (≈ 180 dp on a 360 dp phone). At ≥ 480 px the original sizes are
|
||||
/// restored so desktop/tablet layouts are unaffected.
|
||||
type HudScoreFont<'w, 's> =
|
||||
Query<'w, 's, &'static mut TextFont, (With<HudScore>, Without<HudMoves>, Without<HudTime>)>;
|
||||
type HudMovesFont<'w, 's> =
|
||||
Query<'w, 's, &'static mut TextFont, (With<HudMoves>, Without<HudScore>, Without<HudTime>)>;
|
||||
type HudTimeFont<'w, 's> =
|
||||
Query<'w, 's, &'static mut TextFont, (With<HudTime>, Without<HudScore>, Without<HudMoves>)>;
|
||||
|
||||
pub(super) fn update_hud_typography(
|
||||
mut events: MessageReader<WindowResized>,
|
||||
mut score_q: HudScoreFont,
|
||||
mut moves_q: HudMovesFont,
|
||||
mut time_q: HudTimeFont,
|
||||
) {
|
||||
let Some(ev) = events.read().last() else {
|
||||
return;
|
||||
};
|
||||
let (score_size, secondary_size) = if ev.width < 480.0 {
|
||||
(TYPE_BODY_LG, TYPE_CAPTION)
|
||||
} else {
|
||||
(TYPE_HEADLINE, TYPE_BODY_LG)
|
||||
};
|
||||
for mut font in &mut score_q {
|
||||
font.font_size = score_size;
|
||||
}
|
||||
for mut font in &mut moves_q {
|
||||
font.font_size = secondary_size;
|
||||
}
|
||||
for mut font in &mut time_q {
|
||||
font.font_size = secondary_size;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn apply_hud_visibility(
|
||||
hud_vis: Res<HudVisibility>,
|
||||
mut action_bar: Query<&mut Visibility, With<HudActionBar>>,
|
||||
) {
|
||||
if !hud_vis.is_changed() {
|
||||
return;
|
||||
}
|
||||
let v = if *hud_vis == HudVisibility::Visible {
|
||||
Visibility::Visible
|
||||
} else {
|
||||
Visibility::Hidden
|
||||
};
|
||||
for mut vis in &mut action_bar {
|
||||
*vis = v;
|
||||
}
|
||||
// The bottom action bar is a pure overlay — it does not claim any
|
||||
// space in the card layout, so no WindowResized event is needed.
|
||||
}
|
||||
|
||||
pub(super) fn restore_hud_on_modal(
|
||||
new_scrims: Query<(), (With<ModalScrim>, Added<ModalScrim>)>,
|
||||
mut hud_vis: ResMut<HudVisibility>,
|
||||
) {
|
||||
if !new_scrims.is_empty() {
|
||||
*hud_vis = HudVisibility::Visible;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the action-bar label font size for a given logical window width.
|
||||
pub(super) fn action_bar_font_size(window_width: f32) -> f32 {
|
||||
if USE_TOUCH_UI_LAYOUT {
|
||||
// Seven word-labels ("Menu","Undo","Pause","Help","Hint","Mode","New")
|
||||
// must share one row. The widest characters are in FiraMono (a
|
||||
// monospace whose advance is ~0.62 of the font size). On a 900
|
||||
// logical-px phone the row budget after bar padding (2*12) and six
|
||||
// 4 px column gaps is ~852 px for ~28 label chars + 7*2*3 px button
|
||||
// padding. Solving 28*0.62*size + 42 <= 852 gives size <= ~46, so the
|
||||
// labels are advance-bound only on very narrow viewports; the real
|
||||
// constraint is legibility, not fit. ~1/60 of the width yields ~15 px
|
||||
// at 900 px — comfortably one row with margin to spare — clamped so it
|
||||
// never drops below the 12 px legibility floor or grows past 18 px on
|
||||
// landscape tablets where it would crowd the row again.
|
||||
(window_width / 60.0).clamp(12.0, 18.0)
|
||||
} else {
|
||||
TYPE_BODY
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn action_button_metrics() -> (UiRect, Val, Val) {
|
||||
if USE_TOUCH_UI_LAYOUT {
|
||||
// Tight 3 px horizontal padding (down from 4) trims 14 px off the row
|
||||
// total across 7 buttons, and a 44 px min_width (down from 52) lets the
|
||||
// shortest labels ("New", "Help") shrink to their text rather than
|
||||
// padding the row out past the 900 logical-px viewport. min_height
|
||||
// stays at 44 px to preserve the comfortable touch target.
|
||||
(
|
||||
UiRect::axes(Val::Px(3.0), Val::Px(4.0)),
|
||||
Val::Px(44.0),
|
||||
Val::Px(44.0),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
UiRect::axes(VAL_SPACE_2, VAL_SPACE_2),
|
||||
Val::Px(48.0),
|
||||
Val::Px(48.0),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn spawn_action_button_label(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
label: &str,
|
||||
font: &TextFont,
|
||||
text_color: Color,
|
||||
) {
|
||||
if USE_TOUCH_UI_LAYOUT {
|
||||
parent.spawn((
|
||||
ActionButtonLabel,
|
||||
Text::new(label),
|
||||
font.clone(),
|
||||
TextColor(text_color),
|
||||
));
|
||||
} else {
|
||||
parent.spawn((Text::new(label), font.clone(), TextColor(text_color)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Resizes the glyph text inside every [`ActionButtonLabel`] to match the
|
||||
/// current viewport width whenever [`LayoutResource`] changes (orientation
|
||||
/// change or window resize).
|
||||
#[cfg(target_os = "android")]
|
||||
pub(super) fn resize_action_bar_labels(
|
||||
layout: Res<crate::layout::LayoutResource>,
|
||||
windows: Query<&Window>,
|
||||
mut labels: Query<&mut TextFont, With<ActionButtonLabel>>,
|
||||
) {
|
||||
let w = windows
|
||||
.iter()
|
||||
.next()
|
||||
.map_or(layout.0.card_size.x * 7.25, |win| win.width());
|
||||
let new_size = action_bar_font_size(w);
|
||||
for mut font in &mut labels {
|
||||
font.font_size = new_size;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user