From 20cf18cfbc673df24683a846e67d93d3a94b08d6 Mon Sep 17 00:00:00 2001 From: chris ha Date: Thu, 5 Nov 2026 15:56:50 +0000 Subject: [PATCH] Fix feed-switch glitch: outgoing list fades out, tab emphasis hands over mid-ease Two teleports remained after #27: (1) the new list popped in at full brightness before the dip faded it, and (2) the active tab's bold/underline snapped instantly while its color eased over 160ms. Now set_stories_for stashes the outgoing list (stories/selection/scroll) and render swaps it in during the 110ms out-phase so the old list fades through the scrim color before the new one fades in; the tab's bold/underline transfer at the ease midpoint (alpha >= 0.5) instead of at frame zero. Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- src/app.rs | 9 ++++++ src/ui/stories.rs | 73 ++++++++++++++++++++++++++++++++++------------ src/ui/ui_tests.rs | 53 ++++++++++++++++++++++++++++++++- 3 files changed, 115 insertions(+), 20 deletions(-) diff --git a/src/app.rs b/src/app.rs index ae63da5..e8252f4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -92,6 +92,9 @@ pub struct App { pub selection_glide: Option<(Instant, f32, usize)>, /// Last list switch: (started, previous story type) for the dip + tab ease pub feed_switched: Option<(Instant, StoryType)>, + /// Outgoing list (stories, selection, scroll) retained during the dip's + /// out-phase so the old list fades away instead of the new one popping in + pub outgoing_feed: Option<(Vec, usize, usize)>, /// Current page number pub current_page: u32, /// Comments for current story @@ -165,6 +168,7 @@ impl App { story_scroll: 0, selection_glide: None, feed_switched: None, + outgoing_feed: None, current_page: 1, comments: Vec::new(), visible_comment_paths: Vec::new(), @@ -477,6 +481,11 @@ impl App { if let Some((prev_type, prev_page)) = self.stories_for { if prev_type != story_type || prev_page != page { self.feed_switched = Some((Instant::now(), prev_type)); + self.outgoing_feed = Some(( + self.stories.clone(), + self.selected_index, + self.story_scroll, + )); } } self.stories_for = Some((story_type, page)); diff --git a/src/ui/stories.rs b/src/ui/stories.rs index 750242b..9f964f8 100644 --- a/src/ui/stories.rs +++ b/src/ui/stories.rs @@ -29,6 +29,25 @@ pub fn render(f: &mut Frame, app: &mut App, tick: usize) { ]) .split(f.area()); + // Feed switch: during the out-phase the outgoing list is swapped in so it + // fades through the chrome color; the in-phase then fades the new list in, + // so the new content never pops in at full brightness. + let now = Instant::now(); + let dip = app + .feed_switched + .filter(|_| app.theme().motion) + .map(|(start, _)| now.saturating_duration_since(start)) + .filter(|elapsed| *elapsed < FEED_DIP_OUT + FEED_DIP_IN); + let mut swapped_outgoing = false; + if let (Some(elapsed), Some(outgoing)) = (dip, &mut app.outgoing_feed) { + if elapsed < FEED_DIP_OUT { + std::mem::swap(&mut app.stories, &mut outgoing.0); + std::mem::swap(&mut app.selected_index, &mut outgoing.1); + std::mem::swap(&mut app.story_scroll, &mut outgoing.2); + swapped_outgoing = true; + } + } + render_title(f, chunks[0], app, tick); if !app.stories.is_empty() { @@ -80,27 +99,32 @@ pub fn render(f: &mut Frame, app: &mut App, tick: usize) { f.render_widget(Paragraph::new(lines).alignment(Alignment::Center), area); } - // Feed switch: dip the outgoing list through the chrome color, then back in. - let now = Instant::now(); - if let Some((start, _)) = app.feed_switched { - let theme = app.theme(); - let elapsed = now.saturating_duration_since(start); - if theme.motion { - let scrim = theme.scrim.fg.unwrap(); - if elapsed < FEED_DIP_OUT { - fx::fade_to_fg(scrim, (110, Interpolation::QuadIn)) - .with_color_space(ColorSpace::Rgb) - .process(elapsed.into(), f.buffer_mut(), chunks[1]); - } else if elapsed < FEED_DIP_OUT + FEED_DIP_IN { - fx::fade_from_fg(scrim, (160, Interpolation::QuadOut)) - .with_color_space(ColorSpace::Rgb) - .process((elapsed - FEED_DIP_OUT).into(), f.buffer_mut(), chunks[1]); - } + if swapped_outgoing { + if let Some(outgoing) = &mut app.outgoing_feed { + std::mem::swap(&mut app.stories, &mut outgoing.0); + std::mem::swap(&mut app.selected_index, &mut outgoing.1); + std::mem::swap(&mut app.story_scroll, &mut outgoing.2); } - if elapsed >= FEED_DIP_OUT + FEED_DIP_IN { - app.feed_switched = None; + } + if let Some(elapsed) = dip { + let scrim = app.theme().scrim.fg.unwrap(); + if elapsed < FEED_DIP_OUT { + fx::fade_to_fg(scrim, (110, Interpolation::QuadIn)) + .with_color_space(ColorSpace::Rgb) + .process(elapsed.into(), f.buffer_mut(), chunks[1]); + } else { + fx::fade_from_fg(scrim, (160, Interpolation::QuadOut)) + .with_color_space(ColorSpace::Rgb) + .process((elapsed - FEED_DIP_OUT).into(), f.buffer_mut(), chunks[1]); } } + if app + .feed_switched + .is_some_and(|(start, _)| now.saturating_duration_since(start) >= FEED_DIP_OUT + FEED_DIP_IN) + { + app.feed_switched = None; + app.outgoing_feed = None; + } let status = widgets::render_stories_status(chunks[2], app, tick); f.render_widget(status, chunks[2]); @@ -156,13 +180,24 @@ fn render_title(f: &mut Frame, area: Rect, app: &App, tick: usize) { theme.dim }; if let Some((_, prev, alpha)) = tab_ease { + // Colors ease over the whole window, but bold/underline are binary: + // hand them over at the midpoint so the emphasis doesn't teleport + // while the colors are still crossing. + let emphasized = if active { + alpha >= 0.5 + } else { + *story_type == prev && alpha < 0.5 + }; if active { number_style = Style::default() .fg(lerp_color(theme.dim.fg.unwrap(), theme.accent.fg.unwrap(), alpha)); - name_style = number_style.add_modifier(Modifier::BOLD | Modifier::UNDERLINED); } else if *story_type == prev { number_style = Style::default() .fg(lerp_color(theme.accent.fg.unwrap(), theme.dim.fg.unwrap(), alpha)); + } + if emphasized { + name_style = number_style.add_modifier(Modifier::BOLD | Modifier::UNDERLINED); + } else if active || *story_type == prev { name_style = number_style; } } diff --git a/src/ui/ui_tests.rs b/src/ui/ui_tests.rs index 40b1c5d..46794da 100644 --- a/src/ui/ui_tests.rs +++ b/src/ui/ui_tests.rs @@ -652,12 +652,62 @@ fn feed_switch_dips_the_list_and_eases_the_tab_color() { theme.scrim.fg.unwrap(), ); let mut terminal = Terminal::new(TestBackend::new(100, 24)).unwrap(); - // Mid out-phase: 55ms into the 110ms dip, the rank cell is pulled toward scrim. + // Mid out-phase: 55ms into the 110ms dip, the OLD list is still rendered + // (fading toward scrim) — the new list must not pop in at full brightness. app.feed_switched = Some((Instant::now() - Duration::from_millis(55), prev)); + app.outgoing_feed = Some(( + (1..=12) + .map(|id| crate::HNCLIItem { + id, + title: format!("Old {id}"), + url: "https://example.com".into(), + author: "reader".into(), + time: String::new(), + time_ago: "2h".into(), + score: 10, + text: String::new(), + links: vec![], + comments: Some(2), + }) + .collect(), + 0, + 0, + )); let frame = terminal.draw(|f| render(f, &mut app, 0)).unwrap(); + let text = buffer_text(frame.buffer); + assert!(text.contains("Old 1"), "out-phase must render the outgoing list"); + assert!(!text.contains("Story 20"), "new list must not pop in during the out-phase"); let cell = frame.buffer[(2, 2)].fg; assert_ne!(cell, dim, "list must be mid-dip"); assert!(between(cell, dim, scrim), "dip must stay on the dim→scrim line"); + // In-phase: 150ms in, the new list is fading back in. + app.feed_switched = Some((Instant::now() - Duration::from_millis(150), prev)); + let frame = terminal.draw(|f| render(f, &mut app, 0)).unwrap(); + assert!(buffer_text(frame.buffer).contains("Story 20"), "in-phase renders the new list"); + // Bold/underline hand over at the ease midpoint, not instantly: at 40ms + // (alpha < 0.5) the old tab is still emphasized, at 120ms (alpha >= 0.5) + // the new one is. + use ratatui::style::Modifier; + app.feed_switched = Some((Instant::now() - Duration::from_millis(40), prev)); + let frame = terminal.draw(|f| render(f, &mut app, 0)).unwrap(); + assert!( + frame.buffer[(24, 0)].modifier.contains(Modifier::UNDERLINED), + "old tab keeps the underline before the midpoint" + ); + assert!( + !frame.buffer[(16, 0)].modifier.contains(Modifier::UNDERLINED), + "new tab must not be underlined before the midpoint" + ); + app.feed_switched = Some((Instant::now() - Duration::from_millis(120), prev)); + let frame = terminal.draw(|f| render(f, &mut app, 0)).unwrap(); + assert!( + frame.buffer[(16, 0)].modifier.contains(Modifier::UNDERLINED), + "new tab takes the underline after the midpoint" + ); + assert!( + !frame.buffer[(24, 0)].modifier.contains(Modifier::UNDERLINED), + "old tab drops the underline after the midpoint" + ); // Tab ease at 80ms: the newly active tab sits between dim and accent on row 0. app.feed_switched = Some((Instant::now() - Duration::from_millis(80), prev)); let frame = terminal.draw(|f| render(f, &mut app, 0)).unwrap(); @@ -683,3 +733,4 @@ fn first_feed_load_does_not_dip() { let app = feed(); assert!(app.feed_switched.is_none(), "initial load snaps, no dip"); } +