1
0
mirror of https://github.com/mfontanini/presenterm.git synced 2026-09-01 23:33:21 -06:00

Merge branch 'master' into feat/auto-theme

This commit is contained in:
JOTSR 2025-11-20 00:38:36 +01:00 committed by GitHub
commit 4d15779f03
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1124 additions and 281 deletions

View File

@ -83,4 +83,8 @@ jobs:
- name: Checkout sources
uses: actions/checkout@v2
- name: Validate config file schema
run: ./scripts/validate-config-file-schema.sh
run: |
if ! ./scripts/validate-config-file-schema.sh; then
echo -e "\033[31;1mRun ./scripts/generate-config-file-schema.sh to regenerate JSON schema"
exit 1
fi

157
Cargo.lock generated
View File

@ -100,6 +100,12 @@ dependencies = [
"serde",
]
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.10.0"
@ -143,6 +149,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
[[package]]
name = "clap"
version = "4.5.51"
@ -239,7 +251,7 @@ version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
dependencies = [
"bitflags",
"bitflags 2.10.0",
"crossterm_winapi",
"document-features",
"mio",
@ -299,10 +311,10 @@ dependencies = [
]
[[package]]
name = "downcast"
version = "0.11.0"
name = "downcast-rs"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "dyn-clone"
@ -353,6 +365,17 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "filedescriptor"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d"
dependencies = [
"libc",
"thiserror 1.0.69",
"winapi",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.4"
@ -480,6 +503,12 @@ version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47f142fe24a9c9944451e8349de0a56af5f3e7226dc46f3ed4d4ecc0b85af75e"
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "libc"
version = "0.2.177"
@ -492,7 +521,7 @@ version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb"
dependencies = [
"bitflags",
"bitflags 2.10.0",
"libc",
]
@ -609,6 +638,18 @@ dependencies = [
"pxfm",
]
[[package]]
name = "nix"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4"
dependencies = [
"bitflags 2.10.0",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "num-conv"
version = "0.1.0"
@ -651,7 +692,7 @@ version = "6.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0"
dependencies = [
"bitflags",
"bitflags 2.10.0",
"libc",
"once_cell",
"onig_sys",
@ -731,13 +772,34 @@ version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0"
dependencies = [
"bitflags",
"bitflags 2.10.0",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]]
name = "portable-pty"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e"
dependencies = [
"anyhow",
"bitflags 1.3.2",
"downcast-rs",
"filedescriptor",
"lazy_static",
"libc",
"log",
"nix",
"serial2",
"shared_library",
"shell-words",
"winapi",
"winreg",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
@ -790,6 +852,7 @@ dependencies = [
"merge-struct",
"once_cell",
"os_pipe",
"portable-pty",
"rstest",
"schemars",
"serde",
@ -801,9 +864,10 @@ dependencies = [
"syntect",
"tempfile",
"termbg",
"thiserror",
"thiserror 2.0.17",
"tl",
"unicode-width",
"vt100",
"vte",
]
@ -849,7 +913,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
"bitflags 2.10.0",
]
[[package]]
@ -860,7 +924,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
dependencies = [
"getrandom",
"libredox",
"thiserror",
"thiserror 2.0.17",
]
[[package]]
@ -952,7 +1016,7 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e"
dependencies = [
"bitflags",
"bitflags 2.10.0",
"errno",
"libc",
"linux-raw-sys 0.11.0",
@ -1077,6 +1141,33 @@ dependencies = [
"unsafe-libyaml",
]
[[package]]
name = "serial2"
version = "0.2.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8cc76fa68e25e771492ca1e3c53d447ef0be3093e05cd3b47f4b712ba10c6f3c"
dependencies = [
"cfg-if",
"libc",
"winapi",
]
[[package]]
name = "shared_library"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11"
dependencies = [
"lazy_static",
"libc",
]
[[package]]
name = "shell-words"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde"
[[package]]
name = "shlex"
version = "1.3.0"
@ -1218,7 +1309,7 @@ dependencies = [
"serde",
"serde_derive",
"serde_json",
"thiserror",
"thiserror 2.0.17",
"walkdir",
]
@ -1264,13 +1355,33 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl 1.0.69",
]
[[package]]
name = "thiserror"
version = "2.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
dependencies = [
"thiserror-impl",
"thiserror-impl 2.0.17",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
@ -1383,6 +1494,17 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "vt100"
version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "054ff75fb8fa83e609e685106df4faeffdf3a735d3c74ebce97ec557d5d36fd9"
dependencies = [
"itoa",
"unicode-width",
"vte",
]
[[package]]
name = "vte"
version = "0.15.0"
@ -1608,6 +1730,15 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winreg"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
dependencies = [
"winapi",
]
[[package]]
name = "zune-core"
version = "0.4.12"

View File

@ -23,6 +23,7 @@ sixel-rs = { version = "0.4.1", optional = true }
merge-struct = "0.1.0"
itertools = "0.14"
once_cell = "1.19"
portable-pty = "0.9"
schemars = { version = "0.8", optional = true }
serde = { version = "1.0", features = ["derive"] }
serde_yaml = "0.9"
@ -38,6 +39,7 @@ os_pipe = "1.1.5"
libc = "0.2"
vte = "0.15"
termbg = "0.6.2"
vt100 = "0.16"
[dev-dependencies]
rstest = { version = "0.26", default-features = false }

View File

@ -853,6 +853,7 @@
"Clojure",
"Cpp",
"Css",
"Dart",
"D2",
"DLang",
"Diff",
@ -901,6 +902,7 @@
"Terraform",
"Toml",
"TypeScript",
"TypeScriptReact",
"Typst",
"Xml",
"Yaml",

View File

@ -65,7 +65,7 @@ When you pass in this flag, _presenterm_ will:
that the `+validate` flag is a special one that doesn't make a snippet executable but still validates it by running it
during development.
* Report an error if any of the snippets returns an exit code other than 0.
* Re-run all snippets `+exec` and `+exec_repalce` snippets every time the presentation is reloaded.
* Re-run all snippets `+exec` and `+exec_replace` snippets every time the presentation is reloaded.
In case you expect a snippet to return an exit code other than 0, you can use the `+expect:failure` flag. This will
cause _presenterm_ to display an error if the snippet does not fail.

View File

@ -7,18 +7,31 @@ bash:
c++:
filename: snippet.cpp
commands:
- ["g++", "-std=c++20", "-fdiagnostics-color=always", "$pwd/snippet.cpp", "-o", "$pwd/snippet"]
- [
"g++",
"-std=c++20",
"-fdiagnostics-color=always",
"$pwd/snippet.cpp",
"-o",
"$pwd/snippet",
]
- ["$pwd/snippet"]
hidden_line_prefix: "/// "
c:
filename: snippet.c
commands:
- ["gcc", "$pwd/snippet.c", "-fdiagnostics-color=always", "-o", "$pwd/snippet"]
- [
"gcc",
"$pwd/snippet.c",
"-fdiagnostics-color=always",
"-o",
"$pwd/snippet",
]
- ["$pwd/snippet"]
hidden_line_prefix: "/// "
elixir:
filename: snippet.exs
commands:
commands:
- ["elixir", "$pwd/snippet.exs"]
hidden_line_prefix: "## "
fish:
@ -44,9 +57,84 @@ java:
hidden_line_prefix: "/// "
js:
filename: snippet.js
environment:
FORCE_COLOR: "true"
commands:
- ["node", "$pwd/snippet.js"]
hidden_line_prefix: "/// "
alternative:
deno:
filename: "snippet.js"
environment:
FORCE_COLOR: "true"
commands:
- ["deno", "run", "-A", "$pwd/snippet.js"]
bun:
filename: "snippet.js"
environment:
FORCE_COLOR: "true"
commands:
- ["bun", "run", "$pwd/snippet.js"]
jsx:
filename: snippet.jsx
environment:
FORCE_COLOR: "true"
commands:
- ["node", "$pwd/snippet.jsx"]
hidden_line_prefix: "/// "
alternative:
deno:
filename: "snippet.jsx"
environment:
FORCE_COLOR: "true"
commands:
- ["deno", "run", "-A", "$pwd/snippet.jsx"]
bun:
filename: "snippet.jsx"
environment:
FORCE_COLOR: "true"
commands:
- ["bun", "run", "$pwd/snippet.jsx"]
ts:
filename: snippet.ts
environment:
FORCE_COLOR: "true"
commands:
- ["node", "$pwd/snippet.ts"]
hidden_line_prefix: "/// "
alternative:
deno:
filename: "snippet.ts"
environment:
FORCE_COLOR: "true"
commands:
- ["deno", "run", "-A", "$pwd/snippet.ts"]
bun:
filename: "snippet.ts"
environment:
FORCE_COLOR: "true"
commands:
- ["bun", "run", "$pwd/snippet.ts"]
tsx:
filename: snippet.tsx
environment:
FORCE_COLOR: "true"
commands:
- ["node", "$pwd/snippet.tsx"]
hidden_line_prefix: "/// "
alternative:
deno:
filename: "snippet.tsx"
environment:
FORCE_COLOR: "true"
commands:
- ["deno", "run", "-A", "$pwd/snippet.tsx"]
bun:
filename: "snippet.tsx"
environment:
FORCE_COLOR: "true"
commands:
- ["bun", "run", "$pwd/snippet.tsx"]
julia:
filename: snippet.jl
commands:
@ -107,7 +195,16 @@ rust-script:
rust:
filename: snippet.rs
commands:
- ["rustc", "--crate-name", "presenterm_snippet", "$pwd/snippet.rs", "-o", "$pwd/snippet", "--color", "always"]
- [
"rustc",
"--crate-name",
"presenterm_snippet",
"$pwd/snippet.rs",
"-o",
"$pwd/snippet",
"--color",
"always",
]
- ["$pwd/snippet"]
hidden_line_prefix: "# "
alternative:

View File

@ -0,0 +1,17 @@
#!/bin/bash
set -euo pipefail
script_dir=$(dirname "$0")
root_dir="${script_dir}/../"
current_schema=$(mktemp)
docker run \
--rm \
-v "${root_dir}:/tmp/workspace" \
-w "/tmp/workspace" \
rust:1.86 \
cargo run --features json-schema -q -- --generate-config-file-schema >"${current_schema}"
cp "$current_schema" "${root_dir}/config-file-schema.json"
rm "$current_schema"

View File

@ -1,8 +1,8 @@
//! Code execution.
use super::snippet::{SnippetExecutorSpec, SnippetRepr};
use super::snippet::SnippetExecutorSpec;
use crate::{
code::snippet::{Snippet, SnippetLanguage},
code::snippet::{Snippet, SnippetExecution, SnippetLanguage, SnippetRepr},
config::{LanguageSnippetExecutionConfig, SnippetExecutorConfig},
};
use once_cell::sync::Lazy;
@ -115,8 +115,8 @@ impl LanguageSnippetExecutor {
pub(crate) fn execute_async(&self, snippet: &Snippet) -> Result<ExecutionHandle, CodeExecuteError> {
let script_dir = self.write_snippet(snippet)?;
let state: Arc<Mutex<ExecutionState>> = Default::default();
let output_type = match snippet.attributes.representation {
SnippetRepr::Image => OutputType::Binary,
let output_type = match &snippet.attributes.execution {
SnippetExecution::Exec(args) if matches!(args.repr, SnippetRepr::Image) => OutputType::Binary,
_ => OutputType::Lines,
};
let reader_handle = CommandsRunner::spawn(
@ -135,28 +135,58 @@ impl LanguageSnippetExecutor {
pub(crate) fn execute_sync(&self, snippet: &Snippet) -> Result<(), CodeExecuteError> {
let script_dir = self.write_snippet(snippet)?;
let script_dir_path = script_dir.path().to_string_lossy();
for mut commands in self.config.commands.clone() {
for command in &mut commands {
*command = command.replace("$pwd", &script_dir_path);
}
let (command, args) = commands.split_first().expect("no commands");
let child = process::Command::new(command)
.args(args)
.envs(&self.config.environment)
.current_dir(&self.cwd)
.stderr(Stdio::piped())
.spawn()
.map_err(|e| CodeExecuteError::SpawnProcess(command.clone(), e))?;
let output = child.wait_with_output().map_err(CodeExecuteError::Waiting)?;
if !output.status.success() {
let error = String::from_utf8_lossy(&output.stderr).to_string();
return Err(CodeExecuteError::Running(error));
}
for commands in self.config.commands.clone() {
self.execute_command(commands, &script_dir_path)?;
}
Ok(())
}
/// Creates the necessary context to run this snippet in a PTY.
pub(crate) fn pty_execution_context(&self, snippet: &Snippet) -> Result<PtySnippetContext, CodeExecuteError> {
let script_dir = self.write_snippet(snippet)?;
let script_dir_path = script_dir.path().to_string_lossy();
// Run the first N-1 commands normally and assume the last one is the one that actually
// invokes the thing (e.g. rust snippet compilation happens here, snippet execution in PTY)
for commands in self.config.commands.iter().take(self.config.commands.len() - 1).cloned() {
self.execute_command(commands, &script_dir_path)?;
}
let mut commands = self.config.commands.last().cloned().unwrap();
for command in &mut commands {
*command = command.replace("$pwd", &script_dir_path);
}
let (command, args) = commands.split_first().expect("no commands");
let mut command = portable_pty::CommandBuilder::new(command);
command.args(args);
command.cwd(&self.cwd);
for (key, value) in &self.config.environment {
command.env(key, value);
}
Ok(PtySnippetContext { command, _temp: script_dir })
}
fn execute_command(&self, mut commands: Vec<String>, script_dir_path: &str) -> Result<(), CodeExecuteError> {
for command in &mut commands {
*command = command.replace("$pwd", script_dir_path);
}
let (command, args) = commands.split_first().expect("no commands");
let child = process::Command::new(command)
.args(args)
.envs(&self.config.environment)
.current_dir(&self.cwd)
.stderr(Stdio::piped())
.spawn()
.map_err(|e| CodeExecuteError::SpawnProcess(command.clone(), e))?;
let output = child.wait_with_output().map_err(CodeExecuteError::Waiting)?;
if output.status.success() {
Ok(())
} else {
let error = String::from_utf8_lossy(&output.stderr).to_string();
Err(CodeExecuteError::Running(error))
}
}
fn write_snippet(&self, snippet: &Snippet) -> Result<TempDir, CodeExecuteError> {
let hide_prefix = self.hidden_line_prefix.as_deref();
let code = snippet.executable_contents(hide_prefix);
@ -169,6 +199,11 @@ impl LanguageSnippetExecutor {
}
}
pub(crate) struct PtySnippetContext {
pub(crate) command: portable_pty::CommandBuilder,
_temp: TempDir,
}
/// An invalid executor was found.
#[derive(thiserror::Error, Debug)]
#[error("invalid snippet execution for '{0:?}': {1}")]
@ -328,7 +363,7 @@ pub(crate) struct ExecutionState {
}
/// The status of a process.
#[derive(Clone, Debug, Default)]
#[derive(Clone, Copy, Debug, Default)]
pub(crate) enum ProcessStatus {
#[default]
Running,
@ -346,7 +381,7 @@ impl ProcessStatus {
#[cfg(test)]
mod test {
use super::*;
use crate::code::snippet::{SnippetAttributes, SnippetExec};
use crate::code::snippet::{SnippetAttributes, SnippetExecution};
#[test]
fn shell_code_execution() {
@ -357,7 +392,10 @@ echo 'bye'"
let snippet = Snippet {
contents,
language: SnippetLanguage::Shell,
attributes: SnippetAttributes { execution: SnippetExec::Exec(Default::default()), ..Default::default() },
attributes: SnippetAttributes {
execution: SnippetExecution::Exec(Default::default()),
..Default::default()
},
};
let executor = SnippetExecutor::default().language_executor(&snippet.language, &Default::default()).unwrap();
let handle = executor.execute_async(&snippet).expect("execution failed");
@ -382,7 +420,10 @@ echo 'hello world'
let snippet = Snippet {
contents,
language: SnippetLanguage::Shell,
attributes: SnippetAttributes { execution: SnippetExec::Exec(Default::default()), ..Default::default() },
attributes: SnippetAttributes {
execution: SnippetExecution::Exec(Default::default()),
..Default::default()
},
};
let executor = SnippetExecutor::default().language_executor(&snippet.language, &Default::default()).unwrap();
let handle = executor.execute_async(&snippet).expect("execution failed");
@ -408,7 +449,10 @@ echo 'hello world'
let snippet = Snippet {
contents,
language: SnippetLanguage::Shell,
attributes: SnippetAttributes { execution: SnippetExec::Exec(Default::default()), ..Default::default() },
attributes: SnippetAttributes {
execution: SnippetExecution::Exec(Default::default()),
..Default::default()
},
};
let executor = SnippetExecutor::default().language_executor(&snippet.language, &Default::default()).unwrap();
let handle = executor.execute_async(&snippet).expect("execution failed");

View File

@ -115,6 +115,7 @@ impl SnippetHighlighter {
Cpp => "cpp",
Crontab => "crontab",
Css => "css",
Dart => "dart",
D2 => "txt",
DLang => "d",
Diff => "diff",
@ -163,6 +164,7 @@ impl SnippetHighlighter {
Terraform => "tf",
Toml => "toml",
TypeScript => "ts",
TypeScriptReact => "tsx",
Typst => "txt",
// default to plain text so we get the same look&feel
Unknown(_) => "txt",

View File

@ -209,7 +209,7 @@ impl SnippetParser {
fn parse_block_info(input: &str) -> ParseResult<(SnippetLanguage, SnippetAttributes)> {
let (language, input) = Self::parse_language(input);
let attributes = Self::parse_attributes(input)?;
if attributes.width.is_some() && !matches!(attributes.representation, SnippetRepr::Render) {
if attributes.width.is_some() && !matches!(attributes.execution, SnippetExecution::Render) {
return Err(SnippetBlockParseError::NotRenderSnippet("width"));
}
Ok((language, attributes))
@ -233,38 +233,58 @@ impl SnippetParser {
}
use SnippetAttribute::*;
match attribute {
ExecReplace(_) | Image | Render if attributes.representation != SnippetRepr::Snippet => {
return Err(SnippetBlockParseError::MultipleRepresentation);
}
LineNumbers => attributes.line_numbers = true,
Exec(spec) => {
if !matches!(attributes.execution, SnippetExec::AcquireTerminal(_)) {
attributes.execution = SnippetExec::Exec(spec);
}
attributes.execution = attributes
.execution
.try_merge(SnippetExecution::Exec(SnippetExecArgs { spec, ..Default::default() }))?;
}
AutoExec(spec) => {
if !matches!(attributes.execution, SnippetExec::AcquireTerminal(_)) {
attributes.execution = SnippetExec::AutoExec(spec);
}
attributes.execution = attributes.execution.try_merge(SnippetExecution::Exec(SnippetExecArgs {
spec,
auto: true,
..Default::default()
}))?;
}
ExecPty(spec, args) => {
attributes.execution = attributes.execution.try_merge(SnippetExecution::Exec(SnippetExecArgs {
spec,
pty: Some(args),
..Default::default()
}))?;
}
ExecReplace(spec) => {
attributes.representation = SnippetRepr::ExecReplace;
attributes.execution = SnippetExec::Exec(spec);
attributes.execution = attributes.execution.try_merge(SnippetExecution::Exec(SnippetExecArgs {
spec,
repr: SnippetRepr::ExecReplace,
..Default::default()
}))?;
}
Id(id) => {
attributes.id = Some(id);
}
Validate(spec) => {
if matches!(attributes.execution, SnippetExec::None) {
attributes.execution = SnippetExec::Validate(spec);
if attributes.validate.is_some() {
return Err(SnippetBlockParseError::DuplicateAttribute("+validate"));
}
attributes.validate = Some(spec);
}
Image => {
attributes.representation = SnippetRepr::Image;
attributes.execution = SnippetExec::Exec(Default::default());
attributes.execution = attributes.execution.try_merge(SnippetExecution::Exec(SnippetExecArgs {
repr: SnippetRepr::Image,
..Default::default()
}))?;
}
Render => {
attributes.execution = attributes.execution.try_merge(SnippetExecution::Render)?;
}
AcquireTerminal(spec) => {
attributes.execution = attributes.execution.try_merge(SnippetExecution::Exec(SnippetExecArgs {
spec,
repr: SnippetRepr::AcquireTerminal,
..Default::default()
}))?;
}
Render => attributes.representation = SnippetRepr::Render,
AcquireTerminal(spec) => attributes.execution = SnippetExec::AcquireTerminal(spec),
NoBackground => attributes.no_background = true,
HighlightedLines(lines) => attributes.highlight_groups = lines,
Width(width) => attributes.width = Some(width),
@ -294,6 +314,7 @@ impl SnippetParser {
"render" => SnippetAttribute::Render,
"no_background" => SnippetAttribute::NoBackground,
"acquire_terminal" => SnippetAttribute::AcquireTerminal(SnippetExecutorSpec::default()),
"pty" => SnippetAttribute::ExecPty(SnippetExecutorSpec::default(), Default::default()),
other => {
let (attribute, parameter) = other
.split_once(':')
@ -330,6 +351,7 @@ impl SnippetParser {
));
}
},
"pty" => SnippetAttribute::ExecPty(SnippetExecutorSpec::default(), parameter.parse()?),
_ => return Err(SnippetBlockParseError::InvalidToken(Self::next_identifier(input).into())),
}
}
@ -420,6 +442,9 @@ pub enum SnippetBlockParseError {
#[error("invalid width: {0}")]
InvalidWidth(PercentParseError),
#[error("invalid pty args, expected '<columns>:<rows>'")]
InvalidPtyArgs,
#[error("duplicate attribute: {0}")]
DuplicateAttribute(&'static str),
@ -436,6 +461,7 @@ enum SnippetAttribute {
Exec(SnippetExecutorSpec),
AutoExec(SnippetExecutorSpec),
ExecReplace(SnippetExecutorSpec),
ExecPty(SnippetExecutorSpec, PtyArgs),
Validate(SnippetExecutorSpec),
Image,
Render,
@ -514,6 +540,7 @@ pub enum SnippetLanguage {
Clojure,
Cpp,
Css,
Dart,
D2,
DLang,
Diff,
@ -562,6 +589,7 @@ pub enum SnippetLanguage {
Terraform,
Toml,
TypeScript,
TypeScriptReact,
Typst,
Unknown(String),
Xml,
@ -591,6 +619,7 @@ impl FromStr for SnippetLanguage {
"clojure" => Clojure,
"cpp" | "c++" => Cpp,
"css" => Css,
"dart" => Dart,
"d2" => D2,
"d" => DLang,
"diff" => Diff,
@ -639,6 +668,7 @@ impl FromStr for SnippetLanguage {
"terraform" => Terraform,
"toml" => Toml,
"typescript" | "ts" => TypeScript,
"tsx" => TypeScriptReact,
"typst" => Typst,
"xml" => Xml,
"yaml" => Yaml,
@ -655,11 +685,8 @@ impl FromStr for SnippetLanguage {
/// Attributes for code snippets.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) struct SnippetAttributes {
/// The way the snippet should be represented.
pub(crate) representation: SnippetRepr,
/// The way the snippet should be executed.
pub(crate) execution: SnippetExec,
/// The way the snippet should be executed, if any.
pub(crate) execution: SnippetExecution,
/// Whether the snippet should show line numbers.
pub(crate) line_numbers: bool,
@ -675,6 +702,9 @@ pub(crate) struct SnippetAttributes {
/// Whether to add no background to a snippet.
pub(crate) no_background: bool,
/// The spec to use to validate this snippet.
pub(crate) validate: Option<SnippetExecutorSpec>,
/// The expected execution result for a snippet.
pub(crate) expected_execution_result: ExpectedSnippetExecutionResult,
@ -685,20 +715,74 @@ pub(crate) struct SnippetAttributes {
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) enum SnippetRepr {
#[default]
Snippet,
SnippetOutput,
Image,
Render,
ExecReplace,
AcquireTerminal,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) enum SnippetExec {
pub(crate) struct PtyArgs {
pub(crate) columns: Option<u16>,
pub(crate) rows: Option<u16>,
}
impl FromStr for PtyArgs {
type Err = SnippetBlockParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let Some((columns, rows)) = s.split_once(':') else {
return Err(SnippetBlockParseError::InvalidPtyArgs);
};
let columns = columns.parse().map_err(|_| SnippetBlockParseError::InvalidPtyArgs)?;
let rows = rows.parse().map_err(|_| SnippetBlockParseError::InvalidPtyArgs)?;
Ok(Self { columns: Some(columns), rows: Some(rows) })
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) struct SnippetExecArgs {
pub(crate) spec: SnippetExecutorSpec,
pub(crate) auto: bool,
pub(crate) pty: Option<PtyArgs>,
pub(crate) repr: SnippetRepr,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) enum SnippetExecution {
#[default]
None,
Exec(SnippetExecutorSpec),
AutoExec(SnippetExecutorSpec),
AcquireTerminal(SnippetExecutorSpec),
Validate(SnippetExecutorSpec),
Render,
Exec(SnippetExecArgs),
}
impl SnippetExecution {
fn try_merge(self, other: SnippetExecution) -> ParseResult<Self> {
match (self, other) {
(Self::None, other) => Ok(other),
(Self::Render, Self::None) => Ok(Self::Render),
(Self::Render, Self::Render) => Err(SnippetBlockParseError::DuplicateAttribute("+render")),
(Self::Render, Self::Exec(_)) | (Self::Exec(_), Self::Render) => {
Err(SnippetBlockParseError::MultipleRepresentation)
}
(Self::Exec(mut ours), Self::Exec(theirs)) => {
let SnippetExecArgs { spec, auto, pty, repr } = theirs;
ours.auto = ours.auto || auto;
ours.pty = pty.or(ours.pty);
ours.spec = match ours.spec {
SnippetExecutorSpec::Default => spec,
SnippetExecutorSpec::Alternative(spec) => SnippetExecutorSpec::Alternative(spec),
};
ours.repr = match (ours.repr, repr) {
(SnippetRepr::SnippetOutput, other) => other,
(ours, SnippetRepr::SnippetOutput) => ours,
_ => return Err(SnippetBlockParseError::MultipleRepresentation),
};
Ok(Self::Exec(ours))
}
(Self::Exec(args), Self::None) => Ok(Self::Exec(args)),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
@ -796,30 +880,34 @@ mod test {
#[test]
fn one_attribute() {
let attributes = parse_attributes("bash +exec");
assert_eq!(attributes.execution, SnippetExec::Exec(Default::default()));
assert_eq!(attributes.execution, SnippetExecution::Exec(Default::default()));
assert!(!attributes.line_numbers);
}
#[test]
fn two_attributes() {
let attributes = parse_attributes("bash +exec +line_numbers");
assert_eq!(attributes.execution, SnippetExec::Exec(Default::default()));
assert_eq!(attributes.execution, SnippetExecution::Exec(Default::default()));
assert!(attributes.line_numbers);
}
#[test]
fn acquire_terminal() {
let attributes = parse_attributes("bash +acquire_terminal +exec");
assert_eq!(attributes.execution, SnippetExec::AcquireTerminal(Default::default()));
assert_eq!(attributes.representation, SnippetRepr::Snippet);
assert_eq!(
attributes.execution,
SnippetExecution::Exec(SnippetExecArgs { repr: SnippetRepr::AcquireTerminal, ..Default::default() })
);
assert!(!attributes.line_numbers);
}
#[test]
fn image() {
let attributes = parse_attributes("bash +image +exec");
assert_eq!(attributes.execution, SnippetExec::Exec(Default::default()));
assert_eq!(attributes.representation, SnippetRepr::Image);
assert_eq!(
attributes.execution,
SnippetExecution::Exec(SnippetExecArgs { repr: SnippetRepr::Image, ..Default::default() })
);
assert!(!attributes.line_numbers);
}
@ -877,7 +965,7 @@ mod test {
#[test]
fn parse_width() {
let attributes = parse_attributes("mermaid +width:50% +render");
assert_eq!(attributes.representation, SnippetRepr::Render);
assert_eq!(attributes.execution, SnippetExecution::Render);
assert_eq!(attributes.width, Some(Percent(50)));
}
@ -936,13 +1024,21 @@ println!("Hello world");
#[case::exec_replace_and_more("bash +exec_replace:foo +line_numbers", SnippetExecutorSpec::Alternative("foo".into()))]
fn alternative_executor(#[case] input: &str, #[case] spec: SnippetExecutorSpec) {
let attributes = parse_attributes(input);
assert_eq!(attributes.execution, SnippetExec::Exec(spec));
let SnippetExecution::Exec(args) = attributes.execution else { panic!("not an exec snippet") };
assert_eq!(args.spec, spec);
}
#[test]
fn acquire_terminal_alternative() {
let attributes = parse_attributes("bash +acquire_terminal:foo");
assert_eq!(attributes.execution, SnippetExec::AcquireTerminal(SnippetExecutorSpec::Alternative("foo".into())));
assert_eq!(
attributes.execution,
SnippetExecution::Exec(SnippetExecArgs {
spec: SnippetExecutorSpec::Alternative("foo".into()),
repr: SnippetRepr::AcquireTerminal,
..Default::default()
})
);
}
#[rstest]

View File

@ -311,6 +311,30 @@ impl Color {
Self::Rgb { r, g, b }
}
pub(crate) fn from_8bit(color: u8) -> Option<Self> {
match color {
0 | 8 => Self::Black.into(),
1 | 9 => Self::Red.into(),
2 | 10 => Self::Green.into(),
3 | 11 => Self::Yellow.into(),
4 | 12 => Self::Blue.into(),
5 | 13 => Self::Magenta.into(),
6 | 14 => Self::Cyan.into(),
7 | 15 => Self::White.into(),
16..=231 => {
let mapping = [0, 95, 95 + 40, 95 + 80, 95 + 120, 95 + 160];
let mut value = color - 16;
let b = (value % 6) as usize;
value /= 6;
let g = (value % 6) as usize;
value /= 6;
let r = (value % 6) as usize;
Some(Self::new(mapping[r], mapping[g], mapping[b]))
}
_ => None,
}
}
pub(crate) fn as_rgb(&self) -> Option<(u8, u8, u8)> {
match self {
Self::Rgb { r, g, b } => Some((*r, *g, *b)),

View File

@ -31,7 +31,7 @@ use crate::{
},
third_party::ThirdPartyRender,
ui::{
execution::output::SnippetHandle,
execution::output::WrappedSnippetHandle,
footer::{FooterGenerator, FooterVariables},
modals::{IndexBuilder, KeyBindingsModalBuilder},
separator::RenderSeparator,
@ -171,7 +171,7 @@ pub(crate) struct PresentationBuilder<'a, 'b> {
bindings_config: KeyBindingsConfig,
slides_without_footer: HashSet<usize>,
markdown_parser: &'a MarkdownParser<'b>,
executable_snippets: HashMap<String, SnippetHandle>,
executable_snippets: HashMap<String, WrappedSnippetHandle>,
sources: MarkdownSources,
options: PresentationBuilderOptions,
}

View File

@ -3,9 +3,9 @@ use crate::{
code::{
execute::LanguageSnippetExecutor,
snippet::{
ExternalFile, Highlight, HighlightContext, HighlightGroup, HighlightMutator, HighlightedLine, Snippet,
SnippetExec, SnippetExecutorSpec, SnippetLanguage, SnippetLine, SnippetParser, SnippetRepr,
SnippetSplitter,
ExternalFile, Highlight, HighlightContext, HighlightGroup, HighlightMutator, HighlightedLine, PtyArgs,
Snippet, SnippetExecArgs, SnippetExecution, SnippetExecutorSpec, SnippetLanguage, SnippetLine,
SnippetParser, SnippetRepr, SnippetSplitter,
},
},
markdown::elements::SourcePosition,
@ -19,7 +19,8 @@ use crate::{
ui::execution::{
RunAcquireTerminalSnippet, RunImageSnippet, SnippetExecutionDisabledOperation, SnippetOutputOperation,
disabled::ExecutionType,
output::{ExecIndicator, ExecIndicatorStyle, RunSnippetTrigger, SnippetHandle},
output::{ExecIndicator, ExecIndicatorStyle, RunSnippetTrigger, SnippetHandle, WrappedSnippetHandle},
pty::{PtySnippetHandle, PtySnippetOutputOperation, RunPtySnippetTrigger},
validator::ValidateSnippetOperation,
},
};
@ -37,12 +38,14 @@ impl PresentationBuilder<'_, '_> {
snippet.attributes.line_numbers = true;
}
if self.options.auto_render_languages.contains(&snippet.language) {
snippet.attributes.representation = SnippetRepr::Render;
snippet.attributes.execution = SnippetExecution::Render;
}
// Ids can only be used in `+exec` snippets.
if snippet.attributes.id.is_some()
&& (!matches!(snippet.attributes.execution, SnippetExec::Exec(_))
|| !matches!(snippet.attributes.representation, SnippetRepr::Snippet))
&& !matches!(
snippet.attributes.execution,
SnippetExecution::Exec(SnippetExecArgs { repr: SnippetRepr::SnippetOutput, .. })
)
{
return Err(self.invalid_presentation(source_position, InvalidPresentation::SnippetIdNonExec));
}
@ -51,85 +54,110 @@ impl PresentationBuilder<'_, '_> {
// Redraw slide if attributes change
self.push_differ(format!("{:?}", snippet.attributes));
let execution_allowed = self.is_execution_allowed(&snippet);
match snippet.attributes.representation {
SnippetRepr::Render => return self.push_rendered_code(snippet, source_position),
SnippetRepr::Image => {
if execution_allowed {
return self.push_code_as_image(snippet);
}
}
SnippetRepr::ExecReplace => {
if execution_allowed {
return self.push_replace_code_execution(snippet.clone());
}
}
SnippetRepr::Snippet => (),
};
if let Some(spec) = &snippet.attributes.validate {
// Take the execution spec if we use the default one, otherwise use the one provided.
// This allows using `rust +exec:foo +validate` and using `foo` as the executor.
let spec = match (spec, &snippet.attributes.execution) {
(SnippetExecutorSpec::Default, SnippetExecution::Exec(args)) => &args.spec,
_ => spec,
};
let executor = self.snippet_executor.language_executor(&snippet.language, spec)?;
self.push_validator(&snippet, &executor);
}
let block_length = self.push_code_lines(&snippet);
match snippet.attributes.execution.clone() {
SnippetExec::None => Ok(()),
SnippetExec::Exec(_) | SnippetExec::AutoExec(_) | SnippetExec::AcquireTerminal(_) if !execution_allowed => {
let mut exec_type = match snippet.attributes.representation {
match &snippet.attributes.execution {
SnippetExecution::None => {
self.push_code_lines(&snippet);
}
SnippetExecution::Render => {
self.push_rendered_code(snippet, source_position)?;
}
SnippetExecution::Exec(args) if !self.is_execution_allowed(args) => {
self.push_code_lines(&snippet);
let mut exec_type = match args.repr {
SnippetRepr::Image => ExecutionType::Image,
SnippetRepr::ExecReplace => ExecutionType::ExecReplace,
SnippetRepr::Render | SnippetRepr::Snippet => ExecutionType::Execute,
SnippetRepr::SnippetOutput | SnippetRepr::AcquireTerminal => ExecutionType::Execute,
};
if matches!(snippet.attributes.execution, SnippetExec::AutoExec(_)) {
if args.auto {
exec_type = ExecutionType::ExecReplace;
}
self.push_execution_disabled_operation(exec_type);
Ok(())
}
SnippetExec::Exec(spec) | SnippetExec::AutoExec(spec) => {
let policy = if matches!(snippet.attributes.execution, SnippetExec::AutoExec(_)) {
RenderAsyncStartPolicy::Automatic
} else {
RenderAsyncStartPolicy::OnDemand
};
let executor = self.snippet_executor.language_executor(&snippet.language, &spec)?;
let alignment = self.code_style(&snippet).alignment;
let handle = SnippetHandle::new(snippet.clone(), executor, policy);
self.chunk_operations
.push(RenderOperation::RenderAsync(Rc::new(RunSnippetTrigger::new(handle.clone()))));
self.push_indicator(handle.clone(), block_length, alignment);
match snippet.attributes.id.clone() {
Some(id) => {
if self.executable_snippets.insert(id.clone(), handle).is_some() {
return Err(self
.invalid_presentation(source_position, InvalidPresentation::SnippetAlreadyExists(id)));
}
Ok(())
SnippetExecution::Exec(args) => {
let executor = self.snippet_executor.language_executor(&snippet.language, &args.spec)?;
match args.repr {
SnippetRepr::Image => {
self.push_code_as_image(snippet, executor)?;
}
None => {
self.push_line_break();
self.push_code_execution(block_length, handle, alignment)
SnippetRepr::ExecReplace => match &args.pty {
Some(args) => {
let args = args.clone();
self.push_replace_pty_code_execution(snippet, executor, args)?;
}
None => {
self.push_replace_code_execution(snippet, executor)?;
}
},
SnippetRepr::AcquireTerminal => {
let block_length = self.push_code_lines(&snippet);
self.push_acquire_terminal_execution(snippet, block_length, executor)?;
}
SnippetRepr::SnippetOutput => {
let block_length = self.push_code_lines(&snippet);
let policy = if args.auto {
RenderAsyncStartPolicy::Automatic
} else {
RenderAsyncStartPolicy::OnDemand
};
let handle: WrappedSnippetHandle = match &args.pty {
Some(args) => PtySnippetHandle::new(snippet.clone(), executor, policy, args.clone()).into(),
None => SnippetHandle::new(snippet.clone(), executor, policy).into(),
};
self.chunk_operations.push(RenderOperation::RenderAsync(handle.build_trigger().into()));
let alignment = self.code_style(&snippet).alignment;
self.push_indicator(handle.clone(), block_length, alignment);
match snippet.attributes.id.clone() {
Some(id) => {
if self.executable_snippets.insert(id.clone(), handle).is_some() {
return Err(self.invalid_presentation(
source_position,
InvalidPresentation::SnippetAlreadyExists(id),
));
}
}
None => {
self.push_line_break();
match handle {
WrappedSnippetHandle::Normal(handle) => {
self.push_code_execution(block_length, handle, alignment)?;
}
WrappedSnippetHandle::Pty(handle) => self.push_pty_code_execution(handle)?,
}
}
}
}
}
}
SnippetExec::AcquireTerminal(spec) => self.push_acquire_terminal_execution(snippet, block_length, &spec),
SnippetExec::Validate(spec) => {
let executor = self.snippet_executor.language_executor(&snippet.language, &spec)?;
self.push_validator(&snippet, &executor);
Ok(())
};
Ok(())
}
pub(crate) fn push_detached_code_execution(&mut self, handle: WrappedSnippetHandle) -> BuildResult {
match handle {
WrappedSnippetHandle::Normal(handle) => {
let alignment = self.code_style(&handle.snippet()).alignment;
self.push_code_execution(0, handle, alignment)
}
WrappedSnippetHandle::Pty(handle) => self.push_pty_code_execution(handle),
}
}
pub(crate) fn push_detached_code_execution(&mut self, handle: SnippetHandle) -> BuildResult {
let alignment = self.code_style(&handle.snippet()).alignment;
self.push_code_execution(0, handle, alignment)
}
fn is_execution_allowed(&self, snippet: &Snippet) -> bool {
match snippet.attributes.representation {
SnippetRepr::Snippet => match snippet.attributes.execution {
SnippetExec::AutoExec(_) => self.options.enable_snippet_execution_replace,
_ => self.options.enable_snippet_execution,
},
fn is_execution_allowed(&self, args: &SnippetExecArgs) -> bool {
match args.repr {
SnippetRepr::SnippetOutput | SnippetRepr::AcquireTerminal => self.options.enable_snippet_execution,
SnippetRepr::Image | SnippetRepr::ExecReplace => self.options.enable_snippet_execution_replace,
SnippetRepr::Render => true,
}
}
@ -149,11 +177,7 @@ impl PresentationBuilder<'_, '_> {
block_length
}
fn push_replace_code_execution(&mut self, snippet: Snippet) -> BuildResult {
// TODO: representation and execution should probably be merged
let SnippetExec::Exec(spec) = snippet.attributes.execution.clone() else {
panic!("not an exec snippet");
};
fn push_replace_code_execution(&mut self, snippet: Snippet, executor: LanguageSnippetExecutor) -> BuildResult {
let alignment = match self.code_style(&snippet).alignment {
// If we're replacing the snippet output, we have center alignment and no background, use
// center alignment but without any margins and minimum sizes so we truly center the output.
@ -162,12 +186,22 @@ impl PresentationBuilder<'_, '_> {
}
other => other,
};
let executor = self.snippet_executor.language_executor(&snippet.language, &spec)?;
let handle = SnippetHandle::new(snippet, executor, RenderAsyncStartPolicy::Automatic);
self.chunk_operations.push(RenderOperation::RenderAsync(Rc::new(RunSnippetTrigger::new(handle.clone()))));
self.push_code_execution(0, handle, alignment)
}
fn push_replace_pty_code_execution(
&mut self,
snippet: Snippet,
executor: LanguageSnippetExecutor,
args: PtyArgs,
) -> BuildResult {
let handle = PtySnippetHandle::new(snippet, executor, RenderAsyncStartPolicy::Automatic, args);
self.chunk_operations.push(RenderOperation::RenderAsync(Rc::new(RunPtySnippetTrigger::new(handle.clone()))));
self.push_pty_code_execution(handle)
}
fn load_external_snippet(
&mut self,
mut code: Snippet,
@ -280,10 +314,7 @@ impl PresentationBuilder<'_, '_> {
self.chunk_operations.push(RenderOperation::RenderAsync(Rc::new(operation)));
}
fn push_code_as_image(&mut self, snippet: Snippet) -> BuildResult {
let executor = self.snippet_executor.language_executor(&snippet.language, &Default::default())?;
self.push_validator(&snippet, &executor);
fn push_code_as_image(&mut self, snippet: Snippet, executor: LanguageSnippetExecutor) -> BuildResult {
let operation =
RunImageSnippet::new(snippet, executor, self.image_registry.clone(), self.theme.execution_output.status);
let operation = RenderOperation::RenderAsync(Rc::new(operation));
@ -295,9 +326,8 @@ impl PresentationBuilder<'_, '_> {
&mut self,
snippet: Snippet,
block_length: u16,
spec: &SnippetExecutorSpec,
executor: LanguageSnippetExecutor,
) -> BuildResult {
let executor = self.snippet_executor.language_executor(&snippet.language, spec)?;
let block_length = self.theme.code.alignment.adjust_size(block_length);
let operation = RunAcquireTerminalSnippet::new(
snippet,
@ -311,7 +341,7 @@ impl PresentationBuilder<'_, '_> {
Ok(())
}
fn push_indicator(&mut self, handle: SnippetHandle, block_length: u16, alignment: Alignment) {
fn push_indicator<T: Into<WrappedSnippetHandle>>(&mut self, handle: T, block_length: u16, alignment: Alignment) {
let style = ExecIndicatorStyle {
theme: self.theme.execution_output.status,
block_length,
@ -323,10 +353,7 @@ impl PresentationBuilder<'_, '_> {
}
fn push_code_execution(&mut self, block_length: u16, handle: SnippetHandle, alignment: Alignment) -> BuildResult {
let executor = handle.executor();
let snippet = handle.snippet();
self.push_validator(&snippet, &executor);
let default_colors = self.theme.default_style.style.colors;
let mut execution_output_style = self.theme.execution_output.clone();
if snippet.attributes.no_background {
@ -346,6 +373,25 @@ impl PresentationBuilder<'_, '_> {
Ok(())
}
fn push_pty_code_execution(&mut self, handle: PtySnippetHandle) -> BuildResult {
let snippet = handle.snippet();
let mut style = self.theme.execution_output.clone();
if snippet.attributes.no_background {
style.style.colors.background = None;
style.padding = Default::default();
}
let operation = PtySnippetOutputOperation::new(
handle,
style,
// execution_output_style,
// block_length,
self.slide_font_size(),
);
let operation = RenderOperation::RenderDynamic(Rc::new(operation));
self.chunk_operations.push(operation);
Ok(())
}
fn push_differ(&mut self, text: String) {
self.chunk_operations.push(RenderOperation::RenderDynamic(Rc::new(Differ(text))));
}

View File

@ -5,7 +5,7 @@ use std::io::{self, ErrorKind};
///
/// This is the same as [crossterm::terminal::window_size] except with some added functionality,
/// like implementing `Clone`.
#[derive(Debug, Clone, Copy)]
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct WindowSize {
pub(crate) rows: u16,
pub(crate) columns: u16,

View File

@ -34,6 +34,88 @@ impl AnsiParser {
}
}
#[derive(Default)]
pub(crate) struct AnsiColorParser {
starting_style: TextStyle,
}
impl AnsiColorParser {
pub(crate) fn new(starting_style: TextStyle) -> Self {
Self { starting_style }
}
fn parse_8bit(value: u16) -> Option<Color> {
Color::from_8bit(value.try_into().unwrap_or(u8::MAX))
}
fn parse_color(iter: &mut ParamsIter) -> Option<Color> {
match iter.next()? {
[2] => {
let r = iter.next()?.first()?;
let g = iter.next()?.first()?;
let b = iter.next()?.first()?;
Self::try_build_rgb_color(*r, *g, *b)
}
[5] => {
let color = *iter.next()?.first()?;
Color::from_8bit(color.try_into().unwrap_or(u8::MAX))
}
_ => None,
}
}
fn try_build_rgb_color(r: u16, g: u16, b: u16) -> Option<Color> {
let r = r.try_into().ok()?;
let g = g.try_into().ok()?;
let b = b.try_into().ok()?;
Some(Color::new(r, g, b))
}
pub(crate) fn parse(self, mut codes: ParamsIter) -> TextStyle {
let mut style = self.starting_style;
loop {
let Some(&[next]) = codes.next() else {
break;
};
match next {
0 => style = Default::default(),
1 => style = style.bold(),
3 => style = style.italics(),
4 => style = style.underlined(),
9 => style = style.strikethrough(),
39 => {
style.colors.foreground = None;
}
49 => {
style.colors.background = None;
}
30..=37 => {
if let Some(color) = Self::parse_8bit(next - 30) {
style = style.fg_color(color);
}
}
40..=47 => {
if let Some(color) = Self::parse_8bit(next - 40) {
style = style.bg_color(color);
}
}
38 => {
if let Some(color) = Self::parse_color(&mut codes) {
style = style.fg_color(color);
}
}
48 => {
if let Some(color) = Self::parse_color(&mut codes) {
style = style.bg_color(color);
}
}
_ => (),
};
}
style
}
}
struct Handler {
line: Line,
pending_text: Text,
@ -55,101 +137,6 @@ impl Handler {
self.line.0.push(mem::take(&mut self.pending_text));
}
}
fn parse_standard_color(value: u16) -> Option<Color> {
let color = match value {
0 | 8 => Color::Black,
1 | 9 => Color::Red,
2 | 10 => Color::Green,
3 | 11 => Color::Yellow,
4 | 12 => Color::Blue,
5 | 13 => Color::Magenta,
6 | 14 => Color::Cyan,
7 | 15 => Color::White,
_ => return None,
};
Some(color)
}
fn parse_color(iter: &mut ParamsIter) -> Option<Color> {
match iter.next()? {
[2] => {
let r = iter.next()?.first()?;
let g = iter.next()?.first()?;
let b = iter.next()?.first()?;
Self::try_build_rgb_color(*r, *g, *b)
}
[5] => {
let color = *iter.next()?.first()?;
match color {
0..=15 => Self::parse_standard_color(color),
16..=231 => {
let mapping = [0, 95, 95 + 40, 95 + 80, 95 + 120, 95 + 160];
let mut value = color - 16;
let b = (value % 6) as usize;
value /= 6;
let g = (value % 6) as usize;
value /= 6;
let r = (value % 6) as usize;
Some(Color::new(mapping[r], mapping[g], mapping[b]))
}
_ => None,
}
}
_ => None,
}
}
fn try_build_rgb_color(r: u16, g: u16, b: u16) -> Option<Color> {
let r = r.try_into().ok()?;
let g = g.try_into().ok()?;
let b = b.try_into().ok()?;
Some(Color::new(r, g, b))
}
fn update_style(&self, mut codes: ParamsIter) -> TextStyle {
let mut style = self.style;
loop {
let Some(&[next]) = codes.next() else {
break;
};
match next {
0 => style = Default::default(),
1 => style = style.bold(),
3 => style = style.italics(),
4 => style = style.underlined(),
9 => style = style.strikethrough(),
39 => {
style.colors.foreground = None;
}
49 => {
style.colors.background = None;
}
30..=37 => {
if let Some(color) = Self::parse_standard_color(next - 30) {
style = style.fg_color(color);
}
}
40..=47 => {
if let Some(color) = Self::parse_standard_color(next - 40) {
style = style.bg_color(color);
}
}
38 => {
if let Some(color) = Self::parse_color(&mut codes) {
style = style.fg_color(color);
}
}
48 => {
if let Some(color) = Self::parse_color(&mut codes) {
style = style.bg_color(color);
}
}
_ => (),
};
}
style
}
}
impl Perform for Handler {
@ -160,7 +147,7 @@ impl Perform for Handler {
fn csi_dispatch(&mut self, params: &vte::Params, _intermediates: &[u8], _ignore: bool, action: char) {
if action == 'm' {
self.save_pending_text();
self.style = self.update_style(params.iter());
self.style = AnsiColorParser::new(self.style).parse(params.iter());
self.pending_text.style = self.style;
}
}

View File

@ -14,6 +14,7 @@ use crate::{
},
terminal::printer::TerminalCommand,
};
use core::fmt;
use std::{collections::HashMap, io};
#[derive(Clone, Debug, PartialEq)]
@ -197,6 +198,20 @@ impl VirtualTerminal {
}
}
impl fmt::Debug for VirtualTerminal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("VirtualTerminal")
.field("row", &self.row)
.field("column", &self.column)
.field("colors", &self.colors)
.field("background_color", &self.background_color)
.field("images", &self.images)
.field("row_heights", &self.row_heights)
.field("image_behavior", &self.image_behavior)
.finish()
}
}
impl TerminalIo for VirtualTerminal {
fn execute(&mut self, command: &TerminalCommand<'_>) -> Result<(), TerminalError> {
use TerminalCommand::*;

View File

@ -2,6 +2,7 @@ pub(crate) mod acquire_terminal;
pub(crate) mod disabled;
pub(crate) mod image;
pub(crate) mod output;
pub(crate) mod pty;
pub(crate) mod validator;
pub(crate) use acquire_terminal::RunAcquireTerminalSnippet;

View File

@ -16,7 +16,10 @@ use crate::{
},
terminal::ansi::AnsiParser,
theme::{Alignment, ExecutionOutputBlockStyle, ExecutionStatusBlockStyle},
ui::separator::{RenderSeparator, SeparatorWidth},
ui::{
execution::pty::{PtySnippetHandle, RunPtySnippetTrigger},
separator::{RenderSeparator, SeparatorWidth},
},
};
use std::{
io::BufRead,
@ -150,7 +153,7 @@ impl Pollable for OperationPollable {
// 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 status = *status;
let modified = output.len() != self.last_length;
let mut lines = Vec::new();
@ -203,10 +206,6 @@ impl SnippetHandle {
Self(Arc::new(Mutex::new(inner)))
}
pub(crate) fn executor(&self) -> LanguageSnippetExecutor {
self.0.lock().unwrap().executor.clone()
}
pub(crate) fn snippet(&self) -> Snippet {
self.0.lock().unwrap().snippet.clone()
}
@ -245,16 +244,50 @@ pub(crate) struct ExecIndicatorStyle {
pub(crate) alignment: Alignment,
}
#[derive(Clone, Debug)]
pub(crate) enum WrappedSnippetHandle {
Normal(SnippetHandle),
Pty(PtySnippetHandle),
}
impl WrappedSnippetHandle {
pub(crate) fn process_status(&self) -> Option<ProcessStatus> {
match self {
Self::Normal(handle) => handle.0.lock().unwrap().process_status,
Self::Pty(handle) => handle.process_status(),
}
}
pub(crate) fn build_trigger(&self) -> Box<dyn RenderAsync> {
match self.clone() {
Self::Normal(handle) => Box::new(RunSnippetTrigger::new(handle)),
Self::Pty(handle) => Box::new(RunPtySnippetTrigger::new(handle)),
}
}
}
impl From<SnippetHandle> for WrappedSnippetHandle {
fn from(handle: SnippetHandle) -> Self {
Self::Normal(handle)
}
}
impl From<PtySnippetHandle> for WrappedSnippetHandle {
fn from(handle: PtySnippetHandle) -> Self {
Self::Pty(handle)
}
}
#[derive(Debug)]
pub(crate) struct ExecIndicator {
handle: SnippetHandle,
handle: WrappedSnippetHandle,
separator_width: SeparatorWidth,
theme: ExecutionStatusBlockStyle,
font_size: u8,
}
impl ExecIndicator {
pub(crate) fn new(handle: SnippetHandle, style: ExecIndicatorStyle) -> Self {
pub(crate) fn new<T: Into<WrappedSnippetHandle>>(handle: T, style: ExecIndicatorStyle) -> Self {
let ExecIndicatorStyle { theme, block_length, font_size, alignment } = style;
let block_length = alignment.adjust_size(block_length);
let separator_width = match &alignment {
@ -265,14 +298,14 @@ impl ExecIndicator {
SeparatorWidth::Fixed(block_length.max(MINIMUM_SEPARATOR_WIDTH * font_size as u16))
}
};
let handle = handle.into();
Self { handle, separator_width, theme, font_size }
}
}
impl AsRenderOperations for ExecIndicator {
fn as_render_operations(&self, _dimensions: &WindowSize) -> Vec<RenderOperation> {
let inner = self.handle.0.lock().unwrap();
let status = &inner.process_status;
let status = self.handle.process_status();
let description = match status {
Some(ProcessStatus::Running) => Text::new("running", self.theme.running_style),
Some(ProcessStatus::Success) => Text::new("finished", self.theme.success_style),
@ -296,7 +329,7 @@ mod tests {
use crate::{
code::{
execute::SnippetExecutor,
snippet::{SnippetAttributes, SnippetExec, SnippetLanguage},
snippet::{SnippetAttributes, SnippetExecution, SnippetLanguage},
},
markdown::{
elements::{Line, Text},
@ -308,7 +341,10 @@ mod tests {
let snippet = Snippet {
contents: code.into(),
language: SnippetLanguage::Bash,
attributes: SnippetAttributes { execution: SnippetExec::Exec(Default::default()), ..Default::default() },
attributes: SnippetAttributes {
execution: SnippetExecution::Exec(Default::default()),
..Default::default()
},
};
let executor = SnippetExecutor::default().language_executor(&snippet.language, &Default::default()).unwrap();
let policy = RenderAsyncStartPolicy::OnDemand;

339
src/ui/execution/pty.rs Normal file
View File

@ -0,0 +1,339 @@
use crate::{
code::{
execute::{LanguageSnippetExecutor, ProcessStatus, PtySnippetContext},
snippet::{PtyArgs, Snippet},
},
markdown::{
elements::Text,
text_style::{Color, TextStyle},
},
render::{
operation::{
AsRenderOperations, BlockLine, Pollable, PollableState, RenderAsync, RenderAsyncStartPolicy,
RenderOperation,
},
properties::WindowSize,
},
theme::{Alignment, ExecutionOutputBlockStyle},
};
use portable_pty::{MasterPty, PtySize, native_pty_system};
use std::{
fmt, io, mem,
sync::{Arc, Mutex},
thread,
};
const BOTTOM_MARGIN_RATIO: f64 = 0.2;
const BOTTOM_MINIMUM_MARGIN: u16 = 7;
const DEFAULT_COLUMNS: u16 = 80;
const DEFAULT_ROWS: u16 = 24;
#[derive(Default, Debug)]
enum State {
#[default]
Initial,
Running {
pty: PtyMaster,
dirty: bool,
},
ProcessTerminated(ProcessStatus),
Done(ProcessStatus),
}
struct Inner {
snippet: Snippet,
executor: LanguageSnippetExecutor,
parser: vt100::Parser,
size: WindowSize,
update_size: bool,
policy: RenderAsyncStartPolicy,
state: State,
}
impl fmt::Debug for Inner {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Inner")
.field("snippet", &self.snippet)
.field("executor", &self.executor)
.field("size", &self.size)
.field("update_size", &self.update_size)
.field("parser", &"...")
.field("policy", &self.policy)
.field("state", &"...")
.finish()
}
}
#[derive(Debug)]
pub(crate) struct PtySnippetOutputOperation {
handle: PtySnippetHandle,
style: ExecutionOutputBlockStyle,
font_size: u8,
}
impl PtySnippetOutputOperation {
pub(crate) fn new(handle: PtySnippetHandle, style: ExecutionOutputBlockStyle, font_size: u8) -> Self {
Self { handle, style, font_size }
}
}
impl AsRenderOperations for PtySnippetOutputOperation {
fn as_render_operations(&self, dimensions: &WindowSize) -> Vec<RenderOperation> {
let mut inner = self.handle.0.lock().unwrap();
let vertical_padding = ((dimensions.rows as f64 * BOTTOM_MARGIN_RATIO) as u16).max(BOTTOM_MINIMUM_MARGIN);
let dimensions = dimensions
.shrink_rows(vertical_padding / self.font_size as u16)
.shrink_columns(dimensions.columns - dimensions.columns / self.font_size as u16);
if inner.update_size && inner.size != dimensions {
inner.size = dimensions;
inner.parser.screen_mut().set_size(dimensions.rows, dimensions.columns);
}
if matches!(inner.state, State::Initial) {
return Vec::new();
}
let screen = inner.parser.screen();
let (rows, columns) = screen.size();
let mut operations = vec![];
for row in 0..rows {
let mut line = Vec::new();
let mut current_text = String::new();
let mut current_style = TextStyle::default();
for column in 0..columns {
let cell = screen.cell(row, column).expect("no cell");
let mut style = TextStyle::from(cell).size(self.font_size);
if style.colors.foreground.is_none() {
style.colors.foreground = self.style.style.colors.foreground;
}
if style.colors.background.is_none() {
style.colors.background = self.style.style.colors.background;
}
let contents = cell.contents();
if current_style != style && !current_text.is_empty() {
line.push(Text::new(mem::take(&mut current_text), current_style));
}
current_style = style;
if contents.is_empty() {
current_text.push(' ');
} else {
current_text.push_str(contents);
}
}
if !current_text.is_empty() {
line.push(Text::new(current_text, current_style));
}
operations.extend([
RenderOperation::RenderBlockLine(BlockLine {
prefix: "".into(),
right_padding_length: 0,
repeat_prefix_on_wrap: false,
text: line.into(),
block_length: columns,
block_color: None,
alignment: Alignment::Center {
minimum_margin: Default::default(),
minimum_size: Default::default(),
},
}),
RenderOperation::RenderLineBreak,
]);
}
operations
}
}
impl RenderAsync for PtySnippetOutputOperation {
fn pollable(&self) -> Box<dyn Pollable> {
Box::new(OperationPollable { handle: self.handle.clone() })
}
}
#[derive(Debug)]
struct OperationPollable {
handle: PtySnippetHandle,
}
impl OperationPollable {
fn spawn(ctx: PtySnippetContext, dimensions: WindowSize, handle: PtySnippetHandle) -> anyhow::Result<PtyMaster> {
let pty_system = native_pty_system();
let pty_size = PtySize {
rows: dimensions.rows,
cols: dimensions.columns,
pixel_width: dimensions.pixels_per_column() as u16,
pixel_height: dimensions.pixels_per_row() as u16,
};
let pair = pty_system.openpty(pty_size)?;
pair.slave.spawn_command(ctx.command.clone())?;
PtyMaster::new(pair.master, handle, ctx)
}
}
impl Pollable for OperationPollable {
fn poll(&mut self) -> PollableState {
let mut inner = self.handle.0.lock().unwrap();
let current_size = inner.size;
match &mut inner.state {
State::Initial => match inner.executor.pty_execution_context(&inner.snippet) {
Ok(ctx) => match Self::spawn(ctx, inner.size, self.handle.clone()) {
Ok(pty) => {
inner.state = State::Running { pty, dirty: true };
PollableState::Modified
}
Err(e) => {
inner.state = State::Done(ProcessStatus::Failure);
PollableState::Failed { error: format!("failed to run script: {e}") }
}
},
Err(e) => {
inner.state = State::Done(ProcessStatus::Failure);
PollableState::Failed { error: format!("failed to run script: {e}") }
}
},
State::Running { dirty, pty } => {
if let Ok(size) = pty._master.get_size() {
if size.rows != current_size.rows || size.cols != current_size.columns {
let size = PtySize {
rows: current_size.rows,
cols: current_size.columns,
pixel_width: 0,
pixel_height: 0,
};
let _ = pty._master.resize(size);
}
}
if mem::take(dirty) { PollableState::Modified } else { PollableState::Unmodified }
}
State::ProcessTerminated(status) => {
inner.state = State::Done(*status);
PollableState::Modified
}
_ => PollableState::Unmodified,
}
}
}
pub(crate) struct PtyMaster {
_master: Box<dyn MasterPty>,
_ctx: PtySnippetContext,
}
impl PtyMaster {
fn new(master: Box<dyn MasterPty>, handle: PtySnippetHandle, ctx: PtySnippetContext) -> anyhow::Result<Self> {
let reader = master.try_clone_reader()?;
thread::spawn(|| process_output(reader, handle));
Ok(Self { _master: master, _ctx: ctx })
}
}
impl fmt::Debug for PtyMaster {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("PtyMaster").field("master", &"...").finish()
}
}
fn process_output(mut reader: Box<dyn io::Read>, handle: PtySnippetHandle) {
let mut input_buffer = [0; 1024];
let status = loop {
let Ok(bytes_read) = reader.read(&mut input_buffer) else {
break ProcessStatus::Failure;
};
if bytes_read == 0 {
break ProcessStatus::Success;
}
let bytes = &input_buffer[..bytes_read];
let mut inner = handle.0.lock().unwrap();
inner.parser.process(bytes);
if let State::Running { dirty, .. } = &mut inner.state {
*dirty = true;
};
};
handle.0.lock().unwrap().state = State::ProcessTerminated(status);
}
impl From<&vt100::Cell> for TextStyle {
fn from(cell: &vt100::Cell) -> Self {
let mut style = TextStyle::default();
if cell.bold() {
style = style.bold();
}
if cell.italic() {
style = style.italics();
}
if cell.underline() {
style = style.underlined();
}
style.colors.foreground = parse_color(cell.fgcolor());
style.colors.background = parse_color(cell.bgcolor());
style
}
}
fn parse_color(color: vt100::Color) -> Option<Color> {
match color {
vt100::Color::Default => None,
vt100::Color::Idx(value) => Color::from_8bit(value),
vt100::Color::Rgb(r, g, b) => Some(Color::Rgb { r, g, b }),
}
}
#[derive(Debug, Clone)]
pub(crate) struct PtySnippetHandle(Arc<Mutex<Inner>>);
impl PtySnippetHandle {
pub(crate) fn new(
snippet: Snippet,
executor: LanguageSnippetExecutor,
policy: RenderAsyncStartPolicy,
args: PtyArgs,
) -> Self {
let size = WindowSize {
columns: args.columns.unwrap_or(DEFAULT_COLUMNS),
rows: args.rows.unwrap_or(DEFAULT_ROWS),
height: 0,
width: 0,
};
let update_size = args.columns.is_none() || args.rows.is_none();
let parser = vt100::Parser::new(size.rows, size.columns, 1000);
let inner = Inner { snippet, executor, parser, size, update_size, state: Default::default(), policy };
Self(Arc::new(Mutex::new(inner)))
}
pub(crate) fn snippet(&self) -> Snippet {
self.0.lock().unwrap().snippet.clone()
}
pub(crate) fn process_status(&self) -> Option<ProcessStatus> {
match &self.0.lock().unwrap().state {
State::Initial => None,
State::Running { .. } => Some(ProcessStatus::Running),
State::ProcessTerminated(status) | State::Done(status) => Some(*status),
}
}
}
#[derive(Debug)]
pub(crate) struct RunPtySnippetTrigger(PtySnippetHandle);
impl RunPtySnippetTrigger {
pub(crate) fn new(handle: PtySnippetHandle) -> Self {
Self(handle)
}
}
impl AsRenderOperations for RunPtySnippetTrigger {
fn as_render_operations(&self, _dimensions: &WindowSize) -> Vec<RenderOperation> {
vec![]
}
}
impl RenderAsync for RunPtySnippetTrigger {
fn pollable(&self) -> Box<dyn Pollable> {
Box::new(OperationPollable { handle: self.0.clone() })
}
fn start_policy(&self) -> RenderAsyncStartPolicy {
self.0.0.lock().unwrap().policy
}
}