Skip to content

Commit 22af066

Browse files
authored
feat: add +image to code blocks to consume their output as an image (#429)
This adds a new `+image` attribute to code blocks. This inherits `+exec_replace` (meaning it has the same semantics and requires being enabled with the same parameters) but it assumes the output of the executed block is an image and renders it as such. This means a presentation like the following one: ~~~markdown hi ---- ```bash +image curl -s -L -o - 'https://github.com/mfontanini/presenterm/blob/master/examples/doge.png?raw=true' ``` ~~~ Renders like this ![image](https://github.com/user-attachments/assets/e7bd7a97-5dd4-457b-ac24-1a6592ae6d24) For this to work, **the entire output of the code snippet must be an image written to stdout**. An application of this feature could be to have text to image conversions to create titles on the fly. Hopefully this opens up the door for more creative ideas users will definitely have.
2 parents e4b2b38 + 5968289 commit 22af066

File tree

5 files changed

+245
-44
lines changed

5 files changed

+245
-44
lines changed

src/code/execute.rs

+52-24
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use std::{
1010
collections::{BTreeMap, HashMap},
1111
fmt::{self, Debug},
1212
fs::File,
13-
io::{self, BufRead, BufReader, Write},
13+
io::{self, BufRead, BufReader, Read, Write},
1414
path::{Path, PathBuf},
1515
process::{self, Child, Stdio},
1616
sync::{Arc, Mutex},
@@ -59,12 +59,17 @@ impl SnippetExecutor {
5959
let config = self.language_config(snippet)?;
6060
let script_dir = Self::write_snippet(snippet, config)?;
6161
let state: Arc<Mutex<ExecutionState>> = Default::default();
62+
let output_type = match snippet.attributes.image {
63+
true => OutputType::Binary,
64+
false => OutputType::Lines,
65+
};
6266
let reader_handle = CommandsRunner::spawn(
6367
state.clone(),
6468
script_dir,
6569
config.commands.clone(),
6670
config.environment.clone(),
6771
self.cwd.to_path_buf(),
72+
output_type,
6873
);
6974
let handle = ExecutionHandle { state, reader_handle };
7075
Ok(handle)
@@ -183,15 +188,16 @@ impl CommandsRunner {
183188
commands: Vec<Vec<String>>,
184189
env: HashMap<String, String>,
185190
cwd: PathBuf,
191+
output_type: OutputType,
186192
) -> thread::JoinHandle<()> {
187193
let reader = Self { state, script_directory };
188-
thread::spawn(|| reader.run(commands, env, cwd))
194+
thread::spawn(move || reader.run(commands, env, cwd, output_type))
189195
}
190196

191-
fn run(self, commands: Vec<Vec<String>>, env: HashMap<String, String>, cwd: PathBuf) {
197+
fn run(self, commands: Vec<Vec<String>>, env: HashMap<String, String>, cwd: PathBuf, output_type: OutputType) {
192198
let mut last_result = true;
193199
for command in commands {
194-
last_result = self.run_command(command, &env, &cwd);
200+
last_result = self.run_command(command, &env, &cwd, output_type);
195201
if !last_result {
196202
break;
197203
}
@@ -203,17 +209,23 @@ impl CommandsRunner {
203209
self.state.lock().unwrap().status = status;
204210
}
205211

206-
fn run_command(&self, command: Vec<String>, env: &HashMap<String, String>, cwd: &Path) -> bool {
212+
fn run_command(
213+
&self,
214+
command: Vec<String>,
215+
env: &HashMap<String, String>,
216+
cwd: &Path,
217+
output_type: OutputType,
218+
) -> bool {
207219
let (mut child, reader) = match self.launch_process(command, env, cwd) {
208220
Ok(inner) => inner,
209221
Err(e) => {
210222
let mut state = self.state.lock().unwrap();
211223
state.status = ProcessStatus::Failure;
212-
state.output.push(e.to_string());
224+
state.output.extend(e.to_string().into_bytes());
213225
return false;
214226
}
215227
};
216-
let _ = Self::process_output(self.state.clone(), reader);
228+
let _ = Self::process_output(self.state.clone(), reader, output_type);
217229

218230
match child.wait() {
219231
Ok(code) => code.success(),
@@ -246,24 +258,41 @@ impl CommandsRunner {
246258
Ok((child, reader))
247259
}
248260

249-
fn process_output(state: Arc<Mutex<ExecutionState>>, reader: os_pipe::PipeReader) -> io::Result<()> {
250-
let reader = BufReader::new(reader);
251-
for line in reader.lines() {
252-
let mut line = line?;
253-
if line.contains('\t') {
254-
line = line.replace('\t', " ");
261+
fn process_output(
262+
state: Arc<Mutex<ExecutionState>>,
263+
mut reader: os_pipe::PipeReader,
264+
output_type: OutputType,
265+
) -> io::Result<()> {
266+
match output_type {
267+
OutputType::Lines => {
268+
let reader = BufReader::new(reader);
269+
for line in reader.lines() {
270+
let mut state = state.lock().unwrap();
271+
state.output.extend(line?.into_bytes());
272+
state.output.push(b'\n');
273+
}
274+
Ok(())
275+
}
276+
OutputType::Binary => {
277+
let mut buffer = Vec::new();
278+
reader.read_to_end(&mut buffer)?;
279+
state.lock().unwrap().output.extend(buffer);
280+
Ok(())
255281
}
256-
// TODO: consider not locking per line...
257-
state.lock().unwrap().output.push(line);
258282
}
259-
Ok(())
260283
}
261284
}
262285

286+
#[derive(Clone, Copy)]
287+
enum OutputType {
288+
Lines,
289+
Binary,
290+
}
291+
263292
/// The state of the execution of a process.
264293
#[derive(Clone, Default, Debug)]
265294
pub(crate) struct ExecutionState {
266-
pub(crate) output: Vec<String>,
295+
pub(crate) output: Vec<u8>,
267296
pub(crate) status: ProcessStatus,
268297
}
269298

@@ -307,8 +336,8 @@ echo 'bye'"
307336
}
308337
};
309338

310-
let expected_lines = vec!["hello world", "bye"];
311-
assert_eq!(state.output, expected_lines);
339+
let expected = b"hello world\nbye\n";
340+
assert_eq!(state.output, expected);
312341
}
313342

314343
#[test]
@@ -343,8 +372,8 @@ echo 'hello world'
343372
}
344373
};
345374

346-
let expected_lines = vec!["This message redirects to stderr", "hello world"];
347-
assert_eq!(state.output, expected_lines);
375+
let expected = b"This message redirects to stderr\nhello world\n";
376+
assert_eq!(state.output, expected);
348377
}
349378

350379
#[test]
@@ -368,9 +397,8 @@ echo 'hello world'
368397
}
369398
};
370399

371-
let expected_lines =
372-
vec!["this line was hidden", "this line was hidden and contains another prefix /// ", "hello world"];
373-
assert_eq!(state.output, expected_lines);
400+
let expected = b"this line was hidden\nthis line was hidden and contains another prefix /// \nhello world\n";
401+
assert_eq!(state.output, expected);
374402
}
375403

376404
#[test]

src/code/snippet.rs

+10
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,10 @@ impl SnippetParser {
235235
SnippetAttribute::LineNumbers => attributes.line_numbers = true,
236236
SnippetAttribute::Exec => attributes.execute = true,
237237
SnippetAttribute::ExecReplace => attributes.execute_replace = true,
238+
SnippetAttribute::Image => {
239+
attributes.execute_replace = true;
240+
attributes.image = true;
241+
}
238242
SnippetAttribute::Render => attributes.render = true,
239243
SnippetAttribute::NoBackground => attributes.no_background = true,
240244
SnippetAttribute::AcquireTerminal => attributes.acquire_terminal = true,
@@ -259,6 +263,7 @@ impl SnippetParser {
259263
"line_numbers" => SnippetAttribute::LineNumbers,
260264
"exec" => SnippetAttribute::Exec,
261265
"exec_replace" => SnippetAttribute::ExecReplace,
266+
"image" => SnippetAttribute::Image,
262267
"render" => SnippetAttribute::Render,
263268
"no_background" => SnippetAttribute::NoBackground,
264269
"acquire_terminal" => SnippetAttribute::AcquireTerminal,
@@ -373,6 +378,7 @@ enum SnippetAttribute {
373378
LineNumbers,
374379
Exec,
375380
ExecReplace,
381+
Image,
376382
Render,
377383
HighlightedLines(Vec<HighlightGroup>),
378384
Width(Percent),
@@ -570,6 +576,10 @@ pub(crate) struct SnippetAttributes {
570576
/// of its execution.
571577
pub(crate) execute_replace: bool,
572578

579+
/// Whether the snippet should be executed and its output should be considered to be an image
580+
/// and replaced with it.
581+
pub(crate) image: bool,
582+
573583
/// Whether a snippet is marked to be rendered.
574584
///
575585
/// A rendered snippet is transformed during parsing, leading to some visual

src/presentation/builder.rs

+23-9
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ use crate::{
3636
third_party::{ThirdPartyRender, ThirdPartyRenderError, ThirdPartyRenderRequest},
3737
ui::{
3838
execution::{
39-
DisplaySeparator, RunAcquireTerminalSnippet, RunSnippetOperation, SnippetExecutionDisabledOperation,
39+
DisplaySeparator, RunAcquireTerminalSnippet, RunImageSnippet, RunSnippetOperation,
40+
SnippetExecutionDisabledOperation,
4041
},
4142
footer::{FooterContext, FooterGenerator},
4243
modals::{IndexBuilder, KeyBindingsModalBuilder},
@@ -975,18 +976,31 @@ impl<'a> PresentationBuilder<'a> {
975976
style
976977
}
977978

978-
fn push_code_execution(&mut self, code: Snippet, block_length: usize, mode: ExecutionMode) -> BuildResult {
979-
if !self.code_executor.is_execution_supported(&code.language) {
980-
return Err(BuildError::UnsupportedExecution(code.language));
979+
fn push_code_execution(&mut self, snippet: Snippet, block_length: usize, mode: ExecutionMode) -> BuildResult {
980+
if !self.code_executor.is_execution_supported(&snippet.language) {
981+
return Err(BuildError::UnsupportedExecution(snippet.language));
981982
}
982-
if code.attributes.acquire_terminal {
983+
if snippet.attributes.image {
984+
let operation = RunImageSnippet::new(
985+
snippet,
986+
self.code_executor.clone(),
987+
self.image_registry.clone(),
988+
self.theme.execution_output.status.clone(),
989+
);
990+
operation.start_render();
991+
992+
let operation = RenderOperation::RenderAsync(Rc::new(operation));
993+
self.chunk_operations.push(operation);
994+
return Ok(());
995+
}
996+
if snippet.attributes.acquire_terminal {
983997
let block_length = block_length as u16;
984998
let block_length = match self.theme.code.alignment.clone().unwrap_or_default() {
985999
Alignment::Left { .. } | Alignment::Right { .. } => block_length,
9861000
Alignment::Center { minimum_size, .. } => block_length.max(minimum_size),
9871001
};
9881002
let operation = RunAcquireTerminalSnippet::new(
989-
code,
1003+
snippet,
9901004
self.code_executor.clone(),
9911005
self.theme.execution_output.status.clone(),
9921006
block_length,
@@ -999,14 +1013,14 @@ impl<'a> PresentationBuilder<'a> {
9991013
ExecutionMode::AlongSnippet => DisplaySeparator::On,
10001014
ExecutionMode::ReplaceSnippet => DisplaySeparator::Off,
10011015
};
1002-
let alignment = self.code_style(&code).alignment.unwrap_or_default();
1016+
let alignment = self.code_style(&snippet).alignment.unwrap_or_default();
10031017
let default_colors = self.theme.default_style.colors;
10041018
let mut execution_output_style = self.theme.execution_output.clone();
1005-
if code.attributes.no_background {
1019+
if snippet.attributes.no_background {
10061020
execution_output_style.colors.background = None;
10071021
}
10081022
let operation = RunSnippetOperation::new(
1009-
code,
1023+
snippet,
10101024
self.code_executor.clone(),
10111025
default_colors,
10121026
execution_output_style,

src/terminal/image/printer.rs

+15-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ use crate::{
1414
use image::{DynamicImage, ImageError};
1515
use std::{
1616
borrow::Cow,
17-
io,
17+
fmt, io,
1818
path::{Path, PathBuf},
1919
sync::Arc,
2020
};
@@ -162,6 +162,20 @@ impl PrintImage for ImagePrinter {
162162
#[derive(Clone, Default)]
163163
pub(crate) struct ImageRegistry(pub Arc<ImagePrinter>);
164164

165+
impl fmt::Debug for ImageRegistry {
166+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
167+
let inner = match self.0.as_ref() {
168+
ImagePrinter::Kitty(_) => "Kitty",
169+
ImagePrinter::Iterm(_) => "Iterm",
170+
ImagePrinter::Ascii(_) => "Ascii",
171+
ImagePrinter::Null => "Null",
172+
#[cfg(feature = "sixel")]
173+
ImagePrinter::Sixel(_) => "Sixel",
174+
};
175+
write!(f, "ImageRegistry<{inner}>")
176+
}
177+
}
178+
165179
impl ImageRegistry {
166180
pub(crate) fn register_image(&self, image: DynamicImage) -> Result<Image, RegisterImageError> {
167181
let resource = self.0.register(image)?;

0 commit comments

Comments
 (0)