chore: refactor async renders

This commit is contained in:
Matias Fontanini 2025-04-20 14:04:41 -07:00
parent 31f7c6c1e2
commit 5ec3a12d30
18 changed files with 974 additions and 731 deletions

View File

@ -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),
}

View File

@ -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,

View File

@ -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<SnippetExecutor>,
code_executor: Arc<SnippetExecutor>,
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<SnippetExecutor>,
code_executor: Arc<SnippetExecutor>,
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)));
}
}
}

View File

@ -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<SnippetExecutor>,
code_executor: Arc<SnippetExecutor>,
resources: Resources,
printer: Arc<ImagePrinter>,
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,

View File

@ -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<Box<dyn ChunkMutator>>,
slide_builders: Vec<SlideBuilder>,
highlighter: SnippetHighlighter,
code_executor: Rc<SnippetExecutor>,
code_executor: Arc<SnippetExecutor>,
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<SnippetExecutor>,
code_executor: Arc<SnippetExecutor>,
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(

View File

@ -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<SnippetExecutor>,
pub(crate) snippet_executor: Arc<SnippetExecutor>,
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<Box<dyn ChunkMutator>>,
resources: &'a Resources,
image_registry: &'a ImageRegistry,
snippet_executor: Rc<SnippetExecutor>,
snippet_executor: Arc<SnippetExecutor>,
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(())

View File

@ -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<dyn Pollable> {
// Use some random one, we don't care
Box::new(ToggleState::new(Default::default()))
}
}

View File

@ -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<usize> {
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]
}

118
src/presentation/poller.rs Normal file
View File

@ -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<PollerCommand>,
receiver: Receiver<PollableEffect>,
}
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<PollableEffect> {
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<dyn Pollable>, slide: usize },
/// Reset all pollables.
Reset,
}
struct PollerWorker {
receiver: Receiver<PollerCommand>,
sender: Sender<PollableEffect>,
pollables: Vec<(Box<dyn Pollable>, usize)>,
}
impl PollerWorker {
fn new(receiver: Receiver<PollerCommand>, sender: Sender<PollableEffect>) -> 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);
}
}
}

View File

