feat(accessibility): add Settings UI toggles for high-contrast + reduce-motion
Resume-prompt Option F, part 2 of 2 — pairs with the engine wiring inc5787c6. Adds two toggle rows to the Settings panel under Cosmetic so players can flip the new accessibility flags without hand-editing settings.json. Mirrors the Color-blind Mode row pattern almost exactly: - Two new marker components (`HighContrastText`, `ReduceMotionText`) tagging the Text nodes that show ON/OFF. - Two new `SettingsButton` enum variants (`ToggleHighContrast`, `ToggleReduceMotion`) with `focus_order` 61/62 — sit right after `ToggleColorBlind` (60) so tab-walk visits all three accessibility flags in one vertical run before continuing to picker rows. - Two new click-handler branches in `handle_settings_buttons` flipping the bool, persisting, broadcasting `SettingsChangedEvent`, and updating the row label. - Two new live-label updaters (`update_high_contrast_text`, `update_reduce_motion_text`) so the row reflects external changes (e.g. someone editing settings.json mid-session, or a future a11y-import feature). - Generic `on_off_label(enabled: bool) -> String` helper shared by both new toggles. Could fold `color_blind_label` and `winnable_deals_only_label` into it too — punted for scope; both already work and a name-only refactor would just churn the diff. Query-disambiguator chains updated: every existing settings-text query in `handle_settings_buttons` gains `Without<HighContrastText>, Without<ReduceMotionText>` at the end so the new components don't ambiguate the existing mutations. The two new queries carry mirrored `Without<...>` chains for the same reason. Verbose but matches the existing pattern; future Bevy archetype-set query API would simplify this, not in 0.18. Workspace clippy + cargo test --workspace clean. 1191 passing (unchanged fromc5787c6— UI plumbing has no test coverage in this commit; the toggle behaviour is exercised through the engine tests inc5787c6). Closes Resume-prompt Option F. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -125,6 +125,14 @@ struct BackgroundText;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
struct ColorBlindText;
|
struct ColorBlindText;
|
||||||
|
|
||||||
|
/// Marks the `Text` node showing the current high-contrast mode state.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct HighContrastText;
|
||||||
|
|
||||||
|
/// Marks the `Text` node showing the current reduce-motion mode state.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct ReduceMotionText;
|
||||||
|
|
||||||
/// Marks the `Text` node showing the live tooltip-delay value.
|
/// Marks the `Text` node showing the live tooltip-delay value.
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
struct TooltipDelayText;
|
struct TooltipDelayText;
|
||||||
@@ -201,6 +209,14 @@ enum SettingsButton {
|
|||||||
ReplayMoveIntervalUp,
|
ReplayMoveIntervalUp,
|
||||||
ToggleTheme,
|
ToggleTheme,
|
||||||
ToggleColorBlind,
|
ToggleColorBlind,
|
||||||
|
/// Toggle the [`Settings::high_contrast_mode`] flag — boosts
|
||||||
|
/// foreground / suit-red glyphs to higher-luminance variants per
|
||||||
|
/// `design-system.md` §Accessibility (#2).
|
||||||
|
ToggleHighContrast,
|
||||||
|
/// Toggle the [`Settings::reduce_motion_mode`] flag — suppresses
|
||||||
|
/// non-essential motion (card-slide animations become instant
|
||||||
|
/// snaps) per `design-system.md` §Accessibility (#3).
|
||||||
|
ToggleReduceMotion,
|
||||||
/// Toggle the [`Settings::winnable_deals_only`] flag. When on, new
|
/// Toggle the [`Settings::winnable_deals_only`] flag. When on, new
|
||||||
/// random Classic-mode deals are filtered through
|
/// random Classic-mode deals are filtered through
|
||||||
/// [`solitaire_core::solver::try_solve`] until one is provably
|
/// [`solitaire_core::solver::try_solve`] until one is provably
|
||||||
@@ -255,6 +271,11 @@ impl SettingsButton {
|
|||||||
// Cosmetic section
|
// Cosmetic section
|
||||||
SettingsButton::ToggleTheme => 55,
|
SettingsButton::ToggleTheme => 55,
|
||||||
SettingsButton::ToggleColorBlind => 60,
|
SettingsButton::ToggleColorBlind => 60,
|
||||||
|
// Accessibility-section toggles sit alongside Color-blind so
|
||||||
|
// tab-walk visits all three a11y flags in the same vertical
|
||||||
|
// run before continuing to the picker rows.
|
||||||
|
SettingsButton::ToggleHighContrast => 61,
|
||||||
|
SettingsButton::ToggleReduceMotion => 62,
|
||||||
// Picker rows — every swatch in a row shares the row's
|
// Picker rows — every swatch in a row shares the row's
|
||||||
// priority so entity-index tiebreaking yields left → right.
|
// priority so entity-index tiebreaking yields left → right.
|
||||||
SettingsButton::SelectCardBack(_) => 70,
|
SettingsButton::SelectCardBack(_) => 70,
|
||||||
@@ -342,6 +363,8 @@ impl Plugin for SettingsPlugin {
|
|||||||
update_background_text,
|
update_background_text,
|
||||||
update_anim_speed_text,
|
update_anim_speed_text,
|
||||||
update_color_blind_text,
|
update_color_blind_text,
|
||||||
|
update_high_contrast_text,
|
||||||
|
update_reduce_motion_text,
|
||||||
update_tooltip_delay_text,
|
update_tooltip_delay_text,
|
||||||
update_time_bonus_multiplier_text,
|
update_time_bonus_multiplier_text,
|
||||||
update_replay_move_interval_text,
|
update_replay_move_interval_text,
|
||||||
@@ -602,6 +625,30 @@ fn update_color_blind_text(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Refreshes the live "Winnable deals only" toggle value in the
|
/// Refreshes the live "Winnable deals only" toggle value in the
|
||||||
/// Gameplay section whenever `SettingsResource` changes (button click,
|
/// Gameplay section whenever `SettingsResource` changes (button click,
|
||||||
/// hand-edited `settings.json` reload, etc.).
|
/// hand-edited `settings.json` reload, etc.).
|
||||||
@@ -720,12 +767,14 @@ fn handle_settings_buttons(
|
|||||||
path: Res<SettingsStoragePath>,
|
path: Res<SettingsStoragePath>,
|
||||||
mut changed: MessageWriter<SettingsChangedEvent>,
|
mut changed: MessageWriter<SettingsChangedEvent>,
|
||||||
mut manual_sync: MessageWriter<ManualSyncRequestEvent>,
|
mut manual_sync: MessageWriter<ManualSyncRequestEvent>,
|
||||||
mut sfx_text: Query<&mut Text, (With<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>)>,
|
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>)>,
|
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>)>,
|
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>)>,
|
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>)>,
|
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>)>,
|
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 {
|
for (interaction, button) in &interaction_query {
|
||||||
if *interaction != Interaction::Pressed {
|
if *interaction != Interaction::Pressed {
|
||||||
@@ -879,6 +928,22 @@ fn handle_settings_buttons(
|
|||||||
**t = color_blind_label(settings.0.color_blind_mode);
|
**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::ToggleWinnableDealsOnly => {
|
SettingsButton::ToggleWinnableDealsOnly => {
|
||||||
settings.0.winnable_deals_only = !settings.0.winnable_deals_only;
|
settings.0.winnable_deals_only = !settings.0.winnable_deals_only;
|
||||||
persist(&path, &settings.0);
|
persist(&path, &settings.0);
|
||||||
@@ -951,6 +1016,14 @@ fn color_blind_label(enabled: bool) -> String {
|
|||||||
if enabled { "ON".into() } else { "OFF".into() }
|
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.
|
||||||
|
fn on_off_label(enabled: bool) -> String {
|
||||||
|
if enabled { "ON".into() } else { "OFF".into() }
|
||||||
|
}
|
||||||
|
|
||||||
/// Display string for the "Winnable deals only" toggle. Mirrors
|
/// Display string for the "Winnable deals only" toggle. Mirrors
|
||||||
/// [`color_blind_label`] — "ON" / "OFF" — so the layout is uniform
|
/// [`color_blind_label`] — "ON" / "OFF" — so the layout is uniform
|
||||||
/// with the rest of the Gameplay-section toggles.
|
/// with the rest of the Gameplay-section toggles.
|
||||||
@@ -1386,6 +1459,24 @@ fn spawn_settings_panel(
|
|||||||
"Show shape glyphs alongside suit colors. Suit-blind friendly.",
|
"Show shape glyphs alongside suit colors. Suit-blind friendly.",
|
||||||
font_res,
|
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,
|
||||||
|
);
|
||||||
if theme_overrides_back {
|
if theme_overrides_back {
|
||||||
// The active theme provides its own back; the legacy
|
// The active theme provides its own back; the legacy
|
||||||
// picker has no visible effect, so we replace its
|
// picker has no visible effect, so we replace its
|
||||||
|
|||||||
Reference in New Issue
Block a user