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
11 changes: 11 additions & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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)
}))
}

Expand Down Expand Up @@ -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);
}
Expand Down
90 changes: 75 additions & 15 deletions src/ui/stories.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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]);

Expand All @@ -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,
Expand All @@ -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();
Expand Down Expand Up @@ -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,
}
}
71 changes: 71 additions & 0 deletions src/ui/ui_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Loading