mirror of
https://github.com/mfontanini/presenterm.git
synced 2025-05-05 15:32:58 +00:00
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:
commit
58a3ea5b8d
@ -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
|
||||
}
|
||||
]
|
||||
},
|
||||
|
@ -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> {
|
||||
|
@ -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(¤t_string, ¤t_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(¤t_string, ¤t_style));
|
||||
|
@ -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,
|
||||
|
@ -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
134
src/transitions/fade.rs
Normal 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);
|
||||
}
|
||||
}
|
@ -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::*;
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user