From f4fc5d00db4e0112690672cb16186472ce4fb969 Mon Sep 17 00:00:00 2001 From: Matias Fontanini Date: Wed, 18 Oct 2023 19:27:09 -0700 Subject: [PATCH 1/4] Add support for marking code blocks as executable --- src/builder.rs | 8 ++++++-- src/markdown/elements.rs | 18 +++++++++++++++++- src/markdown/parse.rs | 30 ++++++++++++++++++++++++------ src/render/highlighting.rs | 2 +- 4 files changed, 48 insertions(+), 10 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index 1afcfb12..5b7ee368 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -428,7 +428,7 @@ impl<'a> PresentationBuilder<'a> { } fn push_code(&mut self, code: Code) { - let Code { contents, language } = code; + let Code { contents, language, .. } = 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); @@ -817,7 +817,11 @@ mod test { let text = "苹果".to_string(); let elements = vec![ MarkdownElement::BlockQuote(vec![text.clone()]), - MarkdownElement::Code(Code { contents: text.clone(), language: ProgrammingLanguage::Unknown }), + MarkdownElement::Code(Code { + contents: text.clone(), + language: ProgrammingLanguage::Unknown, + flags: Default::default(), + }), ]; let presentation = build_presentation(elements); let slides = presentation.into_slides(); diff --git a/src/markdown/elements.rs b/src/markdown/elements.rs index e9627fc4..ee846fd0 100644 --- a/src/markdown/elements.rs +++ b/src/markdown/elements.rs @@ -152,6 +152,9 @@ pub struct Code { /// The programming language this code is written in. pub language: ProgrammingLanguage, + + /// The flags used for this code. + pub flags: CodeFlags, } /// A programming language. @@ -184,7 +187,7 @@ pub enum ProgrammingLanguage { R, Rust, Scala, - Shell, + Shell(String), Sql, TypeScript, Unknown, @@ -192,6 +195,19 @@ pub enum ProgrammingLanguage { Yaml, } +impl ProgrammingLanguage { + pub fn supports_execution(&self) -> bool { + matches!(self, Self::Shell(_)) + } +} + +/// Flags for code blocks. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct CodeFlags { + /// Whether a code block is marked as executable. + pub execute: bool, +} + /// A table. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Table { diff --git a/src/markdown/parse.rs b/src/markdown/parse.rs index ac64199f..193c2898 100644 --- a/src/markdown/parse.rs +++ b/src/markdown/parse.rs @@ -1,7 +1,7 @@ use crate::{ markdown::elements::{ - Code, ListItem, ListItemType, MarkdownElement, ParagraphElement, ProgrammingLanguage, StyledText, Table, - TableRow, Text, + Code, CodeFlags, ListItem, ListItemType, MarkdownElement, ParagraphElement, ProgrammingLanguage, StyledText, + Table, TableRow, Text, }, style::TextStyle, }; @@ -124,9 +124,10 @@ impl<'a> MarkdownParser<'a> { return Err(ParseErrorKind::UnfencedCodeBlock.with_sourcepos(sourcepos)); } use ProgrammingLanguage::*; - let language = match block.info.as_str() { + let info = block.info.as_str(); + let mut tokens = info.split(' '); + let language = match tokens.next().unwrap_or("") { "asp" => Asp, - "bash" => Bash, "c" => C, "csharp" => CSharp, "clojure" => Clojure, @@ -151,14 +152,16 @@ impl<'a> MarkdownParser<'a> { "r" => R, "rust" => Rust, "scala" => Scala, - "shell" | "sh" | "zsh" | "fish" => Shell, + "shell" => Shell("sh".into()), + interpreter @ ("bash" | "sh" | "zsh" | "fish") => Shell(interpreter.into()), "sql" => Sql, "typescript" | "ts" => TypeScript, "xml" => Xml, "yaml" => Yaml, _ => Unknown, }; - let code = Code { contents: block.literal.clone(), language }; + let flags = CodeFlags { execute: tokens.any(|token| token == "+exec") }; + let code = Code { contents: block.literal.clone(), language, flags }; Ok(MarkdownElement::Code(code)) } @@ -635,6 +638,21 @@ let q = 42; let MarkdownElement::Code(code) = parsed else { panic!("not a code block: {parsed:?}") }; assert_eq!(code.language, ProgrammingLanguage::Rust); assert_eq!(code.contents, "let q = 42;\n"); + assert!(!code.flags.execute); + } + + #[test] + fn executable_code_block() { + let parsed = parse_single( + r" +```bash +exec +echo hi mom +```` +", + ); + let MarkdownElement::Code(code) = parsed else { panic!("not a code block: {parsed:?}") }; + assert_eq!(code.language, ProgrammingLanguage::Shell("bash".into())); + assert!(code.flags.execute); } #[test] diff --git a/src/render/highlighting.rs b/src/render/highlighting.rs index c80cd6fa..a66962fd 100644 --- a/src/render/highlighting.rs +++ b/src/render/highlighting.rs @@ -70,7 +70,7 @@ impl CodeHighlighter { R => "r", Rust => "rs", Scala => "scala", - Shell => "sh", + Shell(_) => "sh", Sql => "sql", TypeScript => "js", Xml => "xml", From 973e98866870df493259b01a79326a0039a89ea0 Mon Sep 17 00:00:00 2001 From: Matias Fontanini Date: Thu, 19 Oct 2023 17:39:24 -0700 Subject: [PATCH 2/4] Add code executer --- Cargo.lock | 1 + Cargo.toml | 1 + src/builder.rs | 4 +- src/execute.rs | 185 +++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/markdown/elements.rs | 8 +- src/markdown/parse.rs | 10 +- src/render/highlighting.rs | 10 +- 8 files changed, 204 insertions(+), 16 deletions(-) create mode 100644 src/execute.rs diff --git a/Cargo.lock b/Cargo.lock index 2ff812aa..20512c50 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1044,6 +1044,7 @@ dependencies = [ "serde_yaml", "strum", "syntect", + "tempfile", "thiserror", "unicode-width", "viuer", diff --git a/Cargo.toml b/Cargo.toml index 122cef16..8491aa2c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ serde_yaml = "0.9" serde_with = "3.3" syntect = "5.1" strum = { version = "0.25", features = ["derive"] } +tempfile = "3.8" thiserror = "1" unicode-width = "0.1" viuer = "0.7.1" diff --git a/src/builder.rs b/src/builder.rs index 5b7ee368..4cdd7d5c 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -706,7 +706,7 @@ mod test { use rstest::rstest; use super::*; - use crate::{markdown::elements::ProgrammingLanguage, presentation::PreformattedLine}; + use crate::{markdown::elements::CodeLanguage, presentation::PreformattedLine}; fn build_presentation(elements: Vec) -> Presentation { try_build_presentation(elements).expect("build failed") @@ -819,7 +819,7 @@ mod test { MarkdownElement::BlockQuote(vec![text.clone()]), MarkdownElement::Code(Code { contents: text.clone(), - language: ProgrammingLanguage::Unknown, + language: CodeLanguage::Unknown, flags: Default::default(), }), ]; diff --git a/src/execute.rs b/src/execute.rs new file mode 100644 index 00000000..3e6a92ba --- /dev/null +++ b/src/execute.rs @@ -0,0 +1,185 @@ +//! Code execution. + +use crate::markdown::elements::{Code, CodeLanguage}; +use std::{ + io::{self, BufRead, BufReader, Write}, + process::{self, ChildStdout, Stdio}, + sync::{Arc, Mutex}, + thread::{self}, +}; +use tempfile::NamedTempFile; + +/// Allows executing code. +pub struct CodeExecuter; + +impl CodeExecuter { + /// Execute a piece of code. + pub fn execute(code: &Code) -> Result { + if !code.language.supports_execution() { + return Err(CodeExecuteError::UnsupportedExecution); + } + if !code.flags.execute { + return Err(CodeExecuteError::NotExecutableCode); + } + match &code.language { + CodeLanguage::Shell(interpreter) => Self::execute_shell(interpreter, &code.contents), + _ => Err(CodeExecuteError::UnsupportedExecution), + } + } + + fn execute_shell(interpreter: &str, code: &str) -> Result { + let mut output_file = NamedTempFile::new().map_err(CodeExecuteError::TempFile)?; + output_file.write_all(code.as_bytes()).map_err(CodeExecuteError::TempFile)?; + output_file.flush().map_err(CodeExecuteError::TempFile)?; + let process_handle = process::Command::new("/usr/bin/env") + .arg(interpreter) + .arg(output_file.path()) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .map_err(CodeExecuteError::SpawnProcess)?; + + let state: Arc> = Default::default(); + let reader_handle = ProcessReader::spawn(process_handle, state.clone(), output_file); + let handle = ExecutionHandle { state, reader_handle }; + Ok(handle) + } +} + +/// An error during the execution of some code. +#[derive(thiserror::Error, Debug)] +pub enum CodeExecuteError { + #[error("code language doesn't support execution")] + UnsupportedExecution, + + #[error("code is not marked for execution")] + NotExecutableCode, + + #[error("error creating temporary file: {0}")] + TempFile(io::Error), + + #[error("error spawning process: {0}")] + SpawnProcess(io::Error), +} + +/// A handle for the execution of a piece of code. +pub struct ExecutionHandle { + state: Arc>, + #[allow(dead_code)] + reader_handle: thread::JoinHandle<()>, +} + +impl ExecutionHandle { + /// Get the current state of the process. + pub fn state(&self) -> ExecutionState { + self.state.lock().unwrap().clone() + } +} + +/// Consumes the output of a process and stores it in a shared state. +struct ProcessReader { + handle: process::Child, + state: Arc>, + #[allow(dead_code)] + file_handle: NamedTempFile, +} + +impl ProcessReader { + fn spawn( + handle: process::Child, + state: Arc>, + file_handle: NamedTempFile, + ) -> thread::JoinHandle<()> { + let reader = Self { handle, state, file_handle }; + thread::spawn(|| reader.run()) + } + + fn run(mut self) { + let stdout = self.handle.stdout.take().expect("no stdout"); + 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() + } + _ => false, + }; + let status = match success { + true => ProcessStatus::Success, + false => ProcessStatus::Failure, + }; + self.state.lock().unwrap().status = status; + } + + fn process_output(state: Arc>, stdout: BufReader) -> io::Result<()> { + for line in stdout.lines() { + let line = line?; + // TODO: consider not locking per line... + state.lock().unwrap().output.push(line); + } + Ok(()) + } +} + +/// The state of the execution of a process. +#[derive(Clone, Default)] +pub struct ExecutionState { + output: Vec, + 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 + } +} + +/// The status of a process. +#[derive(Clone, Debug, Default)] +pub enum ProcessStatus { + #[default] + Running, + Success, + Failure, +} + +#[cfg(test)] +mod test { + use super::*; + use crate::markdown::elements::CodeFlags; + + #[test] + fn shell_code_execution() { + let contents = r" +echo 'hello world' +echo 'bye'" + .into(); + let code = Code { contents, language: CodeLanguage::Shell("sh".into()), flags: CodeFlags { execute: true } }; + let handle = CodeExecuter::execute(&code).expect("execution failed"); + let state = loop { + let state = handle.state(); + if state.is_finished() { + break state; + } + }; + + let expected_lines = vec!["hello world", "bye"]; + assert_eq!(state.into_lines(), expected_lines); + } + + #[test] + fn non_executable_code_cant_be_executed() { + let contents = String::new(); + let code = Code { contents, language: CodeLanguage::Shell("sh".into()), flags: CodeFlags { execute: false } }; + let result = CodeExecuter::execute(&code); + assert!(result.is_err()); + } +} diff --git a/src/lib.rs b/src/lib.rs index 404f20b1..eab30eac 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ pub mod builder; pub mod diff; +pub mod execute; pub mod input; pub mod markdown; pub mod presentation; diff --git a/src/markdown/elements.rs b/src/markdown/elements.rs index ee846fd0..7f6f2d8e 100644 --- a/src/markdown/elements.rs +++ b/src/markdown/elements.rs @@ -151,15 +151,15 @@ pub struct Code { pub contents: String, /// The programming language this code is written in. - pub language: ProgrammingLanguage, + pub language: CodeLanguage, /// The flags used for this code. pub flags: CodeFlags, } -/// A programming language. +/// The language of a piece of code. #[derive(Clone, Debug, PartialEq, Eq, EnumIter)] -pub enum ProgrammingLanguage { +pub enum CodeLanguage { Asp, Bash, BatchFile, @@ -195,7 +195,7 @@ pub enum ProgrammingLanguage { Yaml, } -impl ProgrammingLanguage { +impl CodeLanguage { pub fn supports_execution(&self) -> bool { matches!(self, Self::Shell(_)) } diff --git a/src/markdown/parse.rs b/src/markdown/parse.rs index 193c2898..aba735a3 100644 --- a/src/markdown/parse.rs +++ b/src/markdown/parse.rs @@ -1,7 +1,7 @@ use crate::{ markdown::elements::{ - Code, CodeFlags, ListItem, ListItemType, MarkdownElement, ParagraphElement, ProgrammingLanguage, StyledText, - Table, TableRow, Text, + Code, CodeFlags, CodeLanguage, ListItem, ListItemType, MarkdownElement, ParagraphElement, StyledText, Table, + TableRow, Text, }, style::TextStyle, }; @@ -123,7 +123,7 @@ impl<'a> MarkdownParser<'a> { if !block.fenced { return Err(ParseErrorKind::UnfencedCodeBlock.with_sourcepos(sourcepos)); } - use ProgrammingLanguage::*; + use CodeLanguage::*; let info = block.info.as_str(); let mut tokens = info.split(' '); let language = match tokens.next().unwrap_or("") { @@ -636,7 +636,7 @@ let q = 42; ", ); let MarkdownElement::Code(code) = parsed else { panic!("not a code block: {parsed:?}") }; - assert_eq!(code.language, ProgrammingLanguage::Rust); + assert_eq!(code.language, CodeLanguage::Rust); assert_eq!(code.contents, "let q = 42;\n"); assert!(!code.flags.execute); } @@ -651,7 +651,7 @@ echo hi mom ", ); let MarkdownElement::Code(code) = parsed else { panic!("not a code block: {parsed:?}") }; - assert_eq!(code.language, ProgrammingLanguage::Shell("bash".into())); + assert_eq!(code.language, CodeLanguage::Shell("bash".into())); assert!(code.flags.execute); } diff --git a/src/render/highlighting.rs b/src/render/highlighting.rs index a66962fd..a8a1e8f7 100644 --- a/src/render/highlighting.rs +++ b/src/render/highlighting.rs @@ -1,4 +1,4 @@ -use crate::markdown::elements::ProgrammingLanguage; +use crate::markdown::elements::CodeLanguage; use once_cell::sync::Lazy; use syntect::{ easy::HighlightLines, @@ -26,7 +26,7 @@ impl CodeHighlighter { /// Highlight a piece of code. /// /// This splits the given piece of code into lines, highlights them individually, and returns them. - pub fn highlight<'a>(&self, code: &'a str, language: &ProgrammingLanguage) -> Vec> { + pub fn highlight<'a>(&self, code: &'a str, language: &CodeLanguage) -> Vec> { let extension = Self::language_extension(language); let syntax = SYNTAX_SET.find_syntax_by_extension(extension).unwrap(); let mut highlight_lines = HighlightLines::new(syntax, self.theme); @@ -40,8 +40,8 @@ impl CodeHighlighter { lines } - fn language_extension(language: &ProgrammingLanguage) -> &'static str { - use ProgrammingLanguage::*; + fn language_extension(language: &CodeLanguage) -> &'static str { + use CodeLanguage::*; match language { Asp => "asa", Bash => "bash", @@ -104,7 +104,7 @@ mod test { #[test] fn language_extensions_exist() { - for language in ProgrammingLanguage::iter() { + for language in CodeLanguage::iter() { let extension = CodeHighlighter::language_extension(&language); let syntax = SYNTAX_SET.find_syntax_by_extension(extension); assert!(syntax.is_some(), "extension {extension} for {language:?} not found"); From 8b2aadf73a0df8a05a760f1df92050749e3af5ea Mon Sep 17 00:00:00 2001 From: Matias Fontanini Date: Fri, 20 Oct 2023 17:22:52 -0700 Subject: [PATCH 3/4] 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" From e45058988ac79aac0ee10a0630747835bcd93900 Mon Sep 17 00:00:00 2001 From: Matias Fontanini Date: Fri, 20 Oct 2023 17:27:34 -0700 Subject: [PATCH 4/4] Add note on README re: code execution --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index e28a69cb..ff10b8aa 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ nix run github:mfontanini/presenterm * Configurable [column layouts](/docs/layouts.md) that let you structure parts of your slide into columns. * Support for an introduction slide that displays the presentation title and your name. * Support for slide titles. +* Support for shell code execution. * Create pauses in between each slide so that it progressively renders for a more interactive presentation. * Text formatting support for **bold**, _italics_, ~strikethrough~, and `inline code`. * Automatically reload your presentation every time it changes for a fast development loop. @@ -188,6 +189,15 @@ them to be, and then put any content into them. For example: See the [documentation](/docs/layouts.md) on layouts to learn more. +## Shell code execution + +> **Note**: this is available in the `master` branch and in the upcoming 0.3.0 version. + +Any shell code can be marked for execution, making _presenterm_ execute it and render its output when you press ctrl+e. +In order to do this, annotate the code block with `+exec` (e.g. `bash +exec`). **Obviously use this at your own risk!** + +[![asciicast](https://asciinema.org/a/1v3IqCEtU9tqDjVj78Pp7SSe2.svg)](https://asciinema.org/a/1v3IqCEtU9tqDjVj78Pp7SSe2) + ## Navigation Navigation should be intuitive: jumping to the next/previous slide can be done by using the arrow, _hjkl_, and page