Files
Ferrous-Solitaire/solitaire_engine/src/settings_plugin.rs
T
root 20db4b312a feat(engine): ManualSyncRequestEvent + Sync Now button in settings
- Added ManualSyncRequestEvent to events.rs (exported from lib.rs).
- SyncPlugin now handles ManualSyncRequestEvent: if no pull is in
  flight, spawns a new AsyncComputeTaskPool task and sets status to
  Syncing. Ignores duplicate requests while a pull is active.
- Settings panel "Sync" section now shows the status text alongside a
  "Sync Now" button that fires ManualSyncRequestEvent.
- Cleaned up stale doc comment in input_plugin.rs (Esc pause note).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 00:03:24 +00:00

670 lines
23 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Persists `solitaire_data::Settings`, exposes hotkeys for live tuning,
//! and renders a Bevy UI Settings panel.
//!
//! Hotkeys (always active, no overlay required):
//! - `[` — decrease SFX volume by `SFX_STEP`
//! - `]` — increase SFX volume by `SFX_STEP`
//! - `O` — open / close the Settings panel
//!
//! On change, the plugin persists `settings.json` and fires
//! `SettingsChangedEvent` so dependents (e.g. `AudioPlugin`) can react.
use std::path::PathBuf;
use bevy::prelude::*;
use solitaire_core::game_state::DrawMode;
use solitaire_data::{load_settings_from, save_settings_to, settings_file_path, settings::Theme, Settings};
use crate::events::ManualSyncRequestEvent;
use crate::resources::{SyncStatus, SyncStatusResource};
/// Volume adjustment step applied by the `[` / `]` hotkeys.
pub const SFX_STEP: f32 = 0.1;
/// Bevy resource wrapping the current `Settings`.
#[derive(Resource, Debug, Clone)]
pub struct SettingsResource(pub Settings);
/// Persistence path for `SettingsResource`. `None` disables I/O (used in tests).
#[derive(Resource, Debug, Clone)]
pub struct SettingsStoragePath(pub Option<PathBuf>);
/// Whether the Settings panel is currently visible. Toggle with `O`.
#[derive(Resource, Debug, Clone, Default)]
pub struct SettingsScreen(pub bool);
/// Fired whenever settings change so consumers (audio, UI) can react.
#[derive(Event, Debug, Clone)]
pub struct SettingsChangedEvent(pub Settings);
/// Marker on the root Settings panel entity.
#[derive(Component, Debug)]
struct SettingsPanel;
/// Marks the `Text` node showing the live SFX volume value.
#[derive(Component, Debug)]
struct SfxVolumeText;
/// Marks the `Text` node showing the live music volume value.
#[derive(Component, Debug)]
struct MusicVolumeText;
/// Marks the `Text` node showing the current draw mode.
#[derive(Component, Debug)]
struct DrawModeText;
/// Marks the `Text` node showing the current theme.
#[derive(Component, Debug)]
struct ThemeText;
/// Marks the `Text` node showing the live sync status.
#[derive(Component, Debug)]
struct SyncStatusText;
/// Tags interactive buttons inside the Settings panel.
#[derive(Component, Debug)]
enum SettingsButton {
SfxDown,
SfxUp,
MusicDown,
MusicUp,
ToggleDrawMode,
ToggleTheme,
SyncNow,
Done,
}
/// Plugin that owns the settings lifecycle.
pub struct SettingsPlugin {
/// Path to `settings.json`. `None` in headless/test mode.
pub storage_path: Option<PathBuf>,
/// When `false`, panel spawn/despawn systems are not registered.
/// Use [`SettingsPlugin::headless`] for tests running under `MinimalPlugins`.
pub ui_enabled: bool,
}
impl Default for SettingsPlugin {
fn default() -> Self {
Self {
storage_path: settings_file_path(),
ui_enabled: true,
}
}
}
impl SettingsPlugin {
/// No persistence, no UI — safe to use under `MinimalPlugins` in tests.
pub fn headless() -> Self {
Self {
storage_path: None,
ui_enabled: false,
}
}
}
impl Plugin for SettingsPlugin {
fn build(&self, app: &mut App) {
let loaded = match &self.storage_path {
Some(path) => load_settings_from(path),
None => Settings::default(),
};
app.insert_resource(SettingsResource(loaded))
.insert_resource(SettingsStoragePath(self.storage_path.clone()))
.init_resource::<SettingsScreen>()
.add_event::<SettingsChangedEvent>()
.add_event::<ManualSyncRequestEvent>()
.add_systems(Update, (handle_volume_keys, toggle_settings_screen));
if self.ui_enabled {
app.add_systems(
Update,
(
sync_settings_panel_visibility,
handle_settings_buttons,
update_sync_status_text,
),
);
}
}
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
fn persist(path: &SettingsStoragePath, settings: &Settings) {
let Some(target) = &path.0 else { return };
if let Err(e) = save_settings_to(target, settings) {
warn!("failed to save settings: {e}");
}
}
// ---------------------------------------------------------------------------
// Systems
// ---------------------------------------------------------------------------
fn handle_volume_keys(
keys: Res<ButtonInput<KeyCode>>,
mut settings: ResMut<SettingsResource>,
path: Res<SettingsStoragePath>,
mut changed: EventWriter<SettingsChangedEvent>,
) {
let mut delta = 0.0_f32;
if keys.just_pressed(KeyCode::BracketLeft) {
delta -= SFX_STEP;
}
if keys.just_pressed(KeyCode::BracketRight) {
delta += SFX_STEP;
}
if delta == 0.0 {
return;
}
let before = settings.0.sfx_volume;
let after = settings.0.adjust_sfx_volume(delta);
if (before - after).abs() < f32::EPSILON {
return;
}
persist(&path, &settings.0);
changed.send(SettingsChangedEvent(settings.0.clone()));
}
/// Opens or closes the Settings panel when `O` is pressed.
fn toggle_settings_screen(
keys: Res<ButtonInput<KeyCode>>,
mut screen: ResMut<SettingsScreen>,
) {
if keys.just_pressed(KeyCode::KeyO) {
screen.0 = !screen.0;
}
}
/// Spawns the Settings panel when `SettingsScreen` becomes `true`;
/// despawns it when it becomes `false`.
fn sync_settings_panel_visibility(
screen: Res<SettingsScreen>,
panels: Query<Entity, With<SettingsPanel>>,
mut commands: Commands,
settings: Res<SettingsResource>,
sync_status: Option<Res<SyncStatusResource>>,
) {
if !screen.is_changed() {
return;
}
if screen.0 {
if panels.is_empty() {
let status_label = sync_status
.map(|s| sync_status_label(&s.0))
.unwrap_or_else(|| "Status: not configured".to_string());
spawn_settings_panel(&mut commands, &settings.0, &status_label);
}
} else {
for entity in &panels {
commands.entity(entity).despawn_recursive();
}
}
}
/// Keeps the sync-status text node current while the panel is open.
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();
}
}
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}"),
}
}
/// Reacts to button presses inside the Settings panel.
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
fn handle_settings_buttons(
interaction_query: Query<(&Interaction, &SettingsButton), Changed<Interaction>>,
mut settings: ResMut<SettingsResource>,
mut screen: ResMut<SettingsScreen>,
path: Res<SettingsStoragePath>,
mut changed: EventWriter<SettingsChangedEvent>,
mut manual_sync: EventWriter<ManualSyncRequestEvent>,
mut sfx_text: Query<&mut Text, (With<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>)>,
mut music_text: Query<&mut Text, (With<MusicVolumeText>, Without<SfxVolumeText>, Without<DrawModeText>, Without<ThemeText>)>,
mut draw_text: Query<&mut Text, (With<DrawModeText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<ThemeText>)>,
mut theme_text: Query<&mut Text, (With<ThemeText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>)>,
) {
for (interaction, button) in &interaction_query {
if *interaction != Interaction::Pressed {
continue;
}
match button {
SettingsButton::SfxDown => {
let before = settings.0.sfx_volume;
let after = settings.0.adjust_sfx_volume(-SFX_STEP);
if (before - after).abs() > f32::EPSILON {
persist(&path, &settings.0);
changed.send(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = sfx_text.get_single_mut() {
**t = format!("{:.2}", after);
}
}
}
SettingsButton::SfxUp => {
let before = settings.0.sfx_volume;
let after = settings.0.adjust_sfx_volume(SFX_STEP);
if (before - after).abs() > f32::EPSILON {
persist(&path, &settings.0);
changed.send(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = sfx_text.get_single_mut() {
**t = format!("{:.2}", after);
}
}
}
SettingsButton::MusicDown => {
let before = settings.0.music_volume;
let after = settings.0.adjust_music_volume(-SFX_STEP);
if (before - after).abs() > f32::EPSILON {
persist(&path, &settings.0);
changed.send(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = music_text.get_single_mut() {
**t = format!("{:.2}", after);
}
}
}
SettingsButton::MusicUp => {
let before = settings.0.music_volume;
let after = settings.0.adjust_music_volume(SFX_STEP);
if (before - after).abs() > f32::EPSILON {
persist(&path, &settings.0);
changed.send(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = music_text.get_single_mut() {
**t = format!("{:.2}", after);
}
}
}
SettingsButton::ToggleDrawMode => {
settings.0.draw_mode = match settings.0.draw_mode {
DrawMode::DrawOne => DrawMode::DrawThree,
DrawMode::DrawThree => DrawMode::DrawOne,
};
persist(&path, &settings.0);
changed.send(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = draw_text.get_single_mut() {
**t = draw_mode_label(&settings.0.draw_mode);
}
}
SettingsButton::ToggleTheme => {
settings.0.theme = match settings.0.theme {
Theme::Green => Theme::Blue,
Theme::Blue => Theme::Dark,
Theme::Dark => Theme::Green,
};
persist(&path, &settings.0);
changed.send(SettingsChangedEvent(settings.0.clone()));
if let Ok(mut t) = theme_text.get_single_mut() {
**t = theme_label(&settings.0.theme);
}
}
SettingsButton::SyncNow => {
manual_sync.send(ManualSyncRequestEvent);
}
SettingsButton::Done => {
screen.0 = false;
}
}
}
}
fn draw_mode_label(mode: &DrawMode) -> String {
match mode {
DrawMode::DrawOne => "Draw 1".into(),
DrawMode::DrawThree => "Draw 3".into(),
}
}
fn theme_label(theme: &Theme) -> String {
match theme {
Theme::Green => "Green".into(),
Theme::Blue => "Blue".into(),
Theme::Dark => "Dark".into(),
}
}
// ---------------------------------------------------------------------------
// UI construction
// ---------------------------------------------------------------------------
fn spawn_settings_panel(commands: &mut Commands, settings: &Settings, sync_status: &str) {
commands
.spawn((
SettingsPanel,
Node {
position_type: PositionType::Absolute,
left: Val::Percent(0.0),
top: Val::Percent(0.0),
width: Val::Percent(100.0),
height: Val::Percent(100.0),
flex_direction: FlexDirection::Column,
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
},
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.72)),
ZIndex(200),
))
.with_children(|root| {
// Inner card
root.spawn((
Node {
flex_direction: FlexDirection::Column,
padding: UiRect::all(Val::Px(28.0)),
row_gap: Val::Px(14.0),
min_width: Val::Px(340.0),
..default()
},
BackgroundColor(Color::srgb(0.11, 0.11, 0.14)),
BorderRadius::all(Val::Px(8.0)),
))
.with_children(|card| {
// Title
card.spawn((
Text::new("Settings"),
TextFont {
font_size: 30.0,
..default()
},
TextColor(Color::WHITE),
));
// --- Audio section ---
section_label(card, "Audio");
// SFX volume row
volume_row(card, "SFX Volume", settings.sfx_volume, SfxVolumeText,
SettingsButton::SfxDown, SettingsButton::SfxUp);
// Music volume row
volume_row(card, "Music Volume", settings.music_volume, MusicVolumeText,
SettingsButton::MusicDown, SettingsButton::MusicUp);
// --- Gameplay section ---
section_label(card, "Gameplay");
// Draw mode row
card.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: Val::Px(8.0),
..default()
})
.with_children(|row| {
row.spawn((
Text::new("Draw Mode"),
TextFont { font_size: 18.0, ..default() },
TextColor(Color::srgb(0.85, 0.85, 0.80)),
));
row.spawn((
DrawModeText,
Text::new(draw_mode_label(&settings.draw_mode)),
TextFont { font_size: 18.0, ..default() },
TextColor(Color::WHITE),
));
icon_button(row, "", SettingsButton::ToggleDrawMode);
});
// --- Appearance section ---
section_label(card, "Appearance");
// Theme row
card.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: Val::Px(8.0),
..default()
})
.with_children(|row| {
row.spawn((
Text::new("Theme"),
TextFont { font_size: 18.0, ..default() },
TextColor(Color::srgb(0.85, 0.85, 0.80)),
));
row.spawn((
ThemeText,
Text::new(theme_label(&settings.theme)),
TextFont { font_size: 18.0, ..default() },
TextColor(Color::WHITE),
));
icon_button(row, "", SettingsButton::ToggleTheme);
});
// --- Sync section ---
section_label(card, "Sync");
card.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: Val::Px(10.0),
..default()
})
.with_children(|row| {
row.spawn((
SyncStatusText,
Text::new(sync_status.to_string()),
TextFont { font_size: 16.0, ..default() },
TextColor(Color::srgb(0.65, 0.65, 0.70)),
));
// "Sync Now" button — hidden when SyncPlugin is not installed;
// visible because ManualSyncRequestEvent is always registered.
row.spawn((
SettingsButton::SyncNow,
Button,
Node {
padding: UiRect::axes(Val::Px(10.0), Val::Px(4.0)),
justify_content: JustifyContent::Center,
..default()
},
BackgroundColor(Color::srgb(0.20, 0.30, 0.45)),
BorderRadius::all(Val::Px(4.0)),
))
.with_children(|b| {
b.spawn((
Text::new("Sync Now"),
TextFont { font_size: 14.0, ..default() },
TextColor(Color::WHITE),
));
});
});
// Done button
card.spawn((
SettingsButton::Done,
Button,
Node {
padding: UiRect::axes(Val::Px(20.0), Val::Px(8.0)),
justify_content: JustifyContent::Center,
margin: UiRect::top(Val::Px(6.0)),
..default()
},
BackgroundColor(Color::srgb(0.22, 0.45, 0.22)),
BorderRadius::all(Val::Px(4.0)),
))
.with_children(|b| {
b.spawn((
Text::new("Done"),
TextFont {
font_size: 18.0,
..default()
},
TextColor(Color::WHITE),
));
});
});
});
}
fn section_label(parent: &mut ChildBuilder, title: &str) {
parent.spawn((
Text::new(title),
TextFont {
font_size: 14.0,
..default()
},
TextColor(Color::srgb(0.55, 0.75, 0.55)),
));
}
/// Generic volume row: `Label 0.80 [] [+]`
fn volume_row<Marker: Component>(
parent: &mut ChildBuilder,
label: &str,
value: f32,
marker: Marker,
btn_down: SettingsButton,
btn_up: SettingsButton,
) {
parent
.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: Val::Px(8.0),
..default()
})
.with_children(|row| {
row.spawn((
Text::new(label.to_string()),
TextFont { font_size: 18.0, ..default() },
TextColor(Color::srgb(0.85, 0.85, 0.80)),
));
row.spawn((
marker,
Text::new(format!("{:.2}", value)),
TextFont { font_size: 18.0, ..default() },
TextColor(Color::WHITE),
));
icon_button(row, "", btn_down);
icon_button(row, "+", btn_up);
});
}
fn icon_button(parent: &mut ChildBuilder, label: &str, action: SettingsButton) {
parent
.spawn((
action,
Button,
Node {
width: Val::Px(28.0),
height: Val::Px(28.0),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
},
BackgroundColor(Color::srgb(0.25, 0.25, 0.30)),
BorderRadius::all(Val::Px(4.0)),
))
.with_children(|b| {
b.spawn((
Text::new(label.to_string()),
TextFont {
font_size: 18.0,
..default()
},
TextColor(Color::WHITE),
));
});
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn headless_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(SettingsPlugin::headless());
app.init_resource::<ButtonInput<KeyCode>>();
app.update();
app
}
fn press(app: &mut App, key: KeyCode) {
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.release(key);
input.clear();
input.press(key);
}
#[test]
fn defaults_are_loaded() {
let app = headless_app();
assert_eq!(
app.world().resource::<SettingsResource>().0,
Settings::default()
);
}
#[test]
fn pressing_left_bracket_decreases_volume_and_emits_event() {
let mut app = headless_app();
let before = app.world().resource::<SettingsResource>().0.sfx_volume;
press(&mut app, KeyCode::BracketLeft);
app.update();
let after = app.world().resource::<SettingsResource>().0.sfx_volume;
assert!(after < before);
let events = app.world().resource::<Events<SettingsChangedEvent>>();
let mut cursor = events.get_cursor();
assert_eq!(cursor.read(events).count(), 1);
}
#[test]
fn pressing_right_bracket_increases_volume() {
let mut app = headless_app();
app.world_mut().resource_mut::<SettingsResource>().0.sfx_volume = 0.5;
press(&mut app, KeyCode::BracketRight);
app.update();
let after = app.world().resource::<SettingsResource>().0.sfx_volume;
assert!((after - 0.6).abs() < 1e-3);
}
#[test]
fn clamped_change_does_not_emit_event() {
let mut app = headless_app();
app.world_mut().resource_mut::<SettingsResource>().0.sfx_volume = 1.0;
press(&mut app, KeyCode::BracketRight);
app.update();
let events = app.world().resource::<Events<SettingsChangedEvent>>();
let mut cursor = events.get_cursor();
assert_eq!(cursor.read(events).count(), 0);
}
}