2f373784bf
Closes out the 13-phase menu redesign. Three parts:
1. Post-sync merge summary: the pull poller now keeps the
`ConflictReport`s the merge produces (previously discarded) in a new
`SyncConflictLog` resource and toasts "Synced — N conflicts, kept
newer values" when a merge wasn't clean. No wire changes — the
server-side `SyncResponse.conflicts` field already existed.
2. Account tab detail: a per-field conflict list under the sync row
("win_streak_current — this device: 3 / server: 5") plus a
clean-merge caption, so the last merge is always inspectable.
3. Local data export/import: new `solitaire_data::transfer` module —
one versioned JSON bundle (settings, stats, achievements, progress)
written atomically next to the other saves, with a version-gated
loader that surfaces every failure instead of defaulting. Account
tab grows an Export/Import button pair (desktop+Android only); the
handler runs in `Last`, off the annotated Update spine. Import
rewrites the live resources, persists via the existing save fns,
and fires SettingsChangedEvent so appliers react normally.
Tests: bundle round-trip/version-gate/missing-file in solitaire_data;
ECS-level export→import round trip and failed-import-warns in
settings_plugin. Gate: workspace tests green, clippy -D warnings clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
651 lines
22 KiB
Rust
651 lines
22 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_glass::{GlassSurface, glass_decorations};
|
||
use crate::ui_modal::ModalScrim;
|
||
use crate::ui_theme::{BORDER_SUBTLE_HC, HighContrastBackground, HighContrastBorder};
|
||
use bevy::ui::{BackgroundGradient, BorderGradient};
|
||
|
||
/// 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>>,
|
||
conflict_log: Option<Res<crate::resources::SyncConflictLog>>,
|
||
) {
|
||
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(),
|
||
conflict_log.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>,
|
||
conflict_log: Option<&crate::resources::SyncConflictLog>,
|
||
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,
|
||
conflict_log,
|
||
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>>,
|
||
conflict_log: Option<Res<crate::resources::SyncConflictLog>>,
|
||
) {
|
||
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(),
|
||
conflict_log.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_reduce_transparency_text(
|
||
settings: Res<SettingsResource>,
|
||
mut text_nodes: Query<&mut Text, With<ReduceTransparencyText>>,
|
||
) {
|
||
if !settings.is_changed() {
|
||
return;
|
||
}
|
||
for mut text in &mut text_nodes {
|
||
**text = on_off_label(settings.0.reduce_transparency_mode);
|
||
}
|
||
}
|
||
|
||
/// Retargets every [`GlassSurface`]'s sheen + rim gradients when the
|
||
/// reduce-transparency or high-contrast toggles change. Runs on
|
||
/// `SettingsResource` change and on newly added surfaces (so glass
|
||
/// spawned after startup picks up an already-active toggle). Parallel to
|
||
/// [`update_high_contrast_borders`]: same trigger, different components.
|
||
pub(super) fn update_glass_surfaces(
|
||
settings: Res<SettingsResource>,
|
||
mut surfaces: Query<(&mut BackgroundGradient, &mut BorderGradient), With<GlassSurface>>,
|
||
added: Query<(), Added<GlassSurface>>,
|
||
) {
|
||
if !settings.is_changed() && added.is_empty() {
|
||
return;
|
||
}
|
||
let (sheen, rim) = glass_decorations(
|
||
settings.0.reduce_transparency_mode,
|
||
settings.0.high_contrast_mode,
|
||
);
|
||
for (mut s, mut r) in &mut surfaces {
|
||
*s = sheen.clone();
|
||
*r = rim.clone();
|
||
}
|
||
}
|
||
|
||
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(),
|
||
}
|
||
}
|
||
|
||
/// The four UI-scale steps the settings row cycles through (Phase K).
|
||
pub(super) const UI_SCALE_STEPS: [f32; 4] = [0.9, 1.0, 1.15, 1.3];
|
||
|
||
/// The next UI-scale step after `current`, wrapping 130 % → 90 %. A
|
||
/// hand-edited value between steps advances to the first step larger
|
||
/// than it, so the cycle always makes visible progress.
|
||
pub(super) fn next_ui_scale(current: f32) -> f32 {
|
||
for step in UI_SCALE_STEPS {
|
||
if step > current + 0.001 {
|
||
return step;
|
||
}
|
||
}
|
||
UI_SCALE_STEPS[0]
|
||
}
|
||
|
||
/// Display string for the UI-scale row, e.g. `"115%"`.
|
||
pub(super) fn ui_scale_label(scale: f32) -> String {
|
||
format!("{:.0}%", scale * 100.0)
|
||
}
|
||
|
||
/// Refreshes the live UI-scale value text whenever settings change.
|
||
pub(super) fn update_ui_scale_text(
|
||
settings: Res<SettingsResource>,
|
||
mut text_nodes: Query<&mut Text, With<UiScaleText>>,
|
||
) {
|
||
if !settings.is_changed() {
|
||
return;
|
||
}
|
||
for mut text in &mut text_nodes {
|
||
**text = ui_scale_label(settings.0.ui_scale);
|
||
}
|
||
}
|
||
|
||
/// Applies `Settings::ui_scale` to the live [`bevy::ui::UiScale`]
|
||
/// resource — at startup (first change tick) and whenever the setting
|
||
/// changes. Absent under `MinimalPlugins` (no `bevy_ui`), hence the
|
||
/// `Option`; the table is unaffected either way (`compute_layout`
|
||
/// owns world-space sizing, not UI scale).
|
||
pub(super) fn sync_ui_scale_resource(
|
||
settings: Res<SettingsResource>,
|
||
ui_scale: Option<ResMut<UiScale>>,
|
||
) {
|
||
let Some(mut ui_scale) = ui_scale else { return };
|
||
if !settings.is_changed() {
|
||
return;
|
||
}
|
||
let target = settings.0.ui_scale;
|
||
if (ui_scale.0 - target).abs() > f32::EPSILON {
|
||
ui_scale.0 = target;
|
||
}
|
||
}
|
||
|
||
/// 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")
|
||
}
|