From 63b14fc0eea0ad5361d0ca92820886be6921d553 Mon Sep 17 00:00:00 2001 From: chris ha Date: Thu, 5 Nov 2026 14:19:29 +0000 Subject: [PATCH] Dip-crossfade the story list and ease the active tab on feed switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Motion item C3: switching feeds (1-4) or pages now dips the outgoing list through the chrome color (110ms ease-in out, 160ms ease-out back in) while the newly active tab's color eases dim → accent over 160ms and the previous tab eases back, so tab and list read as one transition. The first load of a feed snaps instantly, and HN_REDUCED_MOTION or non-truecolor palettes skip the motion. Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- src/app.rs | 11 ++++++ src/ui/stories.rs | 90 ++++++++++++++++++++++++++++++++++++++-------- src/ui/ui_tests.rs | 71 ++++++++++++++++++++++++++++++++++++ 3 files changed, 157 insertions(+), 15 deletions(-) diff --git a/src/app.rs b/src/app.rs index 1be80d6..ae63da5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -90,6 +90,8 @@ pub struct App { pub story_scroll: usize, /// In-flight selection glide: (started, from row position, to index) 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)>, /// Current page number pub current_page: u32, /// Comments for current story @@ -162,6 +164,7 @@ impl App { selected_index: 0, story_scroll: 0, selection_glide: None, + feed_switched: None, current_page: 1, comments: Vec::new(), visible_comment_paths: Vec::new(), @@ -386,6 +389,9 @@ impl App { }) || self.selection_glide.is_some_and(|(start, _, to)| { to == self.selected_index && now.saturating_duration_since(start) < SELECTION_GLIDE + }) + || self.feed_switched.is_some_and(|(start, _)| { + now.saturating_duration_since(start) < Duration::from_millis(270) })) } @@ -468,6 +474,11 @@ impl App { .min(stories.len().saturating_sub(1)) }); self.story_scroll = position.map_or(0, |p| p.scroll.min(stories.len().saturating_sub(1))); + 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.stories_for = Some((story_type, page)); self.set_stories(stories); } diff --git a/src/ui/stories.rs b/src/ui/stories.rs index f89598e..750242b 100644 --- a/src/ui/stories.rs +++ b/src/ui/stories.rs @@ -3,14 +3,20 @@ use crate::app::{App, StoryType, SELECTION_GLIDE}; use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, - style::{Modifier, Style}, + style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, Frame, }; +use tachyonfx::{fx, ColorSpace, Interpolation}; use super::widgets; -use std::time::Instant; +use std::time::{Duration, Instant}; + +/// Feed switch motion: 110ms dip out + 160ms dip in on the list, 160ms tab color ease. +const FEED_DIP_OUT: Duration = Duration::from_millis(110); +const FEED_DIP_IN: Duration = Duration::from_millis(160); +const FEED_TAB_EASE: Duration = Duration::from_millis(160); /// Render the stories view pub fn render(f: &mut Frame, app: &mut App, tick: usize) { @@ -74,6 +80,28 @@ 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 elapsed >= FEED_DIP_OUT + FEED_DIP_IN { + app.feed_switched = None; + } + } + let status = widgets::render_stories_status(chunks[2], app, tick); f.render_widget(status, chunks[2]); @@ -92,6 +120,20 @@ fn render_title(f: &mut Frame, area: Rect, app: &App, tick: usize) { Span::styled(" Y ", theme.brand), Span::raw(" "), ]; + // Feed switch: the newly active tab eases dim → accent while the previous + // one eases accent → dim, so tab and list tell one story. + let tab_ease = app + .feed_switched + .filter(|_| app.theme().motion) + .map(|(start, prev)| { + let elapsed = Instant::now().saturating_duration_since(start); + ( + elapsed, + prev, + Interpolation::QuadOut.alpha((elapsed.as_secs_f32() / FEED_TAB_EASE.as_secs_f32()).min(1.0)), + ) + }) + .filter(|(elapsed, _, _)| *elapsed < FEED_TAB_EASE); for (idx, story_type) in [ StoryType::Top, StoryType::New, @@ -105,20 +147,27 @@ fn render_title(f: &mut Frame, area: Rect, app: &App, tick: usize) { tabs.push(Span::raw(" ")); } let active = *story_type == display_type; - tabs.push(Span::styled( - format!("{} ", idx + 1), - if active { theme.accent } else { theme.dim }, - )); - tabs.push(Span::styled( - story_type.display_name(), + let mut number_style = if active { theme.accent } else { theme.dim }; + let mut name_style = if active { + theme + .accent + .add_modifier(Modifier::BOLD | Modifier::UNDERLINED) + } else { + theme.dim + }; + if let Some((_, prev, alpha)) = tab_ease { if active { - theme - .accent - .add_modifier(Modifier::BOLD | Modifier::UNDERLINED) - } else { - theme.dim - }, - )); + 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)); + name_style = number_style; + } + } + tabs.push(Span::styled(format!("{} ", idx + 1), number_style)); + tabs.push(Span::styled(story_type.display_name(), name_style)); } let stale = app.showing_stale_stories(); let mut status = Vec::new(); @@ -302,3 +351,14 @@ fn render_stories_list(f: &mut Frame, area: Rect, app: &mut App) { app.render_save_flash(f.buffer_mut(), cell, id); } } + +/// Interpolate two colors; non-RGB colors fall back to the target. +fn lerp_color(from: Color, to: Color, t: f32) -> Color { + match (from, to) { + (Color::Rgb(r1, g1, b1), Color::Rgb(r2, g2, b2)) => { + let lerp = |a: u8, b: u8| (f32::from(a) + (f32::from(b) - f32::from(a)) * t) as u8; + Color::Rgb(lerp(r1, r2), lerp(g1, g2), lerp(b1, b2)) + } + _ => to, + } +} diff --git a/src/ui/ui_tests.rs b/src/ui/ui_tests.rs index 9fb8b31..40b1c5d 100644 --- a/src/ui/ui_tests.rs +++ b/src/ui/ui_tests.rs @@ -612,3 +612,74 @@ fn selection_glide_disabled_without_motion() { assert_eq!(app.selected_index, 1); assert!(app.selection_glide.is_none()); } + +#[test] +fn feed_switch_dips_the_list_and_eases_the_tab_color() { + use crate::app::StoryType; + use ratatui::style::Color; + use std::time::{Duration, Instant}; + let between = |c: Color, a: Color, b: Color| match (c, a, b) { + (Color::Rgb(r, g, bl), Color::Rgb(r1, g1, b1), Color::Rgb(r2, g2, b2)) => { + [r, g, bl] + .iter() + .zip([r1, g1, b1].iter().zip([r2, g2, b2].iter())) + .all(|(&c, (&a, &b))| (a.min(b)..=a.max(b)).contains(&c)) + } + _ => false, + }; + let item = |id: i32| crate::HNCLIItem { + id, + title: format!("Story {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), + }; + let mut app = feed(); + app.set_story_type(StoryType::New); + app.set_stories_for(StoryType::New, 1, (20..=31).map(item).collect()); + let (_, prev) = app.feed_switched.expect("switching lists starts the dip"); + assert_eq!(prev, StoryType::Best); + assert!(app.has_active_motion(Instant::now())); + let theme = app.theme(); + let (dim, accent, scrim) = ( + theme.dim.fg.unwrap(), + theme.accent.fg.unwrap(), + 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. + app.feed_switched = Some((Instant::now() - Duration::from_millis(55), prev)); + let frame = terminal.draw(|f| render(f, &mut app, 0)).unwrap(); + 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"); + // 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(); + let eased = (0..100).any(|x| { + let fg = frame.buffer[(x, 0)].fg; + fg != dim && fg != accent && between(fg, dim, accent) + }); + assert!(eased, "the active tab must be mid color-ease"); + // Past 270ms the list is back and the switch state clears. + app.feed_switched = Some((Instant::now() - Duration::from_millis(300), prev)); + assert!(!app.has_active_motion(Instant::now())); + let frame = terminal.draw(|f| render(f, &mut app, 0)).unwrap(); + assert!(app.feed_switched.is_none(), "a finished dip must clear"); + assert_eq!(frame.buffer[(2, 2)].fg, dim, "list must be fully back"); + assert!( + (0..100).any(|x| frame.buffer[(x, 0)].fg == accent), + "active tab must land on accent" + ); +} + +#[test] +fn first_feed_load_does_not_dip() { + let app = feed(); + assert!(app.feed_switched.is_none(), "initial load snaps, no dip"); +}