From 8b2aadf73a0df8a05a760f1df92050749e3af5ea Mon Sep 17 00:00:00 2001 From: Matias Fontanini Date: Fri, 20 Oct 2023 17:22:52 -0700 Subject: [PATCH] Render on demand widgets on user command --- Cargo.lock | 10 +++ Cargo.toml | 1 + src/builder.rs | 130 +++++++++++++++++++++++++++++++++-- src/execute.rs | 26 +++---- src/input/source.rs | 29 +++++--- src/input/user.rs | 6 ++ src/presentation.rs | 49 +++++++++++++ src/presenter.rs | 54 ++++++++++++++- src/render/operator.rs | 11 ++- src/theme.rs | 12 ++++ themes/dark.yaml | 4 ++ themes/tokyonight-storm.yaml | 4 ++ 12 files changed, 305 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 20512c50..efd9dc83 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -741,6 +741,15 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.9" @@ -1036,6 +1045,7 @@ dependencies = [ "crossterm", "hex", "image", + "itertools", "merge-struct", "once_cell", "rstest", diff --git a/Cargo.toml b/Cargo.toml index 8491aa2c..8486b709 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ crossterm = { version = "0.27", features = ["serde"] } hex = "0.4" image = "0.24" merge-struct = "0.1.0" +itertools = "0.11" once_cell = "1.18" serde = { version = "1.0", features = ["derive"] } serde_yaml = "0.9" diff --git a/src/builder.rs b/src/builder.rs index 4cdd7d5c..ccb3a337 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -1,4 +1,5 @@ use crate::{ + execute::{CodeExecuter, ExecutionHandle, ExecutionState, ProcessStatus}, markdown::{ elements::{ Code, ListItem, ListItemType, MarkdownElement, ParagraphElement, StyledText, Table, TableRow, Text, @@ -7,7 +8,7 @@ use crate::{ }, presentation::{ AsRenderOperations, MarginProperties, PreformattedLine, Presentation, PresentationMetadata, - PresentationThemeMetadata, RenderOperation, Slide, + PresentationThemeMetadata, RenderOnDemand, RenderOnDemandState, RenderOperation, Slide, }, render::{ highlighting::{CodeHighlighter, CodeLine}, @@ -17,6 +18,7 @@ use crate::{ style::{Colors, TextStyle}, theme::{Alignment, AuthorPositioning, ElementType, FooterStyle, LoadThemeError, Margin, PresentationTheme}, }; +use itertools::Itertools; use serde::Deserialize; use std::{borrow::Cow, cell::RefCell, iter, mem, path::PathBuf, rc::Rc, str::FromStr}; use unicode_width::UnicodeWidthStr; @@ -428,12 +430,12 @@ impl<'a> PresentationBuilder<'a> { } fn push_code(&mut self, code: Code) { - let Code { contents, language, .. } = code; + let Code { contents, language, flags } = code; let mut code = String::new(); let horizontal_padding = self.theme.code.padding.horizontal.unwrap_or(0); let vertical_padding = self.theme.code.padding.vertical.unwrap_or(0); if horizontal_padding == 0 && vertical_padding == 0 { - code = contents; + code = contents.clone(); } else { if vertical_padding > 0 { code.push('\n'); @@ -465,6 +467,19 @@ impl<'a> PresentationBuilder<'a> { })); self.push_line_break(); } + if flags.execute { + self.push_code_execution(Code { contents, language, flags }); + } + } + + fn push_code_execution(&mut self, code: Code) { + let operation = RunCodeOperation::new( + code, + self.theme.default_style.colors.clone(), + self.theme.execution_output.colors.clone(), + ); + let operation = RenderOperation::RenderOnDemand(Rc::new(operation)); + self.slide_operations.push(operation); } fn terminate_slide(&mut self, mode: TerminateMode) { @@ -701,6 +716,112 @@ impl FromStr for CommentCommand { #[error("invalid command: {0}")] pub struct CommandParseError(#[from] serde_yaml::Error); +#[derive(Debug)] +struct RunCodeOperationInner { + handle: Option, + output_lines: Vec, + state: RenderOnDemandState, +} + +#[derive(Debug)] +pub struct RunCodeOperation { + code: Code, + default_colors: Colors, + block_colors: Colors, + inner: Rc>, +} + +impl RunCodeOperation { + fn new(code: Code, default_colors: Colors, block_colors: Colors) -> Self { + let inner = + RunCodeOperationInner { handle: None, output_lines: Vec::new(), state: RenderOnDemandState::default() }; + Self { code, default_colors, block_colors, inner: Rc::new(RefCell::new(inner)) } + } + + fn render_line(&self, line: String) -> RenderOperation { + let line_len = line.len(); + RenderOperation::RenderPreformattedLine(PreformattedLine { + text: line, + unformatted_length: line_len, + block_length: line_len, + alignment: Default::default(), + }) + } +} + +impl AsRenderOperations for RunCodeOperation { + fn as_render_operations(&self, dimensions: &WindowSize) -> Vec { + let inner = self.inner.borrow(); + if matches!(inner.state, RenderOnDemandState::NotStarted) { + return Vec::new(); + } + let state = match inner.state { + RenderOnDemandState::Rendered => "done", + _ => "running", + }; + let heading = format!(" [{state}] "); + // TODO remove `RenderSeparator` and turn it into a dynamic operation with optional heading + let dashes_len = (dimensions.columns as usize).saturating_sub(heading.len()) / 2; + let dashes = "—".repeat(dashes_len); + let separator = format!("{dashes}{heading}{dashes}"); + let mut operations = vec![ + RenderOperation::RenderLineBreak, + self.render_line(separator), + RenderOperation::RenderLineBreak, + RenderOperation::RenderLineBreak, + RenderOperation::SetColors(self.block_colors.clone()), + ]; + + for line in &inner.output_lines { + let chunks = line.chars().chunks(dimensions.columns as usize); + for chunk in &chunks { + operations.push(self.render_line(chunk.collect())); + operations.push(RenderOperation::RenderLineBreak); + } + } + operations.push(RenderOperation::SetColors(self.default_colors.clone())); + operations + } +} + +impl RenderOnDemand for RunCodeOperation { + fn poll_state(&self) -> RenderOnDemandState { + let mut inner = self.inner.borrow_mut(); + if let Some(handle) = inner.handle.as_mut() { + let state = handle.state(); + let ExecutionState { output, status } = state; + if status.is_finished() { + inner.handle.take(); + inner.state = RenderOnDemandState::Rendered; + } + inner.output_lines = output; + if matches!(status, ProcessStatus::Failure) { + inner.output_lines.push("[finished with error]".to_string()); + } + } + inner.state.clone() + } + + fn start_render(&self) -> bool { + let mut inner = self.inner.borrow_mut(); + if !matches!(inner.state, RenderOnDemandState::NotStarted) { + return false; + } + match CodeExecuter::execute(&self.code) { + Ok(handle) => { + inner.handle = Some(handle); + inner.state = RenderOnDemandState::Rendering; + true + } + Err(e) => { + inner.output_lines = vec![e.to_string()]; + inner.state = RenderOnDemandState::Rendered; + true + } + } + } +} + #[cfg(test)] mod test { use rstest::rstest; @@ -753,7 +874,8 @@ mod test { | RenderLineBreak | RenderImage(_) | RenderPreformattedLine(_) - | RenderDynamic(_) => true, + | RenderDynamic(_) + | RenderOnDemand(_) => true, } } diff --git a/src/execute.rs b/src/execute.rs index 3e6a92ba..a1ea64aa 100644 --- a/src/execute.rs +++ b/src/execute.rs @@ -64,6 +64,7 @@ pub enum CodeExecuteError { } /// A handle for the execution of a piece of code. +#[derive(Debug)] pub struct ExecutionHandle { state: Arc>, #[allow(dead_code)] @@ -100,10 +101,7 @@ impl ProcessReader { let stdout = BufReader::new(stdout); let _ = Self::process_output(self.state.clone(), stdout); let success = match self.handle.try_wait() { - Ok(Some(code)) => { - println!("Exit code {code:?}"); - code.success() - } + Ok(Some(code)) => code.success(), _ => false, }; let status = match success { @@ -124,18 +122,13 @@ impl ProcessReader { } /// The state of the execution of a process. -#[derive(Clone, Default)] +#[derive(Clone, Default, Debug)] pub struct ExecutionState { - output: Vec, - status: ProcessStatus, + pub output: Vec, + pub status: ProcessStatus, } impl ExecutionState { - /// Check whether the underlying process is finished. - pub fn is_finished(&self) -> bool { - matches!(self.status, ProcessStatus::Success | ProcessStatus::Failure) - } - /// Extract the lines printed so far. pub fn into_lines(self) -> Vec { self.output @@ -151,6 +144,13 @@ pub enum ProcessStatus { Failure, } +impl ProcessStatus { + /// Check whether the underlying process is finished. + pub fn is_finished(&self) -> bool { + matches!(self, ProcessStatus::Success | ProcessStatus::Failure) + } +} + #[cfg(test)] mod test { use super::*; @@ -166,7 +166,7 @@ echo 'bye'" let handle = CodeExecuter::execute(&code).expect("execution failed"); let state = loop { let state = handle.state(); - if state.is_finished() { + if state.status.is_finished() { break state; } }; diff --git a/src/input/source.rs b/src/input/source.rs index e04d8ae9..4e7a2003 100644 --- a/src/input/source.rs +++ b/src/input/source.rs @@ -23,20 +23,27 @@ impl CommandSource { /// Block until the next command arrives. pub fn next_command(&mut self) -> io::Result { loop { - match self.user_input.poll_next_command(Duration::from_millis(250)) { - Ok(Some(command)) => { - return Ok(Command::User(command)); - } - Ok(None) => (), - Err(e) => { - return Ok(Command::Abort { error: e.to_string() }); - } - }; - if self.watcher.has_modifications()? { - return Ok(Command::ReloadPresentation); + if let Some(command) = self.try_next_command()? { + return Ok(command); } } } + + /// Try to get the next command. + /// + /// This attempts to get a command and returns `Ok(None)` on timeout. + pub fn try_next_command(&mut self) -> io::Result> { + match self.user_input.poll_next_command(Duration::from_millis(250)) { + Ok(Some(command)) => { + return Ok(Some(Command::User(command))); + } + Ok(None) => (), + Err(e) => { + return Ok(Some(Command::Abort { error: e.to_string() })); + } + }; + if self.watcher.has_modifications()? { Ok(Some(Command::ReloadPresentation)) } else { Ok(None) } + } } /// A command. diff --git a/src/input/user.rs b/src/input/user.rs index 787b3d85..e98cbbd9 100644 --- a/src/input/user.rs +++ b/src/input/user.rs @@ -39,6 +39,9 @@ impl UserInput { KeyCode::Char('c') if event.modifiers == KeyModifiers::CONTROL => { (Some(UserCommand::Exit), InputState::Empty) } + KeyCode::Char('e') if event.modifiers == KeyModifiers::CONTROL => { + (Some(UserCommand::RenderWidgets), InputState::Empty) + } KeyCode::Char('G') => Self::apply_uppercase_g(state), KeyCode::Char('g') => Self::apply_lowercase_g(state), KeyCode::Char(number) if number.is_ascii_digit() => { @@ -105,6 +108,9 @@ pub enum UserCommand { /// Jump to one particular slide. JumpSlide(u32), + /// Render any widgets in the currently visible slide. + RenderWidgets, + /// Exit the presentation. Exit, } diff --git a/src/presentation.rs b/src/presentation.rs index 39565448..49718f08 100644 --- a/src/presentation.rs +++ b/src/presentation.rs @@ -89,6 +89,34 @@ impl Presentation { false } } + + /// Render all widgets in this slide. + pub fn render_slide_widgets(&mut self) -> bool { + let slide = self.current_slide_mut(); + let mut any_rendered = false; + for operation in &mut slide.render_operations { + if let RenderOperation::RenderOnDemand(operation) = operation { + any_rendered = any_rendered || operation.start_render(); + } + } + any_rendered + } + + /// Poll every widget in the current slide and check whether they're rendered. + pub fn widgets_rendered(&mut self) -> bool { + let slide = self.current_slide_mut(); + let mut all_rendered = true; + for operation in &mut slide.render_operations { + if let RenderOperation::RenderOnDemand(operation) = operation { + all_rendered = all_rendered && matches!(operation.poll_state(), RenderOnDemandState::Rendered); + } + } + all_rendered + } + + fn current_slide_mut(&mut self) -> &mut Slide { + &mut self.slides[self.current_slide_index] + } } /// A slide. @@ -187,6 +215,9 @@ pub enum RenderOperation { /// [RenderOperation] with the screen itself. RenderDynamic(Rc), + /// An operation that is rendered on demand. + RenderOnDemand(Rc), + /// Initialize a column layout. /// /// The value for each column is the width of the column in column-unit units, where the entire @@ -223,3 +254,21 @@ pub trait AsRenderOperations: std::fmt::Debug { /// Generate render operations. fn as_render_operations(&self, dimensions: &WindowSize) -> Vec; } + +/// A type that can be rendered on demand. +pub trait RenderOnDemand: AsRenderOperations { + /// Start the on demand render for this operation. + fn start_render(&self) -> bool; + + /// Poll and update the internal on demand state and return the latest. + fn poll_state(&self) -> RenderOnDemandState; +} + +/// The state of a [RenderOnDemand]. +#[derive(Clone, Debug, Default)] +pub enum RenderOnDemandState { + #[default] + NotStarted, + Rendering, + Rendered, +} diff --git a/src/presenter.rs b/src/presenter.rs index 14e1a70d..b5e27d61 100644 --- a/src/presenter.rs +++ b/src/presenter.rs @@ -15,6 +15,7 @@ use crate::{ theme::PresentationTheme, }; use std::{ + collections::HashSet, fs, io::{self, Stdout}, mem, @@ -32,6 +33,7 @@ pub struct Presenter<'a> { resources: Resources, mode: PresentMode, state: PresenterState, + slides_with_pending_widgets: HashSet, } impl<'a> Presenter<'a> { @@ -44,7 +46,16 @@ impl<'a> Presenter<'a> { resources: Resources, mode: PresentMode, ) -> Self { - Self { default_theme, default_highlighter, commands, parser, resources, mode, state: PresenterState::Empty } + Self { + default_theme, + default_highlighter, + commands, + parser, + resources, + mode, + state: PresenterState::Empty, + slides_with_pending_widgets: HashSet::new(), + } } /// Run a presentation. @@ -54,9 +65,14 @@ impl<'a> Presenter<'a> { let mut drawer = TerminalDrawer::new(io::stdout())?; loop { self.render(&mut drawer)?; + self.update_widgets(&mut drawer)?; loop { - let command = match self.commands.next_command()? { + self.update_widgets(&mut drawer)?; + let Some(command) = self.commands.try_next_command()? else { + continue; + }; + let command = match command { Command::User(command) => command, Command::ReloadPresentation => { self.try_reload(path); @@ -69,12 +85,28 @@ impl<'a> Presenter<'a> { CommandSideEffect::Redraw => { break; } + CommandSideEffect::PollWidgets => { + self.slides_with_pending_widgets.insert(self.state.presentation().current_slide_index()); + } CommandSideEffect::None => (), }; } } } + fn update_widgets(&mut self, drawer: &mut TerminalDrawer) -> RenderResult { + let current_index = self.state.presentation().current_slide_index(); + if self.slides_with_pending_widgets.contains(¤t_index) { + self.render(drawer)?; + if self.state.presentation_mut().widgets_rendered() { + // Render one last time just in case it _just_ rendered + self.render(drawer)?; + self.slides_with_pending_widgets.remove(¤t_index); + } + } + Ok(()) + } + fn render(&mut self, drawer: &mut TerminalDrawer) -> RenderResult { let result = match &self.state { PresenterState::Presenting(presentation) => drawer.render_slide(presentation), @@ -101,6 +133,14 @@ impl<'a> Presenter<'a> { UserCommand::JumpFirstSlide => presentation.jump_first_slide(), UserCommand::JumpLastSlide => presentation.jump_last_slide(), UserCommand::JumpSlide(number) => presentation.jump_slide(number.saturating_sub(1) as usize), + UserCommand::RenderWidgets => { + if presentation.render_slide_widgets() { + self.slides_with_pending_widgets.insert(self.state.presentation().current_slide_index()); + return CommandSideEffect::PollWidgets; + } else { + return CommandSideEffect::None; + } + } UserCommand::Exit => return CommandSideEffect::Exit, }; if needs_redraw { CommandSideEffect::Redraw } else { CommandSideEffect::None } @@ -110,6 +150,7 @@ impl<'a> Presenter<'a> { if matches!(self.mode, PresentMode::Presentation) { return; } + self.slides_with_pending_widgets.clear(); match self.load_presentation(path) { Ok(mut presentation) => { let current = self.state.presentation(); @@ -138,6 +179,7 @@ impl<'a> Presenter<'a> { enum CommandSideEffect { Exit, Redraw, + PollWidgets, None, } @@ -161,6 +203,14 @@ impl PresenterState { } } + fn presentation_mut(&mut self) -> &mut Presentation { + match self { + Self::Presenting(presentation) => presentation, + Self::Failure { presentation, .. } => presentation, + Self::Empty => panic!("state is empty"), + } + } + fn into_presentation(self) -> Presentation { match self { Self::Presenting(presentation) => presentation, diff --git a/src/render/operator.rs b/src/render/operator.rs index fd847ce2..3c912383 100644 --- a/src/render/operator.rs +++ b/src/render/operator.rs @@ -8,7 +8,7 @@ use super::{ }; use crate::{ markdown::text::WeightedLine, - presentation::{AsRenderOperations, MarginProperties, PreformattedLine, RenderOperation}, + presentation::{AsRenderOperations, MarginProperties, PreformattedLine, RenderOnDemand, RenderOperation}, render::{layout::Positioning, properties::WindowSize}, style::Colors, theme::Alignment, @@ -58,6 +58,7 @@ where RenderOperation::RenderImage(image) => self.render_image(image), RenderOperation::RenderPreformattedLine(operation) => self.render_preformatted_line(operation), RenderOperation::RenderDynamic(generator) => self.render_dynamic(generator.as_ref()), + RenderOperation::RenderOnDemand(generator) => self.render_on_demand(generator.as_ref()), RenderOperation::InitColumnLayout { columns } => self.init_column_layout(columns), RenderOperation::EnterColumn { column } => self.enter_column(*column), RenderOperation::ExitLayout => self.exit_layout(), @@ -175,6 +176,14 @@ where Ok(()) } + fn render_on_demand(&mut self, generator: &dyn RenderOnDemand) -> RenderResult { + let operations = generator.as_render_operations(self.current_dimensions()); + for operation in operations { + self.render_one(&operation)?; + } + Ok(()) + } + fn init_column_layout(&mut self, columns: &[u8]) -> RenderResult { if !matches!(self.layout, LayoutState::Default) { self.exit_layout()?; diff --git a/src/theme.rs b/src/theme.rs index 768132cf..e2c3707a 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -15,6 +15,10 @@ pub struct PresentationTheme { #[serde(default)] pub code: CodeBlockStyle, + /// The style for the execution output of a piece of code. + #[serde(default)] + pub execution_output: ExecutionOutputBlockStyle, + /// The style for inline code. #[serde(default)] pub inline_code: InlineCodeStyle, @@ -335,6 +339,14 @@ pub struct CodeBlockStyle { pub theme_name: Option, } +/// The style for the output of a code execution block. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub struct ExecutionOutputBlockStyle { + /// The colors to be used. + #[serde(default)] + pub colors: Colors, +} + /// The style for inline code. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub struct InlineCodeStyle { diff --git a/themes/dark.yaml b/themes/dark.yaml index e5aa444e..36b2e147 100644 --- a/themes/dark.yaml +++ b/themes/dark.yaml @@ -22,6 +22,10 @@ code: horizontal: 2 vertical: 1 +execution_output: + colors: + background: "2d2d2d" + inline_code: colors: foreground: "04de20" diff --git a/themes/tokyonight-storm.yaml b/themes/tokyonight-storm.yaml index a7a9ce82..466bdc43 100644 --- a/themes/tokyonight-storm.yaml +++ b/themes/tokyonight-storm.yaml @@ -22,6 +22,10 @@ code: horizontal: 2 vertical: 1 +execution_output: + colors: + background: "2d2d2d" + inline_code: colors: foreground: "9ece6a"