fix: revert draw gating; replace frame-count scroll with wall-clock timer

- Revert src/main.rs to draw-at-top-of-loop (original structure) so
  auto-scrolling is no longer coupled to event rate. Lock is released
  before blocking on recv() so the network task is never starved.

- Replace ticks_until_scroll counter in on_ui_scroll_tick() with an
  Instant + Duration (default 100ms). Scroll now advances at a fixed
  wall-clock rate (10 chars/sec) regardless of draw rate or tick rate,
  which is the correct long-term fix for the animation regression.

- Update tests: assert scroll_interval == 100ms in default/new tests;
  rewrite test_on_ui_scroll_tick to cover zero-interval (always fires)
  and long-interval (suppressed) cases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C3FpbniZme9mJqgFkMVowz
This commit is contained in:
Jonathan McCumber
2026-06-30 20:05:00 -05:00
parent a72b2991fd
commit 6f697f64f4
3 changed files with 25 additions and 21 deletions
+12 -10
View File
@@ -5,6 +5,7 @@ mod tests {
use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use serde_json::Value; use serde_json::Value;
use serial_test::serial; use serial_test::serial;
use std::time::{Duration, Instant};
use tokio::sync::mpsc; use tokio::sync::mpsc;
use crate::app::{App, AppConfig, Data, ServarrConfig, interpolate_env_vars}; use crate::app::{App, AppConfig, Data, ServarrConfig, interpolate_env_vars};
@@ -80,7 +81,7 @@ mod tests {
assert_eq!(app.server_tabs.index, 0); assert_eq!(app.server_tabs.index, 0);
assert_eq!(app.server_tabs.tabs, expected_tab_routes); assert_eq!(app.server_tabs.tabs, expected_tab_routes);
assert_eq!(app.tick_until_poll, 400); assert_eq!(app.tick_until_poll, 400);
assert_eq!(app.ticks_until_scroll, 64); assert_eq!(app.scroll_interval, Duration::from_millis(100));
assert_eq!(app.tick_count, 0); assert_eq!(app.tick_count, 0);
assert_eq!(app.ui_scroll_tick_count, 0); assert_eq!(app.ui_scroll_tick_count, 0);
assert!(!app.is_loading); assert!(!app.is_loading);
@@ -101,7 +102,7 @@ mod tests {
assert_eq!(app.error, HorizontallyScrollableText::default()); assert_eq!(app.error, HorizontallyScrollableText::default());
assert_eq!(app.server_tabs.index, 0); assert_eq!(app.server_tabs.index, 0);
assert_eq!(app.tick_until_poll, 400); assert_eq!(app.tick_until_poll, 400);
assert_eq!(app.ticks_until_scroll, 64); assert_eq!(app.scroll_interval, Duration::from_millis(100));
assert_eq!(app.tick_count, 0); assert_eq!(app.tick_count, 0);
assert!(!app.is_loading); assert!(!app.is_loading);
assert!(!app.is_routing); assert!(!app.is_routing);
@@ -247,22 +248,23 @@ mod tests {
#[test] #[test]
fn test_on_ui_scroll_tick() { fn test_on_ui_scroll_tick() {
let mut app = App { let mut app = App {
ticks_until_scroll: 1, scroll_interval: Duration::ZERO,
..App::default() ..App::default()
}; };
// Zero interval: elapsed() >= scroll_interval is always true, so every
// call should signal "scroll now" (ui_scroll_tick_count == 0)
app.on_ui_scroll_tick();
assert_eq!(app.ui_scroll_tick_count, 0); assert_eq!(app.ui_scroll_tick_count, 0);
assert_eq!(app.tick_count, 0);
app.on_ui_scroll_tick(); app.on_ui_scroll_tick();
assert_eq!(app.ui_scroll_tick_count, 0);
// Long interval with a fresh timestamp: should signal "not yet" (count == 1)
app.scroll_interval = Duration::from_secs(60);
app.last_scroll = Instant::now();
app.on_ui_scroll_tick();
assert_eq!(app.ui_scroll_tick_count, 1); assert_eq!(app.ui_scroll_tick_count, 1);
assert_eq!(app.tick_count, 0);
app.on_ui_scroll_tick();
assert_eq!(app.ui_scroll_tick_count, 0);
assert_eq!(app.tick_count, 0);
} }
#[tokio::test] #[tokio::test]
+8 -4
View File
@@ -7,6 +7,7 @@ use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::path::PathBuf; use std::path::PathBuf;
use std::time::{Duration, Instant};
use std::{fs, process}; use std::{fs, process};
use tokio::sync::mpsc::Sender; use tokio::sync::mpsc::Sender;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
@@ -41,7 +42,8 @@ pub struct App<'a> {
pub error: HorizontallyScrollableText, pub error: HorizontallyScrollableText,
pub notification: Option<Notification>, pub notification: Option<Notification>,
pub tick_until_poll: u64, pub tick_until_poll: u64,
pub ticks_until_scroll: u64, pub scroll_interval: Duration,
pub last_scroll: Instant,
pub tick_count: u64, pub tick_count: u64,
pub ui_scroll_tick_count: u64, pub ui_scroll_tick_count: u64,
pub is_routing: bool, pub is_routing: bool,
@@ -171,10 +173,11 @@ impl App<'_> {
} }
pub fn on_ui_scroll_tick(&mut self) { pub fn on_ui_scroll_tick(&mut self) {
if self.ui_scroll_tick_count == self.ticks_until_scroll { if self.last_scroll.elapsed() >= self.scroll_interval {
self.ui_scroll_tick_count = 0; self.ui_scroll_tick_count = 0;
self.last_scroll = Instant::now();
} else { } else {
self.ui_scroll_tick_count += 1; self.ui_scroll_tick_count = 1;
} }
} }
@@ -260,7 +263,8 @@ impl Default for App<'_> {
is_first_render: true, is_first_render: true,
server_tabs: TabState::new(Vec::new()), server_tabs: TabState::new(Vec::new()),
tick_until_poll: 400, tick_until_poll: 400,
ticks_until_scroll: 64, scroll_interval: Duration::from_millis(100),
last_scroll: Instant::now(),
tick_count: 0, tick_count: 0,
ui_scroll_tick_count: 0, ui_scroll_tick_count: 0,
is_loading: false, is_loading: false,
+5 -7
View File
@@ -276,12 +276,12 @@ async fn start_ui(
let input_events = Events::new(); let input_events = Events::new();
{
let mut app = app.lock().await;
terminal.draw(|f| ui(f, &mut app))?;
}
loop { loop {
{
let mut app = app.lock().await;
terminal.draw(|f| ui(f, &mut app))?;
}
match input_events.next()? { match input_events.next()? {
Some(InputEvent::KeyEvent(key)) => { Some(InputEvent::KeyEvent(key)) => {
let mut app = app.lock().await; let mut app = app.lock().await;
@@ -291,13 +291,11 @@ async fn start_ui(
} }
handlers::handle_events(key, &mut app); handlers::handle_events(key, &mut app);
terminal.draw(|f| ui(f, &mut app))?;
} }
Some(InputEvent::Tick) => { Some(InputEvent::Tick) => {
let mut app = app.lock().await; let mut app = app.lock().await;
app.on_tick().await; app.on_tick().await;
terminal.draw(|f| ui(f, &mut app))?;
} }
None => break, None => break,
} }