feat: add fade slide transition (#534)

This adds another animation: `fade`. This essentially draws the new
slide on top of the current one by jumping to cells that have changed
between slides randomly and printing their contents.


https://github.com/user-attachments/assets/eb2d7d68-9967-4855-a06b-cbe208b21c6f

Fixes #364
This commit is contained in:
Matias Fontanini 2025-04-03 16:29:35 -07:00 committed by GitHub
commit 58a3ea5b8d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 251 additions and 42 deletions

View File

@ -543,6 +543,22 @@
}
},
"additionalProperties": false
},
{
"description": "Fade the new slide into the previous one.",
"type": "object",
"required": [
"style"
],
"properties": {
"style": {
"type": "string",
"enum": [
"fade"
]
}
},
"additionalProperties": false
}
]
},

View File

@ -534,6 +534,9 @@ pub struct SlideTransitionConfig {
pub enum SlideTransitionStyleConfig {
/// Slide horizontally.
SlideHorizontal,
/// Fade the new slide into the previous one.
Fade,
}
fn make_keybindings<const N: usize>(raw_bindings: [&str; N]) -> Vec<KeyBinding> {

View File

@ -43,7 +43,9 @@ impl HtmlSlide {
let mut finalized_row = "<div class=\"content-line\"><pre>".to_string();
let mut current_style = row.first().map(|c| c.style).unwrap_or_default();
let mut current_string = String::new();
for (x, c) in row.into_iter().enumerate() {
let mut x = 0;
while x < row.len() {
let c = row[x];
if c.style != current_style {
finalized_row.push_str(&Self::finalize_string(&current_string, &current_style));
current_string = String::new();
@ -63,6 +65,7 @@ impl HtmlSlide {
);
current_string.push_str(&image_tag);
}
x += c.style.size as usize;
}
if !current_string.is_empty() {
finalized_row.push_str(&Self::finalize_string(&current_string, &current_style));

View File

@ -26,7 +26,10 @@ use crate::{
},
theme::{ProcessingThemeError, raw::PresentationTheme},
third_party::ThirdPartyRender,
transitions::{AnimateTransition, AnimationFrame, TransitionDirection, slide_horizontal::SlideHorizontalAnimation},
transitions::{
AnimateTransition, AnimationFrame, LinesFrame, TransitionDirection, fade::FadeAnimation,
slide_horizontal::SlideHorizontalAnimation,
},
};
use std::{
collections::HashSet,
@ -419,7 +422,7 @@ impl<'a> Presenter<'a> {
}
fn next_slide(&mut self, drawer: &mut TerminalDrawer) -> RenderResult {
let Some(transition) = &self.options.transition else {
let Some(config) = self.options.transition.clone() else {
return Ok(());
};
let options = drawer.render_engine_options();
@ -430,18 +433,11 @@ impl<'a> Presenter<'a> {
presentation.jump_next();
let right = Self::virtual_render(presentation.current_slide(), dimensions.clone(), &options)?;
let direction = TransitionDirection::Next;
match transition.animation {
SlideTransitionStyleConfig::SlideHorizontal => self.animate(
drawer,
SlideHorizontalAnimation::new(left, right, dimensions),
transition.clone(),
direction,
),
}
self.animate_transition(drawer, left, right, direction, dimensions, config)
}
fn previous_slide(&mut self, drawer: &mut TerminalDrawer) -> RenderResult {
let Some(transition) = &self.options.transition else {
let Some(config) = self.options.transition.clone() else {
return Ok(());
};
let options = drawer.render_engine_options();
@ -452,22 +448,41 @@ impl<'a> Presenter<'a> {
presentation.jump_previous();
let left = Self::virtual_render(presentation.current_slide(), dimensions.clone(), &options)?;
let direction = TransitionDirection::Previous;
match transition.animation {
SlideTransitionStyleConfig::SlideHorizontal => self.animate(
self.animate_transition(drawer, left, right, direction, dimensions, config)
}
fn animate_transition(
&mut self,
drawer: &mut TerminalDrawer,
left: TerminalGrid,
right: TerminalGrid,
direction: TransitionDirection,
dimensions: WindowSize,
config: SlideTransitionConfig,
) -> RenderResult {
let first = match &direction {
TransitionDirection::Next => left.clone(),
TransitionDirection::Previous => right.clone(),
};
match &config.animation {
SlideTransitionStyleConfig::SlideHorizontal => self.run_animation(
drawer,
SlideHorizontalAnimation::new(left, right, dimensions),
transition.clone(),
direction,
first,
SlideHorizontalAnimation::new(left, right, dimensions, direction),
config,
),
SlideTransitionStyleConfig::Fade => {
self.run_animation(drawer, first, FadeAnimation::new(left, right, direction), config)
}
}
}
fn animate<T>(
fn run_animation<T>(
&mut self,
drawer: &mut TerminalDrawer,
first: TerminalGrid,
animation: T,
config: SlideTransitionConfig,
direction: TransitionDirection,
) -> RenderResult
where
T: AnimateTransition,
@ -476,29 +491,37 @@ impl<'a> Presenter<'a> {
let frames: usize = config.frames;
let total_frames = animation.total_frames();
let step = total_time / (frames as u32 * 2);
let mut last_frame_index = 0;
let mut frame_index = 1;
// Render the first frame as text to have images as ascii
Self::render_frame(&LinesFrame::from(&first).build_commands(), drawer)?;
while frame_index < total_frames {
let start = Instant::now();
let frame = animation.build_frame(frame_index, direction.clone());
// let frame = animation.build_frame(13, direction.clone());
let frame = animation.build_frame(frame_index, last_frame_index);
let commands = frame.build_commands();
drawer.terminal.execute(&TerminalCommand::BeginUpdate)?;
for command in commands {
drawer.terminal.execute(&command)?;
}
drawer.terminal.execute(&TerminalCommand::EndUpdate)?;
drawer.terminal.execute(&TerminalCommand::Flush)?;
Self::render_frame(&commands, drawer)?;
let elapsed = start.elapsed();
let sleep_needed = step.saturating_sub(elapsed);
if sleep_needed.as_millis() > 0 {
std::thread::sleep(step);
}
last_frame_index = frame_index;
frame_index += total_frames.div_ceil(frames);
}
Ok(())
}
fn render_frame(commands: &[TerminalCommand<'_>], drawer: &mut TerminalDrawer) -> RenderResult {
drawer.terminal.execute(&TerminalCommand::BeginUpdate)?;
for command in commands {
drawer.terminal.execute(command)?;
}
drawer.terminal.execute(&TerminalCommand::EndUpdate)?;
drawer.terminal.execute(&TerminalCommand::Flush)?;
Ok(())
}
fn virtual_render(
slide: &Slide,
dimensions: WindowSize,

View File

@ -17,7 +17,7 @@ use crate::{
use image::DynamicImage;
use std::{collections::HashMap, io, ops::Deref};
#[derive(Debug)]
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct PrintedImage {
pub(crate) image: Image,
pub(crate) width_columns: u16,
@ -50,7 +50,7 @@ impl Iterator for TerminalRowIterator<'_> {
}
}
#[derive(Debug)]
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct TerminalGrid {
pub(crate) rows: Vec<Vec<StyledChar>>,
pub(crate) background_color: Option<Color>,
@ -150,7 +150,7 @@ impl VirtualTerminal {
};
cell.character = c;
cell.style = style;
self.column += 1;
self.column += style.size as u16;
}
let height = self.current_row_height().max(style.size as u16);
self.set_current_row_height(height);
@ -252,6 +252,19 @@ pub(crate) struct StyledChar {
pub(crate) style: TextStyle,
}
impl StyledChar {
#[cfg(test)]
pub(crate) fn new(character: char, style: TextStyle) -> Self {
Self { character, style }
}
}
impl From<char> for StyledChar {
fn from(character: char) -> Self {
Self { character, style: Default::default() }
}
}
impl Default for StyledChar {
fn default() -> Self {
Self { character: ' ', style: Default::default() }

134
src/transitions/fade.rs Normal file
View File

@ -0,0 +1,134 @@
use super::{AnimateTransition, AnimationFrame, TransitionDirection};
use crate::{
markdown::text_style::TextStyle,
terminal::{
printer::TerminalCommand,
virt::{StyledChar, TerminalGrid},
},
};
use std::str;
pub(crate) struct FadeAnimation {
changes: Vec<Change>,
}
impl FadeAnimation {
pub(crate) fn new(left: TerminalGrid, right: TerminalGrid, direction: TransitionDirection) -> Self {
let mut changes = Vec::new();
let background = left.background_color;
for (row, (left, right)) in left.rows.into_iter().zip(right.rows).enumerate() {
for (column, (left, right)) in left.into_iter().zip(right).enumerate() {
let character = match &direction {
TransitionDirection::Next => right,
TransitionDirection::Previous => left,
};
if left != right {
let StyledChar { character, mut style } = character;
// If we don't have an explicit background color fall back to the default
style.colors.background = style.colors.background.or(background);
let mut char_buffer = [0; 4];
let char_buffer_len = character.encode_utf8(&mut char_buffer).len() as u8;
changes.push(Change {
row: row as u16,
column: column as u16,
char_buffer,
char_buffer_len,
style,
});
}
}
}
fastrand::shuffle(&mut changes);
Self { changes }
}
}
impl AnimateTransition for FadeAnimation {
type Frame = FadeCellsFrame;
fn build_frame(&self, frame: usize, previous_frame: usize) -> Self::Frame {
let last_frame = self.changes.len().saturating_sub(1);
let previous_frame = previous_frame.min(last_frame);
let frame_index = frame.min(self.changes.len());
let changes = self.changes[previous_frame..frame_index].to_vec();
FadeCellsFrame { changes }
}
fn total_frames(&self) -> usize {
self.changes.len()
}
}
#[derive(Debug)]
pub(crate) struct FadeCellsFrame {
changes: Vec<Change>,
}
impl AnimationFrame for FadeCellsFrame {
fn build_commands(&self) -> Vec<TerminalCommand> {
let mut commands = Vec::new();
for change in &self.changes {
let Change { row, column, char_buffer, char_buffer_len, style } = change;
let char_buffer_len = *char_buffer_len as usize;
// SAFETY: this is an utf8 encoded char so it must be valid
let content = str::from_utf8(&char_buffer[..char_buffer_len]).expect("invalid utf8");
commands.push(TerminalCommand::MoveTo { row: *row, column: *column });
commands.push(TerminalCommand::PrintText { content, style: *style });
}
commands
}
}
#[derive(Clone, Debug)]
struct Change {
row: u16,
column: u16,
char_buffer: [u8; 4],
char_buffer_len: u8,
style: TextStyle,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
WindowSize,
terminal::{printer::TerminalIo, virt::VirtualTerminal},
};
use rstest::rstest;
#[rstest]
#[case::next(TransitionDirection::Next)]
#[case::previous(TransitionDirection::Previous)]
fn transition(#[case] direction: TransitionDirection) {
let left = TerminalGrid {
rows: vec![
vec!['X'.into(), ' '.into(), 'B'.into()],
vec!['C'.into(), StyledChar::new('X', TextStyle::default().size(2)), 'D'.into()],
],
background_color: None,
images: Default::default(),
};
let right = TerminalGrid {
rows: vec![
vec![' '.into(), 'A'.into(), StyledChar::new('B', TextStyle::default().bold())],
vec![StyledChar::new('C', TextStyle::default().size(2)), ' '.into(), '🚀'.into()],
],
background_color: None,
images: Default::default(),
};
let expected = match direction {
TransitionDirection::Next => right.clone(),
TransitionDirection::Previous => left.clone(),
};
let dimensions = WindowSize { rows: 2, columns: 3, height: 0, width: 0 };
let mut virt = VirtualTerminal::new(dimensions, Default::default());
let animation = FadeAnimation::new(left, right, direction);
for command in animation.build_frame(animation.total_frames(), 0).build_commands() {
virt.execute(&command).expect("failed to run")
}
let output = virt.into_contents();
assert_eq!(output, expected);
}
}

View File

@ -1,10 +1,14 @@
use crate::{
markdown::{elements::Line, text_style::Color},
terminal::printer::TerminalCommand,
terminal::{
printer::TerminalCommand,
virt::{TerminalGrid, TerminalRowIterator},
},
};
use std::fmt::Debug;
use unicode_width::UnicodeWidthStr;
pub(crate) mod fade;
pub(crate) mod slide_horizontal;
#[derive(Clone, Debug)]
@ -16,7 +20,7 @@ pub(crate) enum TransitionDirection {
pub(crate) trait AnimateTransition {
type Frame: AnimationFrame + Debug;
fn build_frame(&self, frame: usize, direction: TransitionDirection) -> Self::Frame;
fn build_frame(&self, frame: usize, previous_frame: usize) -> Self::Frame;
fn total_frames(&self) -> usize;
}
@ -47,6 +51,17 @@ impl LinesFrame {
}
}
impl From<&TerminalGrid> for LinesFrame {
fn from(grid: &TerminalGrid) -> Self {
let mut lines = Vec::new();
for row in &grid.rows {
let line = TerminalRowIterator::new(row).collect();
lines.push(Line(line));
}
Self { lines, background_color: grid.background_color }
}
}
impl AnimationFrame for LinesFrame {
fn build_commands(&self) -> Vec<TerminalCommand> {
use TerminalCommand::*;

View File

@ -8,31 +8,33 @@ use crate::{
pub(crate) struct SlideHorizontalAnimation {
grid: TerminalGrid,
dimensions: WindowSize,
direction: TransitionDirection,
}
impl SlideHorizontalAnimation {
pub(crate) fn new(left: TerminalGrid, right: TerminalGrid, dimensions: WindowSize) -> Self {
assert!(left.rows.len() == right.rows.len(), "different row count");
assert!(left.rows[0].len() == right.rows[0].len(), "different column count");
assert!(left.background_color == right.background_color, "different background color");
pub(crate) fn new(
left: TerminalGrid,
right: TerminalGrid,
dimensions: WindowSize,
direction: TransitionDirection,
) -> Self {
let mut rows = Vec::new();
for (mut row, right) in left.rows.into_iter().zip(right.rows) {
row.extend(right);
rows.push(row);
}
let grid = TerminalGrid { rows, background_color: left.background_color, images: Default::default() };
Self { grid, dimensions }
Self { grid, dimensions, direction }
}
}
impl AnimateTransition for SlideHorizontalAnimation {
type Frame = LinesFrame;
fn build_frame(&self, frame: usize, direction: TransitionDirection) -> Self::Frame {
fn build_frame(&self, frame: usize, _previous_frame: usize) -> Self::Frame {
let total = self.total_frames();
let frame = frame.min(total);
let index = match direction {
let index = match &self.direction {
TransitionDirection::Next => frame,
TransitionDirection::Previous => total.saturating_sub(frame),
};
@ -95,8 +97,8 @@ mod tests {
let left = build_grid(&["AB", "CD"]);
let right = build_grid(&["EF", "GH"]);
let dimensions = WindowSize { rows: 2, columns: 2, height: 0, width: 0 };
let transition = SlideHorizontalAnimation::new(left, right, dimensions);
let lines: Vec<_> = transition.build_frame(frame, direction).lines.into_iter().map(as_text).collect();
let transition = SlideHorizontalAnimation::new(left, right, dimensions, direction);
let lines: Vec<_> = transition.build_frame(frame, 0).lines.into_iter().map(as_text).collect();
assert_eq!(lines, expected);
}
}