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>
1363 lines
47 KiB
Rust
1363 lines
47 KiB
Rust
//! Settings panel construction: the spawn function and row builders.
|
||
|
||
use super::*;
|
||
|
||
use std::path::PathBuf;
|
||
|
||
use solitaire_data::Settings;
|
||
|
||
use solitaire_data::settings::SyncBackend;
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
use crate::assets::user_theme_dir;
|
||
use crate::events::InfoToastEvent;
|
||
use crate::font_plugin::FontResource;
|
||
use crate::theme::ThemeThumbnailPair;
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
use crate::theme::{ImportError, import_theme, refresh_registry};
|
||
use crate::ui_focus::FocusRow;
|
||
use crate::ui_modal::{
|
||
ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header,
|
||
};
|
||
use crate::ui_theme::{
|
||
BG_BASE, BG_ELEVATED, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM,
|
||
STATE_SUCCESS, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION,
|
||
VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL,
|
||
};
|
||
use crate::ui_tooltip::Tooltip;
|
||
|
||
/// Spawns the Settings modal.
|
||
///
|
||
/// `theme_overrides_back` is `true` when the active card-art theme
|
||
/// supplies its own back (`CardImageSet::theme_back == Some(_)`). The
|
||
/// "Card Back" picker is rendered with a small caption and the
|
||
/// swatches are hidden in this state — the theme's back wins
|
||
/// regardless of which legacy back is selected, so the picker would
|
||
/// be inert otherwise.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub(super) fn spawn_settings_panel(
|
||
commands: &mut Commands,
|
||
settings: &Settings,
|
||
sync_status: &str,
|
||
unlocked_card_backs: &[usize],
|
||
unlocked_backgrounds: &[usize],
|
||
themes: &[ThemePickerEntry],
|
||
scroll_offset: f32,
|
||
font_res: Option<&FontResource>,
|
||
theme_overrides_back: bool,
|
||
active_tab: SettingsTab,
|
||
) {
|
||
spawn_modal(commands, SettingsPanel, Z_MODAL_PANEL, |card| {
|
||
spawn_modal_header(card, "Settings", font_res);
|
||
|
||
// Tab chips — one group of rows on screen at a time, no
|
||
// scroll-hunting (Phase A, docs/ui-redesign-2026-07.md).
|
||
card.spawn((
|
||
FocusRow,
|
||
Node {
|
||
flex_direction: FlexDirection::Row,
|
||
flex_wrap: FlexWrap::Wrap,
|
||
column_gap: VAL_SPACE_2,
|
||
row_gap: VAL_SPACE_2,
|
||
margin: UiRect::bottom(VAL_SPACE_3),
|
||
..default()
|
||
},
|
||
))
|
||
.with_children(|row| {
|
||
for tab in SettingsTab::ALL {
|
||
tab_chip(row, tab, tab == active_tab, font_res);
|
||
}
|
||
});
|
||
|
||
// Scrollable body — most tabs fit without scrolling, but the
|
||
// Appearance pickers can still overflow short windows. The Done
|
||
// button below stays fixed outside the scroll so it's always one
|
||
// click away.
|
||
card.spawn((
|
||
SettingsPanelScrollable,
|
||
SettingsScrollNode,
|
||
ScrollPosition(Vec2::new(0.0, scroll_offset)),
|
||
Node {
|
||
flex_direction: FlexDirection::Column,
|
||
row_gap: VAL_SPACE_3,
|
||
max_height: Val::Vh(60.0),
|
||
overflow: Overflow::scroll_y(),
|
||
padding: UiRect::bottom(Val::Px(96.0)),
|
||
..default()
|
||
},
|
||
))
|
||
.with_children(|body| match active_tab {
|
||
SettingsTab::Audio => spawn_audio_tab(body, settings, font_res),
|
||
SettingsTab::Gameplay => spawn_gameplay_tab(body, settings, font_res),
|
||
SettingsTab::Appearance => spawn_appearance_tab(
|
||
body,
|
||
settings,
|
||
unlocked_card_backs,
|
||
unlocked_backgrounds,
|
||
themes,
|
||
theme_overrides_back,
|
||
font_res,
|
||
),
|
||
SettingsTab::Accessibility => spawn_accessibility_tab(body, settings, font_res),
|
||
SettingsTab::Account => spawn_account_tab(body, settings, sync_status, font_res),
|
||
});
|
||
|
||
// Done is the only action — primary so the player always knows
|
||
// how to leave the modal. `O` toggles it the same way.
|
||
spawn_modal_actions(card, |actions| {
|
||
spawn_modal_button(
|
||
actions,
|
||
SettingsButton::Done,
|
||
"Done",
|
||
Some("O"),
|
||
ButtonVariant::Primary,
|
||
font_res,
|
||
);
|
||
});
|
||
});
|
||
}
|
||
|
||
/// One Settings tab chip. The active chip is filled + bright; inactive
|
||
/// chips are quiet outlines.
|
||
fn tab_chip(
|
||
parent: &mut ChildSpawnerCommands,
|
||
tab: SettingsTab,
|
||
active: bool,
|
||
font_res: Option<&FontResource>,
|
||
) {
|
||
let font = TextFont {
|
||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||
font_size: TYPE_CAPTION,
|
||
..default()
|
||
};
|
||
parent
|
||
.spawn((
|
||
SettingsTabButton(tab),
|
||
Button,
|
||
Node {
|
||
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
|
||
justify_content: JustifyContent::Center,
|
||
border: UiRect::all(Val::Px(1.0)),
|
||
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
||
..default()
|
||
},
|
||
BackgroundColor(if active { BG_ELEVATED_HI } else { BG_BASE }),
|
||
BorderColor::all(if active { STATE_SUCCESS } else { BORDER_SUBTLE }),
|
||
HighContrastBorder::with_default(if active { STATE_SUCCESS } else { BORDER_SUBTLE }),
|
||
))
|
||
.with_children(|b| {
|
||
b.spawn((
|
||
Text::new(tab.label()),
|
||
font,
|
||
TextColor(if active { TEXT_PRIMARY } else { TEXT_SECONDARY }),
|
||
));
|
||
});
|
||
}
|
||
|
||
/// Audio tab: the two volume rows.
|
||
fn spawn_audio_tab(
|
||
body: &mut ChildSpawnerCommands,
|
||
settings: &Settings,
|
||
font_res: Option<&FontResource>,
|
||
) {
|
||
volume_row(
|
||
body,
|
||
"SFX Volume",
|
||
settings.sfx_volume,
|
||
SfxVolumeText,
|
||
SettingsButton::SfxDown,
|
||
SettingsButton::SfxUp,
|
||
"Lower sound effects volume.",
|
||
"Raise sound effects volume.",
|
||
font_res,
|
||
);
|
||
volume_row(
|
||
body,
|
||
"Music Volume",
|
||
settings.music_volume,
|
||
MusicVolumeText,
|
||
SettingsButton::MusicDown,
|
||
SettingsButton::MusicUp,
|
||
"Lower music and ambience volume.",
|
||
"Raise music and ambience volume.",
|
||
font_res,
|
||
);
|
||
}
|
||
|
||
/// Gameplay tab: rules and pacing.
|
||
fn spawn_gameplay_tab(
|
||
body: &mut ChildSpawnerCommands,
|
||
settings: &Settings,
|
||
font_res: Option<&FontResource>,
|
||
) {
|
||
toggle_row(
|
||
body,
|
||
"Draw Mode",
|
||
DrawModeText,
|
||
draw_mode_label(&settings.draw_mode),
|
||
SettingsButton::ToggleDrawMode,
|
||
"Switch between Draw 1 and Draw 3. Takes effect next deal.",
|
||
font_res,
|
||
);
|
||
toggle_row(
|
||
body,
|
||
"Winnable deals only",
|
||
WinnableDealsOnlyText,
|
||
winnable_deals_only_label(settings.winnable_deals_only),
|
||
SettingsButton::ToggleWinnableDealsOnly,
|
||
"When on, fresh Classic deals are filtered through a solver \
|
||
(may take a moment when on).",
|
||
font_res,
|
||
);
|
||
toggle_row(
|
||
body,
|
||
"Anim Speed",
|
||
AnimSpeedText,
|
||
anim_speed_label(&settings.animation_speed),
|
||
SettingsButton::CycleAnimSpeed,
|
||
"Cycle animation speed: Normal, Fast, Instant.",
|
||
font_res,
|
||
);
|
||
time_bonus_multiplier_row(body, settings.time_bonus_multiplier, font_res);
|
||
replay_move_interval_row(body, settings.replay_move_interval_secs, font_res);
|
||
toggle_row(
|
||
body,
|
||
"Smart window size",
|
||
SmartDefaultSizeText,
|
||
smart_default_size_label(!settings.disable_smart_default_size),
|
||
SettingsButton::ToggleSmartDefaultSize,
|
||
"When ON, fresh launches resize the window to ~70 % of the \
|
||
monitor. OFF pins the 1280\u{00D7}800 baseline. Saved \
|
||
window size always wins.",
|
||
font_res,
|
||
);
|
||
}
|
||
|
||
/// Appearance tab: felt, deck art, backgrounds, and the theme system.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn spawn_appearance_tab(
|
||
body: &mut ChildSpawnerCommands,
|
||
settings: &Settings,
|
||
unlocked_card_backs: &[usize],
|
||
unlocked_backgrounds: &[usize],
|
||
themes: &[ThemePickerEntry],
|
||
theme_overrides_back: bool,
|
||
font_res: Option<&FontResource>,
|
||
) {
|
||
toggle_row(
|
||
body,
|
||
"Theme",
|
||
ThemeText,
|
||
theme_label(&settings.theme),
|
||
SettingsButton::ToggleTheme,
|
||
"Cycle felt color: Green, Blue, Dark.",
|
||
font_res,
|
||
);
|
||
if theme_overrides_back {
|
||
// The active theme provides its own back; the legacy
|
||
// picker has no visible effect, so we replace its
|
||
// swatch row with an informational caption. The
|
||
// player's `selected_card_back` value still
|
||
// round-trips through `settings.json` — the moment
|
||
// they switch to a theme without a back, the picker
|
||
// re-appears with their previous choice intact.
|
||
picker_row_overridden_by_theme(body, "Card Back", font_res);
|
||
} else {
|
||
picker_row(
|
||
body,
|
||
"Card Back",
|
||
unlocked_card_backs,
|
||
settings.selected_card_back,
|
||
SettingsButton::SelectCardBack,
|
||
"Choose your deck art. New backs unlock at higher levels.",
|
||
font_res,
|
||
);
|
||
}
|
||
picker_row(
|
||
body,
|
||
"Background",
|
||
unlocked_backgrounds,
|
||
settings.selected_background,
|
||
SettingsButton::SelectBackground,
|
||
"Choose your felt art. New felts unlock at higher levels.",
|
||
font_res,
|
||
);
|
||
// Card-art theme picker — only renders when the registry has
|
||
// entries (production: always; tests: only when
|
||
// ThemeRegistryPlugin is installed).
|
||
if !themes.is_empty() {
|
||
theme_picker_row(
|
||
body,
|
||
"Card Theme",
|
||
themes,
|
||
&settings.selected_theme_id,
|
||
"Choose card-face artwork. Imported themes appear here.",
|
||
font_res,
|
||
);
|
||
}
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
import_themes_row(body, font_res);
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
theme_store_row(body, font_res);
|
||
}
|
||
|
||
/// Accessibility tab: perception and input assists, gathered from the
|
||
/// old Gameplay/Cosmetic sections so they're findable in one place.
|
||
fn spawn_accessibility_tab(
|
||
body: &mut ChildSpawnerCommands,
|
||
settings: &Settings,
|
||
font_res: Option<&FontResource>,
|
||
) {
|
||
toggle_row(
|
||
body,
|
||
"Color-blind Mode",
|
||
ColorBlindText,
|
||
color_blind_label(settings.color_blind_mode),
|
||
SettingsButton::ToggleColorBlind,
|
||
"Show shape glyphs alongside suit colors. Suit-blind friendly.",
|
||
font_res,
|
||
);
|
||
toggle_row(
|
||
body,
|
||
"High Contrast",
|
||
HighContrastText,
|
||
on_off_label(settings.high_contrast_mode),
|
||
SettingsButton::ToggleHighContrast,
|
||
"Boosts foreground + suit-red glyphs to higher-luminance variants for low-vision readers and low-quality displays.",
|
||
font_res,
|
||
);
|
||
toggle_row(
|
||
body,
|
||
"Reduce Motion",
|
||
ReduceMotionText,
|
||
on_off_label(settings.reduce_motion_mode),
|
||
SettingsButton::ToggleReduceMotion,
|
||
"Skips card-slide animations and other non-essential motion. Cards snap instantly to their target.",
|
||
font_res,
|
||
);
|
||
toggle_row(
|
||
body,
|
||
"Touch Input Mode",
|
||
TouchInputModeText,
|
||
touch_input_mode_label(&settings.touch_input_mode),
|
||
SettingsButton::ToggleTouchInputMode,
|
||
"One-tap: tap a card to auto-move it. Tap to select: first tap selects a card, second tap on a pile moves it.",
|
||
font_res,
|
||
);
|
||
tooltip_delay_row(body, settings.tooltip_delay_secs, font_res);
|
||
}
|
||
|
||
/// Account tab: privacy opt-in (when analytics is configured) and the
|
||
/// sync backend controls.
|
||
fn spawn_account_tab(
|
||
body: &mut ChildSpawnerCommands,
|
||
settings: &Settings,
|
||
sync_status: &str,
|
||
font_res: Option<&FontResource>,
|
||
) {
|
||
// --- Privacy (only shown when a Matomo URL is configured) ---
|
||
if settings.matomo_url.is_some() {
|
||
section_label(body, "Privacy", font_res);
|
||
toggle_row(
|
||
body,
|
||
"Share usage data",
|
||
AnalyticsEnabledText,
|
||
on_off_label(settings.analytics_enabled),
|
||
SettingsButton::ToggleAnalytics,
|
||
"Sends anonymous game events to Matomo for aggregate analytics.",
|
||
font_res,
|
||
);
|
||
}
|
||
|
||
// --- Sync ---
|
||
section_label(body, "Sync", font_res);
|
||
sync_row(body, sync_status, &settings.sync_backend, font_res);
|
||
}
|
||
|
||
/// Section divider — small lavender label inside the scrollable body.
|
||
pub(super) fn section_label(
|
||
parent: &mut ChildSpawnerCommands,
|
||
title: &str,
|
||
font_res: Option<&FontResource>,
|
||
) {
|
||
let font = TextFont {
|
||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||
font_size: TYPE_BODY,
|
||
..default()
|
||
};
|
||
parent.spawn((Text::new(title), font, TextColor(TEXT_SECONDARY)));
|
||
}
|
||
|
||
/// `Label 0.80 [−] [+]` — used for SFX and Music volume rows.
|
||
///
|
||
/// `tooltip_down` / `tooltip_up` are attached to the `−` / `+` buttons
|
||
/// respectively so each glyph carries a one-line reminder of which channel
|
||
/// it adjusts.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub(super) fn volume_row<Marker: Component>(
|
||
parent: &mut ChildSpawnerCommands,
|
||
label: &str,
|
||
value: f32,
|
||
marker: Marker,
|
||
btn_down: SettingsButton,
|
||
btn_up: SettingsButton,
|
||
tooltip_down: &'static str,
|
||
tooltip_up: &'static str,
|
||
font_res: Option<&FontResource>,
|
||
) {
|
||
let label_font = label_text_font(font_res);
|
||
let value_font = value_text_font(font_res);
|
||
// Row spans the full body width with a flex-grow spacer between
|
||
// the left-aligned label and the right-aligned controls cluster.
|
||
// Without `width: 100%` + the spacer, the label / value / buttons
|
||
// bunch against the left edge and a varying-length value (e.g.
|
||
// "0.80" → "1.00") shifts the +/− buttons sideways frame to
|
||
// frame, visually overlapping with adjacent UI on small windows.
|
||
parent
|
||
.spawn(Node {
|
||
flex_direction: FlexDirection::Row,
|
||
align_items: AlignItems::Center,
|
||
column_gap: VAL_SPACE_2,
|
||
width: Val::Percent(100.0),
|
||
..default()
|
||
})
|
||
.with_children(|row| {
|
||
row.spawn((
|
||
Text::new(label.to_string()),
|
||
label_font,
|
||
TextColor(TEXT_SECONDARY),
|
||
));
|
||
// Spacer: takes up all remaining horizontal space so the
|
||
// controls cluster sits flush against the right edge.
|
||
row.spawn(Node {
|
||
flex_grow: 1.0,
|
||
..default()
|
||
});
|
||
// Controls cluster — value + decrement + increment held
|
||
// together so the buttons stay in fixed positions even
|
||
// as the value text width varies.
|
||
row.spawn(Node {
|
||
flex_direction: FlexDirection::Row,
|
||
align_items: AlignItems::Center,
|
||
column_gap: VAL_SPACE_2,
|
||
..default()
|
||
})
|
||
.with_children(|cluster| {
|
||
cluster.spawn((
|
||
marker,
|
||
Text::new(format!("{value:.2}")),
|
||
value_font,
|
||
TextColor(TEXT_PRIMARY),
|
||
));
|
||
icon_button(cluster, "−", btn_down, tooltip_down, font_res);
|
||
icon_button(cluster, "+", btn_up, tooltip_up, font_res);
|
||
});
|
||
});
|
||
}
|
||
|
||
/// `Tooltip Delay 0.5 s [−] [+]` — slider row for the player-tunable
|
||
/// tooltip-hover dwell. Mirrors [`volume_row`] (label, current value,
|
||
/// decrement, increment) but formats the value via [`tooltip_delay_label`]
|
||
/// so `0.0` reads as `"Instant"` and other values as `"{n:.1} s"`.
|
||
pub(super) fn tooltip_delay_row(
|
||
parent: &mut ChildSpawnerCommands,
|
||
value_secs: f32,
|
||
font_res: Option<&FontResource>,
|
||
) {
|
||
let label_font = label_text_font(font_res);
|
||
let value_font = value_text_font(font_res);
|
||
parent
|
||
.spawn(Node {
|
||
flex_direction: FlexDirection::Row,
|
||
align_items: AlignItems::Center,
|
||
column_gap: VAL_SPACE_2,
|
||
width: Val::Percent(100.0),
|
||
..default()
|
||
})
|
||
.with_children(|row| {
|
||
row.spawn((
|
||
Text::new("Tooltip Delay".to_string()),
|
||
label_font,
|
||
TextColor(TEXT_SECONDARY),
|
||
));
|
||
row.spawn(Node {
|
||
flex_grow: 1.0,
|
||
..default()
|
||
});
|
||
row.spawn(Node {
|
||
flex_direction: FlexDirection::Row,
|
||
align_items: AlignItems::Center,
|
||
column_gap: VAL_SPACE_2,
|
||
..default()
|
||
})
|
||
.with_children(|cluster| {
|
||
cluster.spawn((
|
||
TooltipDelayText,
|
||
Text::new(tooltip_delay_label(value_secs)),
|
||
value_font,
|
||
TextColor(TEXT_PRIMARY),
|
||
));
|
||
icon_button(
|
||
cluster,
|
||
"−",
|
||
SettingsButton::TooltipDelayDown,
|
||
"Shorten the hover delay before tooltips appear.",
|
||
font_res,
|
||
);
|
||
icon_button(
|
||
cluster,
|
||
"+",
|
||
SettingsButton::TooltipDelayUp,
|
||
"Lengthen the hover delay before tooltips appear.",
|
||
font_res,
|
||
);
|
||
});
|
||
});
|
||
}
|
||
|
||
/// `Time bonus 1.0× [−] [+]` — slider row for the cosmetic
|
||
/// `Settings::time_bonus_multiplier`. Mirrors [`tooltip_delay_row`]
|
||
/// (label, current value, decrement, increment) but formats the value
|
||
/// via [`time_bonus_label`] so `0.0` reads as `"Off"` and other values
|
||
/// as `"{n:.1}×"`. The multiplier is **cosmetic** — adjusting it
|
||
/// changes only the win-modal score breakdown, not the canonical
|
||
/// scores recorded in stats / achievements / leaderboards.
|
||
pub(super) fn time_bonus_multiplier_row(
|
||
parent: &mut ChildSpawnerCommands,
|
||
value: f32,
|
||
font_res: Option<&FontResource>,
|
||
) {
|
||
let label_font = label_text_font(font_res);
|
||
let value_font = value_text_font(font_res);
|
||
parent
|
||
.spawn(Node {
|
||
flex_direction: FlexDirection::Row,
|
||
align_items: AlignItems::Center,
|
||
column_gap: VAL_SPACE_2,
|
||
width: Val::Percent(100.0),
|
||
..default()
|
||
})
|
||
.with_children(|row| {
|
||
row.spawn((
|
||
Text::new("Time bonus".to_string()),
|
||
label_font,
|
||
TextColor(TEXT_SECONDARY),
|
||
));
|
||
row.spawn(Node {
|
||
flex_grow: 1.0,
|
||
..default()
|
||
});
|
||
row.spawn(Node {
|
||
flex_direction: FlexDirection::Row,
|
||
align_items: AlignItems::Center,
|
||
column_gap: VAL_SPACE_2,
|
||
..default()
|
||
})
|
||
.with_children(|cluster| {
|
||
cluster.spawn((
|
||
TimeBonusMultiplierText,
|
||
Text::new(time_bonus_label(value)),
|
||
value_font,
|
||
TextColor(TEXT_PRIMARY),
|
||
));
|
||
icon_button(
|
||
cluster,
|
||
"−",
|
||
SettingsButton::TimeBonusDown,
|
||
"Shrink the time-bonus shown in the win modal. Cosmetic only.",
|
||
font_res,
|
||
);
|
||
icon_button(
|
||
cluster,
|
||
"+",
|
||
SettingsButton::TimeBonusUp,
|
||
"Boost the time-bonus shown in the win modal. Cosmetic only.",
|
||
font_res,
|
||
);
|
||
});
|
||
});
|
||
}
|
||
|
||
/// `Replay speed 0.45 s/move [−] [+]` — slider row for the
|
||
/// player-tunable replay-playback per-move interval. Mirrors
|
||
/// [`tooltip_delay_row`] (label, current value, decrement, increment)
|
||
/// but formats the value via [`replay_move_interval_label`] as
|
||
/// `"{n:.2} s/move"`. The decrement button speeds playback up
|
||
/// (smaller interval); the increment slows it down — same direction
|
||
/// convention as the tooltip-delay slider.
|
||
pub(super) fn replay_move_interval_row(
|
||
parent: &mut ChildSpawnerCommands,
|
||
value_secs: f32,
|
||
font_res: Option<&FontResource>,
|
||
) {
|
||
let label_font = label_text_font(font_res);
|
||
let value_font = value_text_font(font_res);
|
||
parent
|
||
.spawn(Node {
|
||
flex_direction: FlexDirection::Row,
|
||
align_items: AlignItems::Center,
|
||
column_gap: VAL_SPACE_2,
|
||
width: Val::Percent(100.0),
|
||
..default()
|
||
})
|
||
.with_children(|row| {
|
||
row.spawn((
|
||
Text::new("Replay speed".to_string()),
|
||
label_font,
|
||
TextColor(TEXT_SECONDARY),
|
||
));
|
||
row.spawn(Node {
|
||
flex_grow: 1.0,
|
||
..default()
|
||
});
|
||
row.spawn(Node {
|
||
flex_direction: FlexDirection::Row,
|
||
align_items: AlignItems::Center,
|
||
column_gap: VAL_SPACE_2,
|
||
..default()
|
||
})
|
||
.with_children(|cluster| {
|
||
cluster.spawn((
|
||
ReplayMoveIntervalText,
|
||
Text::new(replay_move_interval_label(value_secs)),
|
||
value_font,
|
||
TextColor(TEXT_PRIMARY),
|
||
));
|
||
icon_button(
|
||
cluster,
|
||
"−",
|
||
SettingsButton::ReplayMoveIntervalDown,
|
||
"Speed up replay playback (shorter per-move interval).",
|
||
font_res,
|
||
);
|
||
icon_button(
|
||
cluster,
|
||
"+",
|
||
SettingsButton::ReplayMoveIntervalUp,
|
||
"Slow down replay playback (longer per-move interval).",
|
||
font_res,
|
||
);
|
||
});
|
||
});
|
||
}
|
||
|
||
/// `Label Value [⇄]` — used for cycle/toggle rows (draw mode, theme,
|
||
/// anim speed, colour-blind).
|
||
///
|
||
/// `tooltip` is attached to the `⇄` button so the cycle glyph carries a
|
||
/// one-line reminder of what it iterates through.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub(super) fn toggle_row<Marker: Component>(
|
||
parent: &mut ChildSpawnerCommands,
|
||
label: &str,
|
||
marker: Marker,
|
||
value: String,
|
||
action: SettingsButton,
|
||
tooltip: &'static str,
|
||
font_res: Option<&FontResource>,
|
||
) {
|
||
let label_font = label_text_font(font_res);
|
||
let value_font = value_text_font(font_res);
|
||
parent
|
||
.spawn(Node {
|
||
flex_direction: FlexDirection::Row,
|
||
align_items: AlignItems::Center,
|
||
column_gap: VAL_SPACE_2,
|
||
width: Val::Percent(100.0),
|
||
..default()
|
||
})
|
||
.with_children(|row| {
|
||
row.spawn((
|
||
Text::new(label.to_string()),
|
||
label_font,
|
||
TextColor(TEXT_SECONDARY),
|
||
));
|
||
row.spawn(Node {
|
||
flex_grow: 1.0,
|
||
..default()
|
||
});
|
||
row.spawn(Node {
|
||
flex_direction: FlexDirection::Row,
|
||
align_items: AlignItems::Center,
|
||
column_gap: VAL_SPACE_2,
|
||
..default()
|
||
})
|
||
.with_children(|cluster| {
|
||
cluster.spawn((
|
||
marker,
|
||
Text::new(value),
|
||
value_font,
|
||
TextColor(TEXT_PRIMARY),
|
||
));
|
||
icon_button(cluster, "⇄", action, tooltip, font_res);
|
||
});
|
||
});
|
||
}
|
||
|
||
/// Wrapping row of indexed swatch buttons — used for card-back and
|
||
/// background pickers. The currently-selected swatch is tinted with
|
||
/// `STATE_SUCCESS` so the user can see it without reading a label.
|
||
///
|
||
/// `tooltip` is attached to every swatch in the row so hovering any chip
|
||
/// reveals what the picker controls and how new entries unlock.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub(super) fn picker_row(
|
||
parent: &mut ChildSpawnerCommands,
|
||
label: &str,
|
||
unlocked: &[usize],
|
||
selected: usize,
|
||
make_button: impl Fn(usize) -> SettingsButton,
|
||
tooltip: &'static str,
|
||
font_res: Option<&FontResource>,
|
||
) {
|
||
let label_font = label_text_font(font_res);
|
||
let chip_font = TextFont {
|
||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||
font_size: TYPE_BODY,
|
||
..default()
|
||
};
|
||
parent
|
||
.spawn((
|
||
// The row container is a `FocusRow` so Left / Right arrow
|
||
// keys cycle within its swatch children. Tab still escapes
|
||
// the row to the next focusable in the modal.
|
||
FocusRow,
|
||
Node {
|
||
flex_direction: FlexDirection::Row,
|
||
align_items: AlignItems::Center,
|
||
column_gap: VAL_SPACE_2,
|
||
flex_wrap: FlexWrap::Wrap,
|
||
..default()
|
||
},
|
||
))
|
||
.with_children(|row| {
|
||
row.spawn((
|
||
Text::new(label.to_string()),
|
||
label_font,
|
||
TextColor(TEXT_SECONDARY),
|
||
));
|
||
// Always show at least swatch 0 (default).
|
||
let entries: &[usize] = if unlocked.is_empty() { &[0] } else { unlocked };
|
||
for &idx in entries {
|
||
let is_selected = idx == selected;
|
||
let bg = if is_selected {
|
||
STATE_SUCCESS
|
||
} else {
|
||
BG_ELEVATED_HI
|
||
};
|
||
row.spawn((
|
||
make_button(idx),
|
||
Button,
|
||
Tooltip::new(tooltip),
|
||
Node {
|
||
width: Val::Px(SWATCH_PX),
|
||
height: Val::Px(SWATCH_PX),
|
||
justify_content: JustifyContent::Center,
|
||
align_items: AlignItems::Center,
|
||
border: UiRect::all(Val::Px(1.0)),
|
||
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
||
..default()
|
||
},
|
||
BackgroundColor(bg),
|
||
BorderColor::all(BORDER_SUBTLE),
|
||
HighContrastBorder::with_default(BORDER_SUBTLE),
|
||
))
|
||
.with_children(|b| {
|
||
let text_color = if is_selected { BG_BASE } else { TEXT_PRIMARY };
|
||
b.spawn((
|
||
Text::new(format!("{}", idx + 1)),
|
||
chip_font.clone(),
|
||
TextColor(text_color),
|
||
));
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
/// Marker on the row spawned by [`picker_row_overridden_by_theme`] so
|
||
/// tests can find the caption without depending on text-content
|
||
/// matching.
|
||
#[derive(Component, Debug)]
|
||
pub(crate) struct CardBackPickerOverriddenByTheme;
|
||
|
||
/// Marker placed on every preview-thumbnail [`ImageNode`] inside a
|
||
/// theme picker chip. Lets tests assert that a chip's children include
|
||
/// the rasterised preview pair, and lets a future system update or
|
||
/// hot-swap thumbnails without scanning the whole UI tree.
|
||
#[derive(Component, Debug)]
|
||
pub(crate) struct ThemeThumbnailMarker;
|
||
|
||
/// Renders the "Card Back" row in its overridden-by-theme state: a
|
||
/// labelled caption explaining why the swatches are hidden, with no
|
||
/// interactive children. This is what the player sees when the active
|
||
/// card-art theme supplies its own `back.svg` — the theme's back wins
|
||
/// over the legacy `selected_card_back` choice, so showing the
|
||
/// swatches would only confuse the player into thinking they were
|
||
/// changing something when they weren't.
|
||
pub(super) fn picker_row_overridden_by_theme(
|
||
parent: &mut ChildSpawnerCommands,
|
||
label: &str,
|
||
font_res: Option<&FontResource>,
|
||
) {
|
||
let label_font = label_text_font(font_res);
|
||
let caption_font = TextFont {
|
||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||
font_size: TYPE_CAPTION,
|
||
..default()
|
||
};
|
||
parent
|
||
.spawn((
|
||
CardBackPickerOverriddenByTheme,
|
||
Node {
|
||
flex_direction: FlexDirection::Row,
|
||
align_items: AlignItems::Center,
|
||
column_gap: VAL_SPACE_2,
|
||
..default()
|
||
},
|
||
))
|
||
.with_children(|row| {
|
||
row.spawn((
|
||
Text::new(label.to_string()),
|
||
label_font,
|
||
TextColor(TEXT_SECONDARY),
|
||
));
|
||
row.spawn((
|
||
Text::new("Active theme provides its own back"),
|
||
caption_font,
|
||
TextColor(TEXT_SECONDARY),
|
||
));
|
||
});
|
||
}
|
||
|
||
/// Logical width (px) of one preview thumbnail inside a picker chip.
|
||
/// Mirrors [`crate::theme::THEME_THUMBNAIL_WIDTH_PX`] but at the UI
|
||
/// scale used by Bevy's flex layout. The rasterised image itself is
|
||
/// 100×140 px; the chip displays it at the same logical size so
|
||
/// scaling artifacts stay minimal.
|
||
const THUMBNAIL_LOGICAL_WIDTH_PX: f32 = 50.0;
|
||
/// Logical height counterpart to [`THUMBNAIL_LOGICAL_WIDTH_PX`] —
|
||
/// preserves the 2:3 card aspect.
|
||
const THUMBNAIL_LOGICAL_HEIGHT_PX: f32 = 70.0;
|
||
|
||
/// Picker row for card-art themes. Distinct from [`picker_row`]
|
||
/// because themes are identified by `String` ids (matching
|
||
/// `ThemeMeta::id`) instead of dense indices, and each chip carries
|
||
/// the theme's display name plus a small Ace + back preview pair
|
||
/// (when available in [`ThemeThumbnailCache`]).
|
||
pub(super) fn theme_picker_row(
|
||
parent: &mut ChildSpawnerCommands,
|
||
label: &str,
|
||
themes: &[ThemePickerEntry],
|
||
selected_id: &str,
|
||
tooltip: &'static str,
|
||
font_res: Option<&FontResource>,
|
||
) {
|
||
let label_font = label_text_font(font_res);
|
||
let chip_font = TextFont {
|
||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||
font_size: TYPE_BODY,
|
||
..default()
|
||
};
|
||
parent
|
||
.spawn((
|
||
FocusRow,
|
||
Node {
|
||
flex_direction: FlexDirection::Row,
|
||
align_items: AlignItems::Center,
|
||
column_gap: VAL_SPACE_2,
|
||
flex_wrap: FlexWrap::Wrap,
|
||
..default()
|
||
},
|
||
))
|
||
.with_children(|row| {
|
||
row.spawn((
|
||
Text::new(label.to_string()),
|
||
label_font,
|
||
TextColor(TEXT_SECONDARY),
|
||
));
|
||
for entry in themes {
|
||
let is_selected = entry.id == selected_id;
|
||
let bg = if is_selected {
|
||
STATE_SUCCESS
|
||
} else {
|
||
BG_ELEVATED_HI
|
||
};
|
||
row.spawn((
|
||
SettingsButton::SelectTheme(entry.id.clone()),
|
||
Button,
|
||
Tooltip::new(tooltip),
|
||
Node {
|
||
// Chips with thumbnails stack the preview pair
|
||
// above the label so a glance reveals the
|
||
// theme's art without hovering for the
|
||
// tooltip.
|
||
flex_direction: FlexDirection::Column,
|
||
// Theme names are wider than numeric chips —
|
||
// pad horizontally instead of using a fixed
|
||
// square swatch.
|
||
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_2),
|
||
min_height: Val::Px(SWATCH_PX),
|
||
row_gap: VAL_SPACE_2,
|
||
justify_content: JustifyContent::Center,
|
||
align_items: AlignItems::Center,
|
||
border: UiRect::all(Val::Px(1.0)),
|
||
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
||
..default()
|
||
},
|
||
BackgroundColor(bg),
|
||
BorderColor::all(BORDER_SUBTLE),
|
||
HighContrastBorder::with_default(BORDER_SUBTLE),
|
||
))
|
||
.with_children(|b| {
|
||
spawn_thumbnail_pair(b, entry.thumbnails.as_ref());
|
||
let text_color = if is_selected { BG_BASE } else { TEXT_PRIMARY };
|
||
b.spawn((
|
||
Text::new(entry.display_name.clone()),
|
||
chip_font.clone(),
|
||
TextColor(text_color),
|
||
));
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
/// Spawns the Ace + back preview pair for a theme picker chip.
|
||
///
|
||
/// When `thumbnails` is `Some(_)` and both handles are non-default,
|
||
/// renders two `ImageNode` siblings (Ace on the left, back on the
|
||
/// right). When the thumbnails are missing or only partially loaded,
|
||
/// renders two muted `BG_ELEVATED` placeholder rectangles at the same
|
||
/// logical size — keeping the chip's overall footprint stable so the
|
||
/// picker row layout doesn't reflow as the cache fills in.
|
||
pub(super) fn spawn_thumbnail_pair(
|
||
parent: &mut ChildSpawnerCommands,
|
||
thumbnails: Option<&ThemeThumbnailPair>,
|
||
) {
|
||
parent
|
||
.spawn(Node {
|
||
flex_direction: FlexDirection::Row,
|
||
column_gap: VAL_SPACE_2,
|
||
align_items: AlignItems::Center,
|
||
..default()
|
||
})
|
||
.with_children(|pair| match thumbnails {
|
||
Some(t) if t.is_fully_populated() => {
|
||
spawn_thumbnail_image(pair, t.ace.clone());
|
||
spawn_thumbnail_image(pair, t.back.clone());
|
||
}
|
||
_ => {
|
||
spawn_thumbnail_placeholder(pair);
|
||
spawn_thumbnail_placeholder(pair);
|
||
}
|
||
});
|
||
}
|
||
|
||
/// Spawns one `ImageNode` thumbnail at the canonical preview size.
|
||
/// Tagged with [`ThemeThumbnailMarker`] so tests can scan a chip's
|
||
/// children for the rendered preview without crawling the whole UI.
|
||
pub(super) fn spawn_thumbnail_image(parent: &mut ChildSpawnerCommands, image: Handle<Image>) {
|
||
parent.spawn((
|
||
ThemeThumbnailMarker,
|
||
ImageNode::new(image),
|
||
Node {
|
||
width: Val::Px(THUMBNAIL_LOGICAL_WIDTH_PX),
|
||
height: Val::Px(THUMBNAIL_LOGICAL_HEIGHT_PX),
|
||
..default()
|
||
},
|
||
));
|
||
}
|
||
|
||
/// Spawns a muted placeholder rectangle for the case where the cache
|
||
/// has not yet generated thumbnails for a theme — or when a user theme
|
||
/// is missing one of its preview SVGs. Same logical size as
|
||
/// [`spawn_thumbnail_image`] so chip layout stays stable.
|
||
pub(super) fn spawn_thumbnail_placeholder(parent: &mut ChildSpawnerCommands) {
|
||
parent.spawn((
|
||
Node {
|
||
width: Val::Px(THUMBNAIL_LOGICAL_WIDTH_PX),
|
||
height: Val::Px(THUMBNAIL_LOGICAL_HEIGHT_PX),
|
||
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
||
..default()
|
||
},
|
||
BackgroundColor(BG_ELEVATED),
|
||
));
|
||
}
|
||
|
||
/// Sync section row — shows different controls depending on whether a server
|
||
/// backend is configured.
|
||
pub(super) fn sync_row(
|
||
parent: &mut ChildSpawnerCommands,
|
||
status_text: &str,
|
||
backend: &SyncBackend,
|
||
font_res: Option<&FontResource>,
|
||
) {
|
||
let status_font = TextFont {
|
||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||
font_size: TYPE_BODY,
|
||
..default()
|
||
};
|
||
let button_font = TextFont {
|
||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||
font_size: TYPE_CAPTION,
|
||
..default()
|
||
};
|
||
|
||
// Helper closure to spawn a small settings-style pill button.
|
||
let small_button = |row: &mut ChildSpawnerCommands,
|
||
marker: SettingsButton,
|
||
label: &str,
|
||
tooltip: String,
|
||
font: TextFont| {
|
||
row.spawn((
|
||
marker,
|
||
Button,
|
||
Tooltip::new(tooltip),
|
||
Node {
|
||
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
|
||
justify_content: JustifyContent::Center,
|
||
border: UiRect::all(Val::Px(1.0)),
|
||
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
||
..default()
|
||
},
|
||
BackgroundColor(BG_ELEVATED_HI),
|
||
BorderColor::all(BORDER_SUBTLE),
|
||
HighContrastBorder::with_default(BORDER_SUBTLE),
|
||
))
|
||
.with_children(|b| {
|
||
b.spawn((Text::new(label.to_string()), font, TextColor(TEXT_PRIMARY)));
|
||
});
|
||
};
|
||
|
||
parent
|
||
.spawn(Node {
|
||
flex_direction: FlexDirection::Column,
|
||
row_gap: VAL_SPACE_2,
|
||
..default()
|
||
})
|
||
.with_children(|col| {
|
||
// Status line + inline action buttons.
|
||
col.spawn(Node {
|
||
flex_direction: FlexDirection::Row,
|
||
align_items: AlignItems::Center,
|
||
column_gap: VAL_SPACE_3,
|
||
flex_wrap: FlexWrap::Wrap,
|
||
row_gap: VAL_SPACE_2,
|
||
..default()
|
||
})
|
||
.with_children(|row| {
|
||
row.spawn((
|
||
SyncStatusText,
|
||
Text::new(status_text.to_string()),
|
||
status_font,
|
||
TextColor(TEXT_SECONDARY),
|
||
));
|
||
|
||
match backend {
|
||
SyncBackend::Local => {
|
||
small_button(
|
||
row,
|
||
SettingsButton::ConnectSync,
|
||
"Connect",
|
||
"Connect to a self-hosted Ferrous Solitaire sync server.".to_string(),
|
||
button_font,
|
||
);
|
||
}
|
||
SyncBackend::SolitaireServer { username, .. } => {
|
||
// Show the logged-in username as a secondary label.
|
||
row.spawn((
|
||
Text::new(format!("({username})")),
|
||
TextFont {
|
||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||
font_size: TYPE_CAPTION,
|
||
..default()
|
||
},
|
||
TextColor(TEXT_SECONDARY),
|
||
));
|
||
small_button(
|
||
row,
|
||
SettingsButton::SyncNow,
|
||
"Sync Now",
|
||
"Push and pull stats now. Runs automatically on launch and exit.".to_string(),
|
||
button_font.clone(),
|
||
);
|
||
small_button(
|
||
row,
|
||
SettingsButton::DisconnectSync,
|
||
"Disconnect",
|
||
"Unlink this device from the sync server.".to_string(),
|
||
button_font.clone(),
|
||
);
|
||
small_button(
|
||
row,
|
||
SettingsButton::DeleteAccount,
|
||
"Delete Account",
|
||
"Permanently delete your account and all server data. Cannot be undone.".to_string(),
|
||
button_font,
|
||
);
|
||
}
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
pub(super) fn label_text_font(font_res: Option<&FontResource>) -> TextFont {
|
||
TextFont {
|
||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||
font_size: TYPE_BODY_LG,
|
||
..default()
|
||
}
|
||
}
|
||
|
||
pub(super) fn value_text_font(font_res: Option<&FontResource>) -> TextFont {
|
||
TextFont {
|
||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||
font_size: TYPE_BODY_LG,
|
||
..default()
|
||
}
|
||
}
|
||
|
||
/// Spawns a small square icon button (volume +/−, toggle, cycle).
|
||
///
|
||
/// `tooltip` is the hover-reveal caption attached via [`Tooltip`]. Every
|
||
/// Settings icon button ships with one because the glyph alone (`+`, `−`,
|
||
/// `⇄`) does not name what it adjusts; the tooltip carries that meaning.
|
||
/// Scans `user_theme_dir()` for `.zip` files and calls [`import_theme`] on
|
||
/// each one. On success, [`ThemeRegistry`] is refreshed in place and an
|
||
/// [`InfoToastEvent`] is fired per imported theme. `IdCollision` errors (theme
|
||
/// already installed) are silently skipped; all other errors produce a warning
|
||
/// toast. A final toast tells the player to reopen Settings to see new themes.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
pub(super) fn handle_scan_themes(
|
||
interaction_query: Query<(&Interaction, &SettingsButton), Changed<Interaction>>,
|
||
mut toast: MessageWriter<InfoToastEvent>,
|
||
mut registry: Option<ResMut<crate::theme::ThemeRegistry>>,
|
||
) {
|
||
for (interaction, button) in &interaction_query {
|
||
if *interaction != Interaction::Pressed {
|
||
continue;
|
||
}
|
||
if !matches!(button, SettingsButton::ScanThemes) {
|
||
continue;
|
||
}
|
||
|
||
let themes_dir = user_theme_dir();
|
||
|
||
let zips: Vec<PathBuf> = match std::fs::read_dir(&themes_dir) {
|
||
Ok(entries) => entries
|
||
.flatten()
|
||
.map(|e| e.path())
|
||
.filter(|p| p.extension().is_some_and(|ext| ext == "zip"))
|
||
.collect(),
|
||
Err(_) => {
|
||
toast.write(InfoToastEvent(
|
||
"Themes folder not found — drop .zip files there first.".to_string(),
|
||
));
|
||
return;
|
||
}
|
||
};
|
||
|
||
if zips.is_empty() {
|
||
toast.write(InfoToastEvent(
|
||
"No .zip files found in themes folder.".to_string(),
|
||
));
|
||
return;
|
||
}
|
||
|
||
let mut imported = 0u32;
|
||
let mut errors = 0u32;
|
||
|
||
for zip_path in &zips {
|
||
match import_theme(zip_path) {
|
||
Ok(theme_id) => {
|
||
toast.write(InfoToastEvent(format!(
|
||
"Imported theme '{}'.",
|
||
theme_id.as_str()
|
||
)));
|
||
imported += 1;
|
||
}
|
||
Err(ImportError::IdCollision { .. }) => {
|
||
// Already installed — silent skip.
|
||
}
|
||
Err(e) => {
|
||
let name = zip_path
|
||
.file_name()
|
||
.map(|n| n.to_string_lossy().into_owned())
|
||
.unwrap_or_default();
|
||
toast.write(InfoToastEvent(format!("Import failed ({name}): {e}")));
|
||
errors += 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
if imported == 0 && errors == 0 {
|
||
toast.write(InfoToastEvent("All themes already installed.".to_string()));
|
||
return;
|
||
}
|
||
|
||
if imported > 0 {
|
||
if let Some(reg) = &mut registry {
|
||
refresh_registry(reg, &themes_dir);
|
||
}
|
||
toast.write(InfoToastEvent(
|
||
"Reopen Settings to see new themes in the picker.".to_string(),
|
||
));
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
/// A small pill-shaped settings button, matching the style used in `sync_row`.
|
||
pub(super) fn pill_button(
|
||
parent: &mut ChildSpawnerCommands,
|
||
marker: SettingsButton,
|
||
label: &str,
|
||
tooltip: &'static str,
|
||
font_res: Option<&FontResource>,
|
||
) {
|
||
let font = TextFont {
|
||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||
font_size: TYPE_CAPTION,
|
||
..default()
|
||
};
|
||
parent
|
||
.spawn((
|
||
marker,
|
||
Button,
|
||
Tooltip::new(tooltip),
|
||
Node {
|
||
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
|
||
justify_content: JustifyContent::Center,
|
||
border: UiRect::all(Val::Px(1.0)),
|
||
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
||
..default()
|
||
},
|
||
BackgroundColor(BG_ELEVATED_HI),
|
||
BorderColor::all(BORDER_SUBTLE),
|
||
HighContrastBorder::with_default(BORDER_SUBTLE),
|
||
))
|
||
.with_children(|b| {
|
||
b.spawn((Text::new(label.to_string()), font, TextColor(TEXT_PRIMARY)));
|
||
});
|
||
}
|
||
|
||
/// "Import Theme" row: folder-path label + "Scan for new themes" button.
|
||
///
|
||
/// The player drops `.zip` theme archives into the themes folder shown here,
|
||
/// then presses the button. [`handle_scan_themes`] picks them up, validates,
|
||
/// and installs them. Reopen Settings to see newly imported themes in the
|
||
/// card-theme picker.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
pub(super) fn import_themes_row(
|
||
parent: &mut ChildSpawnerCommands,
|
||
font_res: Option<&FontResource>,
|
||
) {
|
||
let caption_font = TextFont {
|
||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||
font_size: TYPE_CAPTION,
|
||
..default()
|
||
};
|
||
|
||
parent
|
||
.spawn((
|
||
FocusRow,
|
||
Node {
|
||
flex_direction: FlexDirection::Column,
|
||
row_gap: VAL_SPACE_2,
|
||
..default()
|
||
},
|
||
))
|
||
.with_children(|col| {
|
||
// Folder path hint.
|
||
let path_str = user_theme_dir().to_string_lossy().into_owned();
|
||
col.spawn((
|
||
Text::new(format!("Drop .zip files into: {path_str}")),
|
||
caption_font,
|
||
TextColor(TEXT_SECONDARY),
|
||
));
|
||
|
||
// Scan button.
|
||
col.spawn(Node {
|
||
flex_direction: FlexDirection::Row,
|
||
align_items: AlignItems::Center,
|
||
..default()
|
||
})
|
||
.with_children(|row| {
|
||
pill_button(
|
||
row,
|
||
SettingsButton::ScanThemes,
|
||
"Scan for new themes",
|
||
"Scan the themes folder for .zip archives and install any that are new.",
|
||
font_res,
|
||
);
|
||
});
|
||
});
|
||
}
|
||
|
||
/// "Theme store" row: one pill button that closes Settings and opens
|
||
/// the in-game theme-store modal (`theme_store_plugin`). Sits directly
|
||
/// under the Import row so both install paths live together.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
pub(super) fn theme_store_row(parent: &mut ChildSpawnerCommands, font_res: Option<&FontResource>) {
|
||
parent
|
||
.spawn((
|
||
FocusRow,
|
||
Node {
|
||
flex_direction: FlexDirection::Row,
|
||
align_items: AlignItems::Center,
|
||
..default()
|
||
},
|
||
))
|
||
.with_children(|row| {
|
||
pill_button(
|
||
row,
|
||
SettingsButton::OpenThemeStore,
|
||
"Browse theme store",
|
||
"Download themes from your sync server without leaving the game.",
|
||
font_res,
|
||
);
|
||
});
|
||
}
|
||
|
||
pub(super) fn icon_button(
|
||
parent: &mut ChildSpawnerCommands,
|
||
label: &str,
|
||
action: SettingsButton,
|
||
tooltip: &'static str,
|
||
font_res: Option<&FontResource>,
|
||
) {
|
||
let glyph_font = TextFont {
|
||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||
font_size: TYPE_BODY_LG,
|
||
..default()
|
||
};
|
||
parent
|
||
.spawn((
|
||
action,
|
||
Button,
|
||
Tooltip::new(tooltip),
|
||
Node {
|
||
width: Val::Px(ICON_BUTTON_PX),
|
||
height: Val::Px(ICON_BUTTON_PX),
|
||
justify_content: JustifyContent::Center,
|
||
align_items: AlignItems::Center,
|
||
border: UiRect::all(Val::Px(1.0)),
|
||
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
||
..default()
|
||
},
|
||
BackgroundColor(BG_ELEVATED_HI),
|
||
BorderColor::all(BORDER_SUBTLE),
|
||
HighContrastBorder::with_default(BORDER_SUBTLE),
|
||
))
|
||
.with_children(|b| {
|
||
b.spawn((
|
||
Text::new(label.to_string()),
|
||
glyph_font,
|
||
TextColor(TEXT_PRIMARY),
|
||
));
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tests
|
||
// ---------------------------------------------------------------------------
|