Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 13 additions & 7 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1206,8 +1206,14 @@ fn main() -> io::Result<()> {
};

// Drain available events.
// In realtime mode, first poll may block until next frame; subsequent polls
// are non-blocking so we can flush queued input quickly.
// The first poll may block (frame pacing); subsequent polls are
// non-blocking so everything already queued is flushed before the
// next frame. Draining matters in normal mode too: key auto-repeat
// (~25-30/s) outruns the ~10 fps frame rate, and any events left
// queued keep replaying after the key is released (reported on the
// achievement browser). Bounded so a runaway paste can't starve
// the tick loop.
let mut drained_events = 0u32;
while event::poll(poll_duration)? {
let ev = event::read()?;
if let Event::Resize(_, _) = &ev {
Expand All @@ -1217,9 +1223,7 @@ fn main() -> io::Result<()> {
if let Event::Key(key_event) = ev {
// Only handle key press events (ignore release/repeat)
if key_event.kind != KeyEventKind::Press {
if !realtime_mode {
break;
}
poll_duration = Duration::ZERO;
continue;
}
// Chrono Surge summary: any key dismisses
Expand Down Expand Up @@ -1442,8 +1446,10 @@ fn main() -> io::Result<()> {
InputAction::Continue => {}
}
}
// Normal mode: process one event per frame. Realtime: drain all.
if !realtime_mode {
// Flush whatever is already queued before drawing the next
// frame (bounded), in both normal and realtime mode.
drained_events += 1;
if drained_events >= 32 {
break;
}
poll_duration = Duration::ZERO;
Expand Down
2 changes: 1 addition & 1 deletion src/main_helpers/overlay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ pub fn draw_game_overlays(
extras: &OverlayExtras<'_>,
) {
let state: &GameState = ctx.state;
let overlay: &GameOverlay = ctx.overlay;
let overlay: &mut GameOverlay = ctx.overlay;
let haven: &haven::Haven = ctx.haven;
let haven_ui: &HavenUiState = ctx.haven_ui;
let soulforge_ui: &SoulforgeUiState = ctx.soulforge_ui;
Expand Down
11 changes: 10 additions & 1 deletion src/main_helpers/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1175,7 +1175,16 @@ pub fn show_startup_splash_screen(
match key_event.code {
KeyCode::Up => achievement_browser.move_up(),
KeyCode::Down => {
achievement_browser.move_down(category_achievements.len())
let count = if achievement_browser.selected_category
== achievements::AchievementCategory::Stats
{
// Stats view scrolls by offset; the renderer
// clamps and persists the real maximum.
100
} else {
category_achievements.len()
};
achievement_browser.move_down(count)
}
KeyCode::Left | KeyCode::Char(',') | KeyCode::Char('<') => {
achievement_browser.prev_category()
Expand Down
66 changes: 62 additions & 4 deletions src/ui/achievement_browser_scene.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ pub fn render_achievement_browser(
frame: &mut Frame,
area: Rect,
achievements: &Achievements,
ui_state: &AchievementBrowserState,
ui_state: &mut AchievementBrowserState,
enhancement: &EnhancementProgress,
_ctx: &super::responsive::LayoutContext,
) {
Expand Down Expand Up @@ -151,7 +151,11 @@ pub fn render_achievement_browser(

// Content area: stats view or list+detail
if ui_state.selected_category == AchievementCategory::Stats {
super::achievement_details::render_stats_view(
// Persist the renderer's clamped scroll: the input layer doesn't
// know the viewport, so without this a held Down runs the offset
// invisibly past the content and [Up] has to unwind the excess
// before anything moves again.
ui_state.selected_index = super::achievement_details::render_stats_view(
frame,
chunks[2],
achievements,
Expand Down Expand Up @@ -405,13 +409,67 @@ mod tests {
}
}

#[cfg(test)]
mod stats_scroll_clamp_tests {
use super::*;
use ratatui::{backend::TestBackend, Terminal};

fn render_at(ui: &mut AchievementBrowserState, w: u16, h: u16) {
let ach = Achievements::default();
let enh = crate::enhancement::EnhancementProgress::default();
let mut t = Terminal::new(TestBackend::new(w, h)).unwrap();
t.draw(|f| {
let area = f.area();
let ctx = crate::ui::responsive::LayoutContext::from_size(area.width, area.height);
render_achievement_browser(f, area, &ach, ui, &enh, &ctx);
})
.unwrap();
}

/// Regression (reported 2026-07-14): on the Stats tab a held Down ran
/// the scroll offset far past the content — the view stopped moving
/// but [Up] had to unwind the invisible excess before anything
/// scrolled back. The renderer now persists its clamped offset.
#[test]
fn stats_overscroll_is_clamped_back_by_the_renderer() {
let mut ui = AchievementBrowserState::new();
ui.selected_category = AchievementCategory::Stats;

// Way past any real content height (the input layer allows 100).
ui.selected_index = 100;
render_at(&mut ui, 120, 30);
let max_scroll = ui.selected_index;
assert!(
max_scroll < 100,
"the renderer must clamp the persisted offset ({max_scroll})"
);

// One Up from the clamp must move immediately — that's the bug.
ui.move_up();
assert_eq!(ui.selected_index, max_scroll.saturating_sub(1));

// The clamp is a fixed point: re-rendering doesn't drift it.
ui.selected_index = max_scroll;
render_at(&mut ui, 120, 30);
assert_eq!(ui.selected_index, max_scroll);

// On a terminal tall enough to show everything, no scroll at all.
ui.selected_index = 100;
render_at(&mut ui, 120, 200);
assert_eq!(
ui.selected_index, 0,
"content that fits entirely on screen has nothing to scroll"
);
}
}

#[cfg(test)]
mod act_row_render_tests {
use super::*;
use crate::achievements::Act;
use ratatui::{backend::TestBackend, Terminal};

fn render_rows(ui: &AchievementBrowserState) -> Vec<String> {
fn render_rows(ui: &mut AchievementBrowserState) -> Vec<String> {
let ach = Achievements::default();
let enh = crate::enhancement::EnhancementProgress::default();
let mut t = Terminal::new(TestBackend::new(120, 20)).unwrap();
Expand Down Expand Up @@ -440,7 +498,7 @@ mod act_row_render_tests {
let mut ui = AchievementBrowserState::new();
ui.toggle_act();
assert_eq!(ui.selected_act, Act::ActII);
let rows = render_rows(&ui);
let rows = render_rows(&mut ui);
assert!(rows[1].contains("ACT II \u{00b7} The Crossing"));
assert!(rows[2].contains("The Voyage"));
assert!(rows.iter().any(|r| r.contains("The Burn")));
Expand Down
7 changes: 6 additions & 1 deletion src/ui/achievement_details.rs
Original file line number Diff line number Diff line change
Expand Up @@ -213,13 +213,17 @@ pub(super) fn render_achievement_detail(
}

/// Render the stats view (full-width, two columns).
/// Renders the two stats columns and returns the scroll offset actually
/// used — clamped so the last content line stops at the bottom of the
/// viewport. Callers persist the returned value (see the browser scene)
/// so a held Down key can't run the offset invisibly past the content.
pub(super) fn render_stats_view(
frame: &mut Frame,
area: Rect,
achievements: &Achievements,
enhancement: &EnhancementProgress,
scroll_offset: usize,
) {
) -> usize {
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(super::themed_border_color(Color::DarkGray)));
Expand All @@ -245,6 +249,7 @@ pub(super) fn render_stats_view(

render_lines(frame, columns[0], left_lines, scroll);
render_lines(frame, columns[2], right_lines, scroll);
scroll
}

/// Build the left column lines: raw stats with dot-leaders.
Expand Down
Loading