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>
696 lines
28 KiB
Rust
696 lines
28 KiB
Rust
//! Input handling for the Settings panel: button presses, keyboard
|
||
//! accelerators, focus attachment, and scrolling.
|
||
|
||
use super::*;
|
||
|
||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||
use bevy::ui::{ComputedNode, UiGlobalTransform};
|
||
use solitaire_core::DrawStockConfig;
|
||
use solitaire_data::{
|
||
AnimSpeed, REPLAY_MOVE_INTERVAL_STEP_SECS, TIME_BONUS_MULTIPLIER_STEP, TOOLTIP_DELAY_STEP_SECS,
|
||
settings::Theme,
|
||
};
|
||
|
||
use crate::events::{
|
||
DeleteAccountRequestEvent, InfoToastEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent,
|
||
SyncLogoutRequestEvent, ToggleSettingsRequestEvent,
|
||
};
|
||
use crate::ui_focus::{FocusGroup, Focusable, FocusedButton};
|
||
use crate::ui_modal::{ModalButton, ModalScrim};
|
||
use crate::ui_theme::SPACE_2;
|
||
|
||
pub(super) fn handle_volume_keys(
|
||
keys: Res<ButtonInput<KeyCode>>,
|
||
mut settings: ResMut<SettingsResource>,
|
||
path: Res<SettingsStoragePath>,
|
||
mut changed: MessageWriter<SettingsChangedEvent>,
|
||
mut toast: MessageWriter<InfoToastEvent>,
|
||
) {
|
||
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.write(SettingsChangedEvent(settings.0.clone()));
|
||
toast.write(InfoToastEvent(format!(
|
||
"SFX volume: {}%",
|
||
(after * 100.0).round() as i32
|
||
)));
|
||
}
|
||
|
||
/// Opens or closes the Settings panel — `O` keyboard accelerator or
|
||
/// `ToggleSettingsRequestEvent` from the HUD Menu popover. Esc closes
|
||
/// too (Phase C dismissal audit), but only when Settings is the
|
||
/// topmost modal — with sync-setup or the theme store stacked on top,
|
||
/// the stacked dialog owns Esc.
|
||
pub(super) fn toggle_settings_screen(
|
||
keys: Res<ButtonInput<KeyCode>>,
|
||
mut requests: MessageReader<ToggleSettingsRequestEvent>,
|
||
mut screen: ResMut<SettingsScreen>,
|
||
other_modal_scrims: Query<(), (With<ModalScrim>, Without<SettingsPanel>)>,
|
||
) {
|
||
let button_clicked = requests.read().count() > 0;
|
||
if keys.just_pressed(KeyCode::KeyO) || button_clicked {
|
||
screen.0 = !screen.0;
|
||
} else if keys.just_pressed(KeyCode::Escape) && screen.0 && other_modal_scrims.is_empty() {
|
||
screen.0 = false;
|
||
}
|
||
}
|
||
|
||
/// Reacts to button presses inside the Settings panel.
|
||
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
|
||
pub(super) fn handle_settings_buttons(
|
||
interaction_query: Query<(&Interaction, &SettingsButton), Changed<Interaction>>,
|
||
mut settings: ResMut<SettingsResource>,
|
||
mut screen: ResMut<SettingsScreen>,
|
||
path: Res<SettingsStoragePath>,
|
||
mut changed: MessageWriter<SettingsChangedEvent>,
|
||
mut sfx_text: Query<
|
||
&mut Text,
|
||
(
|
||
With<SfxVolumeText>,
|
||
Without<MusicVolumeText>,
|
||
Without<DrawModeText>,
|
||
Without<ThemeText>,
|
||
Without<AnimSpeedText>,
|
||
Without<ColorBlindText>,
|
||
Without<HighContrastText>,
|
||
Without<ReduceMotionText>,
|
||
),
|
||
>,
|
||
mut music_text: Query<
|
||
&mut Text,
|
||
(
|
||
With<MusicVolumeText>,
|
||
Without<SfxVolumeText>,
|
||
Without<DrawModeText>,
|
||
Without<ThemeText>,
|
||
Without<AnimSpeedText>,
|
||
Without<ColorBlindText>,
|
||
Without<HighContrastText>,
|
||
Without<ReduceMotionText>,
|
||
),
|
||
>,
|
||
mut draw_text: Query<
|
||
&mut Text,
|
||
(
|
||
With<DrawModeText>,
|
||
Without<SfxVolumeText>,
|
||
Without<MusicVolumeText>,
|
||
Without<ThemeText>,
|
||
Without<AnimSpeedText>,
|
||
Without<ColorBlindText>,
|
||
Without<HighContrastText>,
|
||
Without<ReduceMotionText>,
|
||
),
|
||
>,
|
||
mut theme_text: Query<
|
||
&mut Text,
|
||
(
|
||
With<ThemeText>,
|
||
Without<SfxVolumeText>,
|
||
Without<MusicVolumeText>,
|
||
Without<DrawModeText>,
|
||
Without<AnimSpeedText>,
|
||
Without<ColorBlindText>,
|
||
Without<HighContrastText>,
|
||
Without<ReduceMotionText>,
|
||
),
|
||
>,
|
||
mut anim_speed_text: Query<
|
||
&mut Text,
|
||
(
|
||
With<AnimSpeedText>,
|
||
Without<SfxVolumeText>,
|
||
Without<MusicVolumeText>,
|
||
Without<DrawModeText>,
|
||
Without<ThemeText>,
|
||
Without<ColorBlindText>,
|
||
Without<HighContrastText>,
|
||
Without<ReduceMotionText>,
|
||
),
|
||
>,
|
||
mut color_blind_text: Query<
|
||
&mut Text,
|
||
(
|
||
With<ColorBlindText>,
|
||
Without<SfxVolumeText>,
|
||
Without<MusicVolumeText>,
|
||
Without<DrawModeText>,
|
||
Without<ThemeText>,
|
||
Without<AnimSpeedText>,
|
||
Without<HighContrastText>,
|
||
Without<ReduceMotionText>,
|
||
),
|
||
>,
|
||
mut high_contrast_text: Query<
|
||
&mut Text,
|
||
(
|
||
With<HighContrastText>,
|
||
Without<SfxVolumeText>,
|
||
Without<MusicVolumeText>,
|
||
Without<DrawModeText>,
|
||
Without<ThemeText>,
|
||
Without<AnimSpeedText>,
|
||
Without<ColorBlindText>,
|
||
Without<ReduceMotionText>,
|
||
),
|
||
>,
|
||
mut reduce_motion_text: Query<
|
||
&mut Text,
|
||
(
|
||
With<ReduceMotionText>,
|
||
Without<SfxVolumeText>,
|
||
Without<MusicVolumeText>,
|
||
Without<DrawModeText>,
|
||
Without<ThemeText>,
|
||
Without<AnimSpeedText>,
|
||
Without<ColorBlindText>,
|
||
Without<HighContrastText>,
|
||
),
|
||
>,
|
||
) {
|
||
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.write(SettingsChangedEvent(settings.0.clone()));
|
||
if let Ok(mut t) = sfx_text.single_mut() {
|
||
**t = format!("{after:.2}");
|
||
}
|
||
}
|
||
}
|
||
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.write(SettingsChangedEvent(settings.0.clone()));
|
||
if let Ok(mut t) = sfx_text.single_mut() {
|
||
**t = format!("{after:.2}");
|
||
}
|
||
}
|
||
}
|
||
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.write(SettingsChangedEvent(settings.0.clone()));
|
||
if let Ok(mut t) = music_text.single_mut() {
|
||
**t = format!("{after:.2}");
|
||
}
|
||
}
|
||
}
|
||
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.write(SettingsChangedEvent(settings.0.clone()));
|
||
if let Ok(mut t) = music_text.single_mut() {
|
||
**t = format!("{after:.2}");
|
||
}
|
||
}
|
||
}
|
||
SettingsButton::ToggleDrawMode => {
|
||
settings.0.draw_mode = match settings.0.draw_mode {
|
||
DrawStockConfig::DrawOne => DrawStockConfig::DrawThree,
|
||
DrawStockConfig::DrawThree => DrawStockConfig::DrawOne,
|
||
};
|
||
persist(&path, &settings.0);
|
||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||
if let Ok(mut t) = draw_text.single_mut() {
|
||
**t = draw_mode_label(&settings.0.draw_mode);
|
||
}
|
||
}
|
||
SettingsButton::CycleAnimSpeed => {
|
||
settings.0.animation_speed = match settings.0.animation_speed {
|
||
AnimSpeed::Normal => AnimSpeed::Fast,
|
||
AnimSpeed::Fast => AnimSpeed::Instant,
|
||
AnimSpeed::Instant => AnimSpeed::Normal,
|
||
};
|
||
persist(&path, &settings.0);
|
||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||
if let Ok(mut t) = anim_speed_text.single_mut() {
|
||
**t = anim_speed_label(&settings.0.animation_speed);
|
||
}
|
||
}
|
||
SettingsButton::TooltipDelayDown => {
|
||
let before = settings.0.tooltip_delay_secs;
|
||
let after = settings.0.adjust_tooltip_delay(-TOOLTIP_DELAY_STEP_SECS);
|
||
if (before - after).abs() > f32::EPSILON {
|
||
persist(&path, &settings.0);
|
||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||
// The Text node is refreshed by `update_tooltip_delay_text`
|
||
// on the next frame via `settings.is_changed()`.
|
||
}
|
||
}
|
||
SettingsButton::TooltipDelayUp => {
|
||
let before = settings.0.tooltip_delay_secs;
|
||
let after = settings.0.adjust_tooltip_delay(TOOLTIP_DELAY_STEP_SECS);
|
||
if (before - after).abs() > f32::EPSILON {
|
||
persist(&path, &settings.0);
|
||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||
}
|
||
}
|
||
SettingsButton::TimeBonusDown => {
|
||
let before = settings.0.time_bonus_multiplier;
|
||
let after = settings
|
||
.0
|
||
.adjust_time_bonus_multiplier(-TIME_BONUS_MULTIPLIER_STEP);
|
||
if (before - after).abs() > f32::EPSILON {
|
||
persist(&path, &settings.0);
|
||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||
// The Text node is refreshed by
|
||
// `update_time_bonus_multiplier_text` on the next
|
||
// frame via `settings.is_changed()`.
|
||
}
|
||
}
|
||
SettingsButton::TimeBonusUp => {
|
||
let before = settings.0.time_bonus_multiplier;
|
||
let after = settings
|
||
.0
|
||
.adjust_time_bonus_multiplier(TIME_BONUS_MULTIPLIER_STEP);
|
||
if (before - after).abs() > f32::EPSILON {
|
||
persist(&path, &settings.0);
|
||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||
}
|
||
}
|
||
SettingsButton::ReplayMoveIntervalDown => {
|
||
let before = settings.0.replay_move_interval_secs;
|
||
let after = settings
|
||
.0
|
||
.adjust_replay_move_interval(-REPLAY_MOVE_INTERVAL_STEP_SECS);
|
||
if (before - after).abs() > f32::EPSILON {
|
||
persist(&path, &settings.0);
|
||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||
// The Text node is refreshed by
|
||
// `update_replay_move_interval_text` on the next
|
||
// frame via `settings.is_changed()`.
|
||
}
|
||
}
|
||
SettingsButton::ReplayMoveIntervalUp => {
|
||
let before = settings.0.replay_move_interval_secs;
|
||
let after = settings
|
||
.0
|
||
.adjust_replay_move_interval(REPLAY_MOVE_INTERVAL_STEP_SECS);
|
||
if (before - after).abs() > f32::EPSILON {
|
||
persist(&path, &settings.0);
|
||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||
}
|
||
}
|
||
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.write(SettingsChangedEvent(settings.0.clone()));
|
||
if let Ok(mut t) = theme_text.single_mut() {
|
||
**t = theme_label(&settings.0.theme);
|
||
}
|
||
}
|
||
SettingsButton::ToggleColorBlind => {
|
||
settings.0.color_blind_mode = !settings.0.color_blind_mode;
|
||
persist(&path, &settings.0);
|
||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||
if let Ok(mut t) = color_blind_text.single_mut() {
|
||
**t = color_blind_label(settings.0.color_blind_mode);
|
||
}
|
||
}
|
||
SettingsButton::ToggleHighContrast => {
|
||
settings.0.high_contrast_mode = !settings.0.high_contrast_mode;
|
||
persist(&path, &settings.0);
|
||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||
if let Ok(mut t) = high_contrast_text.single_mut() {
|
||
**t = on_off_label(settings.0.high_contrast_mode);
|
||
}
|
||
}
|
||
SettingsButton::ToggleReduceMotion => {
|
||
settings.0.reduce_motion_mode = !settings.0.reduce_motion_mode;
|
||
persist(&path, &settings.0);
|
||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||
if let Ok(mut t) = reduce_motion_text.single_mut() {
|
||
**t = on_off_label(settings.0.reduce_motion_mode);
|
||
}
|
||
}
|
||
SettingsButton::ToggleReduceTransparency => {
|
||
settings.0.reduce_transparency_mode = !settings.0.reduce_transparency_mode;
|
||
persist(&path, &settings.0);
|
||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||
// Text refreshed by `update_reduce_transparency_text`; the
|
||
// glass chrome itself follows via `update_glass_surfaces`
|
||
// next frame.
|
||
}
|
||
SettingsButton::ToggleTouchInputMode => {
|
||
use solitaire_data::settings::TouchInputMode;
|
||
settings.0.touch_input_mode = match settings.0.touch_input_mode {
|
||
TouchInputMode::OneTap => TouchInputMode::TapToSelect,
|
||
TouchInputMode::TapToSelect => TouchInputMode::OneTap,
|
||
};
|
||
persist(&path, &settings.0);
|
||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||
// Text refreshed by `update_touch_input_mode_text` next frame.
|
||
}
|
||
SettingsButton::CycleUiScale => {
|
||
settings.0.ui_scale = next_ui_scale(settings.0.ui_scale);
|
||
persist(&path, &settings.0);
|
||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||
// Text refreshed by `update_ui_scale_text`; the live
|
||
// `bevy::ui::UiScale` resource follows via
|
||
// `sync_ui_scale_resource` next frame.
|
||
}
|
||
SettingsButton::ToggleWinnableDealsOnly => {
|
||
settings.0.winnable_deals_only = !settings.0.winnable_deals_only;
|
||
persist(&path, &settings.0);
|
||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||
// The Text node is refreshed by `update_winnable_deals_only_text`
|
||
// on the next frame via `settings.is_changed()`.
|
||
}
|
||
SettingsButton::ToggleAnalytics => {
|
||
settings.0.analytics_enabled = !settings.0.analytics_enabled;
|
||
persist(&path, &settings.0);
|
||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||
// Text refreshed by `update_analytics_enabled_text` next frame.
|
||
}
|
||
SettingsButton::ToggleSmartDefaultSize => {
|
||
settings.0.disable_smart_default_size = !settings.0.disable_smart_default_size;
|
||
persist(&path, &settings.0);
|
||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||
// The Text node is refreshed by
|
||
// `update_smart_default_size_text` next frame. The
|
||
// sizer system is gated only at startup, so flipping
|
||
// this mid-session takes effect on the next launch —
|
||
// documented on the field in `solitaire_data::Settings`.
|
||
}
|
||
SettingsButton::SelectCardBack(idx) => {
|
||
settings.0.selected_card_back = *idx;
|
||
persist(&path, &settings.0);
|
||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||
}
|
||
SettingsButton::SelectBackground(idx) => {
|
||
settings.0.selected_background = *idx;
|
||
persist(&path, &settings.0);
|
||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||
}
|
||
SettingsButton::SelectTheme(theme_id) => {
|
||
if settings.0.selected_theme_id != *theme_id {
|
||
settings.0.selected_theme_id = theme_id.clone();
|
||
persist(&path, &settings.0);
|
||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||
}
|
||
}
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
SettingsButton::ScanThemes => {
|
||
// Handled by `handle_scan_themes`.
|
||
}
|
||
SettingsButton::SyncNow
|
||
| SettingsButton::ConnectSync
|
||
| SettingsButton::DisconnectSync
|
||
| SettingsButton::DeleteAccount => {
|
||
// Handled by `handle_sync_buttons`.
|
||
}
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
SettingsButton::OpenThemeStore => {
|
||
// Handled by `handle_sync_buttons`.
|
||
}
|
||
SettingsButton::ExportData | SettingsButton::ImportData => {
|
||
// Handled by `handle_sync_buttons`.
|
||
}
|
||
SettingsButton::Done => {
|
||
screen.0 = false;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Switches the active Settings tab when a chip is pressed. The
|
||
/// rebuild itself happens in `rebuild_panel_on_tab_change`, driven by
|
||
/// change detection on [`ActiveSettingsTab`].
|
||
pub(super) fn handle_tab_buttons(
|
||
interactions: Query<(&Interaction, &SettingsTabButton), Changed<Interaction>>,
|
||
mut active: ResMut<ActiveSettingsTab>,
|
||
) {
|
||
for (interaction, chip) in &interactions {
|
||
if *interaction != Interaction::Pressed {
|
||
continue;
|
||
}
|
||
// Compare before writing so re-clicking the active chip doesn't
|
||
// trip change detection and rebuild the panel for nothing.
|
||
if active.0 != chip.0 {
|
||
active.0 = chip.0;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Handles sync-related settings buttons: Sync Now, Connect, Disconnect,
|
||
/// Delete Account, and the Phase M Export/Import data pair. Split from
|
||
/// `handle_settings_buttons` to stay within Bevy's 16-parameter system
|
||
/// limit.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub(super) fn handle_sync_buttons(
|
||
interaction_query: Query<(&Interaction, &SettingsButton), Changed<Interaction>>,
|
||
mut manual_sync: MessageWriter<ManualSyncRequestEvent>,
|
||
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
|
||
mut logout_sync: MessageWriter<SyncLogoutRequestEvent>,
|
||
mut delete_account: MessageWriter<DeleteAccountRequestEvent>,
|
||
mut export_data: MessageWriter<crate::events::DataExportRequestEvent>,
|
||
mut import_data: MessageWriter<crate::events::DataImportRequestEvent>,
|
||
#[cfg(not(target_arch = "wasm32"))] mut open_theme_store: MessageWriter<
|
||
crate::events::ThemeStoreOpenRequestEvent,
|
||
>,
|
||
mut screen: ResMut<SettingsScreen>,
|
||
) {
|
||
for (interaction, button) in &interaction_query {
|
||
if *interaction != Interaction::Pressed {
|
||
continue;
|
||
}
|
||
match button {
|
||
SettingsButton::SyncNow => {
|
||
manual_sync.write(ManualSyncRequestEvent);
|
||
}
|
||
SettingsButton::ConnectSync => {
|
||
// Close settings before the sync-setup modal opens so the
|
||
// guard in open_sync_setup_modal doesn't block on our own scrim.
|
||
screen.0 = false;
|
||
configure_sync.write(SyncConfigureRequestEvent);
|
||
}
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
SettingsButton::OpenThemeStore => {
|
||
// Same shape as ConnectSync: close settings first so the
|
||
// store modal's own-scrim guard doesn't block.
|
||
screen.0 = false;
|
||
open_theme_store.write(crate::events::ThemeStoreOpenRequestEvent);
|
||
}
|
||
SettingsButton::DisconnectSync => {
|
||
logout_sync.write(SyncLogoutRequestEvent);
|
||
}
|
||
SettingsButton::DeleteAccount => {
|
||
delete_account.write(DeleteAccountRequestEvent);
|
||
}
|
||
SettingsButton::ExportData => {
|
||
export_data.write(crate::events::DataExportRequestEvent);
|
||
}
|
||
SettingsButton::ImportData => {
|
||
import_data.write(crate::events::DataImportRequestEvent);
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Auto-attaches [`Focusable`] to every bespoke Settings button — icon
|
||
/// buttons (volume +/−, toggle, cycle), swatch buttons (card-back,
|
||
/// background pickers), and the "Sync Now" button. The "Done" button is
|
||
/// already tagged by `attach_focusable_to_modal_buttons` (it carries
|
||
/// [`ModalButton`]) and is filtered out here.
|
||
///
|
||
/// Walks ancestors via [`ChildOf`] to find the [`ModalScrim`] that owns
|
||
/// the panel so the new [`Focusable`]'s group is bound to that scrim —
|
||
/// same defensive shape as the Phase 1 / 2 attach systems.
|
||
#[allow(clippy::type_complexity)]
|
||
pub(super) fn attach_focusable_to_settings_buttons(
|
||
mut commands: Commands,
|
||
new_buttons: Query<
|
||
(Entity, &SettingsButton),
|
||
(With<Button>, Without<Focusable>, Without<ModalButton>),
|
||
>,
|
||
parents: Query<&ChildOf>,
|
||
scrims: Query<(), With<ModalScrim>>,
|
||
) {
|
||
for (button, settings_button) in &new_buttons {
|
||
let mut current = button;
|
||
let mut scrim_entity: Option<Entity> = None;
|
||
for _ in 0..32 {
|
||
if scrims.get(current).is_ok() {
|
||
scrim_entity = Some(current);
|
||
break;
|
||
}
|
||
match parents.get(current) {
|
||
Ok(parent) => current = parent.parent(),
|
||
Err(_) => break,
|
||
}
|
||
}
|
||
if let Some(scrim) = scrim_entity {
|
||
commands.entity(button).insert(Focusable {
|
||
group: FocusGroup::Modal(scrim),
|
||
order: settings_button.focus_order(),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Vertical padding (logical px) added around the focused button when
|
||
/// scrolling it into view. Keeps the focus ring's halo visible above /
|
||
/// below the viewport edge.
|
||
const FOCUS_SCROLL_PADDING: f32 = SPACE_2;
|
||
|
||
/// When the focused entity sits outside the visible Settings scroll
|
||
/// viewport, adjust the viewport's [`ScrollPosition`] so the button is
|
||
/// fully visible. No-op when:
|
||
///
|
||
/// - `FocusedButton` is `None`
|
||
/// - the focused entity has no [`UiGlobalTransform`] / [`ComputedNode`]
|
||
/// (e.g. a freshly-spawned modal hasn't laid out yet)
|
||
/// - the focused entity is not a descendant of the
|
||
/// [`SettingsPanelScrollable`] container
|
||
///
|
||
/// The viewport's visible Y range is `[scroll_y, scroll_y +
|
||
/// viewport_height]` in physical pixels (matching `ComputedNode.size`).
|
||
/// The focused button's vertical extent is computed from its
|
||
/// `UiGlobalTransform.translation.y` (centre, physical) ± half its
|
||
/// `ComputedNode.size.y`. Because the scroll container's local
|
||
/// coordinates run [0, content_height] and the visible window is
|
||
/// [scroll_y, scroll_y + viewport], we convert the button's window-
|
||
/// space Y to container-local Y by subtracting the container's window-
|
||
/// space top and adding the current scroll offset.
|
||
#[allow(clippy::type_complexity)]
|
||
pub(super) fn scroll_focus_into_view(
|
||
focused: Res<FocusedButton>,
|
||
parents: Query<&ChildOf>,
|
||
nodes: Query<(&UiGlobalTransform, &ComputedNode)>,
|
||
mut containers: Query<
|
||
(&mut ScrollPosition, &UiGlobalTransform, &ComputedNode),
|
||
With<SettingsPanelScrollable>,
|
||
>,
|
||
) {
|
||
let Some(target) = focused.0 else { return };
|
||
// Gather button geometry.
|
||
let Ok((target_transform, target_node)) = nodes.get(target) else {
|
||
return;
|
||
};
|
||
|
||
// Walk ancestors looking for the scroll container. Bounded to keep
|
||
// a malformed hierarchy from hanging the system.
|
||
let mut current = target;
|
||
let mut container_entity: Option<Entity> = None;
|
||
for _ in 0..32 {
|
||
if containers.get(current).is_ok() {
|
||
container_entity = Some(current);
|
||
break;
|
||
}
|
||
match parents.get(current) {
|
||
Ok(parent) => current = parent.parent(),
|
||
Err(_) => break,
|
||
}
|
||
}
|
||
let Some(container) = container_entity else {
|
||
return;
|
||
};
|
||
|
||
let Ok((mut scroll, container_transform, container_node)) = containers.get_mut(container)
|
||
else {
|
||
return;
|
||
};
|
||
|
||
// Geometry is reported in physical pixels by `ComputedNode.size` and
|
||
// `UiGlobalTransform.translation`. `ScrollPosition` is in logical px,
|
||
// so convert via `inverse_scale_factor` before we write.
|
||
let inv = target_node.inverse_scale_factor;
|
||
let target_height = target_node.size().y;
|
||
let target_centre_y = target_transform.translation.y;
|
||
let target_top = target_centre_y - target_height * 0.5;
|
||
let target_bottom = target_centre_y + target_height * 0.5;
|
||
|
||
let container_height = container_node.size().y;
|
||
let container_top = container_transform.translation.y - container_height * 0.5;
|
||
|
||
// Convert button window-space Y to container-local Y. The container
|
||
// is currently scrolled by `scroll.0.y` *logical* pixels — multiply
|
||
// by physical-per-logical to compare with physical pixel extents.
|
||
let scroll_phys = scroll.0.y / inv.max(f32::EPSILON);
|
||
let viewport_top = container_top + scroll_phys;
|
||
let viewport_bottom = viewport_top + container_height;
|
||
|
||
// Layout may not have run yet (zero size on first frame) — no
|
||
// sensible scroll target until the container has dimensions.
|
||
if container_height <= 0.0 {
|
||
return;
|
||
}
|
||
|
||
let pad_phys = FOCUS_SCROLL_PADDING / inv.max(f32::EPSILON);
|
||
if target_top < viewport_top {
|
||
// Button extends above the viewport — scroll up.
|
||
let new_top = target_top - pad_phys;
|
||
let delta = new_top - viewport_top;
|
||
scroll.0.y = ((scroll_phys + delta) * inv).max(0.0);
|
||
} else if target_bottom > viewport_bottom {
|
||
// Button extends below the viewport — scroll down.
|
||
let new_bottom = target_bottom + pad_phys;
|
||
let delta = new_bottom - viewport_bottom;
|
||
scroll.0.y = ((scroll_phys + delta) * inv).max(0.0);
|
||
}
|
||
}
|
||
|
||
/// Scrolls the settings panel inner card in response to mouse-wheel events.
|
||
///
|
||
/// `offset_y` increases downward (0 = top of content). Scrolling down (ev.y < 0)
|
||
/// adds to the offset; scrolling up subtracts. Clamped to >= 0 so it never
|
||
/// scrolls past the top.
|
||
pub(super) fn scroll_settings_panel(
|
||
mut scroll_evr: MessageReader<MouseWheel>,
|
||
screen: Res<SettingsScreen>,
|
||
mut scrollables: Query<&mut ScrollPosition, With<SettingsPanelScrollable>>,
|
||
) {
|
||
if !screen.0 {
|
||
scroll_evr.clear();
|
||
return;
|
||
}
|
||
let delta_y: f32 = scroll_evr
|
||
.read()
|
||
.map(|ev| match ev.unit {
|
||
MouseScrollUnit::Line => ev.y * 50.0,
|
||
MouseScrollUnit::Pixel => ev.y,
|
||
})
|
||
.sum();
|
||
if delta_y == 0.0 {
|
||
return;
|
||
}
|
||
for mut sp in scrollables.iter_mut() {
|
||
sp.0.y = (sp.0.y - delta_y).max(0.0);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Window geometry persistence
|
||
// ---------------------------------------------------------------------------
|