diff --git a/src/builder.rs b/src/builder.rs index 14954ae8..69972341 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -8,7 +8,7 @@ use crate::{ }, presentation::{ AsRenderOperations, MarginProperties, PreformattedLine, Presentation, PresentationMetadata, - PresentationThemeMetadata, RenderOnDemand, RenderOnDemandState, RenderOperation, Slide, + PresentationThemeMetadata, RenderOnDemand, RenderOnDemandState, RenderOperation, Slide, SlideChunk, }, render::{ highlighting::{CodeHighlighter, CodeLine}, @@ -31,6 +31,7 @@ static DEFAULT_BOTTOM_SLIDE_MARGIN: u16 = 3; /// This type transforms [MarkdownElement]s and turns them into a presentation, which is made up of /// render operations. pub(crate) struct PresentationBuilder<'a> { + slide_chunks: Vec, slide_operations: Vec, slides: Vec, highlighter: CodeHighlighter, @@ -51,6 +52,7 @@ impl<'a> PresentationBuilder<'a> { resources: &'a mut Resources, ) -> Self { Self { + slide_chunks: Vec::new(), slide_operations: Vec::new(), slides: Vec::new(), highlighter: default_highlighter, @@ -82,8 +84,8 @@ impl<'a> PresentationBuilder<'a> { self.push_line_break(); } } - if !self.slide_operations.is_empty() { - self.terminate_slide(TerminateMode::ResetState); + if !self.slide_operations.is_empty() || !self.slide_chunks.is_empty() { + self.terminate_slide(); } self.footer_context.borrow_mut().total_slides = self.slides.len(); @@ -217,7 +219,7 @@ impl<'a> PresentationBuilder<'a> { }; self.push_text(Text::from(text), ElementType::PresentationAuthor); } - self.terminate_slide(TerminateMode::ResetState); + self.terminate_slide(); } fn process_comment(&mut self, comment: String) -> Result<(), BuildError> { @@ -228,7 +230,7 @@ impl<'a> PresentationBuilder<'a> { let comment = comment.parse::()?; match comment { CommentCommand::Pause => self.process_pause(), - CommentCommand::EndSlide => self.terminate_slide(TerminateMode::ResetState), + CommentCommand::EndSlide => self.terminate_slide(), CommentCommand::InitColumnLayout(columns) => { Self::validate_column_layout(&columns)?; self.layout = LayoutState::InLayout { columns_count: columns.len() }; @@ -276,9 +278,9 @@ impl<'a> PresentationBuilder<'a> { self.slide_operations.pop(); } - let next_operations = self.slide_operations.clone(); - self.terminate_slide(TerminateMode::KeepState); - self.slide_operations = next_operations; + let chunk_operations = mem::take(&mut self.slide_operations); + self.slide_chunks.push(SlideChunk::new(chunk_operations)); + // self.terminate_slide(TerminateMode::KeepState); } fn push_slide_title(&mut self, mut text: Text) { @@ -482,26 +484,27 @@ impl<'a> PresentationBuilder<'a> { self.slide_operations.push(operation); } - fn terminate_slide(&mut self, mode: TerminateMode) { - self.push_footer(); + fn terminate_slide(&mut self) { + let footer = self.generate_footer(); let operations = mem::take(&mut self.slide_operations); - self.slides.push(Slide::new(operations)); + self.slide_chunks.push(SlideChunk::new(operations)); + + let chunks = mem::take(&mut self.slide_chunks); + self.slides.push(Slide::new(chunks, footer)); self.push_slide_prelude(); - if matches!(mode, TerminateMode::ResetState) { - self.ignore_element_line_break = true; - self.needs_enter_column = false; - self.layout = Default::default(); - } + self.ignore_element_line_break = true; + self.needs_enter_column = false; + self.layout = Default::default(); } - fn push_footer(&mut self) { + fn generate_footer(&mut self) -> Vec { let generator = FooterGenerator { style: self.theme.footer.clone(), current_slide: self.slides.len(), context: self.footer_context.clone(), }; - self.slide_operations.extend([ + vec![ // Exit any layout we're in so this gets rendered on a default screen size. RenderOperation::ExitLayout, // Pop the slide margin so we're at the terminal rect. @@ -509,7 +512,7 @@ impl<'a> PresentationBuilder<'a> { // Jump to the very bottom of the terminal rect and draw the footer. RenderOperation::JumpToBottom, RenderOperation::RenderDynamic(Rc::new(generator)), - ]); + ] } fn push_table(&mut self, table: Table) { @@ -564,11 +567,6 @@ impl<'a> PresentationBuilder<'a> { } } -enum TerminateMode { - KeepState, - ResetState, -} - #[derive(Debug, Default)] enum LayoutState { #[default] @@ -1078,6 +1076,6 @@ mod test { fn pause_inside_layout() { let elements = vec![build_column_layout(1), build_pause(), build_column(0)]; let presentation = build_presentation(elements); - assert_eq!(presentation.iter_slides().count(), 2); + assert_eq!(presentation.iter_slides().count(), 1); } } diff --git a/src/diff.rs b/src/diff.rs index 0637ef7b..ae3b3dd7 100644 --- a/src/diff.rs +++ b/src/diff.rs @@ -1,19 +1,28 @@ -use crate::presentation::{Presentation, RenderOperation, Slide}; -use std::{cmp::Ordering, mem}; +use crate::presentation::{Presentation, RenderOperation, SlideChunk}; +use std::{cmp::Ordering, fmt::Debug, mem}; /// Allow diffing presentations. pub(crate) struct PresentationDiffer; impl PresentationDiffer { - /// Find the first modified slide between original and updated. - /// - /// This tries to take into account both content and style changes such that changing - pub(crate) fn first_modified_slide(original: &Presentation, updated: &Presentation) -> Option { + /// Find the first modification between two presentations. + pub(crate) fn find_first_modification(original: &Presentation, updated: &Presentation) -> Option { let original_slides = original.iter_slides(); let updated_slides = updated.iter_slides(); - for (index, (original, updated)) in original_slides.zip(updated_slides).enumerate() { - if original.is_content_different(updated) { - return Some(index); + for (slide_index, (original, updated)) in original_slides.zip(updated_slides).enumerate() { + for (chunk_index, (original, updated)) in original.iter_chunks().zip(updated.iter_chunks()).enumerate() { + if original.is_content_different(updated) { + return Some(Modification { slide_index, chunk_index }); + } + } + let total_original = original.iter_chunks().count(); + let total_updated = updated.iter_chunks().count(); + match total_original.cmp(&total_updated) { + Ordering::Equal => (), + Ordering::Less => return Some(Modification { slide_index, chunk_index: total_original }), + Ordering::Greater => { + return Some(Modification { slide_index, chunk_index: total_updated.saturating_sub(1) }); + } } } let total_original = original.iter_slides().count(); @@ -22,18 +31,26 @@ impl PresentationDiffer { // If they have the same number of slides there's no difference. Ordering::Equal => None, // If the original had fewer, let's scroll to the first new one. - Ordering::Less => Some(total_original), + Ordering::Less => Some(Modification { slide_index: total_original, chunk_index: 0 }), // If the original had more, let's scroll to the last one. - Ordering::Greater => Some(total_updated.saturating_sub(1)), + Ordering::Greater => { + Some(Modification { slide_index: total_updated.saturating_sub(1), chunk_index: usize::MAX }) + } } } } +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Modification { + pub(crate) slide_index: usize, + pub(crate) chunk_index: usize, +} + trait ContentDiff { fn is_content_different(&self, other: &Self) -> bool; } -impl ContentDiff for Slide { +impl ContentDiff for SlideChunk { fn is_content_different(&self, other: &Self) -> bool { self.iter_operations().is_content_different(&other.iter_operations()) } @@ -70,18 +87,19 @@ impl ContentDiff for RenderOperation { impl<'a, T, U> ContentDiff for T where T: IntoIterator + Clone, - U: ContentDiff + 'a, + // TODO no debu + U: ContentDiff + 'a + Debug, { fn is_content_different(&self, other: &Self) -> bool { - let mut lhs = self.clone().into_iter(); - let mut rhs = other.clone().into_iter(); - for (lhs, rhs) in lhs.by_ref().zip(rhs.by_ref()) { + let lhs = self.clone().into_iter(); + let rhs = other.clone().into_iter(); + for (lhs, rhs) in lhs.zip(rhs) { if lhs.is_content_different(rhs) { return true; } } // If either have more than the other, they've changed - lhs.next().is_some() != rhs.next().is_some() + self.clone().into_iter().count() != other.clone().into_iter().count() } } @@ -89,7 +107,7 @@ where mod test { use super::*; use crate::{ - presentation::{AsRenderOperations, PreformattedLine}, + presentation::{AsRenderOperations, PreformattedLine, Slide}, render::properties::WindowSize, style::{Color, Colors}, theme::{Alignment, Margin}, @@ -157,62 +175,98 @@ mod test { #[test] fn no_slide_changes() { let presentation = Presentation::new(vec![ - Slide::new(vec![RenderOperation::JumpToBottom]), - Slide::new(vec![RenderOperation::JumpToBottom]), - Slide::new(vec![RenderOperation::JumpToBottom]), + Slide::from(vec![RenderOperation::JumpToBottom]), + Slide::from(vec![RenderOperation::JumpToBottom]), + Slide::from(vec![RenderOperation::JumpToBottom]), ]); - assert_eq!(PresentationDiffer::first_modified_slide(&presentation, &presentation), None); + assert_eq!(PresentationDiffer::find_first_modification(&presentation, &presentation), None); } #[test] fn slides_truncated() { let lhs = Presentation::new(vec![ - Slide::new(vec![RenderOperation::JumpToBottom]), - Slide::new(vec![RenderOperation::JumpToBottom]), + Slide::from(vec![RenderOperation::JumpToBottom]), + Slide::from(vec![RenderOperation::JumpToBottom]), ]); - let rhs = Presentation::new(vec![Slide::new(vec![RenderOperation::JumpToBottom])]); + let rhs = Presentation::new(vec![Slide::from(vec![RenderOperation::JumpToBottom])]); - assert_eq!(PresentationDiffer::first_modified_slide(&lhs, &rhs), Some(0)); + assert_eq!( + PresentationDiffer::find_first_modification(&lhs, &rhs), + Some(Modification { slide_index: 0, chunk_index: usize::MAX }) + ); } #[test] fn slides_added() { - let lhs = Presentation::new(vec![Slide::new(vec![RenderOperation::JumpToBottom])]); + let lhs = Presentation::new(vec![Slide::from(vec![RenderOperation::JumpToBottom])]); let rhs = Presentation::new(vec![ - Slide::new(vec![RenderOperation::JumpToBottom]), - Slide::new(vec![RenderOperation::JumpToBottom]), + Slide::from(vec![RenderOperation::JumpToBottom]), + Slide::from(vec![RenderOperation::JumpToBottom]), ]); - assert_eq!(PresentationDiffer::first_modified_slide(&lhs, &rhs), Some(1)); + assert_eq!( + PresentationDiffer::find_first_modification(&lhs, &rhs), + Some(Modification { slide_index: 1, chunk_index: 0 }) + ); } #[test] fn second_slide_content_changed() { let lhs = Presentation::new(vec![ - Slide::new(vec![RenderOperation::JumpToBottom]), - Slide::new(vec![RenderOperation::JumpToBottom]), - Slide::new(vec![RenderOperation::JumpToBottom]), + Slide::from(vec![RenderOperation::JumpToBottom]), + Slide::from(vec![RenderOperation::JumpToBottom]), + Slide::from(vec![RenderOperation::JumpToBottom]), ]); let rhs = Presentation::new(vec![ - Slide::new(vec![RenderOperation::JumpToBottom]), - Slide::new(vec![RenderOperation::JumpToVerticalCenter]), - Slide::new(vec![RenderOperation::JumpToBottom]), + Slide::from(vec![RenderOperation::JumpToBottom]), + Slide::from(vec![RenderOperation::JumpToVerticalCenter]), + Slide::from(vec![RenderOperation::JumpToBottom]), ]); - assert_eq!(PresentationDiffer::first_modified_slide(&lhs, &rhs), Some(1)); + assert_eq!( + PresentationDiffer::find_first_modification(&lhs, &rhs), + Some(Modification { slide_index: 1, chunk_index: 0 }) + ); } #[test] fn presentation_changed_style() { - let lhs = Presentation::new(vec![Slide::new(vec![RenderOperation::SetColors(Colors { + let lhs = Presentation::new(vec![Slide::from(vec![RenderOperation::SetColors(Colors { background: None, foreground: Some(Color::new(255, 0, 0)), })])]); - let rhs = Presentation::new(vec![Slide::new(vec![RenderOperation::SetColors(Colors { + let rhs = Presentation::new(vec![Slide::from(vec![RenderOperation::SetColors(Colors { background: None, foreground: Some(Color::new(0, 0, 0)), })])]); - assert_eq!(PresentationDiffer::first_modified_slide(&lhs, &rhs), None); + assert_eq!(PresentationDiffer::find_first_modification(&lhs, &rhs), None); + } + + #[test] + fn chunk_change() { + let lhs = Presentation::new(vec![ + Slide::from(vec![RenderOperation::JumpToBottom]), + Slide::new(vec![SlideChunk::default(), SlideChunk::new(vec![RenderOperation::JumpToBottom])], vec![]), + ]); + let rhs = Presentation::new(vec![ + Slide::from(vec![RenderOperation::JumpToBottom]), + Slide::new( + vec![ + SlideChunk::default(), + SlideChunk::new(vec![RenderOperation::JumpToBottom, RenderOperation::JumpToBottom]), + ], + vec![], + ), + ]); + + assert_eq!( + PresentationDiffer::find_first_modification(&lhs, &rhs), + Some(Modification { slide_index: 1, chunk_index: 1 }) + ); + assert_eq!( + PresentationDiffer::find_first_modification(&rhs, &lhs), + Some(Modification { slide_index: 1, chunk_index: 1 }) + ); } } diff --git a/src/presentation.rs b/src/presentation.rs index 5fd207df..adc8ee57 100644 --- a/src/presentation.rs +++ b/src/presentation.rs @@ -42,8 +42,14 @@ impl Presentation { /// Jump to the next slide. pub(crate) fn jump_next_slide(&mut self) -> bool { + let current_slide = self.current_slide_mut(); + if current_slide.increase_visible_chunks() { + return true; + } if self.current_slide_index < self.slides.len() - 1 { self.current_slide_index += 1; + // Going forward we show only the first chunk. + self.current_slide_mut().show_first_chunk(); true } else { false @@ -52,8 +58,14 @@ impl Presentation { /// Jump to the previous slide. pub(crate) fn jump_previous_slide(&mut self) -> bool { + let current_slide = self.current_slide_mut(); + if current_slide.decrease_visible_chunks() { + return true; + } if self.current_slide_index > 0 { self.current_slide_index -= 1; + // Going backwards we show all chunks. + self.current_slide_mut().show_all_chunks(); true } else { false @@ -62,40 +74,37 @@ impl Presentation { /// Jump to the first slide. pub(crate) fn jump_first_slide(&mut self) -> bool { - if self.current_slide_index != 0 { - self.current_slide_index = 0; - true - } else { - false - } + self.jump_slide(0) } /// Jump to the last slide. pub(crate) fn jump_last_slide(&mut self) -> bool { let last_slide_index = self.slides.len().saturating_sub(1); - if self.current_slide_index != last_slide_index { - self.current_slide_index = last_slide_index; - true - } else { - false - } + self.jump_slide(last_slide_index) } /// Jump to a specific slide. pub(crate) fn jump_slide(&mut self, slide_index: usize) -> bool { if slide_index < self.slides.len() { self.current_slide_index = slide_index; + // Always show only the first slide when jumping to a particular one. + self.current_slide_mut().show_first_chunk(); true } else { false } } + /// Jump to a specific chunk within the current slide. + pub(crate) fn jump_chunk(&mut self, chunk_index: usize) { + self.current_slide_mut().jump_chunk(chunk_index); + } + /// Render all widgets in this slide. pub(crate) fn render_slide_widgets(&mut self) -> bool { let slide = self.current_slide_mut(); let mut any_rendered = false; - for operation in &mut slide.operations { + for operation in slide.iter_operations_mut() { if let RenderOperation::RenderOnDemand(operation) = operation { any_rendered = any_rendered || operation.start_render(); } @@ -107,7 +116,7 @@ impl Presentation { pub(crate) fn widgets_rendered(&mut self) -> bool { let slide = self.current_slide_mut(); let mut all_rendered = true; - for operation in &mut slide.operations { + for operation in slide.iter_operations_mut() { if let RenderOperation::RenderOnDemand(operation) = operation { all_rendered = all_rendered && matches!(operation.poll_state(), RenderOnDemandState::Rendered); } @@ -126,21 +135,84 @@ impl Presentation { /// the terminal's screen. #[derive(Clone, Debug)] pub(crate) struct Slide { - operations: Vec, + chunks: Vec, + footer: Vec, + visible_chunks: usize, } impl Slide { - pub(crate) fn new(operations: Vec) -> Self { - Self { operations } + pub(crate) fn new(chunks: Vec, footer: Vec) -> Self { + Self { chunks, footer, visible_chunks: 1 } } pub(crate) fn iter_operations(&self) -> impl Iterator + Clone { - self.operations.iter() + self.chunks.iter().take(self.visible_chunks).flat_map(|chunk| chunk.0.iter()).chain(self.footer.iter()) + } + + pub(crate) fn iter_chunks(&self) -> impl Iterator { + self.chunks.iter() + } + + pub(crate) fn iter_operations_mut(&mut self) -> impl Iterator { + self.chunks + .iter_mut() + .take(self.visible_chunks) + .flat_map(|chunk| chunk.0.iter_mut()) + .chain(self.footer.iter_mut()) + } + + fn jump_chunk(&mut self, chunk_index: usize) { + self.visible_chunks = (chunk_index + 1).min(self.chunks.len()); } #[cfg(test)] pub(crate) fn into_operations(self) -> Vec { - self.operations + self.chunks.into_iter().flat_map(|chunk| chunk.0.into_iter()).chain(self.footer.into_iter()).collect() + } + + fn show_first_chunk(&mut self) { + self.visible_chunks = 1; + } + + fn show_all_chunks(&mut self) { + self.visible_chunks = self.chunks.len(); + } + + fn decrease_visible_chunks(&mut self) -> bool { + if self.visible_chunks == 1 { + false + } else { + self.visible_chunks -= 1; + true + } + } + + fn increase_visible_chunks(&mut self) -> bool { + if self.visible_chunks == self.chunks.len() { + false + } else { + self.visible_chunks += 1; + true + } + } +} + +impl From> for Slide { + fn from(operations: Vec) -> Self { + Self::new(vec![SlideChunk::new(operations)], vec![]) + } +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct SlideChunk(Vec); + +impl SlideChunk { + pub(crate) fn new(operations: Vec) -> Self { + Self(operations) + } + + pub(crate) fn iter_operations(&self) -> impl Iterator + Clone { + self.0.iter() } } @@ -285,3 +357,54 @@ pub(crate) enum RenderOnDemandState { Rendering, Rendered, } + +#[cfg(test)] +mod test { + use super::*; + use rstest::rstest; + + enum Jump { + First, + Last, + Next, + Previous, + Specific(usize), + } + + #[rstest] + #[case::previous_from_first(0, &[Jump::Previous], 0, 0)] + #[case::next_from_first(0, &[Jump::Next], 0, 1)] + #[case::next_next_from_first(0, &[Jump::Next, Jump::Next], 1, 0)] + #[case::last_from_first(0, &[Jump::Last], 2, 0)] + #[case::previous_from_second(1, &[Jump::Previous], 0, 1)] + #[case::next_from_second(1, &[Jump::Next], 1, 1)] + #[case::specific_first_from_second(1, &[Jump::Specific(0)], 0, 0)] + #[case::specific_last_from_second(1, &[Jump::Specific(2)], 2, 0)] + #[case::first_from_last(2, &[Jump::First], 0, 0)] + fn jumping( + #[case] from: usize, + #[case] jumps: &[Jump], + #[case] expected_slide: usize, + #[case] expected_chunk: usize, + ) { + let mut presentation = Presentation::new(vec![ + Slide::new(vec![SlideChunk::from(SlideChunk::default()), SlideChunk::default()], vec![]), + Slide::new(vec![SlideChunk::from(SlideChunk::default()), SlideChunk::default()], vec![]), + Slide::new(vec![SlideChunk::from(SlideChunk::default()), SlideChunk::default()], vec![]), + ]); + presentation.jump_slide(from); + + use Jump::*; + for jump in jumps { + match jump { + First => presentation.jump_first_slide(), + Last => presentation.jump_last_slide(), + Next => presentation.jump_next_slide(), + Previous => presentation.jump_previous_slide(), + Specific(index) => presentation.jump_slide(*index), + }; + } + assert_eq!(presentation.current_slide_index(), expected_slide); + assert_eq!(presentation.current_slide().visible_chunks - 1, expected_chunk); + } +} diff --git a/src/presenter.rs b/src/presenter.rs index b5e27d61..77ae5197 100644 --- a/src/presenter.rs +++ b/src/presenter.rs @@ -154,9 +154,10 @@ impl<'a> Presenter<'a> { match self.load_presentation(path) { Ok(mut presentation) => { let current = self.state.presentation(); - let target_slide = PresentationDiffer::first_modified_slide(current, &presentation) - .unwrap_or(current.current_slide_index()); - presentation.jump_slide(target_slide); + if let Some(modification) = PresentationDiffer::find_first_modification(current, &presentation) { + presentation.jump_slide(modification.slide_index); + presentation.jump_chunk(modification.chunk_index); + } self.state = PresenterState::Presenting(presentation) } Err(e) => {