fix(engine): scroll the modals whose content overflows the viewport

Smoke-test report: the Achievements list isn't scrollable. With 19
achievements the panel overflows the modal at the 800x600 minimum
window and the bottom rows are clipped. The same problem applies to
several other modals whose content has grown over the v0.13–v0.15
rounds.

Mirrors the existing SettingsPanelScrollable pattern from
settings_plugin: each modal's body Node gets Overflow::scroll_y()
plus a max_height (Val::Vh(70.0) for most, Val::Vh(50.0) for the
leaderboard's variable-length ranking section), a marker component
so the scroll system can find it, and a sibling system that routes
MouseWheel events into the body's ScrollPosition.

Five modals fixed:
- Achievements: 19 rows clearly overflow; AchievementsScrollable +
  scroll_achievements_panel.
- Help: ~28 reference rows overflow at 800x600; HelpScrollable +
  scroll_help_panel.
- Stats: 8-cell primary grid + per-mode bests + progression +
  weekly goals + unlocks + Time Attack readout + replay caption is
  enough content to overflow once the player has any progress;
  StatsScrollable + scroll_stats_panel.
- Profile: Sync + Progression + 14-day calendar + up to 18
  unlocked achievements + Stats summary overflows once a few
  achievements unlock; ProfileScrollable + scroll_profile_panel.
- Leaderboard: 10-row cap is at the edge of overflow on 800x600
  with long display names; LeaderboardScrollable +
  scroll_leaderboard_panel (max_height = 50vh — the ranking section
  is the only variable-length part).

Home modal NOT scrolled — five mode cards plus a Cancel button
were sized to fit at 800x600 by design and adding scroll there
would clutter the launcher.

Five new tests pin the contract: each modal's body has the
scrollable marker, a non-default max_height, and Overflow::scroll_y.

Defer-list (small UX nits surfaced during the sweep, not fixed
here):
- Modal close-on-click-outside is missing across the board; would
  need Interaction on ModalScrim in ui_modal.
- ModalButton hover doesn't set a pointer cursor.
- Tab focus on modal open is initialised on the next frame instead
  of the same frame; first Tab press selects rather than focus
  already being on the primary.

These are bigger touches than the scroll fix and don't fit a
30-LOC budget; surfacing for a follow-up round.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-05 23:30:04 +00:00
parent 89699a8a86
commit 7a3032b74c
5 changed files with 997 additions and 489 deletions
+140 -48
View File
@@ -4,6 +4,7 @@
//! is an optional accelerator. Listed shortcuts are grouped by intent —
//! gameplay, modes, and overlays.
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::prelude::*;
use crate::events::HelpRequestEvent;
@@ -24,6 +25,16 @@ pub struct HelpScreen;
#[derive(Component, Debug)]
pub struct HelpCloseButton;
/// Marker on the scrollable body Node inside the Help modal.
///
/// The controls reference is six sections totalling ~28 rows, which
/// overflows the modal on the 800x600 minimum window. This marker tags
/// the inner container that carries `Overflow::scroll_y()` plus a
/// `max_height` constraint so every row stays reachable. Mirrors the
/// `SettingsPanelScrollable` pattern.
#[derive(Component, Debug)]
pub struct HelpScrollable;
/// Spawns and despawns the help / controls overlay shown when the player
/// clicks the "Help" HUD button or presses `F1`. All hotkeys and gesture
/// guides live here.
@@ -32,7 +43,14 @@ pub struct HelpPlugin;
impl Plugin for HelpPlugin {
fn build(&self, app: &mut App) {
app.add_message::<HelpRequestEvent>()
.add_systems(Update, (toggle_help_screen, handle_help_close_button));
// `MouseWheel` is emitted by Bevy's input plugin under
// `DefaultPlugins`; register it explicitly so the help-scroll
// system also runs cleanly under `MinimalPlugins` in tests.
.add_message::<MouseWheel>()
.add_systems(
Update,
(toggle_help_screen, handle_help_close_button, scroll_help_panel),
);
}
}
@@ -71,6 +89,32 @@ fn handle_help_close_button(
}
}
/// Routes mouse-wheel events into the Help modal's scrollable body while
/// the panel is open. No-op when no `HelpScrollable` exists in the world
/// (modal closed). Mirrors `scroll_settings_panel`.
fn scroll_help_panel(
mut scroll_evr: MessageReader<MouseWheel>,
mut scrollables: Query<&mut ScrollPosition, With<HelpScrollable>>,
) {
if scrollables.is_empty() {
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);
}
}
/// Each entry in the controls reference table.
struct ControlRow {
keys: &'static str,
@@ -168,59 +212,77 @@ fn spawn_help_screen(commands: &mut Commands, font_res: Option<&FontResource>) {
spawn_modal(commands, HelpScreen, Z_MODAL_PANEL, |card| {
spawn_modal_header(card, "Controls", font_res);
for section in CONTROL_SECTIONS {
// Section title in muted text — distinguishes from row content.
card.spawn((
Text::new(section.title),
font_section.clone(),
TextColor(TEXT_SECONDARY),
));
// Scrollable body — the controls reference is six sections totalling
// ~28 rows, which overflows the modal on the 800x600 minimum
// window. Wrapping in an `Overflow::scroll_y()` Node with a
// constrained `max_height` keeps every row reachable; the Done
// button below stays fixed outside the scroll.
card.spawn((
HelpScrollable,
ScrollPosition::default(),
Node {
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_2,
max_height: Val::Vh(70.0),
overflow: Overflow::scroll_y(),
..default()
},
))
.with_children(|body| {
for section in CONTROL_SECTIONS {
// Section title in muted text — distinguishes from row content.
body.spawn((
Text::new(section.title),
font_section.clone(),
TextColor(TEXT_SECONDARY),
));
// Each row is a flex-row: kbd-style chip + description.
for row in section.rows {
card.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: VAL_SPACE_3,
..default()
})
.with_children(|line| {
// The hotkey rendered as a small chip with a border —
// visual cue that it's a key reference, not part of
// the description text.
line.spawn((
Node {
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
min_width: Val::Px(64.0),
justify_content: JustifyContent::Center,
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
..default()
},
BorderColor::all(BORDER_SUBTLE),
))
.with_children(|chip| {
chip.spawn((
Text::new(row.keys),
font_kbd.clone(),
// Each row is a flex-row: kbd-style chip + description.
for row in section.rows {
body.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: VAL_SPACE_3,
..default()
})
.with_children(|line| {
// The hotkey rendered as a small chip with a border —
// visual cue that it's a key reference, not part of
// the description text.
line.spawn((
Node {
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
min_width: Val::Px(64.0),
justify_content: JustifyContent::Center,
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
..default()
},
BorderColor::all(BORDER_SUBTLE),
))
.with_children(|chip| {
chip.spawn((
Text::new(row.keys),
font_kbd.clone(),
TextColor(TEXT_PRIMARY),
));
});
line.spawn((
Text::new(row.description),
font_row.clone(),
TextColor(TEXT_PRIMARY),
));
});
line.spawn((
Text::new(row.description),
font_row.clone(),
TextColor(TEXT_PRIMARY),
));
}
// Section spacer — small empty box. Keeps each section
// visually grouped.
body.spawn(Node {
height: Val::Px(SPACE_2),
..default()
});
}
// Section spacer — small empty box. Keeps each section
// visually grouped.
card.spawn(Node {
height: Val::Px(SPACE_2),
..default()
});
}
});
spawn_modal_actions(card, |actions| {
spawn_modal_button(
@@ -264,6 +326,36 @@ mod tests {
);
}
#[test]
fn help_modal_body_is_scrollable() {
let mut app = headless_app();
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::F1);
app.update();
let count = app
.world_mut()
.query::<&HelpScrollable>()
.iter(app.world())
.count();
assert_eq!(
count, 1,
"Help modal must spawn exactly one HelpScrollable body"
);
let mut q = app
.world_mut()
.query_filtered::<&Node, With<HelpScrollable>>();
let nodes: Vec<&Node> = q.iter(app.world()).collect();
assert_ne!(
nodes[0].max_height,
Val::Auto,
"scrollable body must set a non-default max_height"
);
assert_eq!(nodes[0].overflow, Overflow::scroll_y());
}
#[test]
fn pressing_f1_twice_closes_help_screen() {
let mut app = headless_app();