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
9 changes: 9 additions & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<HNCLIItem>, usize, usize)>,
/// Current page number
pub current_page: u32,
/// Comments for current story
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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));
Expand Down
73 changes: 54 additions & 19 deletions src/ui/stories.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -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;
}
}
Expand Down
53 changes: 52 additions & 1 deletion src/ui/ui_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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");
}

Loading