diff --git a/src/commands/listener.rs b/src/commands/listener.rs index ecf592f..1e6f8d5 100644 --- a/src/commands/listener.rs +++ b/src/commands/listener.rs @@ -36,7 +36,7 @@ impl CommandListener { return Ok(Some(command)); } } - match self.keyboard.poll_next_command(Duration::from_millis(250))? { + match self.keyboard.poll_next_command(Duration::from_millis(100))? { Some(command) => Ok(Some(command)), None => Ok(None), } diff --git a/src/demo.rs b/src/demo.rs index 81908bd..4495045 100644 --- a/src/demo.rs +++ b/src/demo.rs @@ -14,7 +14,7 @@ use crate::{ terminal::emulator::TerminalEmulator, theme::raw::PresentationTheme, }; -use std::{io, rc::Rc}; +use std::{io, sync::Arc}; const PRESENTATION: &str = r#" # Header 1 @@ -109,7 +109,7 @@ impl ThemesDemo { theme_options: ThemeOptions { font_size_supported: TerminalEmulator::capabilities().font_size }, ..Default::default() }; - let executer = Rc::new(SnippetExecutor::default()); + let executer = Arc::new(SnippetExecutor::default()); let bindings_config = Default::default(); let builder = PresentationBuilder::new( theme, diff --git a/src/export/exporter.rs b/src/export/exporter.rs index 8b617d9..5f08806 100644 --- a/src/export/exporter.rs +++ b/src/export/exporter.rs @@ -5,12 +5,13 @@ use crate::{ export::pdf::PdfRender, markdown::{parse::ParseError, text_style::Color}, presentation::{ - Presentation, Slide, + Presentation, builder::{BuildError, PresentationBuilder, PresentationBuilderOptions, Themes}, + poller::{Poller, PollerCommand}, }, render::{ RenderError, - operation::{AsRenderOperations, RenderAsyncState, RenderOperation}, + operation::{AsRenderOperations, PollableState, RenderOperation}, properties::WindowSize, }, theme::{ProcessingThemeError, raw::PresentationTheme}, @@ -28,8 +29,7 @@ use std::{ fs, io, path::{Path, PathBuf}, rc::Rc, - thread::sleep, - time::Duration, + sync::Arc, }; use tempfile::TempDir; @@ -63,7 +63,7 @@ pub struct Exporter<'a> { default_theme: &'a PresentationTheme, resources: Resources, third_party: ThirdPartyRender, - code_executor: Rc, + code_executor: Arc, themes: Themes, dimensions: WindowSize, options: PresentationBuilderOptions, @@ -77,7 +77,7 @@ impl<'a> Exporter<'a> { default_theme: &'a PresentationTheme, resources: Resources, third_party: ThirdPartyRender, - code_executor: Rc, + code_executor: Arc, themes: Themes, mut options: PresentationBuilderOptions, mut dimensions: WindowSize, @@ -129,9 +129,7 @@ impl<'a> Exporter<'a> { let mut render = PdfRender::new(self.dimensions, output_directory); Self::log("waiting for images to be generated and code to be executed, if any...")?; - for slide in presentation.iter_slides_mut() { - Self::render_async_images(slide); - } + Self::render_async_images(&mut presentation); for (index, slide) in presentation.into_slides().into_iter().enumerate() { let index = index + 1; Self::log(&format!("processing slide {index}..."))?; @@ -154,24 +152,33 @@ impl<'a> Exporter<'a> { Ok(()) } - fn render_async_images(slide: &mut Slide) { - for op in slide.iter_operations_mut() { - if let RenderOperation::RenderAsync(inner) = op { - loop { - match inner.poll_state() { - RenderAsyncState::Rendering { .. } => { - sleep(Duration::from_millis(200)); - continue; - } - RenderAsyncState::Rendered | RenderAsyncState::JustFinishedRendering => break, - RenderAsyncState::NotStarted => inner.start_render(), - }; + fn render_async_images(presentation: &mut Presentation) { + let poller = Poller::launch(); + let mut pollables = Vec::new(); + for (index, slide) in presentation.iter_slides().enumerate() { + for op in slide.iter_operations() { + if let RenderOperation::RenderAsync(inner) = op { + // Send a pollable to the poller and keep one for ourselves. + poller.send(PollerCommand::Poll { pollable: inner.pollable(), slide: index }); + pollables.push(inner.pollable()) + } + } + } + + // Poll until they're all done + for mut pollable in pollables { + while let PollableState::Unmodified | PollableState::Modified = pollable.poll() {} + } + + // Replace render asyncs with new operations that contains the replaced image + // and any other unmodified operations. + for slide in presentation.iter_slides_mut() { + for op in slide.iter_operations_mut() { + if let RenderOperation::RenderAsync(inner) = op { + let window_size = WindowSize { rows: 0, columns: 0, width: 0, height: 0 }; + let new_operations = inner.as_render_operations(&window_size); + *op = RenderOperation::RenderDynamic(Rc::new(RenderMany(new_operations))); } - let window_size = WindowSize { rows: 0, columns: 0, width: 0, height: 0 }; - let new_operations = inner.as_render_operations(&window_size); - // Replace this operation with a new operation that contains the replaced image - // and any other unmodified operations. - *op = RenderOperation::RenderDynamic(Rc::new(RenderMany(new_operations))); } } } diff --git a/src/main.rs b/src/main.rs index 717e645..c93b8f4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,7 +31,6 @@ use std::{ env::{self, current_dir}, io, path::{Path, PathBuf}, - rc::Rc, sync::Arc, }; use terminal::emulator::TerminalEmulator; @@ -192,7 +191,7 @@ impl Customizations { struct CoreComponents { third_party: ThirdPartyRender, - code_executor: Rc, + code_executor: Arc, resources: Resources, printer: Arc, builder_options: PresentationBuilderOptions, @@ -242,7 +241,7 @@ impl CoreComponents { threads: config.snippet.render.threads, }; let third_party = ThirdPartyRender::new(third_party_config, registry, &resources_path); - let code_executor = Rc::new(code_executor); + let code_executor = Arc::new(code_executor); Ok(Self { third_party, code_executor, diff --git a/src/presentation/builder/mod.rs b/src/presentation/builder/mod.rs index 340d5a1..21ad638 100644 --- a/src/presentation/builder/mod.rs +++ b/src/presentation/builder/mod.rs @@ -40,7 +40,7 @@ use comrak::{Arena, nodes::AlertType}; use image::DynamicImage; use serde::Deserialize; use snippet::{SnippetOperations, SnippetProcessor, SnippetProcessorState}; -use std::{collections::HashSet, fmt::Display, iter, mem, path::PathBuf, rc::Rc, str::FromStr}; +use std::{collections::HashSet, fmt::Display, iter, mem, path::PathBuf, rc::Rc, str::FromStr, sync::Arc}; use unicode_width::UnicodeWidthStr; mod snippet; @@ -125,7 +125,7 @@ pub(crate) struct PresentationBuilder<'a> { chunk_mutators: Vec>, slide_builders: Vec, highlighter: SnippetHighlighter, - code_executor: Rc, + code_executor: Arc, theme: PresentationTheme, default_raw_theme: &'a raw::PresentationTheme, resources: Resources, @@ -148,7 +148,7 @@ impl<'a> PresentationBuilder<'a> { default_raw_theme: &'a raw::PresentationTheme, resources: Resources, third_party: &'a mut ThirdPartyRender, - code_executor: Rc, + code_executor: Arc, themes: &'a Themes, image_registry: ImageRegistry, bindings_config: KeyBindingsConfig, @@ -934,11 +934,9 @@ impl<'a> PresentationBuilder<'a> { image_registry: &self.image_registry, snippet_executor: self.code_executor.clone(), theme: &self.theme, - presentation_state: &self.presentation_state, third_party: self.third_party, highlighter: &self.highlighter, options: &self.options, - slide_number: self.slide_builders.len() + 1, font_size: self.slide_font_size(), }; let processor = SnippetProcessor::new(state); @@ -1174,6 +1172,7 @@ pub enum BuildError { InvalidFooter(#[from] InvalidFooterTemplateError), } +#[derive(Debug)] enum ExecutionMode { AlongSnippet, ReplaceSnippet, @@ -1371,7 +1370,7 @@ mod test { let tmp_dir = std::env::temp_dir(); let resources = Resources::new(&tmp_dir, &tmp_dir, Default::default()); let mut third_party = ThirdPartyRender::default(); - let code_executor = Rc::new(SnippetExecutor::default()); + let code_executor = Arc::new(SnippetExecutor::default()); let themes = Themes::default(); let bindings = KeyBindingsConfig::default(); let builder = PresentationBuilder::new( diff --git a/src/presentation/builder/snippet.rs b/src/presentation/builder/snippet.rs index a76c126..67ca665 100644 --- a/src/presentation/builder/snippet.rs +++ b/src/presentation/builder/snippet.rs @@ -10,31 +10,29 @@ use crate::{ }, }, markdown::elements::SourcePosition, - presentation::{ChunkMutator, PresentationState}, + presentation::ChunkMutator, render::{ - operation::{AsRenderOperations, RenderAsync, RenderOperation}, + operation::{AsRenderOperations, RenderAsyncStartPolicy, RenderOperation}, properties::WindowSize, }, resource::Resources, theme::{CodeBlockStyle, PresentationTheme}, third_party::{ThirdPartyRender, ThirdPartyRenderRequest}, ui::execution::{ - DisplaySeparator, RunAcquireTerminalSnippet, RunImageSnippet, RunSnippetOperation, - SnippetExecutionDisabledOperation, + RunAcquireTerminalSnippet, RunImageSnippet, RunSnippetOperation, SnippetExecutionDisabledOperation, + disabled::ExecutionType, snippet::DisplaySeparator, }, }; -use std::{cell::RefCell, rc::Rc}; +use std::{cell::RefCell, rc::Rc, sync::Arc}; pub(crate) struct SnippetProcessorState<'a> { pub(crate) resources: &'a Resources, pub(crate) image_registry: &'a ImageRegistry, - pub(crate) snippet_executor: Rc, + pub(crate) snippet_executor: Arc, pub(crate) theme: &'a PresentationTheme, - pub(crate) presentation_state: &'a PresentationState, pub(crate) third_party: &'a ThirdPartyRender, pub(crate) highlighter: &'a SnippetHighlighter, pub(crate) options: &'a PresentationBuilderOptions, - pub(crate) slide_number: usize, pub(crate) font_size: u8, } @@ -43,13 +41,11 @@ pub(crate) struct SnippetProcessor<'a> { mutators: Vec>, resources: &'a Resources, image_registry: &'a ImageRegistry, - snippet_executor: Rc, + snippet_executor: Arc, theme: &'a PresentationTheme, - presentation_state: &'a PresentationState, third_party: &'a ThirdPartyRender, highlighter: &'a SnippetHighlighter, options: &'a PresentationBuilderOptions, - slide_number: usize, font_size: u8, } @@ -60,11 +56,9 @@ impl<'a> SnippetProcessor<'a> { image_registry, snippet_executor, theme, - presentation_state, third_party, highlighter, options, - slide_number, font_size, } = state; Self { @@ -74,11 +68,9 @@ impl<'a> SnippetProcessor<'a> { image_registry, snippet_executor, theme, - presentation_state, third_party, highlighter, options, - slide_number, font_size, } } @@ -128,11 +120,12 @@ impl<'a> SnippetProcessor<'a> { match snippet.attributes.execution { SnippetExec::None => Ok(()), SnippetExec::Exec | SnippetExec::AcquireTerminal if !execution_allowed => { - let auto_start = match snippet.attributes.representation { - SnippetRepr::Image | SnippetRepr::ExecReplace => true, - SnippetRepr::Render | SnippetRepr::Snippet => false, + let exec_type = match snippet.attributes.representation { + SnippetRepr::Image => ExecutionType::Image, + SnippetRepr::ExecReplace => ExecutionType::ExecReplace, + SnippetRepr::Render | SnippetRepr::Snippet => ExecutionType::Execute, }; - self.push_execution_disabled_operation(auto_start); + self.push_execution_disabled_operation(exec_type); Ok(()) } SnippetExec::Exec => self.push_code_execution(snippet, block_length, ExecutionMode::AlongSnippet), @@ -184,7 +177,6 @@ impl<'a> SnippetProcessor<'a> { fn push_rendered_code(&mut self, code: Snippet, source_position: SourcePosition) -> BuildResult { let Snippet { contents, language, attributes } = code; - let error_holder = self.presentation_state.async_error_holder(); let request = match language { SnippetLanguage::Typst => ThirdPartyRenderRequest::Typst(contents, self.theme.typst.clone()), SnippetLanguage::Latex => ThirdPartyRenderRequest::Latex(contents, self.theme.typst.clone()), @@ -196,8 +188,7 @@ impl<'a> SnippetProcessor<'a> { })?; } }; - let operation = - self.third_party.render(request, self.theme, error_holder, self.slide_number, attributes.width)?; + let operation = self.third_party.render(request, self.theme, attributes.width)?; self.operations.push(operation); Ok(()) } @@ -251,14 +242,17 @@ impl<'a> SnippetProcessor<'a> { style } - fn push_execution_disabled_operation(&mut self, auto_start: bool) { + fn push_execution_disabled_operation(&mut self, exec_type: ExecutionType) { + let policy = match exec_type { + ExecutionType::ExecReplace | ExecutionType::Image => RenderAsyncStartPolicy::Automatic, + ExecutionType::Execute => RenderAsyncStartPolicy::OnDemand, + }; let operation = SnippetExecutionDisabledOperation::new( self.theme.execution_output.status.failure_style, self.theme.code.alignment, + policy, + exec_type, ); - if auto_start { - operation.start_render(); - } self.operations.push(RenderOperation::RenderAsync(Rc::new(operation))); } @@ -272,8 +266,6 @@ impl<'a> SnippetProcessor<'a> { self.image_registry.clone(), self.theme.execution_output.status.clone(), ); - operation.start_render(); - let operation = RenderOperation::RenderAsync(Rc::new(operation)); self.operations.push(operation); Ok(()) @@ -310,6 +302,10 @@ impl<'a> SnippetProcessor<'a> { if snippet.attributes.no_background { execution_output_style.style.colors.background = None; } + let policy = match mode { + ExecutionMode::AlongSnippet => RenderAsyncStartPolicy::OnDemand, + ExecutionMode::ReplaceSnippet => RenderAsyncStartPolicy::Automatic, + }; let operation = RunSnippetOperation::new( snippet, self.snippet_executor.clone(), @@ -319,10 +315,8 @@ impl<'a> SnippetProcessor<'a> { separator, alignment, self.font_size, + policy, ); - if matches!(mode, ExecutionMode::ReplaceSnippet) { - operation.start_render(); - } let operation = RenderOperation::RenderAsync(Rc::new(operation)); self.operations.push(operation); Ok(()) diff --git a/src/presentation/diff.rs b/src/presentation/diff.rs index 3817552..a18153f 100644 --- a/src/presentation/diff.rs +++ b/src/presentation/diff.rs @@ -120,7 +120,7 @@ mod test { }, presentation::{Slide, SlideBuilder}, render::{ - operation::{AsRenderOperations, BlockLine, RenderAsync, RenderAsyncState}, + operation::{AsRenderOperations, BlockLine, Pollable, RenderAsync, ToggleState}, properties::WindowSize, }, theme::{Alignment, Margin}, @@ -138,12 +138,9 @@ mod test { } impl RenderAsync for Dynamic { - fn start_render(&self) -> bool { - false - } - - fn poll_state(&self) -> RenderAsyncState { - RenderAsyncState::Rendered + fn pollable(&self) -> Box { + // Use some random one, we don't care + Box::new(ToggleState::new(Default::default())) } } diff --git a/src/presentation/mod.rs b/src/presentation/mod.rs index 6c110c1..4928fbf 100644 --- a/src/presentation/mod.rs +++ b/src/presentation/mod.rs @@ -1,11 +1,7 @@ -use crate::{ - config::OptionsConfig, - render::operation::{RenderAsyncState, RenderOperation}, -}; +use crate::{config::OptionsConfig, render::operation::RenderOperation}; use serde::Deserialize; use std::{ cell::RefCell, - collections::HashSet, fmt::Debug, ops::Deref, rc::Rc, @@ -14,6 +10,7 @@ use std::{ pub(crate) mod builder; pub(crate) mod diff; +pub(crate) mod poller; #[derive(Debug)] pub(crate) struct Modals { @@ -143,61 +140,7 @@ impl Presentation { self.current_slide().current_chunk_index() } - /// Trigger async render operations in this slide. - pub(crate) fn trigger_slide_async_renders(&mut self) -> bool { - let slide = self.current_slide_mut(); - let mut any_rendered = false; - for operation in slide.iter_visible_operations_mut() { - if let RenderOperation::RenderAsync(operation) = operation { - let is_rendered = operation.start_render(); - any_rendered = any_rendered || is_rendered; - } - } - any_rendered - } - - // Get all slides that contain async render operations. - pub(crate) fn slides_with_async_renders(&self) -> HashSet { - let mut indexes = HashSet::new(); - for (index, slide) in self.slides.iter().enumerate() { - for operation in slide.iter_operations() { - if let RenderOperation::RenderAsync(operation) = operation { - if matches!(operation.poll_state(), RenderAsyncState::Rendering { .. }) { - indexes.insert(index); - break; - } - } - } - } - indexes - } - - /// Poll every async render operation in the current slide and check whether they're completed. - pub(crate) fn poll_slide_async_renders(&mut self, slide: usize) -> RenderAsyncState { - let slide = &mut self.slides[slide]; - let mut slide_state = RenderAsyncState::Rendered; - for operation in slide.iter_operations_mut() { - if let RenderOperation::RenderAsync(operation) = operation { - let state = operation.poll_state(); - slide_state = match (&slide_state, &state) { - // If one finished rendering and another one still is rendering, claim that we - // are still rendering and there's modifications. - (RenderAsyncState::JustFinishedRendering, RenderAsyncState::Rendering { .. }) - | (RenderAsyncState::Rendering { .. }, RenderAsyncState::JustFinishedRendering) => { - RenderAsyncState::Rendering { modified: true } - } - // Render + modified overrides anything, rendering overrides only "rendered". - (_, RenderAsyncState::Rendering { modified: true }) - | (RenderAsyncState::Rendered, RenderAsyncState::Rendering { .. }) - | (_, RenderAsyncState::JustFinishedRendering) => state, - _ => slide_state, - }; - } - } - slide_state - } - - fn current_slide_mut(&mut self) -> &mut Slide { + pub(crate) fn current_slide_mut(&mut self) -> &mut Slide { let index = self.current_slide_index(); &mut self.slides[index] } diff --git a/src/presentation/poller.rs b/src/presentation/poller.rs new file mode 100644 index 0000000..69c0f0f --- /dev/null +++ b/src/presentation/poller.rs @@ -0,0 +1,118 @@ +use crate::render::operation::{Pollable, PollableState}; +use std::{ + sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel}, + thread, + time::Duration, +}; + +const POLL_INTERVAL: Duration = Duration::from_millis(25); + +pub(crate) struct Poller { + sender: Sender, + receiver: Receiver, +} + +impl Poller { + pub(crate) fn launch() -> Self { + let (command_sender, command_receiver) = channel(); + let (effect_sender, effect_receiver) = channel(); + let worker = PollerWorker::new(command_receiver, effect_sender); + thread::spawn(move || { + worker.run(); + }); + Self { sender: command_sender, receiver: effect_receiver } + } + + pub(crate) fn send(&self, command: PollerCommand) { + let _ = self.sender.send(command); + } + + pub(crate) fn next_effect(&mut self) -> Option { + self.receiver.try_recv().ok() + } +} + +/// An effect caused by a pollable. +#[derive(Clone)] +pub(crate) enum PollableEffect { + /// Refresh the given slide. + RefreshSlide(usize), + + /// Display an error for the given slide. + DisplayError { slide: usize, error: String }, +} + +/// A poller command. +pub(crate) enum PollerCommand { + /// Start polling a pollable that's positioned in the given slide. + Poll { pollable: Box, slide: usize }, + + /// Reset all pollables. + Reset, +} + +struct PollerWorker { + receiver: Receiver, + sender: Sender, + pollables: Vec<(Box, usize)>, +} + +impl PollerWorker { + fn new(receiver: Receiver, sender: Sender) -> Self { + Self { receiver, sender, pollables: Default::default() } + } + + fn run(mut self) { + loop { + match self.receiver.recv_timeout(POLL_INTERVAL) { + Ok(command) => self.process_command(command), + // TODO don't loop forever. + Err(RecvTimeoutError::Timeout) => self.poll(), + Err(RecvTimeoutError::Disconnected) => break, + }; + } + } + + fn process_command(&mut self, command: PollerCommand) { + match command { + PollerCommand::Poll { mut pollable, slide } => { + // Poll and only insert if it's still running. + match pollable.poll() { + PollableState::Unmodified | PollableState::Modified => { + self.pollables.push((pollable, slide)); + } + PollableState::Done => { + let _ = self.sender.send(PollableEffect::RefreshSlide(slide)); + } + PollableState::Failed { error } => { + let _ = self.sender.send(PollableEffect::DisplayError { slide, error }); + } + }; + } + PollerCommand::Reset => self.pollables.clear(), + } + } + + fn poll(&mut self) { + let mut removables = Vec::new(); + for (index, (pollable, slide)) in self.pollables.iter_mut().enumerate() { + let slide = *slide; + let (effect, remove) = match pollable.poll() { + PollableState::Unmodified => (None, false), + PollableState::Modified => (Some(PollableEffect::RefreshSlide(slide)), false), + PollableState::Done => (Some(PollableEffect::RefreshSlide(slide)), true), + PollableState::Failed { error } => (Some(PollableEffect::DisplayError { slide, error }), true), + }; + if let Some(effect) = effect { + let _ = self.sender.send(effect); + } + if remove { + removables.push(index); + } + } + // Walk back and swap remove to avoid invalidating indexes. + for index in removables.iter().rev() { + self.pollables.swap_remove(*index); + } + } +} diff --git a/src/presenter.rs b/src/presenter.rs index 5f0641f..5036cb4 100644 --- a/src/presenter.rs +++ b/src/presenter.rs @@ -10,12 +10,13 @@ use crate::{ Presentation, Slide, builder::{BuildError, PresentationBuilder, PresentationBuilderOptions, Themes}, diff::PresentationDiffer, + poller::{PollableEffect, Poller, PollerCommand}, }, render::{ ErrorSource, RenderError, RenderResult, TerminalDrawer, TerminalDrawerOptions, ascii_scaler::AsciiScaler, engine::{MaxSize, RenderEngine, RenderEngineOptions}, - operation::RenderAsyncState, + operation::{Pollable, RenderAsyncStartPolicy, RenderOperation}, properties::WindowSize, validate::OverflowValidator, }, @@ -33,14 +34,12 @@ use crate::{ }, }; use std::{ - collections::HashSet, fmt::Display, fs, io::{self}, mem, ops::Deref, path::Path, - rc::Rc, sync::Arc, time::{Duration, Instant}, }; @@ -64,13 +63,13 @@ pub struct Presenter<'a> { parser: MarkdownParser<'a>, resources: Resources, third_party: ThirdPartyRender, - code_executor: Rc, + code_executor: Arc, state: PresenterState, - slides_with_pending_async_renders: HashSet, image_printer: Arc, themes: Themes, options: PresenterOptions, speaker_notes_event_publisher: Option, + poller: Poller, } impl<'a> Presenter<'a> { @@ -82,7 +81,7 @@ impl<'a> Presenter<'a> { parser: MarkdownParser<'a>, resources: Resources, third_party: ThirdPartyRender, - code_executor: Rc, + code_executor: Arc, themes: Themes, image_printer: Arc, options: PresenterOptions, @@ -96,11 +95,11 @@ impl<'a> Presenter<'a> { third_party, code_executor, state: PresenterState::Empty, - slides_with_pending_async_renders: HashSet::new(), image_printer, themes, options, speaker_notes_event_publisher, + poller: Poller::launch(), } } @@ -119,11 +118,10 @@ impl<'a> Presenter<'a> { let mut drawer = TerminalDrawer::new(self.image_printer.clone(), drawer_options)?; loop { // Poll async renders once before we draw just in case. - self.poll_async_renders()?; self.render(&mut drawer)?; loop { - if self.poll_async_renders()? { + if self.process_poller_effects()? { self.render(&mut drawer)?; } @@ -173,6 +171,36 @@ impl<'a> Presenter<'a> { } } + fn process_poller_effects(&mut self) -> Result { + let current_slide = match &self.state { + PresenterState::Presenting(presentation) + | PresenterState::SlideIndex(presentation) + | PresenterState::KeyBindings(presentation) + | PresenterState::Failure { presentation, .. } => presentation.current_slide_index(), + PresenterState::Empty => usize::MAX, + }; + let mut refreshed = false; + let mut needs_render = false; + while let Some(effect) = self.poller.next_effect() { + match effect { + PollableEffect::RefreshSlide(index) => { + needs_render = needs_render || index == current_slide; + refreshed = true; + } + PollableEffect::DisplayError { slide, error } => { + let presentation = mem::take(&mut self.state).into_presentation(); + self.state = + PresenterState::failure(error, presentation, ErrorSource::Slide(slide + 1), FailureMode::Other); + needs_render = true; + } + } + } + if refreshed { + self.try_scale_transition_images()?; + } + Ok(needs_render) + } + fn publish_event(&self, event: SpeakerNotesEvent) -> io::Result<()> { if let Some(publisher) = &self.speaker_notes_event_publisher { publisher.send(event)?; @@ -198,31 +226,6 @@ impl<'a> Presenter<'a> { } } - fn poll_async_renders(&mut self) -> Result { - if matches!(self.state, PresenterState::Failure { .. }) { - return Ok(false); - } - let current_index = self.state.presentation().current_slide_index(); - self.poll_slide_async_renders(current_index) - } - - fn poll_slide_async_renders(&mut self, slide: usize) -> Result { - if self.slides_with_pending_async_renders.contains(&slide) { - let state = self.state.presentation_mut().poll_slide_async_renders(slide); - match state { - RenderAsyncState::NotStarted | RenderAsyncState::Rendering { modified: false } => (), - RenderAsyncState::Rendering { modified: true } => { - return Ok(true); - } - RenderAsyncState::Rendered | RenderAsyncState::JustFinishedRendering => { - self.slides_with_pending_async_renders.remove(&slide); - return Ok(true); - } - }; - } - Ok(false) - } - fn render(&mut self, drawer: &mut TerminalDrawer) -> RenderResult { let result = match &self.state { PresenterState::Presenting(presentation) => { @@ -304,8 +307,11 @@ impl<'a> Presenter<'a> { Command::LastSlide => presentation.jump_last_slide(), Command::GoToSlide(number) => presentation.go_to_slide(number.saturating_sub(1) as usize), Command::RenderAsyncOperations => { - if presentation.trigger_slide_async_renders() { - self.slides_with_pending_async_renders.insert(self.state.presentation().current_slide_index()); + let pollables = Self::trigger_slide_async_renders(presentation); + if !pollables.is_empty() { + for pollable in pollables { + self.poller.send(PollerCommand::Poll { pollable, slide: presentation.current_slide_index() }); + } return CommandSideEffect::Redraw; } else { return CommandSideEffect::None; @@ -336,7 +342,7 @@ impl<'a> Presenter<'a> { if matches!(self.options.mode, PresentMode::Presentation) && !force { return Ok(()); } - self.slides_with_pending_async_renders.clear(); + self.poller.send(PollerCommand::Reset); self.resources.clear_watches(); match self.load_presentation(path) { Ok(mut presentation) => { @@ -348,7 +354,7 @@ impl<'a> Presenter<'a> { presentation.go_to_slide(current.current_slide_index()); presentation.jump_chunk(current.current_chunk()); } - self.slides_with_pending_async_renders = presentation.slides_with_async_renders().into_iter().collect(); + self.start_automatic_async_renders(&mut presentation); self.state = self.validate_overflows(presentation); self.try_scale_transition_images()?; } @@ -371,6 +377,19 @@ impl<'a> Presenter<'a> { Ok(()) } + fn trigger_slide_async_renders(presentation: &mut Presentation) -> Vec> { + let slide = presentation.current_slide_mut(); + let mut pollables = Vec::new(); + for operation in slide.iter_visible_operations_mut() { + if let RenderOperation::RenderAsync(operation) = operation { + if let RenderAsyncStartPolicy::OnDemand = operation.start_policy() { + pollables.push(operation.pollable()); + } + } + } + pollables + } + fn is_displaying_other_error(&self) -> bool { matches!(self.state, PresenterState::Failure { mode: FailureMode::Other, .. }) } @@ -444,7 +463,6 @@ impl<'a> Presenter<'a> { let Some(config) = self.options.transition.clone() else { return Ok(()); }; - self.poll_and_scale_images()?; let options = drawer.render_engine_options(); let presentation = self.state.presentation_mut(); @@ -461,7 +479,6 @@ impl<'a> Presenter<'a> { let Some(config) = self.options.transition.clone() else { return Ok(()); }; - self.poll_and_scale_images()?; let options = drawer.render_engine_options(); let presentation = self.state.presentation_mut(); @@ -560,15 +577,17 @@ impl<'a> Presenter<'a> { Ok(term.into_contents()) } - fn poll_and_scale_images(&mut self) -> RenderResult { - let mut needs_scaling = false; - for index in 0..self.state.presentation().iter_slides().count() { - needs_scaling = self.poll_slide_async_renders(index)? || needs_scaling; + fn start_automatic_async_renders(&self, presentation: &mut Presentation) { + for (index, slide) in presentation.iter_slides_mut().enumerate() { + for operation in slide.iter_operations_mut() { + if let RenderOperation::RenderAsync(operation) = operation { + if let RenderAsyncStartPolicy::Automatic = operation.start_policy() { + let pollable = operation.pollable(); + self.poller.send(PollerCommand::Poll { pollable, slide: index }); + } + } + } } - if needs_scaling { - self.try_scale_transition_images()?; - } - Ok(()) } } diff --git a/src/render/operation.rs b/src/render/operation.rs index e8460ee..3e2b88e 100644 --- a/src/render/operation.rs +++ b/src/render/operation.rs @@ -7,7 +7,11 @@ use crate::{ terminal::image::Image, theme::{Alignment, Margin}, }; -use std::{fmt::Debug, rc::Rc}; +use std::{ + fmt::Debug, + rc::Rc, + sync::{Arc, Mutex}, +}; const DEFAULT_IMAGE_Z_INDEX: i32 = -2; @@ -111,7 +115,7 @@ impl Default for ImageRenderProperties { size: Default::default(), restore_cursor: false, background_color: None, - position: ImagePosition::Cursor, + position: ImagePosition::Center, } } } @@ -160,24 +164,57 @@ pub(crate) trait AsRenderOperations: Debug + 'static { /// An operation that can be rendered asynchronously. pub(crate) trait RenderAsync: AsRenderOperations { - /// Start the render for this operation. + /// Create a pollable for this render async. /// - /// Should return true if the invocation triggered the rendering (aka if rendering wasn't - /// already started before). - fn start_render(&self) -> bool; + /// The pollable will be used to poll this by a separate thread, so all state that will + /// be loaded asynchronously should be shared between this operation and any pollables + /// generated from it. + fn pollable(&self) -> Box; + /// Get the start policy for this render. + fn start_policy(&self) -> RenderAsyncStartPolicy { + RenderAsyncStartPolicy::OnDemand + } +} + +/// The start policy for an async render. +#[derive(Copy, Clone, Debug)] +pub(crate) enum RenderAsyncStartPolicy { + /// Start automatically. + Automatic, + + /// Start on demand. + OnDemand, +} + +/// A pollable that can be used to pull and update the state of an operation asynchronously. +pub(crate) trait Pollable: Send + 'static { /// Update the internal state and return the updated state. - fn poll_state(&self) -> RenderAsyncState; + fn poll(&mut self) -> PollableState; } -/// The state of a [RenderAsync]. -#[derive(Clone, Debug, Default)] -pub(crate) enum RenderAsyncState { - #[default] - NotStarted, - Rendering { - modified: bool, - }, - Rendered, - JustFinishedRendering, +/// The state of a [Pollable]. +#[derive(Clone, Debug)] +pub(crate) enum PollableState { + Unmodified, + Modified, + Done, + Failed { error: String }, +} + +pub(crate) struct ToggleState { + toggled: Arc>, +} + +impl ToggleState { + pub(crate) fn new(toggled: Arc>) -> Self { + Self { toggled } + } +} + +impl Pollable for ToggleState { + fn poll(&mut self) -> PollableState { + *self.toggled.lock().unwrap() = true; + PollableState::Done + } } diff --git a/src/third_party.rs b/src/third_party.rs index 9b83fe3..b441566 100644 --- a/src/third_party.rs +++ b/src/third_party.rs @@ -5,10 +5,10 @@ use crate::{ elements::{Line, Percent, Text}, text_style::{Color, TextStyle}, }, - presentation::{AsyncPresentationError, AsyncPresentationErrorHolder}, render::{ operation::{ - AsRenderOperations, ImageRenderProperties, ImageSize, RenderAsync, RenderAsyncState, RenderOperation, + AsRenderOperations, ImageRenderProperties, ImageSize, Pollable, PollableState, RenderAsync, + RenderAsyncStartPolicy, RenderOperation, }, properties::WindowSize, }, @@ -53,12 +53,10 @@ impl ThirdPartyRender { &self, request: ThirdPartyRenderRequest, theme: &PresentationTheme, - error_holder: AsyncPresentationErrorHolder, - slide: usize, width: Option, ) -> Result { let result = self.render_pool.render(request); - let operation = Rc::new(RenderThirdParty::new(result, theme.default_style.style, error_holder, slide, width)); + let operation = Rc::new(RenderThirdParty::new(result, theme.default_style.style, width)); Ok(RenderOperation::RenderAsync(operation)) } } @@ -311,54 +309,32 @@ struct ImageSnippet { #[derive(Debug)] pub(crate) struct RenderThirdParty { - contents: Arc>>, + contents: Arc>>, pending_result: Arc>, default_style: TextStyle, - error_holder: AsyncPresentationErrorHolder, - slide: usize, width: Option, } impl RenderThirdParty { - fn new( - pending_result: Arc>, - default_style: TextStyle, - error_holder: AsyncPresentationErrorHolder, - slide: usize, - width: Option, - ) -> Self { - Self { contents: Default::default(), pending_result, default_style, error_holder, slide, width } + fn new(pending_result: Arc>, default_style: TextStyle, width: Option) -> Self { + Self { contents: Default::default(), pending_result, default_style, width } } } impl RenderAsync for RenderThirdParty { - fn start_render(&self) -> bool { - false + fn pollable(&self) -> Box { + Box::new(OperationPollable { contents: self.contents.clone(), pending_result: self.pending_result.clone() }) } - fn poll_state(&self) -> RenderAsyncState { - let mut contents = self.contents.lock().unwrap(); - if contents.is_some() { - return RenderAsyncState::Rendered; - } - match mem::take(&mut *self.pending_result.lock().unwrap()) { - RenderResult::Success(image) => { - *contents = Some(image); - RenderAsyncState::JustFinishedRendering - } - RenderResult::Failure(error) => { - *self.error_holder.lock().unwrap() = Some(AsyncPresentationError { slide: self.slide, error }); - RenderAsyncState::JustFinishedRendering - } - RenderResult::Pending => RenderAsyncState::Rendering { modified: false }, - } + fn start_policy(&self) -> RenderAsyncStartPolicy { + RenderAsyncStartPolicy::Automatic } } impl AsRenderOperations for RenderThirdParty { fn as_render_operations(&self, _: &WindowSize) -> Vec { match &*self.contents.lock().unwrap() { - Some(image) => { + Some(Output::Image(image)) => { let size = match &self.width { Some(percent) => ImageSize::WidthScaled { ratio: percent.as_ratio() }, None => Default::default(), @@ -371,6 +347,7 @@ impl AsRenderOperations for RenderThirdParty { vec![RenderOperation::RenderImage(image.clone(), properties)] } + Some(Output::Error) => Vec::new(), None => { let text = Line::from(Text::new("Loading...", TextStyle::default().bold())); vec![RenderOperation::RenderText { @@ -381,3 +358,35 @@ impl AsRenderOperations for RenderThirdParty { } } } + +#[derive(Debug)] +enum Output { + Image(Image), + Error, +} + +#[derive(Clone)] +struct OperationPollable { + contents: Arc>>, + pending_result: Arc>, +} + +impl Pollable for OperationPollable { + fn poll(&mut self) -> PollableState { + let mut contents = self.contents.lock().unwrap(); + if contents.is_some() { + return PollableState::Done; + } + match mem::take(&mut *self.pending_result.lock().unwrap()) { + RenderResult::Success(image) => { + *contents = Some(Output::Image(image)); + PollableState::Done + } + RenderResult::Failure(error) => { + *contents = Some(Output::Error); + PollableState::Failed { error } + } + RenderResult::Pending => PollableState::Unmodified, + } + } +} diff --git a/src/ui/execution.rs b/src/ui/execution.rs deleted file mode 100644 index 6a6956e..0000000 --- a/src/ui/execution.rs +++ /dev/null @@ -1,495 +0,0 @@ -use super::separator::{RenderSeparator, SeparatorWidth}; -use crate::{ - code::{ - execute::{ExecutionHandle, ExecutionState, ProcessStatus, SnippetExecutor}, - snippet::Snippet, - }, - markdown::{ - elements::{Line, Text}, - text::WeightedLine, - text_style::{Colors, TextStyle}, - }, - render::{ - operation::{ - AsRenderOperations, BlockLine, ImageRenderProperties, RenderAsync, RenderAsyncState, RenderOperation, - }, - properties::WindowSize, - }, - terminal::{ - ansi::AnsiSplitter, - image::{ - Image, - printer::{ImageRegistry, ImageSpec}, - }, - should_hide_cursor, - }, - theme::{Alignment, ExecutionOutputBlockStyle, ExecutionStatusBlockStyle, Margin}, -}; -use crossterm::{ - ExecutableCommand, cursor, - terminal::{self, disable_raw_mode, enable_raw_mode}, -}; -use std::{ - cell::RefCell, - io::{self, BufRead}, - mem, - ops::{Deref, DerefMut}, - rc::Rc, -}; - -const MINIMUM_SEPARATOR_WIDTH: u16 = 32; - -#[derive(Debug)] -struct RunSnippetOperationInner { - handle: Option, - output_lines: Vec, - state: RenderAsyncState, - max_line_length: u16, - starting_style: TextStyle, - last_length: usize, -} - -#[derive(Debug)] -pub(crate) struct RunSnippetOperation { - code: Snippet, - executor: Rc, - default_colors: Colors, - block_colors: Colors, - style: ExecutionStatusBlockStyle, - block_length: u16, - alignment: Alignment, - inner: Rc>, - state_description: RefCell, - separator: DisplaySeparator, - font_size: u8, -} - -impl RunSnippetOperation { - #[allow(clippy::too_many_arguments)] - pub(crate) fn new( - code: Snippet, - executor: Rc, - default_colors: Colors, - style: ExecutionOutputBlockStyle, - block_length: u16, - separator: DisplaySeparator, - alignment: Alignment, - font_size: u8, - ) -> Self { - let block_colors = style.style.colors; - let status_colors = style.status.clone(); - let block_length = alignment.adjust_size(block_length); - let inner = RunSnippetOperationInner { - handle: None, - output_lines: Vec::new(), - state: RenderAsyncState::default(), - max_line_length: 0, - starting_style: TextStyle::default().size(font_size), - last_length: 0, - }; - Self { - code, - executor, - default_colors, - block_colors, - style: status_colors, - block_length, - alignment, - inner: Rc::new(RefCell::new(inner)), - state_description: Text::new("not started", style.status.not_started_style).into(), - separator, - font_size, - } - } -} - -#[derive(Debug)] -pub(crate) enum DisplaySeparator { - On, - Off, -} - -impl AsRenderOperations for RunSnippetOperation { - fn as_render_operations(&self, _dimensions: &WindowSize) -> Vec { - let inner = self.inner.borrow(); - let description = self.state_description.borrow(); - let mut operations = match self.separator { - DisplaySeparator::On => { - let heading = Line(vec![" [".into(), description.clone(), "] ".into()]); - let separator_width = match &self.alignment { - Alignment::Left { .. } | Alignment::Right { .. } => SeparatorWidth::FitToWindow, - // We need a minimum here otherwise if the code/block length is too narrow, the separator is - // word-wrapped and looks bad. - Alignment::Center { .. } => SeparatorWidth::Fixed(self.block_length.max(MINIMUM_SEPARATOR_WIDTH)), - }; - let separator = RenderSeparator::new(heading, separator_width, self.font_size); - vec![ - RenderOperation::RenderLineBreak, - RenderOperation::RenderDynamic(Rc::new(separator)), - RenderOperation::RenderLineBreak, - ] - } - DisplaySeparator::Off => vec![], - }; - if matches!(inner.state, RenderAsyncState::NotStarted) { - return operations; - } - operations.push(RenderOperation::RenderLineBreak); - - if self.block_colors.background.is_some() { - operations.push(RenderOperation::SetColors(self.block_colors)); - } - - let has_margin = match &self.alignment { - Alignment::Left { margin } => !margin.is_empty(), - Alignment::Right { margin } => !margin.is_empty(), - Alignment::Center { minimum_margin, minimum_size } => !minimum_margin.is_empty() || minimum_size != &0, - }; - let block_length = - if has_margin { self.block_length.max(inner.max_line_length) } else { inner.max_line_length }; - for line in &inner.output_lines { - operations.push(RenderOperation::RenderBlockLine(BlockLine { - prefix: "".into(), - right_padding_length: 0, - repeat_prefix_on_wrap: false, - text: line.clone(), - block_length, - alignment: self.alignment, - block_color: self.block_colors.background, - })); - operations.push(RenderOperation::RenderLineBreak); - } - operations.push(RenderOperation::SetColors(self.default_colors)); - operations - } -} - -impl RenderAsync for RunSnippetOperation { - fn poll_state(&self) -> RenderAsyncState { - let mut inner = self.inner.borrow_mut(); - let last_length = inner.last_length; - if let Some(handle) = inner.handle.as_mut() { - let mut state = handle.state.lock().unwrap(); - let ExecutionState { output, status } = &mut *state; - *self.state_description.borrow_mut() = match status { - ProcessStatus::Running => Text::new("running", self.style.running_style), - ProcessStatus::Success => Text::new("finished", self.style.success_style), - ProcessStatus::Failure => Text::new("finished with error", self.style.failure_style), - }; - let modified = output.len() != last_length; - let is_finished = status.is_finished(); - let mut lines = Vec::new(); - for line in output.lines() { - let mut line = line.expect("invalid utf8"); - if line.contains('\t') { - line = line.replace('\t', " "); - } - lines.push(line); - } - drop(state); - - let mut max_line_length = 0; - let (lines, style) = AnsiSplitter::new(inner.starting_style).split_lines(&lines); - for line in &lines { - let width = u16::try_from(line.width()).unwrap_or(u16::MAX); - max_line_length = max_line_length.max(width); - } - inner.starting_style = style; - if is_finished { - inner.handle.take(); - inner.state = RenderAsyncState::JustFinishedRendering; - } else { - inner.state = RenderAsyncState::Rendering { modified }; - } - inner.output_lines = lines; - inner.max_line_length = inner.max_line_length.max(max_line_length); - } - inner.state.clone() - } - - fn start_render(&self) -> bool { - let mut inner = self.inner.borrow_mut(); - if !matches!(inner.state, RenderAsyncState::NotStarted) { - return false; - } - match self.executor.execute_async(&self.code) { - Ok(handle) => { - inner.handle = Some(handle); - inner.state = RenderAsyncState::Rendering { modified: false }; - true - } - Err(e) => { - inner.output_lines = vec![WeightedLine::from(e.to_string())]; - inner.state = RenderAsyncState::Rendered; - true - } - } - } -} - -#[derive(Clone, Debug)] -pub(crate) struct SnippetExecutionDisabledOperation { - style: TextStyle, - alignment: Alignment, - started: RefCell, -} - -impl SnippetExecutionDisabledOperation { - pub(crate) fn new(style: TextStyle, alignment: Alignment) -> Self { - Self { style, alignment, started: Default::default() } - } -} - -impl AsRenderOperations for SnippetExecutionDisabledOperation { - fn as_render_operations(&self, _: &WindowSize) -> Vec { - if !*self.started.borrow() { - return Vec::new(); - } - vec![ - RenderOperation::RenderLineBreak, - RenderOperation::RenderText { - line: vec![Text::new("snippet execution is disabled", self.style)].into(), - alignment: self.alignment, - }, - RenderOperation::RenderLineBreak, - ] - } -} - -impl RenderAsync for SnippetExecutionDisabledOperation { - fn start_render(&self) -> bool { - let was_started = mem::replace(&mut *self.started.borrow_mut(), true); - !was_started - } - - fn poll_state(&self) -> RenderAsyncState { - RenderAsyncState::Rendered - } -} - -#[derive(Default, Clone)] -enum AcquireTerminalSnippetState { - #[default] - NotStarted, - Success, - Failure(Vec), -} - -impl std::fmt::Debug for AcquireTerminalSnippetState { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::NotStarted => write!(f, "NotStarted"), - Self::Success => write!(f, "Success"), - Self::Failure(_) => write!(f, "Failure"), - } - } -} - -#[derive(Debug)] -pub(crate) struct RunAcquireTerminalSnippet { - snippet: Snippet, - block_length: u16, - executor: Rc, - colors: ExecutionStatusBlockStyle, - state: RefCell, - font_size: u8, -} - -impl RunAcquireTerminalSnippet { - pub(crate) fn new( - snippet: Snippet, - executor: Rc, - colors: ExecutionStatusBlockStyle, - block_length: u16, - font_size: u8, - ) -> Self { - Self { snippet, block_length, executor, colors, state: Default::default(), font_size } - } -} - -impl RunAcquireTerminalSnippet { - fn invoke(&self) -> Result<(), String> { - let mut stdout = io::stdout(); - stdout - .execute(terminal::LeaveAlternateScreen) - .and_then(|_| disable_raw_mode()) - .map_err(|e| format!("failed to deinit terminal: {e}"))?; - - // save result for later, but first reinit the terminal - let result = self.executor.execute_sync(&self.snippet).map_err(|e| format!("failed to run snippet: {e}")); - - stdout - .execute(terminal::EnterAlternateScreen) - .and_then(|_| enable_raw_mode()) - .map_err(|e| format!("failed to reinit terminal: {e}"))?; - if should_hide_cursor() { - stdout.execute(cursor::Hide).map_err(|e| e.to_string())?; - } - result - } -} - -impl AsRenderOperations for RunAcquireTerminalSnippet { - fn as_render_operations(&self, _dimensions: &WindowSize) -> Vec { - let state = self.state.borrow(); - let separator_text = match state.deref() { - AcquireTerminalSnippetState::NotStarted => Text::new("not started", self.colors.not_started_style), - AcquireTerminalSnippetState::Success => Text::new("finished", self.colors.success_style), - AcquireTerminalSnippetState::Failure(_) => Text::new("finished with error", self.colors.failure_style), - }; - - let heading = Line(vec![" [".into(), separator_text, "] ".into()]); - let separator_width = SeparatorWidth::Fixed(self.block_length.max(MINIMUM_SEPARATOR_WIDTH)); - let separator = RenderSeparator::new(heading, separator_width, self.font_size); - let mut ops = vec![ - RenderOperation::RenderLineBreak, - RenderOperation::RenderDynamic(Rc::new(separator)), - RenderOperation::RenderLineBreak, - ]; - if let AcquireTerminalSnippetState::Failure(lines) = state.deref() { - ops.push(RenderOperation::RenderLineBreak); - for line in lines { - ops.extend([ - RenderOperation::RenderText { - line: vec![Text::new(line, self.colors.failure_style)].into(), - alignment: Alignment::Left { margin: Margin::Percent(25) }, - }, - RenderOperation::RenderLineBreak, - ]); - } - } - ops - } -} - -impl RenderAsync for RunAcquireTerminalSnippet { - fn start_render(&self) -> bool { - if !matches!(*self.state.borrow(), AcquireTerminalSnippetState::NotStarted) { - return false; - } - if let Err(e) = self.invoke() { - let lines = e.lines().map(ToString::to_string).collect(); - *self.state.borrow_mut() = AcquireTerminalSnippetState::Failure(lines); - } else { - *self.state.borrow_mut() = AcquireTerminalSnippetState::Success; - } - true - } - - fn poll_state(&self) -> RenderAsyncState { - RenderAsyncState::Rendered - } -} - -#[derive(Debug)] -pub(crate) struct RunImageSnippet { - snippet: Snippet, - executor: Rc, - state: RefCell, - image_registry: ImageRegistry, - colors: ExecutionStatusBlockStyle, -} - -impl RunImageSnippet { - pub(crate) fn new( - snippet: Snippet, - executor: Rc, - image_registry: ImageRegistry, - colors: ExecutionStatusBlockStyle, - ) -> Self { - Self { snippet, executor, image_registry, colors, state: Default::default() } - } - - fn load_image(&self, data: &[u8]) -> Result { - let image = match image::load_from_memory(data) { - Ok(image) => image, - Err(e) => { - return Err(e.to_string()); - } - }; - self.image_registry.register(ImageSpec::Generated(image)).map_err(|e| e.to_string()) - } -} - -impl RenderAsync for RunImageSnippet { - fn start_render(&self) -> bool { - if !matches!(*self.state.borrow(), RunImageSnippetState::NotStarted) { - return false; - } - let state = match self.executor.execute_async(&self.snippet) { - Ok(handle) => RunImageSnippetState::Running(handle), - Err(e) => RunImageSnippetState::Failure(e.to_string().lines().map(ToString::to_string).collect()), - }; - *self.state.borrow_mut() = state; - true - } - - fn poll_state(&self) -> RenderAsyncState { - let mut state = self.state.borrow_mut(); - match state.deref_mut() { - RunImageSnippetState::NotStarted => RenderAsyncState::NotStarted, - RunImageSnippetState::Running(handle) => { - let mut inner = handle.state.lock().unwrap(); - match inner.status { - ProcessStatus::Running => RenderAsyncState::Rendering { modified: false }, - ProcessStatus::Success => { - let data = mem::take(&mut inner.output); - drop(inner); - - let image = match self.load_image(&data) { - Ok(image) => image, - Err(e) => { - *state = RunImageSnippetState::Failure(vec![e.to_string()]); - return RenderAsyncState::JustFinishedRendering; - } - }; - *state = RunImageSnippetState::Success(image); - RenderAsyncState::JustFinishedRendering - } - ProcessStatus::Failure => { - let mut lines = Vec::new(); - for line in inner.output.lines() { - lines.push(line.unwrap_or_else(|_| String::new())); - } - drop(inner); - - *state = RunImageSnippetState::Failure(lines); - RenderAsyncState::JustFinishedRendering - } - } - } - RunImageSnippetState::Success(_) | RunImageSnippetState::Failure(_) => RenderAsyncState::Rendered, - } - } -} - -impl AsRenderOperations for RunImageSnippet { - fn as_render_operations(&self, _dimensions: &WindowSize) -> Vec { - let state = self.state.borrow(); - match state.deref() { - RunImageSnippetState::NotStarted | RunImageSnippetState::Running(_) => vec![], - RunImageSnippetState::Success(image) => { - vec![RenderOperation::RenderImage(image.clone(), ImageRenderProperties::default())] - } - RunImageSnippetState::Failure(lines) => { - let mut output = Vec::new(); - for line in lines { - output.extend([RenderOperation::RenderText { - line: vec![Text::new(line, self.colors.failure_style)].into(), - alignment: Alignment::Left { margin: Margin::Percent(25) }, - }]); - } - output - } - } - } -} - -#[derive(Debug, Default)] -enum RunImageSnippetState { - #[default] - NotStarted, - Running(ExecutionHandle), - Success(Image), - Failure(Vec), -} diff --git a/src/ui/execution/acquire_terminal.rs b/src/ui/execution/acquire_terminal.rs new file mode 100644 index 0000000..9b03062 --- /dev/null +++ b/src/ui/execution/acquire_terminal.rs @@ -0,0 +1,141 @@ +use crate::{ + code::{execute::SnippetExecutor, snippet::Snippet}, + markdown::elements::{Line, Text}, + render::{ + operation::{AsRenderOperations, Pollable, PollableState, RenderAsync, RenderOperation}, + properties::WindowSize, + }, + terminal::should_hide_cursor, + theme::{Alignment, ExecutionStatusBlockStyle, Margin}, + ui::separator::{RenderSeparator, SeparatorWidth}, +}; +use crossterm::{ + ExecutableCommand, cursor, + terminal::{self, disable_raw_mode, enable_raw_mode}, +}; +use std::{ + io::{self}, + ops::Deref, + rc::Rc, + sync::{Arc, Mutex}, +}; + +const MINIMUM_SEPARATOR_WIDTH: u16 = 32; + +#[derive(Debug)] +pub(crate) struct RunAcquireTerminalSnippet { + snippet: Snippet, + block_length: u16, + executor: Arc, + colors: ExecutionStatusBlockStyle, + state: Arc>, + font_size: u8, +} + +impl RunAcquireTerminalSnippet { + pub(crate) fn new( + snippet: Snippet, + executor: Arc, + colors: ExecutionStatusBlockStyle, + block_length: u16, + font_size: u8, + ) -> Self { + Self { snippet, block_length, executor, colors, state: Default::default(), font_size } + } + + fn invoke(&self) -> Result<(), String> { + let mut stdout = io::stdout(); + stdout + .execute(terminal::LeaveAlternateScreen) + .and_then(|_| disable_raw_mode()) + .map_err(|e| format!("failed to deinit terminal: {e}"))?; + + // save result for later, but first reinit the terminal + let result = self.executor.execute_sync(&self.snippet).map_err(|e| format!("failed to run snippet: {e}")); + + stdout + .execute(terminal::EnterAlternateScreen) + .and_then(|_| enable_raw_mode()) + .map_err(|e| format!("failed to reinit terminal: {e}"))?; + if should_hide_cursor() { + stdout.execute(cursor::Hide).map_err(|e| e.to_string())?; + } + result + } +} + +impl AsRenderOperations for RunAcquireTerminalSnippet { + fn as_render_operations(&self, _dimensions: &WindowSize) -> Vec { + let state = self.state.lock().unwrap(); + let separator_text = match state.deref() { + State::NotStarted => Text::new("not started", self.colors.not_started_style), + State::Success => Text::new("finished", self.colors.success_style), + State::Failure(_) => Text::new("finished with error", self.colors.failure_style), + }; + + let heading = Line(vec![" [".into(), separator_text, "] ".into()]); + let separator_width = SeparatorWidth::Fixed(self.block_length.max(MINIMUM_SEPARATOR_WIDTH)); + let separator = RenderSeparator::new(heading, separator_width, self.font_size); + let mut ops = vec![ + RenderOperation::RenderLineBreak, + RenderOperation::RenderDynamic(Rc::new(separator)), + RenderOperation::RenderLineBreak, + ]; + if let State::Failure(lines) = state.deref() { + ops.push(RenderOperation::RenderLineBreak); + for line in lines { + ops.extend([ + RenderOperation::RenderText { + line: vec![Text::new(line, self.colors.failure_style)].into(), + alignment: Alignment::Left { margin: Margin::Percent(25) }, + }, + RenderOperation::RenderLineBreak, + ]); + } + } + ops + } +} + +impl RenderAsync for RunAcquireTerminalSnippet { + fn pollable(&self) -> Box { + // Run within this method because we need to release/acquire the raw terminal in the main + // thread. + let mut state = self.state.lock().unwrap(); + if matches!(*state, State::NotStarted) { + if let Err(e) = self.invoke() { + let lines = e.lines().map(ToString::to_string).collect(); + *state = State::Failure(lines); + } else { + *state = State::Success; + } + } + Box::new(OperationPollable) + } +} + +#[derive(Default, Clone)] +enum State { + #[default] + NotStarted, + Success, + Failure(Vec), +} + +impl std::fmt::Debug for State { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NotStarted => write!(f, "NotStarted"), + Self::Success => write!(f, "Success"), + Self::Failure(_) => write!(f, "Failure"), + } + } +} + +struct OperationPollable; + +impl Pollable for OperationPollable { + fn poll(&mut self) -> PollableState { + PollableState::Done + } +} diff --git a/src/ui/execution/disabled.rs b/src/ui/execution/disabled.rs new file mode 100644 index 0000000..a4c8a65 --- /dev/null +++ b/src/ui/execution/disabled.rs @@ -0,0 +1,64 @@ +use crate::{ + markdown::{elements::Text, text_style::TextStyle}, + render::{ + operation::{AsRenderOperations, Pollable, RenderAsync, RenderAsyncStartPolicy, RenderOperation, ToggleState}, + properties::WindowSize, + }, + theme::Alignment, +}; +use std::sync::{Arc, Mutex}; + +#[derive(Clone, Debug)] +pub(crate) struct SnippetExecutionDisabledOperation { + text: Text, + alignment: Alignment, + policy: RenderAsyncStartPolicy, + toggled: Arc>, +} + +impl SnippetExecutionDisabledOperation { + pub(crate) fn new( + style: TextStyle, + alignment: Alignment, + policy: RenderAsyncStartPolicy, + exec_type: ExecutionType, + ) -> Self { + let (attribute, cli_parameter) = match exec_type { + ExecutionType::Execute => ("+exec", "-x"), + ExecutionType::ExecReplace => ("+exec_replace", "-X"), + ExecutionType::Image => ("+image", "-X"), + }; + let text = Text::new(format!("snippet {attribute} is disabled, run with {cli_parameter} to enable"), style); + Self { text, alignment, policy, toggled: Default::default() } + } +} + +impl AsRenderOperations for SnippetExecutionDisabledOperation { + fn as_render_operations(&self, _: &WindowSize) -> Vec { + if !*self.toggled.lock().unwrap() { + return Vec::new(); + } + vec![ + RenderOperation::RenderLineBreak, + RenderOperation::RenderText { line: vec![self.text.clone()].into(), alignment: self.alignment }, + RenderOperation::RenderLineBreak, + ] + } +} + +impl RenderAsync for SnippetExecutionDisabledOperation { + fn pollable(&self) -> Box { + Box::new(ToggleState::new(self.toggled.clone())) + } + + fn start_policy(&self) -> RenderAsyncStartPolicy { + self.policy + } +} + +#[derive(Debug)] +pub(crate) enum ExecutionType { + Execute, + ExecReplace, + Image, +} diff --git a/src/ui/execution/image.rs b/src/ui/execution/image.rs new file mode 100644 index 0000000..9fe6bef --- /dev/null +++ b/src/ui/execution/image.rs @@ -0,0 +1,159 @@ +use crate::{ + code::{ + execute::{ExecutionHandle, ProcessStatus, SnippetExecutor}, + snippet::Snippet, + }, + markdown::elements::Text, + render::{ + operation::{ + AsRenderOperations, ImageRenderProperties, Pollable, PollableState, RenderAsync, RenderAsyncStartPolicy, + RenderOperation, + }, + properties::WindowSize, + }, + terminal::image::{ + Image, + printer::{ImageRegistry, ImageSpec}, + }, + theme::{Alignment, ExecutionStatusBlockStyle, Margin}, +}; +use std::{ + io::BufRead, + mem, + ops::Deref, + sync::{Arc, Mutex}, +}; + +#[derive(Debug)] +pub(crate) struct RunImageSnippet { + snippet: Snippet, + executor: Arc, + state: Arc>, + image_registry: ImageRegistry, + colors: ExecutionStatusBlockStyle, +} + +impl RunImageSnippet { + pub(crate) fn new( + snippet: Snippet, + executor: Arc, + image_registry: ImageRegistry, + colors: ExecutionStatusBlockStyle, + ) -> Self { + Self { snippet, executor, image_registry, colors, state: Default::default() } + } +} + +impl RenderAsync for RunImageSnippet { + fn pollable(&self) -> Box { + Box::new(OperationPollable { + state: self.state.clone(), + executor: self.executor.clone(), + snippet: self.snippet.clone(), + image_registry: self.image_registry.clone(), + }) + } + + fn start_policy(&self) -> RenderAsyncStartPolicy { + RenderAsyncStartPolicy::Automatic + } +} + +impl AsRenderOperations for RunImageSnippet { + fn as_render_operations(&self, _dimensions: &WindowSize) -> Vec { + let state = self.state.lock().unwrap(); + match state.deref() { + State::NotStarted | State::Running(_) => vec![], + State::Success(image) => { + vec![RenderOperation::RenderImage(image.clone(), ImageRenderProperties::default())] + } + State::Failure(lines) => { + let mut output = Vec::new(); + for line in lines { + output.extend([RenderOperation::RenderText { + line: vec![Text::new(line, self.colors.failure_style)].into(), + alignment: Alignment::Left { margin: Margin::Percent(25) }, + }]); + } + output + } + } + } +} + +struct OperationPollable { + state: Arc>, + executor: Arc, + snippet: Snippet, + image_registry: ImageRegistry, +} + +impl OperationPollable { + fn load_image(&self, data: &[u8]) -> Result { + let image = match image::load_from_memory(data) { + Ok(image) => image, + Err(e) => { + return Err(e.to_string()); + } + }; + self.image_registry.register(ImageSpec::Generated(image)).map_err(|e| e.to_string()) + } +} + +impl Pollable for OperationPollable { + fn poll(&mut self) -> PollableState { + let mut state = self.state.lock().unwrap(); + match state.deref() { + State::NotStarted => match self.executor.execute_async(&self.snippet) { + Ok(handle) => { + *state = State::Running(handle); + PollableState::Unmodified + } + Err(e) => { + *state = State::Failure(e.to_string().lines().map(ToString::to_string).collect()); + PollableState::Done + } + }, + State::Running(handle) => { + let mut inner = handle.state.lock().unwrap(); + match inner.status { + ProcessStatus::Running => PollableState::Unmodified, + ProcessStatus::Success => { + let data = mem::take(&mut inner.output); + drop(inner); + + match self.load_image(&data) { + Ok(image) => { + *state = State::Success(image); + } + Err(e) => { + *state = State::Failure(vec![e.to_string()]); + } + }; + PollableState::Done + } + ProcessStatus::Failure => { + let mut lines = Vec::new(); + for line in inner.output.lines() { + lines.push(line.unwrap_or_else(|_| String::new())); + } + drop(inner); + + *state = State::Failure(lines); + PollableState::Done + } + } + } + State::Success(_) | State::Failure(_) => PollableState::Done, + } + } +} + +#[derive(Debug, Default)] +enum State { + #[default] + NotStarted, + Running(ExecutionHandle), + Success(Image), + Failure(Vec), +} diff --git a/src/ui/execution/mod.rs b/src/ui/execution/mod.rs new file mode 100644 index 0000000..ae64f2e --- /dev/null +++ b/src/ui/execution/mod.rs @@ -0,0 +1,9 @@ +pub(crate) mod acquire_terminal; +pub(crate) mod disabled; +pub(crate) mod image; +pub(crate) mod snippet; + +pub(crate) use acquire_terminal::RunAcquireTerminalSnippet; +pub(crate) use disabled::SnippetExecutionDisabledOperation; +pub(crate) use image::RunImageSnippet; +pub(crate) use snippet::RunSnippetOperation; diff --git a/src/ui/execution/snippet.rs b/src/ui/execution/snippet.rs new file mode 100644 index 0000000..4069f8b --- /dev/null +++ b/src/ui/execution/snippet.rs @@ -0,0 +1,243 @@ +use crate::{ + code::{ + execute::{ExecutionHandle, ExecutionState, ProcessStatus, SnippetExecutor}, + snippet::Snippet, + }, + markdown::{ + elements::{Line, Text}, + text::WeightedLine, + text_style::{Colors, TextStyle}, + }, + render::{ + operation::{ + AsRenderOperations, BlockLine, Pollable, PollableState, RenderAsync, RenderAsyncStartPolicy, + RenderOperation, + }, + properties::WindowSize, + }, + terminal::ansi::AnsiSplitter, + theme::{Alignment, ExecutionOutputBlockStyle, ExecutionStatusBlockStyle}, + ui::separator::{RenderSeparator, SeparatorWidth}, +}; +use std::{ + io::BufRead, + rc::Rc, + sync::{Arc, Mutex}, +}; + +const MINIMUM_SEPARATOR_WIDTH: u16 = 32; + +#[derive(Debug)] +struct Inner { + output_lines: Vec, + max_line_length: u16, + process_status: Option, + started: bool, +} + +#[derive(Debug)] +pub(crate) struct RunSnippetOperation { + code: Snippet, + executor: Arc, + default_colors: Colors, + block_colors: Colors, + style: ExecutionStatusBlockStyle, + block_length: u16, + alignment: Alignment, + inner: Arc>, + separator: DisplaySeparator, + font_size: u8, + policy: RenderAsyncStartPolicy, +} + +impl RunSnippetOperation { + #[allow(clippy::too_many_arguments)] + pub(crate) fn new( + code: Snippet, + executor: Arc, + default_colors: Colors, + style: ExecutionOutputBlockStyle, + block_length: u16, + separator: DisplaySeparator, + alignment: Alignment, + font_size: u8, + policy: RenderAsyncStartPolicy, + ) -> Self { + let block_colors = style.style.colors; + let status_colors = style.status.clone(); + let block_length = alignment.adjust_size(block_length); + let inner = Inner { output_lines: Vec::new(), max_line_length: 0, process_status: None, started: false }; + Self { + code, + executor, + default_colors, + block_colors, + style: status_colors, + block_length, + alignment, + inner: Arc::new(Mutex::new(inner)), + separator, + font_size, + policy, + } + } +} + +#[derive(Debug)] +pub(crate) enum DisplaySeparator { + On, + Off, +} + +impl AsRenderOperations for RunSnippetOperation { + fn as_render_operations(&self, _dimensions: &WindowSize) -> Vec { + let inner = self.inner.lock().unwrap(); + let description = match &inner.process_status { + Some(ProcessStatus::Running) => Text::new("running", self.style.running_style), + Some(ProcessStatus::Success) => Text::new("finished", self.style.success_style), + Some(ProcessStatus::Failure) => Text::new("finished with error", self.style.failure_style), + None => Text::new("not started", self.style.not_started_style), + }; + let mut operations = match self.separator { + DisplaySeparator::On => { + let heading = Line(vec![" [".into(), description.clone(), "] ".into()]); + let separator_width = match &self.alignment { + Alignment::Left { .. } | Alignment::Right { .. } => SeparatorWidth::FitToWindow, + // We need a minimum here otherwise if the code/block length is too narrow, the separator is + // word-wrapped and looks bad. + Alignment::Center { .. } => SeparatorWidth::Fixed(self.block_length.max(MINIMUM_SEPARATOR_WIDTH)), + }; + let separator = RenderSeparator::new(heading, separator_width, self.font_size); + vec![ + RenderOperation::RenderLineBreak, + RenderOperation::RenderDynamic(Rc::new(separator)), + RenderOperation::RenderLineBreak, + ] + } + DisplaySeparator::Off => vec![], + }; + if !inner.started { + return operations; + } + operations.push(RenderOperation::RenderLineBreak); + + if self.block_colors.background.is_some() { + operations.push(RenderOperation::SetColors(self.block_colors)); + } + + let has_margin = match &self.alignment { + Alignment::Left { margin } => !margin.is_empty(), + Alignment::Right { margin } => !margin.is_empty(), + Alignment::Center { minimum_margin, minimum_size } => !minimum_margin.is_empty() || minimum_size != &0, + }; + let block_length = + if has_margin { self.block_length.max(inner.max_line_length) } else { inner.max_line_length }; + for line in &inner.output_lines { + operations.push(RenderOperation::RenderBlockLine(BlockLine { + prefix: "".into(), + right_padding_length: 0, + repeat_prefix_on_wrap: false, + text: line.clone(), + block_length, + alignment: self.alignment, + block_color: self.block_colors.background, + })); + operations.push(RenderOperation::RenderLineBreak); + } + operations.push(RenderOperation::SetColors(self.default_colors)); + operations + } +} + +impl RenderAsync for RunSnippetOperation { + fn pollable(&self) -> Box { + Box::new(OperationPollable { + inner: self.inner.clone(), + executor: self.executor.clone(), + code: self.code.clone(), + handle: None, + last_length: 0, + starting_style: TextStyle::default().size(self.font_size), + }) + } + + fn start_policy(&self) -> RenderAsyncStartPolicy { + self.policy + } +} + +struct OperationPollable { + inner: Arc>, + executor: Arc, + code: Snippet, + handle: Option, + last_length: usize, + starting_style: TextStyle, +} + +impl OperationPollable { + fn try_start(&mut self) { + let mut inner = self.inner.lock().unwrap(); + if inner.started { + return; + } + inner.started = true; + match self.executor.execute_async(&self.code) { + Ok(handle) => { + self.handle = Some(handle); + } + Err(e) => { + inner.output_lines = vec![WeightedLine::from(e.to_string())]; + } + } + } +} + +impl Pollable for OperationPollable { + fn poll(&mut self) -> PollableState { + self.try_start(); + + // At this point if we don't have a handle it's because we're done. + let Some(handle) = self.handle.as_mut() else { return PollableState::Done }; + + // Pull data out of the process' output and drop the handle state. + let mut state = handle.state.lock().unwrap(); + let ExecutionState { output, status } = &mut *state; + let status = status.clone(); + + let modified = output.len() != self.last_length; + let mut lines = Vec::new(); + for line in output.lines() { + let mut line = line.expect("invalid utf8"); + if line.contains('\t') { + line = line.replace('\t', " "); + } + lines.push(line); + } + drop(state); + + let mut max_line_length = 0; + let (lines, style) = AnsiSplitter::new(self.starting_style).split_lines(&lines); + for line in &lines { + let width = u16::try_from(line.width()).unwrap_or(u16::MAX); + max_line_length = max_line_length.max(width); + } + + let mut inner = self.inner.lock().unwrap(); + let is_finished = status.is_finished(); + inner.process_status = Some(status); + inner.output_lines = lines; + inner.max_line_length = inner.max_line_length.max(max_line_length); + if is_finished { + self.handle.take(); + PollableState::Done + } else { + // Save the style so we continue with it next time + self.starting_style = style; + match modified { + true => PollableState::Modified, + false => PollableState::Unmodified, + } + } + } +}