50d6d41d85
Test / test (pull_request) Failing after 7m15s
Replaces the 37-row single-scroll settings modal with five tabs (Audio, Gameplay, Appearance, Accessibility, Account); only the active tab's rows spawn, so every tab fits without scroll-hunting. - SettingsTab + ActiveSettingsTab (session-only) + SettingsTabButton chips under the modal header; rebuild-on-switch mirrors the leaderboard despawn/respawn pattern via a shared build_panel helper - Accessibility tab gathers color-blind, high-contrast, reduce-motion, touch-input, and tooltip-delay from the old Gameplay/Cosmetic mix; Account = Sync + Privacy - SettingsButton enum, input handlers, and persistence untouched - Tests: per-tab Focusable/Tooltip sweeps, tab-switch rebuild test, picker/thumbnail tests pinned to the Appearance tab Plan: docs/ui-redesign-2026-07.md (also added, phases A–M) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
556 lines
18 KiB
Rust
556 lines
18 KiB
Rust
//! Per-frame value-text updater systems and their label helpers.
|
||
|
||
use super::*;
|
||
|
||
use solitaire_core::DrawStockConfig;
|
||
use solitaire_data::{AnimSpeed, settings::Theme};
|
||
|
||
use crate::font_plugin::FontResource;
|
||
use crate::progress_plugin::ProgressResource;
|
||
use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource};
|
||
use crate::theme::ThemeThumbnailCache;
|
||
use crate::ui_modal::ModalScrim;
|
||
use crate::ui_theme::{BORDER_SUBTLE_HC, HighContrastBackground, HighContrastBorder};
|
||
|
||
/// Spawns the Settings panel when `SettingsScreen` becomes `true`;
|
||
/// despawns it when it becomes `false`.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub(super) fn sync_settings_panel_visibility(
|
||
screen: Res<SettingsScreen>,
|
||
active_tab: Res<ActiveSettingsTab>,
|
||
panels: Query<Entity, With<SettingsPanel>>,
|
||
other_modal_scrims: Query<(), (With<ModalScrim>, Without<SettingsPanel>)>,
|
||
scroll_nodes: Query<&ScrollPosition, With<SettingsScrollNode>>,
|
||
mut scroll_pos: ResMut<SettingsScrollPos>,
|
||
mut commands: Commands,
|
||
settings: Res<SettingsResource>,
|
||
sync_status: Option<Res<SyncStatusResource>>,
|
||
progress: Option<Res<ProgressResource>>,
|
||
font_res: Option<Res<FontResource>>,
|
||
theme_registry: Option<Res<crate::theme::ThemeRegistry>>,
|
||
theme_thumbs: Option<Res<ThemeThumbnailCache>>,
|
||
card_images: Option<Res<crate::card_plugin::CardImageSet>>,
|
||
) {
|
||
if !screen.is_changed() {
|
||
return;
|
||
}
|
||
if screen.0 {
|
||
if panels.is_empty() && other_modal_scrims.is_empty() {
|
||
build_panel(
|
||
&mut commands,
|
||
&settings.0,
|
||
sync_status.as_deref(),
|
||
progress.as_deref(),
|
||
font_res.as_deref(),
|
||
theme_registry.as_deref(),
|
||
theme_thumbs.as_deref(),
|
||
card_images.as_deref(),
|
||
scroll_pos.0,
|
||
active_tab.0,
|
||
);
|
||
}
|
||
} else {
|
||
// Save the current scroll offset before despawning the panel.
|
||
if let Ok(sp) = scroll_nodes.single() {
|
||
scroll_pos.0 = sp.0.y;
|
||
}
|
||
for entity in &panels {
|
||
commands.entity(entity).despawn();
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Gathers the spawn context (sync label, unlocks, theme snapshot,
|
||
/// back-override flag) and spawns the panel showing `active_tab`.
|
||
/// Shared by [`sync_settings_panel_visibility`] (open) and
|
||
/// [`rebuild_panel_on_tab_change`] (tab switch).
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn build_panel(
|
||
commands: &mut Commands,
|
||
settings: &Settings,
|
||
sync_status: Option<&SyncStatusResource>,
|
||
progress: Option<&ProgressResource>,
|
||
font_res: Option<&FontResource>,
|
||
theme_registry: Option<&crate::theme::ThemeRegistry>,
|
||
theme_thumbs: Option<&ThemeThumbnailCache>,
|
||
card_images: Option<&crate::card_plugin::CardImageSet>,
|
||
scroll_offset: f32,
|
||
active_tab: SettingsTab,
|
||
) {
|
||
let status_label = sync_status.map_or_else(
|
||
|| "Status: local only".to_string(),
|
||
|s| sync_status_label(&s.0),
|
||
);
|
||
let unlocked_backs = progress.map_or(&[0][..], |p| p.0.unlocked_card_backs.as_slice());
|
||
let unlocked_bgs = progress.map_or(&[0][..], |p| p.0.unlocked_backgrounds.as_slice());
|
||
// Snapshot themes by id, display_name and (optional)
|
||
// thumbnail pair so spawn_settings_panel doesn't have to
|
||
// know about the registry / cache shapes. Empty when
|
||
// ThemeRegistryPlugin isn't installed (tests under
|
||
// MinimalPlugins) — the picker row simply won't render.
|
||
// Missing thumbnails (cache not ready, or partial user
|
||
// theme) leave `thumbnails: None` so the chip renders its
|
||
// plain-text fallback instead of a broken sprite.
|
||
let themes: Vec<ThemePickerEntry> = theme_registry
|
||
.map(|r| {
|
||
r.iter()
|
||
.map(|e| ThemePickerEntry {
|
||
id: e.id.clone(),
|
||
display_name: e.display_name.clone(),
|
||
thumbnails: theme_thumbs
|
||
.and_then(|c| c.get(&e.id))
|
||
.filter(|p| p.is_fully_populated())
|
||
.cloned(),
|
||
})
|
||
.collect()
|
||
})
|
||
.unwrap_or_default();
|
||
// The active card-art theme can supply its own back image —
|
||
// see `card_plugin::CardImageSet::theme_back`. When that is
|
||
// populated the legacy "Card Back" picker has no visible
|
||
// effect, so we render it muted with an explanatory caption
|
||
// rather than letting the player click swatches that do
|
||
// nothing. Absent under `MinimalPlugins`; treated as
|
||
// "no override" in that case.
|
||
let theme_overrides_back = card_images.is_some_and(|cs| cs.theme_back.is_some());
|
||
spawn_settings_panel(
|
||
commands,
|
||
settings,
|
||
&status_label,
|
||
unlocked_backs,
|
||
unlocked_bgs,
|
||
&themes,
|
||
scroll_offset,
|
||
font_res,
|
||
theme_overrides_back,
|
||
active_tab,
|
||
);
|
||
}
|
||
|
||
/// Rebuilds the open panel when [`ActiveSettingsTab`] changes (tab chip
|
||
/// clicked). No-op while the panel is closed — the next open reads the
|
||
/// resource directly.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub(super) fn rebuild_panel_on_tab_change(
|
||
active_tab: Res<ActiveSettingsTab>,
|
||
panels: Query<Entity, With<SettingsPanel>>,
|
||
mut scroll_pos: ResMut<SettingsScrollPos>,
|
||
mut commands: Commands,
|
||
settings: Res<SettingsResource>,
|
||
sync_status: Option<Res<SyncStatusResource>>,
|
||
progress: Option<Res<ProgressResource>>,
|
||
font_res: Option<Res<FontResource>>,
|
||
theme_registry: Option<Res<crate::theme::ThemeRegistry>>,
|
||
theme_thumbs: Option<Res<ThemeThumbnailCache>>,
|
||
card_images: Option<Res<crate::card_plugin::CardImageSet>>,
|
||
) {
|
||
if !active_tab.is_changed() || active_tab.is_added() {
|
||
return;
|
||
}
|
||
if panels.is_empty() {
|
||
return;
|
||
}
|
||
for entity in &panels {
|
||
commands.entity(entity).despawn();
|
||
}
|
||
// A fresh tab starts at the top; per-tab scroll memory isn't worth
|
||
// the bookkeeping now that no tab needs much scrolling.
|
||
scroll_pos.0 = 0.0;
|
||
build_panel(
|
||
&mut commands,
|
||
&settings.0,
|
||
sync_status.as_deref(),
|
||
progress.as_deref(),
|
||
font_res.as_deref(),
|
||
theme_registry.as_deref(),
|
||
theme_thumbs.as_deref(),
|
||
card_images.as_deref(),
|
||
0.0,
|
||
active_tab.0,
|
||
);
|
||
}
|
||
|
||
/// Keeps the sync-status text node current while the panel is open.
|
||
pub(super) fn update_sync_status_text(
|
||
sync_status: Option<Res<SyncStatusResource>>,
|
||
mut text_nodes: Query<&mut Text, With<SyncStatusText>>,
|
||
) {
|
||
let Some(status) = sync_status else {
|
||
return;
|
||
};
|
||
if !status.is_changed() {
|
||
return;
|
||
}
|
||
let label = sync_status_label(&status.0);
|
||
for mut text in &mut text_nodes {
|
||
**text = label.clone();
|
||
}
|
||
}
|
||
|
||
pub(super) fn update_card_back_text(
|
||
settings: Res<SettingsResource>,
|
||
mut text_nodes: Query<&mut Text, With<CardBackText>>,
|
||
) {
|
||
if !settings.is_changed() {
|
||
return;
|
||
}
|
||
for mut text in &mut text_nodes {
|
||
**text = card_back_label(settings.0.selected_card_back);
|
||
}
|
||
}
|
||
|
||
pub(super) fn update_background_text(
|
||
settings: Res<SettingsResource>,
|
||
mut text_nodes: Query<&mut Text, With<BackgroundText>>,
|
||
) {
|
||
if !settings.is_changed() {
|
||
return;
|
||
}
|
||
for mut text in &mut text_nodes {
|
||
**text = background_label(settings.0.selected_background);
|
||
}
|
||
}
|
||
|
||
pub(super) fn update_anim_speed_text(
|
||
settings: Res<SettingsResource>,
|
||
mut text_nodes: Query<&mut Text, With<AnimSpeedText>>,
|
||
) {
|
||
if !settings.is_changed() {
|
||
return;
|
||
}
|
||
for mut text in &mut text_nodes {
|
||
**text = anim_speed_label(&settings.0.animation_speed);
|
||
}
|
||
}
|
||
|
||
pub(super) fn update_color_blind_text(
|
||
settings: Res<SettingsResource>,
|
||
mut text_nodes: Query<&mut Text, With<ColorBlindText>>,
|
||
) {
|
||
if !settings.is_changed() {
|
||
return;
|
||
}
|
||
for mut text in &mut text_nodes {
|
||
**text = color_blind_label(settings.0.color_blind_mode);
|
||
}
|
||
}
|
||
|
||
pub(super) fn update_high_contrast_text(
|
||
settings: Res<SettingsResource>,
|
||
mut text_nodes: Query<&mut Text, With<HighContrastText>>,
|
||
) {
|
||
if !settings.is_changed() {
|
||
return;
|
||
}
|
||
for mut text in &mut text_nodes {
|
||
**text = on_off_label(settings.0.high_contrast_mode);
|
||
}
|
||
}
|
||
|
||
/// Repaints `BorderColor` on every entity tagged with
|
||
/// [`HighContrastBorder`] based on `Settings::high_contrast_mode`.
|
||
/// Off → the marker's `default_color`; on → `BORDER_SUBTLE_HC`
|
||
/// (`#a0a0a0`). Compares against the current border colour and
|
||
/// only mutates when different so Bevy's change-detection
|
||
/// doesn't trigger repaints every frame.
|
||
///
|
||
/// Spec at `design-system.md` §Accessibility (#2): under HC,
|
||
/// outlines boost from `#505050` (BORDER_STRONG) to `#a0a0a0` so
|
||
/// modal panels, popover edges, and focus-ring carriers stay
|
||
/// legible on low-quality displays / for low-vision users.
|
||
///
|
||
/// Tagged sites in v0.21.x: the modal scaffold's card border
|
||
/// (`ui_modal::spawn_modal`). More sites can be tagged in
|
||
/// follow-ups by adding `HighContrastBorder::with_default(...)`
|
||
/// to their spawn tuple.
|
||
pub(super) fn update_high_contrast_borders(
|
||
settings: Res<SettingsResource>,
|
||
mut borders: Query<(&HighContrastBorder, &mut BorderColor)>,
|
||
) {
|
||
let high_contrast = settings.0.high_contrast_mode;
|
||
for (marker, mut border) in borders.iter_mut() {
|
||
let target = if high_contrast {
|
||
BORDER_SUBTLE_HC
|
||
} else {
|
||
marker.default_color
|
||
};
|
||
// Only mutate when actually different — avoids per-frame
|
||
// change-detection churn. `border.left` is representative
|
||
// because every tagged site uses `BorderColor::all(...)`.
|
||
if border.left != target {
|
||
*border = BorderColor::all(target);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Repaints `BackgroundColor` on every entity tagged with
|
||
/// [`HighContrastBackground`] based on `Settings::high_contrast_mode`.
|
||
/// Off → the marker's `default_color`; on → `BORDER_SUBTLE_HC`
|
||
/// (`#a0a0a0`). Compares against the current background and only
|
||
/// mutates when different so Bevy's change-detection doesn't trigger
|
||
/// repaints every frame.
|
||
///
|
||
/// Parallel to [`update_high_contrast_borders`]. Same on/off rule,
|
||
/// same change-suppression idiom, different colour channel —
|
||
/// `BackgroundColor` for tick marks, decorative strips, fine
|
||
/// separators that paint their shape directly rather than via a
|
||
/// `BorderColor` on a wider Node.
|
||
///
|
||
/// Tagged sites in v0.21.x: the replay overlay's 1 px scrub track
|
||
/// + 5 quarter-mark notch ticks (`replay_overlay::spawn_overlay`).
|
||
///
|
||
/// More sites can be tagged in follow-ups by adding
|
||
/// `HighContrastBackground::with_default(...)` to their spawn tuple.
|
||
pub(super) fn update_high_contrast_backgrounds(
|
||
settings: Res<SettingsResource>,
|
||
mut backgrounds: Query<(&HighContrastBackground, &mut BackgroundColor)>,
|
||
) {
|
||
let high_contrast = settings.0.high_contrast_mode;
|
||
for (marker, mut bg) in backgrounds.iter_mut() {
|
||
let target = if high_contrast {
|
||
marker.hc_color
|
||
} else {
|
||
marker.default_color
|
||
};
|
||
if bg.0 != target {
|
||
*bg = BackgroundColor(target);
|
||
}
|
||
}
|
||
}
|
||
|
||
pub(super) fn update_reduce_motion_text(
|
||
settings: Res<SettingsResource>,
|
||
mut text_nodes: Query<&mut Text, With<ReduceMotionText>>,
|
||
) {
|
||
if !settings.is_changed() {
|
||
return;
|
||
}
|
||
for mut text in &mut text_nodes {
|
||
**text = on_off_label(settings.0.reduce_motion_mode);
|
||
}
|
||
}
|
||
|
||
pub(super) fn update_touch_input_mode_text(
|
||
settings: Res<SettingsResource>,
|
||
mut text_nodes: Query<&mut Text, With<TouchInputModeText>>,
|
||
) {
|
||
if !settings.is_changed() {
|
||
return;
|
||
}
|
||
for mut text in &mut text_nodes {
|
||
**text = touch_input_mode_label(&settings.0.touch_input_mode);
|
||
}
|
||
}
|
||
|
||
/// Refreshes the live "Winnable deals only" toggle value in the
|
||
/// Gameplay section whenever `SettingsResource` changes (button click,
|
||
/// hand-edited `settings.json` reload, etc.).
|
||
pub(super) fn update_winnable_deals_only_text(
|
||
settings: Res<SettingsResource>,
|
||
mut text_nodes: Query<&mut Text, With<WinnableDealsOnlyText>>,
|
||
) {
|
||
if !settings.is_changed() {
|
||
return;
|
||
}
|
||
for mut text in &mut text_nodes {
|
||
**text = winnable_deals_only_label(settings.0.winnable_deals_only);
|
||
}
|
||
}
|
||
|
||
/// Refreshes the live "Share usage data" toggle value in the Privacy section
|
||
/// whenever `SettingsResource` changes.
|
||
pub(super) fn update_analytics_enabled_text(
|
||
settings: Res<SettingsResource>,
|
||
mut text_nodes: Query<&mut Text, With<AnalyticsEnabledText>>,
|
||
) {
|
||
if !settings.is_changed() {
|
||
return;
|
||
}
|
||
for mut text in &mut text_nodes {
|
||
**text = on_off_label(settings.0.analytics_enabled);
|
||
}
|
||
}
|
||
|
||
/// Refreshes the live "Smart window size" toggle value whenever
|
||
/// `SettingsResource` changes. The flag is stored negatively as
|
||
/// `disable_smart_default_size`, so the label inverts.
|
||
pub(super) fn update_smart_default_size_text(
|
||
settings: Res<SettingsResource>,
|
||
mut text_nodes: Query<&mut Text, With<SmartDefaultSizeText>>,
|
||
) {
|
||
if !settings.is_changed() {
|
||
return;
|
||
}
|
||
for mut text in &mut text_nodes {
|
||
**text = smart_default_size_label(!settings.0.disable_smart_default_size);
|
||
}
|
||
}
|
||
|
||
/// Refreshes the live tooltip-delay value in the Gameplay section
|
||
/// whenever `SettingsResource` changes (slider buttons, hand-edited
|
||
/// settings.json reload, etc.).
|
||
pub(super) fn update_tooltip_delay_text(
|
||
settings: Res<SettingsResource>,
|
||
mut text_nodes: Query<&mut Text, With<TooltipDelayText>>,
|
||
) {
|
||
if !settings.is_changed() {
|
||
return;
|
||
}
|
||
for mut text in &mut text_nodes {
|
||
**text = tooltip_delay_label(settings.0.tooltip_delay_secs);
|
||
}
|
||
}
|
||
|
||
/// Refreshes the live time-bonus-multiplier value in the Gameplay
|
||
/// section whenever `SettingsResource` changes.
|
||
pub(super) fn update_time_bonus_multiplier_text(
|
||
settings: Res<SettingsResource>,
|
||
mut text_nodes: Query<&mut Text, With<TimeBonusMultiplierText>>,
|
||
) {
|
||
if !settings.is_changed() {
|
||
return;
|
||
}
|
||
for mut text in &mut text_nodes {
|
||
**text = time_bonus_label(settings.0.time_bonus_multiplier);
|
||
}
|
||
}
|
||
|
||
/// Refreshes the live replay-playback per-move-interval value in the
|
||
/// Gameplay section whenever `SettingsResource` changes (slider buttons,
|
||
/// hand-edited settings.json reload, etc.).
|
||
pub(super) fn update_replay_move_interval_text(
|
||
settings: Res<SettingsResource>,
|
||
mut text_nodes: Query<&mut Text, With<ReplayMoveIntervalText>>,
|
||
) {
|
||
if !settings.is_changed() {
|
||
return;
|
||
}
|
||
for mut text in &mut text_nodes {
|
||
**text = replay_move_interval_label(settings.0.replay_move_interval_secs);
|
||
}
|
||
}
|
||
|
||
pub(super) fn card_back_label(idx: usize) -> String {
|
||
if idx == 0 {
|
||
"Default".to_string()
|
||
} else {
|
||
format!("Style {idx}")
|
||
}
|
||
}
|
||
|
||
pub(super) fn background_label(idx: usize) -> String {
|
||
if idx == 0 {
|
||
"Default".to_string()
|
||
} else {
|
||
format!("Style {idx}")
|
||
}
|
||
}
|
||
|
||
pub(super) fn sync_status_label(status: &SyncStatus) -> String {
|
||
match status {
|
||
SyncStatus::Idle => "Status: idle".to_string(),
|
||
SyncStatus::Syncing => "Status: syncing…".to_string(),
|
||
SyncStatus::LastSynced(t) => {
|
||
let secs = chrono::Utc::now()
|
||
.signed_duration_since(*t)
|
||
.num_seconds()
|
||
.max(0);
|
||
if secs < 60 {
|
||
format!("Last synced: {secs}s ago")
|
||
} else {
|
||
format!("Last synced: {}m ago", secs / 60)
|
||
}
|
||
}
|
||
SyncStatus::Error(e) => format!("Sync error: {e}"),
|
||
}
|
||
}
|
||
|
||
pub(super) fn draw_mode_label(mode: &DrawStockConfig) -> String {
|
||
match mode {
|
||
DrawStockConfig::DrawOne => "Draw 1".into(),
|
||
DrawStockConfig::DrawThree => "Draw 3".into(),
|
||
}
|
||
}
|
||
|
||
pub(super) fn anim_speed_label(speed: &AnimSpeed) -> String {
|
||
match speed {
|
||
AnimSpeed::Normal => "Normal".into(),
|
||
AnimSpeed::Fast => "Fast".into(),
|
||
AnimSpeed::Instant => "Instant".into(),
|
||
}
|
||
}
|
||
|
||
pub(super) fn theme_label(theme: &Theme) -> String {
|
||
match theme {
|
||
Theme::Green => "Green".into(),
|
||
Theme::Blue => "Blue".into(),
|
||
Theme::Dark => "Dark".into(),
|
||
}
|
||
}
|
||
|
||
pub(super) fn color_blind_label(enabled: bool) -> String {
|
||
if enabled { "ON".into() } else { "OFF".into() }
|
||
}
|
||
|
||
/// Generic ON/OFF label shared by the high-contrast and reduce-
|
||
/// motion accessibility toggles. Same format as
|
||
/// [`color_blind_label`] / [`winnable_deals_only_label`] —
|
||
/// keeping all simple boolean toggle rows visually uniform.
|
||
pub(super) fn on_off_label(enabled: bool) -> String {
|
||
if enabled { "ON".into() } else { "OFF".into() }
|
||
}
|
||
|
||
/// Display string for the "Winnable deals only" toggle. Mirrors
|
||
/// [`color_blind_label`] — "ON" / "OFF" — so the layout is uniform
|
||
/// with the rest of the Gameplay-section toggles.
|
||
pub(super) fn winnable_deals_only_label(enabled: bool) -> String {
|
||
if enabled { "ON".into() } else { "OFF".into() }
|
||
}
|
||
|
||
pub(super) fn touch_input_mode_label(mode: &solitaire_data::settings::TouchInputMode) -> String {
|
||
use solitaire_data::settings::TouchInputMode;
|
||
match mode {
|
||
TouchInputMode::OneTap => "One-tap".into(),
|
||
TouchInputMode::TapToSelect => "Tap to select".into(),
|
||
}
|
||
}
|
||
|
||
/// Display string for the "Smart window size" toggle. The argument
|
||
/// is the *enabled* state (i.e. the inverse of the underlying
|
||
/// `disable_smart_default_size` field) so reading the label gives
|
||
/// the player intuitive ON/OFF semantics.
|
||
pub(super) fn smart_default_size_label(enabled: bool) -> String {
|
||
if enabled { "ON".into() } else { "OFF".into() }
|
||
}
|
||
|
||
/// Formats the tooltip-hover delay for display in the Settings panel.
|
||
/// `0.0` reads as `"Instant"` so the zero-delay case has a name; any
|
||
/// other value prints as `"{n:.1} s"` (e.g. `"0.5 s"`, `"1.2 s"`).
|
||
pub(super) fn tooltip_delay_label(secs: f32) -> String {
|
||
if secs <= 0.0 {
|
||
"Instant".into()
|
||
} else {
|
||
format!("{secs:.1} s")
|
||
}
|
||
}
|
||
|
||
/// Formats the cosmetic time-bonus multiplier for display in the
|
||
/// Settings panel. `0.0` reads as `"Off"` so the player understands the
|
||
/// time-bonus row will be hidden; any other value prints as
|
||
/// `"{n:.1}×"` (e.g. `"1.0×"`, `"1.5×"`).
|
||
pub(super) fn time_bonus_label(value: f32) -> String {
|
||
if value <= 0.0 {
|
||
"Off".into()
|
||
} else {
|
||
format!("{value:.1}×")
|
||
}
|
||
}
|
||
|
||
/// Formats the replay-playback per-move interval for display in the
|
||
/// Settings panel. Mirrors [`tooltip_delay_label`] for parity — the
|
||
/// readout is `"{n:.2} s/move"` (e.g. `"0.45 s/move"`, `"0.10 s/move"`),
|
||
/// using two decimal places because the step is 0.05 s.
|
||
pub(super) fn replay_move_interval_label(secs: f32) -> String {
|
||
format!("{secs:.2} s/move")
|
||
}
|