@ -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<SnippetExecutor>,
code_executor: Arc<SnippetExecutor>,
state: PresenterState,
slides_with_pending_async_renders: HashSet<usize>,
image_printer: Arc<ImagePrinter>,
themes: Themes,
options: PresenterOptions,
speaker_notes_event_publisher: Option<SpeakerNotesEventPublisher>,
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<SnippetExecutor>,
code_executor: Arc<SnippetExecutor>,
themes: Themes,
image_printer: Arc<ImagePrinter>,
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<bool, PresentationError> {
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<bool, RenderError> {
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<bool, RenderError> {
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<Box<dyn Pollable>> {
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(())
}
}

View File

@ -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<dyn Pollable>;
/// 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<Mutex<bool>>,
}
impl ToggleState {
pub(crate) fn new(toggled: Arc<Mutex<bool>>) -> Self {
Self { toggled }
}
}
impl Pollable for ToggleState {
fn poll(&mut self) -> PollableState {
*self.toggled.lock().unwrap() = true;
PollableState::Done
}
}

View File

@ -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<Percent>,
) -> Result<RenderOperation, ThirdPartyRenderError> {
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<Mutex<Option<Image>>>,
contents: Arc<Mutex<Option<Output>>>,
pending_result: Arc<Mutex<RenderResult>>,
default_style: TextStyle,
error_holder: AsyncPresentationErrorHolder,
slide: usize,
width: Option<Percent>,
}
impl RenderThirdParty {
fn new(
pending_result: Arc<Mutex<RenderResult>>,
default_style: TextStyle,
error_holder: AsyncPresentationErrorHolder,
slide: usize,
width: Option<Percent>,
) -> Self {
Self { contents: Default::default(), pending_result, default_style, error_holder, slide, width }
fn new(pending_result: Arc<Mutex<RenderResult>>, default_style: TextStyle, width: Option<Percent>) -> Self {
Self { contents: Default::default(), pending_result, default_style, width }
}
}
impl RenderAsync for RenderThirdParty {
fn start_render(&self) -> bool {
false
fn pollable(&self) -> Box<dyn Pollable> {
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<RenderOperation> {
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<Mutex<Option<Output>>>,
pending_result: Arc<Mutex<RenderResult>>,
}
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,
}
}
}

View File

@ -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<ExecutionHandle>,
output_lines: Vec<WeightedLine>,
state: RenderAsyncState,
max_line_length: u16,
starting_style: TextStyle,
last_length: usize,
}
#[derive(Debug)]
pub(crate) struct RunSnippetOperation {
code: Snippet,
executor: Rc<SnippetExecutor>,
default_colors: Colors,
block_colors: Colors,
style: ExecutionStatusBlockStyle,
block_length: u16,
alignment: Alignment,
inner: Rc<RefCell<RunSnippetOperationInner>>,
state_description: RefCell<Text>,
separator: DisplaySeparator,
font_size: u8,
}
impl RunSnippetOperation {
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
code: Snippet,
executor: Rc<SnippetExecutor>,
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<RenderOperation> {
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<bool>,
}
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<RenderOperation> {
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<String>),
}
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<SnippetExecutor>,
colors: ExecutionStatusBlockStyle,
state: RefCell<AcquireTerminalSnippetState>,
font_size: u8,
}
impl RunAcquireTerminalSnippet {
pub(crate) fn new(
snippet: Snippet,
executor: Rc<SnippetExecutor>,
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<RenderOperation> {
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<SnippetExecutor>,
state: RefCell<RunImageSnippetState>,
image_registry: ImageRegistry,
colors: ExecutionStatusBlockStyle,
}
impl RunImageSnippet {
pub(crate) fn new(
snippet: Snippet,
executor: Rc<SnippetExecutor>,
image_registry: ImageRegistry,
colors: ExecutionStatusBlockStyle,
) -> Self {
Self { snippet, executor, image_registry, colors, state: Default::default() }
}
fn load_image(&self, data: &[u8]) -> Result<Image, String> {
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<RenderOperation> {
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<String>),
}

View File

@ -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<SnippetExecutor>,
colors: ExecutionStatusBlockStyle,
state: Arc<Mutex<State>>,
font_size: u8,
}
impl RunAcquireTerminalSnippet {
pub(crate) fn new(
snippet: Snippet,
executor: Arc<SnippetExecutor>,
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<RenderOperation> {
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<dyn Pollable> {
// 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<String>),
}
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
}
}

View File

@ -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<Mutex<bool>>,
}
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<RenderOperation> {
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<dyn Pollable> {
Box::new(ToggleState::new(self.toggled.clone()))
}
fn start_policy(&self) -> RenderAsyncStartPolicy {
self.policy
}
}
#[derive(Debug)]
pub(crate) enum ExecutionType {
Execute,
ExecReplace,
Image,
}

159
src/ui/execution/image.rs Normal file
View File

@ -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<SnippetExecutor>,
state: Arc<Mutex<State>>,
image_registry: ImageRegistry,
colors: ExecutionStatusBlockStyle,
}
impl RunImageSnippet {
pub(crate) fn new(
snippet: Snippet,
executor: Arc<SnippetExecutor>,
image_registry: ImageRegistry,
colors: ExecutionStatusBlockStyle,
) -> Self {
Self { snippet, executor, image_registry, colors, state: Default::default() }
}
}
impl RenderAsync for RunImageSnippet {
fn pollable(&self) -> Box<dyn Pollable> {
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<RenderOperation> {
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<Mutex<State>>,
executor: Arc<SnippetExecutor>,
snippet: Snippet,
image_registry: ImageRegistry,
}
impl OperationPollable {
fn load_image(&self, data: &[u8]) -> Result<Image, String> {
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<String>),
}

9
src/ui/execution/mod.rs Normal file
View File

@ -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;

243
src/ui/execution/snippet.rs Normal file
View File

@ -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<WeightedLine>,
max_line_length: u16,
process_status: Option<ProcessStatus>,
started: bool,
}
#[derive(Debug)]
pub(crate) struct RunSnippetOperation {
code: Snippet,
executor: Arc<SnippetExecutor>,
default_colors: Colors,
block_colors: Colors,
style: ExecutionStatusBlockStyle,
block_length: u16,
alignment: Alignment,
inner: Arc<Mutex<Inner>>,
separator: DisplaySeparator,
font_size: u8,
policy: RenderAsyncStartPolicy,
}
impl RunSnippetOperation {
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
code: Snippet,
executor: Arc<SnippetExecutor>,
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<RenderOperation> {
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<dyn Pollable> {
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<Mutex<Inner>>,
executor: Arc<SnippetExecutor>,
code: Snippet,
handle: Option<ExecutionHandle>,
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,
}
}
}
}