mirror of
https://github.com/mfontanini/presenterm.git
synced 2025-05-05 15:32:58 +00:00
Compare commits
456 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
2a4ea80a46 | ||
|
b25fa12b82 | ||
|
725312e71c | ||
|
5565d420f5 | ||
|
afb0f0797f | ||
|
68a210da5a | ||
|
60f6208594 | ||
|
e2dab4d7ef | ||
|
14d2edfeb5 | ||
|
8f40a8295b | ||
|
8d54fe225a | ||
|
257fa137c5 | ||
|
262b2af3e7 | ||
|
7b2ba0eb8c | ||
|
cae76380fa | ||
|
78a3df199e | ||
|
fe818344fe | ||
|
4bf1f10d83 | ||
|
c561907259 | ||
|
0836c82f68 | ||
|
232fc34fce | ||
|
a894105b9b | ||
|
6ff8e87924 | ||
|
76561b1281 | ||
|
519aad16e8 | ||
|
0d4ffceede | ||
|
c3fb212f90 | ||
|
d0ea46ce85 | ||
|
54bddaf017 | ||
|
510af31320 | ||
|
abc132e9b5 | ||
|
8eaee8355d | ||
|
d89f25792c | ||
|
d2f6617ec6 | ||
|
910c9cbe84 | ||
|
94ce0a9225 | ||
|
3d31c5f722 | ||
|
0c2f7ee945 | ||
|
e063f46a86 | ||
|
aa7cdae105 | ||
|
5ec3a12d30 | ||
|
31f7c6c1e2 | ||
|
93359444de | ||
|
f59c19af36 | ||
|
7908b65a51 | ||
|
5eee8a9fae | ||
|
a97e66fedf | ||
|
eca6ce91bf | ||
|
9e1f2beca2 | ||
|
73211528a4 | ||
|
a5e89eb5a3 | ||
|
733786154b | ||
|
4786c5a84c | ||
|
7f3d878410 | ||
|
2b6864c215 | ||
|
02fcba89cc | ||
|
1eb7d9e995 | ||
|
913c5ed838 | ||
|
13ab57f7f6 | ||
|
8c5cdf0a92 | ||
|
a060afff7e | ||
|
58a3ea5b8d | ||
|
8b0677e418 | ||
|
e287624595 | ||
|
e8901b2aa2 | ||
|
81747f7f1d | ||
|
8749daa537 | ||
|
33fd38313b | ||
|
4c00f7731f | ||
|
a30f78e2ed | ||
|
0d9b4ded83 | ||
|
cccfb76545 | ||
|
a239e395d7 | ||
|
1d7b7d9719 | ||
|
3a7f6ae661 | ||
|
9f3c53efdc | ||
|
b3d386e9dd | ||
|
f477ba4551 | ||
|
f2a3abe85d | ||
|
636cac33b9 | ||
|
238c85f849 | ||
|
f8e94a0016 | ||
|
3715bfa4da | ||
|
ca0a8b3453 | ||
|
8ec745a4f0 | ||
|
ed7f50ef89 | ||
|
2d40544e58 | ||
|
78d2695f7a | ||
|
74bbe9f8d5 | ||
|
24221f4538 | ||
|
d7c7dba34f | ||
|
9f316abcf9 | ||
|
b52fd4ce8f | ||
|
3f3b66b52d | ||
|
3ef9d75277 | ||
|
d7216d2af5 | ||
|
3a7a967a1e | ||
|
146862f12b | ||
|
1d4e5b1c59 | ||
|
59b96d718b | ||
|
9dd4b2105c | ||
|
ec6926358a | ||
|
aa38a7120b | ||
|
66091f3b6a | ||
|
f933032958 | ||
|
0e4fad5e5e | ||
|
2784dee624 | ||
|
4254a0bafd | ||
|
1b3e79fa57 | ||
|
cae9452c15 | ||
|
ccc58deaea | ||
|
a861501091 | ||
|
99be30211b | ||
|
995cf9683e | ||
|
28f121218e | ||
|
964b36e0fb | ||
|
e7ee9a7316 | ||
|
d3d1b29a24 | ||
|
788d041ad1 | ||
|
4a6bb4197f | ||
|
af82ee747b | ||
|
c47721cfca | ||
|
94f43c4cb9 | ||
|
cbbf0b4c0b | ||
|
ed09b06103 | ||
|
e5486a8043 | ||
|
6642a2eb0b | ||
|
6230ef566c | ||
|
14e8e3ad49 | ||
|
410e671438 | ||
|
19364b2193 | ||
|
10bf968f86 | ||
|
979aebe6da | ||
|
6de1f83105 | ||
|
ad0c9badc1 | ||
|
6fb9df56a3 | ||
|
44f0787bb5 | ||
|
00ed4fb01c | ||
|
c4011b67d3 | ||
|
0057b8ba5e | ||
|
cafc6bb850 | ||
|
50040bfcc1 | ||
|
c6223a2ab6 | ||
|
60dd8eecc0 | ||
|
3b40c8fd3d | ||
|
d5b172048a | ||
|
77979984bf | ||
|
644a57f9f9 | ||
|
f17724bf91 | ||
|
92313b4fd9 | ||
|
4bf584e211 | ||
|
6f26928be3 | ||
|
0419cf3e2e | ||
|
24e6ea8386 | ||
|
ec1be93a06 | ||
|
6f12f893d0 | ||
|
a7973cccb3 | ||
|
1f2bea4a67 | ||
|
7af0e4a18b | ||
|
f190910646 | ||
|
5c03cc9950 | ||
|
967db854a2 | ||
|
6587cc955d | ||
|
ace1dfc18d | ||
|
a3ef63208f | ||
|
dfe0e8160e | ||
|
4ceb07c6de | ||
|
161110e763 | ||
|
3a3c7e031e | ||
|
350f692ed9 | ||
|
2ef27f4313 | ||
|
33619c3255 | ||
|
2e198d2dbc | ||
|
b6459701f3 | ||
|
83e33d7709 | ||
|
3e11cbe6fd | ||
|
e7dd8f7e86 | ||
|
7f1e2cbdb4 | ||
|
fb4ca37746 | ||
|
fa4d862834 | ||
|
8a806d76a1 | ||
|
60eee62e84 | ||
|
0f6a8ec73f | ||
|
5a9c2d7a45 | ||
|
dc75f43ab3 | ||
|
430872846b | ||
|
fda4eeb108 | ||
|
61cc8125ea | ||
|
49ab5690dd | ||
|
7437422a0b | ||
|
d5c56d2523 | ||
|
0f80362558 | ||
|
1aea867700 | ||
|
73429b98bd | ||
|
0c00558cd0 | ||
|
8e0bc18791 | ||
|
b6e393cde1 | ||
|
5507ea4dfd | ||
|
54d4c0db74 | ||
|
46d283743f | ||
|
8935e1d110 | ||
|
793073a373 | ||
|
bf6a15dce5 | ||
|
75116ba29c | ||
|
17476f2c0c | ||
|
f58cc80820 | ||
|
828ef016ec | ||
|
fc01bc57df | ||
|
af8c7d6f0d | ||
|
6771c2f8a2 | ||
|
fc5062eb7a | ||
|
80c6df34aa | ||
|
56923ab97a | ||
|
d2c0379465 | ||
|
99b5212af9 | ||
|
dbd4f9c1ea | ||
|
1e3b3ff26d | ||
|
7abfb5a7bc | ||
|
fb0223bb83 | ||
|
8093875aea | ||
|
2935eb617f | ||
|
1235a26f75 | ||
|
33c7c9705c | ||
|
dacb291de2 | ||
|
5a909259c8 | ||
|
3379d7a9cb | ||
|
f8e9ec6728 | ||
|
64b52334d7 | ||
|
5eb2391d62 | ||
|
c51ce7dbf9 | ||
|
7bbd1ff9db | ||
|
a02b8c4d86 | ||
|
3911f15cbc | ||
|
fd45d96e1c | ||
|
09e9f003ed | ||
|
55dcee47c1 | ||
|
4a294b9fe7 | ||
|
68b10c1807 | ||
|
622453b96c | ||
|
272ba07abe | ||
|
96b020ce6d | ||
|
6f305ef5b1 | ||
|
55639a4523 | ||
|
808a1209ff | ||
|
f6daf38c46 | ||
|
7643b8f988 | ||
|
3a389c1c7e | ||
|
22af0665c0 | ||
|
5968289562 | ||
|
e4b2b388b7 | ||
|
7f73d00f19 | ||
|
7d9115c3ee | ||
|
2292146b20 | ||
|
3fae9aea7a | ||
|
165a0bfe6e | ||
|
b73ec4db45 | ||
|
9e7e5ad58c | ||
|
16b024ce33 | ||
|
4f4b3684fe | ||
|
efc833543f | ||
|
5506a7bb1e | ||
|
5f81ef3155 | ||
|
ee06739b9c | ||
|
f6df7aa110 | ||
|
388932f2b4 | ||
|
c8e413c6bc | ||
|
d66fe47e2b | ||
|
6a494c63cd | ||
|
51f8b73180 | ||
|
dcdf5613d3 | ||
|
897d496035 | ||
|
4bb9705b07 | ||
|
98f1c388f2 | ||
|
e2d8313132 | ||
|
954f112bfd | ||
|
1f5acd8a79 | ||
|
fd9a1284b2 | ||
|
338acbfb12 | ||
|
607c38212b | ||
|
9b3e9249e1 | ||
|
b7df61d36c | ||
|
e2f23476e7 | ||
|
19d47a36cb | ||
|
9a8faf4c71 | ||
|
e428f9cc25 | ||
|
4cf9c93180 | ||
|
abce141f5e | ||
|
99b49c364f | ||
|
5bd18f9dcd | ||
|
a5bd8b9986 | ||
|
22c4e28899 | ||
|
778144160f | ||
|
e096a7b991 | ||
|
a8e78d2304 | ||
|
5ae691b48a | ||
|
5e651f6360 | ||
|
37d71a9ed4 | ||
|
cd76a97544 | ||
|
34bf39eb90 | ||
|
28d9c9f72e | ||
|
664caac3ca | ||
|
1809efb536 | ||
|
7ea9cb1e74 | ||
|
ec192d1481 | ||
|
dd2737f9d9 | ||
|
a324bc9218 | ||
|
b82a7e365d | ||
|
792452416b | ||
|
64dcc88d7d | ||
|
2a469f39d3 | ||
|
2cbfa09e8b | ||
|
254b55c9c0 | ||
|
c5beb82a67 | ||
|
5ed8d48f5b | ||
|
209cd10da8 | ||
|
2964ac9397 | ||
|
13e2d4655f | ||
|
52d3f04009 | ||
|
3114df9de4 | ||
|
ddbaf41977 | ||
|
43b44c5b7f | ||
|
0d9e9ffc9a | ||
|
792ed7c1b4 | ||
|
7479395fe3 | ||
|
c2899e91d2 | ||
|
27a6151d9a | ||
|
dc0e03927f | ||
|
e2e7e5a222 | ||
|
f06cf33ac8 | ||
|
61cd49f2b2 | ||
|
56c17b4651 | ||
|
149d954069 | ||
|
f089a10522 | ||
|
0f83b5a595 | ||
|
0fd812e7a5 | ||
|
5f781aca34 | ||
|
4416af81c4 | ||
|
e9b61ea1e5 | ||
|
91bd6a0645 | ||
|
12be307b93 | ||
|
fb1ffcc996 | ||
|
81cfbbcc2e | ||
|
df051d3980 | ||
|
4c7726ef92 | ||
|
22d70f7940 | ||
|
93a0dcba3e | ||
|
cbadf07bc0 | ||
|
3151fbdb7c | ||
|
fd6d57bf52 | ||
|
de42655389 | ||
|
67b71bbef8 | ||
|
a3dd66c7c7 | ||
|
8f54421a18 | ||
|
14d4bee671 | ||
|
0488bb5135 | ||
|
aee4c2e2d8 | ||
|
43e53b74ea | ||
|
6cf25e5eb9 | ||
|
b549bd7dcc | ||
|
d5e7c9f4bc | ||
|
77343e5edc | ||
|
1fd0bca2e4 | ||
|
657155eafe | ||
|
a8d44e158c | ||
|
a3eacf7277 | ||
|
e1a05307ac | ||
|
dec21d51cf | ||
|
e5868f4b62 | ||
|
b1906bdb10 | ||
|
11c2649e70 | ||
|
b6b4ce724a | ||
|
a5ad829a4e | ||
|
43bc5ffaca | ||
|
74205dbd34 | ||
|
e927f8b812 | ||
|
0056105008 | ||
|
eaf2ff91f8 | ||
|
c08d33bf37 | ||
|
41db0f6e2b | ||
|
a065300649 | ||
|
2f6fb6e849 | ||
|
fc9b25c154 | ||
|
d22910c912 | ||
|
1d6c34ac5b | ||
|
b71ceb458b | ||
|
bf19b497b2 | ||
|
5c634c2aed | ||
|
a2616833ab | ||
|
b4435545a6 | ||
|
d21adcd5e0 | ||
|
32d5046303 | ||
|
778f03efe6 | ||
|
d8b91363c6 | ||
|
9e87136dfd | ||
|
dfa370ee89 | ||
|
2076386eb5 | ||
|
9e8ce891ed | ||
|
a823cf5fc2 | ||
|
cc02c82469 | ||
|
145530012d | ||
|
7e4bc50eb0 | ||
|
f3dc245756 | ||
|
96cd1d4751 | ||
|
672a5bd56b | ||
|
cd35bd752f | ||
|
916ab0cab5 | ||
|
895dc11c96 | ||
|
9a00d0f756 | ||
|
50d615245c | ||
|
a19d435edf | ||
|
3cffa04077 | ||
|
459b4aba40 | ||
|
a2301d81c6 | ||
|
e1d3a2761f | ||
|
56a43c2d59 | ||
|
7ed3ed42ee | ||
|
4c6ffebb5b | ||
|
66aa3a311d | ||
|
9aa1ef4d74 | ||
|
a31f6e4e96 | ||
|
b8a546f610 | ||
|
ceac5af26f | ||
|
d1852c7971 | ||
|
cdc8cbde99 | ||
|
dbc897a710 | ||
|
02ba5e8f87 | ||
|
9bda297b56 | ||
|
322fad6c48 | ||
|
39782d072e | ||
|
9075aa4fc2 | ||
|
d221b0a835 | ||
|
bb2ace9579 | ||
|
bffe10458d | ||
|
87687d6373 | ||
|
a9bf2c3264 | ||
|
4047edb298 | ||
|
7c1002f208 | ||
|
fb53aa00a4 | ||
|
e45f21a255 | ||
|
f54f5875ff | ||
|
d61375c37c | ||
|
d7093d5b61 | ||
|
b52dbb9179 | ||
|
27e3472bf5 | ||
|
6fa85350f1 | ||
|
7dba250b6e | ||
|
f81ff58c54 | ||
|
8fc9f0ccec | ||
|
05d0603ae6 | ||
|
c240bf182f | ||
|
fd2a9d9518 | ||
|
28521fa263 | ||
|
1fc64cbcfa | ||
|
09b13f396b | ||
|
2db26706b9 | ||
|
a79f88dcf4 |
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@ -0,0 +1 @@
|
||||
github: mfontanini
|
9
.github/workflows/docs.yaml
vendored
9
.github/workflows/docs.yaml
vendored
@ -16,11 +16,12 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install mdBook
|
||||
- name: Install cargo-binstall
|
||||
uses: cargo-bins/cargo-binstall@v1.10.22
|
||||
|
||||
- name: Install mdbook
|
||||
run: |
|
||||
curl -sSL -o /tmp/mdbook.tar.gz https://github.com/rust-lang/mdBook/releases/download/v0.4.36/mdbook-v0.4.36-x86_64-unknown-linux-gnu.tar.gz
|
||||
tar -xf /tmp/mdbook.tar.gz -C /usr/local/bin
|
||||
mdbook --version
|
||||
cargo binstall -y mdbook@0.4.44 mdbook-alerts@0.7.0
|
||||
|
||||
- name: Build the book
|
||||
run: |
|
||||
|
60
.github/workflows/merge.yaml
vendored
60
.github/workflows/merge.yaml
vendored
@ -8,49 +8,55 @@ name: Merge checks
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Check
|
||||
name: Checks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install rust toolchain
|
||||
uses: dtolnay/rust-toolchain@1.74.0
|
||||
uses: dtolnay/rust-toolchain@1.78.0
|
||||
with:
|
||||
components: clippy
|
||||
|
||||
- name: Run cargo check
|
||||
run: cargo check --features sixel
|
||||
|
||||
test:
|
||||
name: Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install rust toolchain
|
||||
uses: dtolnay/rust-toolchain@1.74.0
|
||||
|
||||
- name: Run cargo test
|
||||
run: cargo test
|
||||
|
||||
lints:
|
||||
name: Lints
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install stable toolchain
|
||||
uses: dtolnay/rust-toolchain@nightly
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Run cargo fmt
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Run cargo clippy
|
||||
run: cargo clippy -- -D warnings
|
||||
|
||||
- name: Install nightly toolchain
|
||||
uses: dtolnay/rust-toolchain@nightly
|
||||
with:
|
||||
components: rustfmt
|
||||
|
||||
- name: Run cargo fmt
|
||||
run: cargo +nightly fmt --all -- --check
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
|
||||
- name: Install weasyprint
|
||||
run: |
|
||||
uv venv
|
||||
source ./.venv/bin/activate
|
||||
uv pip install weasyprint
|
||||
|
||||
- name: Export demo presentation as PDF and HTML
|
||||
run: |
|
||||
cat >/tmp/config.yaml <<EOL
|
||||
export:
|
||||
dimensions:
|
||||
rows: 35
|
||||
columns: 135
|
||||
EOL
|
||||
source ./.venv/bin/activate
|
||||
cargo run -- --export-pdf -c /tmp/config.yaml examples/demo.md
|
||||
cargo run -- --export-html -c /tmp/config.yaml examples/demo.md
|
||||
|
||||
nix-flake:
|
||||
name: Validate nix flake
|
||||
runs-on: ubuntu-latest
|
||||
|
102
.github/workflows/nightly.yaml
vendored
Normal file
102
.github/workflows/nightly.yaml
vendored
Normal file
@ -0,0 +1,102 @@
|
||||
name: Nightly build
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 0 * * *"
|
||||
|
||||
env:
|
||||
RELEASE_VERSION: nightly
|
||||
|
||||
jobs:
|
||||
vars:
|
||||
name: Set release variables
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
timestamp: ${{ steps.set.outputs.timestamp }}
|
||||
git_hash: ${{ steps.set.outputs.git_hash }}
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set variables
|
||||
id: set
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
timestamp=$(date -u)
|
||||
git_hash=$(git rev-parse HEAD)
|
||||
echo "timestamp=$timestamp" >> "$GITHUB_OUTPUT"
|
||||
echo "git_hash=$git_hash" >> "$GITHUB_OUTPUT"
|
||||
publish-github:
|
||||
name: Publish on GitHub
|
||||
runs-on: ${{ matrix.config.OS }}
|
||||
needs: vars
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
config:
|
||||
- { OS: ubuntu-latest, TARGET: "x86_64-unknown-linux-gnu" }
|
||||
- { OS: ubuntu-latest, TARGET: "x86_64-unknown-linux-musl" }
|
||||
- { OS: ubuntu-latest, TARGET: "i686-unknown-linux-gnu" }
|
||||
- { OS: ubuntu-latest, TARGET: "i686-unknown-linux-musl" }
|
||||
- { OS: ubuntu-latest, TARGET: "armv5te-unknown-linux-gnueabi" }
|
||||
- { OS: ubuntu-latest, TARGET: "armv7-unknown-linux-gnueabihf" }
|
||||
- { OS: ubuntu-latest, TARGET: "aarch64-unknown-linux-gnu" }
|
||||
- { OS: ubuntu-latest, TARGET: "aarch64-unknown-linux-musl" }
|
||||
- { OS: macos-latest, TARGET: "x86_64-apple-darwin" }
|
||||
- { OS: macos-latest, TARGET: "aarch64-apple-darwin" }
|
||||
- { OS: windows-latest, TARGET: "x86_64-pc-windows-msvc" }
|
||||
- { OS: windows-latest, TARGET: "i686-pc-windows-msvc" }
|
||||
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
target: ${{ matrix.config.TARGET }}
|
||||
override: true
|
||||
|
||||
- name: Build
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
use-cross: true
|
||||
command: build
|
||||
args: --release --locked --target ${{ matrix.config.TARGET }}
|
||||
|
||||
- name: Prepare release assets
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir release/
|
||||
cp {LICENSE,README.md} release/
|
||||
cp target/${{ matrix.config.TARGET }}/release/presenterm release/
|
||||
mv release/ presenterm-${{ env.RELEASE_VERSION }}/
|
||||
- name: Create release artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${{ matrix.config.OS }}" = "windows-latest" ]; then
|
||||
7z a -tzip "presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.zip" \
|
||||
presenterm-${{ env.RELEASE_VERSION }}
|
||||
sha512sum "presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.zip" \
|
||||
> presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.zip.sha512
|
||||
else
|
||||
tar -czvf presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.tar.gz \
|
||||
presenterm-${{ env.RELEASE_VERSION }}/
|
||||
shasum -a 512 presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.tar.gz \
|
||||
> presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.tar.gz.sha512
|
||||
fi
|
||||
- name: Upload the release
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
tag: nightly
|
||||
file: presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.*
|
||||
file_glob: true
|
||||
overwrite: true
|
||||
prerelease: true
|
||||
release_name: Nightly
|
||||
body: |
|
||||
This is a nightly build based on ref [${{ needs.vars.outputs.git_hash }}](https://github.com/mfontanini/presenterm/commit/${{ needs.vars.outputs.git_hash }})
|
||||
Generated on `${{ needs.vars.outputs.timestamp }}`
|
229
CHANGELOG.md
229
CHANGELOG.md
@ -1,3 +1,232 @@
|
||||
# v0.13.0 - 2025-04-25
|
||||
|
||||
## Breaking changes
|
||||
|
||||
* The CLI parameter to generate the JSON schema for the config file (`--generate-config-file-schema`) is now hidden behind a `json-schema` feature flag. The JSON schema file for the latest version is already publicly available at `https://github.com/mfontanini/presenterm/blob/${VERSION}/config-file-schema.json`, so anyone can use it without having to generate it by hand. This allows cutting down the number of dependencies in this project quite a bit ([#563](https://github.com/mfontanini/presenterm/issues/563)).
|
||||
|
||||
## New features
|
||||
|
||||
* Support for [slide transitions](https://mfontanini.github.io/presenterm/features/slide-transitions.html) is now available ([#530](https://github.com/mfontanini/presenterm/issues/530)):
|
||||
* Add fade slide transition ([#534](https://github.com/mfontanini/presenterm/issues/534)).
|
||||
* Add slide horizontally slide transition animation ([#528](https://github.com/mfontanini/presenterm/issues/528)).
|
||||
* Add `collapse_horizontal` slide transition ([#560](https://github.com/mfontanini/presenterm/issues/560)).
|
||||
* Add `--output` option to specify the path where the output file is written to during an export ([#526](https://github.com/mfontanini/presenterm/issues/526)) - thanks @marianozunino.
|
||||
* Allow specifying [start/end lines](https://mfontanini.github.io/presenterm/features/code/highlighting.html#including-external-code-snippets) in file snippet type ([#565](https://github.com/mfontanini/presenterm/issues/565)).
|
||||
* Allow letting [pauses become new slides](https://mfontanini.github.io/presenterm/configuration/settings.html#pause-behavior) when exporting ([#557](https://github.com/mfontanini/presenterm/issues/557)).
|
||||
* Allow [using images on right in footer](https://mfontanini.github.io/presenterm/features/themes/definition.html#footer-images) ([#554](https://github.com/mfontanini/presenterm/issues/554)).
|
||||
* Add [`max_rows` configuration](https://mfontanini.github.io/presenterm/configuration/settings.html#maximum-presentation-height) to cap vertical size ([#531](https://github.com/mfontanini/presenterm/issues/531)).
|
||||
* Add julia language highlighting and execution support ([#561](https://github.com/mfontanini/presenterm/issues/561)).
|
||||
|
||||
## Fixes
|
||||
|
||||
* Center overflow lines when using centered text ([#546](https://github.com/mfontanini/presenterm/issues/546)).
|
||||
* Don't add extra space before heading if prefix in theme is empty ([#542](https://github.com/mfontanini/presenterm/issues/542)).
|
||||
* Use no typst background in terminal-* built in themes ([#535](https://github.com/mfontanini/presenterm/issues/535)).
|
||||
* Use `std::env::temp_dir` in the `external_snippet` test ([#533](https://github.com/mfontanini/presenterm/issues/533)) - thanks @Medovi.
|
||||
* Respect `extends` in a theme set via `path` in front matter ([#532](https://github.com/mfontanini/presenterm/issues/532)).
|
||||
|
||||
## Misc
|
||||
|
||||
* Refactor async renders (e.g. mermaid/typst/latex `+render` blocks, `+exec` blocks, etc) to work truly asynchronously. This causes the output to be polled faster, and causes jumping to a slide that contains an async render to take a likely negligible (but maybe noticeable) amount of time to be jumped to. This was needed for slide transitions to work seemlessly ([#556](https://github.com/mfontanini/presenterm/issues/556)).
|
||||
* Get rid of `textproperties` ([#529](https://github.com/mfontanini/presenterm/issues/529)).
|
||||
* Add links to presentations using presenterm ([#544](https://github.com/mfontanini/presenterm/issues/544)) - thanks @orhun.
|
||||
|
||||
## Performance improvements
|
||||
|
||||
* A few performance improvements had to be done for slide transitions to work seemlessly:
|
||||
* Pre-scale ASCII images when transitions are enabled ([#550](https://github.com/mfontanini/presenterm/issues/550)).
|
||||
* Pre-scale generated images ([#553](https://github.com/mfontanini/presenterm/issues/553)).
|
||||
* Cache resized ASCII images ([#547](https://github.com/mfontanini/presenterm/issues/547)).
|
||||
|
||||
## ❤️ Sponsors
|
||||
|
||||
Thanks to the following users who supported _presenterm_ via a [github sponsorship](https://github.com/sponsors/mfontanini) in this release:
|
||||
|
||||
* [@0atman](https://github.com/0atman)
|
||||
* [@orhun](https://github.com/orhun)
|
||||
* [@fipoac](https://github.com/fipoac)
|
||||
|
||||
# v0.12.0 - 2025-03-24
|
||||
|
||||
## Breaking changes
|
||||
|
||||
* Using incremental lists now adds an extra pause before and after a list. Use the `defaults.incremental_lists` [configuration parameter](https://mfontanini.github.io/presenterm/features/commands.html#incremental-lists-behavior) to go back to the previous behavior ([#487](https://github.com/mfontanini/presenterm/issues/487)) ([#498](https://github.com/mfontanini/presenterm/issues/498)).
|
||||
|
||||
## New features
|
||||
|
||||
* [PDF exports](https://mfontanini.github.io/presenterm/features/pdf-export.html) are now generated by invoking [weasyprint](https://pypi.org/project/weasyprint/) rather than by using the now deprecated _presenterm-export_. This gets rid of the need for _tmux_ and opens up the door for other export formats ([#509](https://github.com/mfontanini/presenterm/issues/509)) ([#517](https://github.com/mfontanini/presenterm/issues/517)).
|
||||
* PDF export dimensions can now also be [specified in the config file](https://mfontanini.github.io/presenterm/configuration/settings.html#pdf-export-size) rather than always having them inferred by the terminal size ([#511](https://github.com/mfontanini/presenterm/issues/511)).
|
||||
* Allow specifying path for temporary files generated during presentation export ([#518](https://github.com/mfontanini/presenterm/issues/518)).
|
||||
* Respect font sizes in generated PDF ([#510](https://github.com/mfontanini/presenterm/issues/510)).
|
||||
* Add [`skip_slide` comment command](https://mfontanini.github.io/presenterm/features/commands.html#skip-slide) to avoid including a slide in the final presentation ([#505](https://github.com/mfontanini/presenterm/issues/505)).
|
||||
* Add [`alignment` comment](https://mfontanini.github.io/presenterm/features/commands.html#text-alignment) command to specify text alignment for the remainder of a slide ([#493](https://github.com/mfontanini/presenterm/issues/493)) ([#522](https://github.com/mfontanini/presenterm/issues/522)).
|
||||
* Add `--current-theme` CLI parameter to display the theme being used ([#489](https://github.com/mfontanini/presenterm/issues/489)).
|
||||
* Add gruvbox dark theme ([#483](https://github.com/mfontanini/presenterm/issues/483)) - thanks @ret2src.
|
||||
|
||||
## Fixes
|
||||
|
||||
* Fix broken ANSI escape code parsing which would cause command output to sometimes be incorrectly parsed and therefore led to its colors/attributes not being respected ([#500](https://github.com/mfontanini/presenterm/issues/500)).
|
||||
* Center lists correctly ([#512](https://github.com/mfontanini/presenterm/issues/512)) ([#520](https://github.com/mfontanini/presenterm/issues/520)).
|
||||
* Respect end slide shorthand in speaker notes mode ([#494](https://github.com/mfontanini/presenterm/issues/494)).
|
||||
* Use more visible colors in snippet execution output in terminal-light/dark themes ([#485](https://github.com/mfontanini/presenterm/issues/485)).
|
||||
* Show error if sixel mode is selected but disabled ([#525](https://github.com/mfontanini/presenterm/issues/525)).
|
||||
|
||||
## CI
|
||||
|
||||
* Add nightly build job ([#496](https://github.com/mfontanini/presenterm/issues/496)).
|
||||
|
||||
## Docs
|
||||
|
||||
* Fix typo in README.md ([#490](https://github.com/mfontanini/presenterm/issues/490)) - thanks @eltociear.
|
||||
* Correctly include layout pic ([#495](https://github.com/mfontanini/presenterm/issues/495)) - thanks @Tuxified.
|
||||
|
||||
## Misc
|
||||
|
||||
* Cleanup text attributes ([#519](https://github.com/mfontanini/presenterm/issues/519)).
|
||||
* Refactor snippet processing ([#484](https://github.com/mfontanini/presenterm/issues/484)).
|
||||
|
||||
## Sponsors
|
||||
|
||||
It is now possible to sponsor this project via [github sponsors](https://github.com/sponsors/mfontanini).
|
||||
|
||||
Thanks to [@0atman](https://github.com/0atman) for being the first project sponsor!
|
||||
|
||||
# v0.11.0 - 2025-03-08
|
||||
|
||||
## Breaking changes
|
||||
|
||||
* Footer templates are now sanitized, and any variables surrounded in braces that aren't supported (e.g. `{potato}`) will now cause _presenterm_ to display an error. If you'd like to use braces in contexts where you're not trying to reference a variable you can use double braces, e.g. `live at {{PotatoConf}}` ([#442](https://github.com/mfontanini/presenterm/issues/442)) ([#467](https://github.com/mfontanini/presenterm/issues/467)) ([#469](https://github.com/mfontanini/presenterm/issues/469)) ([#471](https://github.com/mfontanini/presenterm/issues/471)).
|
||||
|
||||
## New features
|
||||
|
||||
* [Add support for kitty's font size protocol](https://mfontanini.github.io/presenterm/features/introduction.html#font-sizes). This is now used by default in built in themes in a few components such as the intro slide's title and slide titles. See the [example presentation gif](https://github.com/mfontanini/presenterm/blob/master/docs/src/assets/demo.gif) to check out how this looks like. Terminal suport for this feature is detected on startup and will be ignored if unsupported. This requires _kitty_ >= 0.40.0 ([#438](https://github.com/mfontanini/presenterm/issues/438)) ([#460](https://github.com/mfontanini/presenterm/issues/460)) ([#470](https://github.com/mfontanini/presenterm/issues/470)).
|
||||
* [Allow specifying font size in a comment command](https://mfontanini.github.io/presenterm/features/commands.html#font-size), which causes any subsequent text in a slide to use the specified font size. Just like the above, only supported in _kitty_ >= 0.40.0 for now ([#458](https://github.com/mfontanini/presenterm/issues/458)).
|
||||
* [Footers can now contain images](https://mfontanini.github.io/presenterm/features/themes/definition.html#footer-images) in the left and center components. This allows including some form of branding/company logo to your presentations ([#450](https://github.com/mfontanini/presenterm/issues/450)) ([#476](https://github.com/mfontanini/presenterm/issues/476)).
|
||||
* [Footers can now contain inline markdown](https://mfontanini.github.io/presenterm/features/themes/definition.html#template-footers), which allows using bold, italics, `<span>` tags for colors, etc ([#466](https://github.com/mfontanini/presenterm/issues/466)).
|
||||
* [Presentation titles can now contain inline markdown](https://mfontanini.github.io/presenterm/features/introduction.html#introduction-slide) ([#464](https://github.com/mfontanini/presenterm/issues/464)).
|
||||
* [Introduce palette.classes in themes](https://mfontanini.github.io/presenterm/features/themes/definition.html#color-palette) to allow specifying combinations of foreground/background colors that can be referenced via the `class` attribute in `<span>` tags ([#468](https://github.com/mfontanini/presenterm/issues/468)).
|
||||
* It's now possible to [configure the alignment](https://mfontanini.github.io/presenterm/configuration/settings.html#maximum-presentation-width) to use when `max_columns` is configured and the terminal width is larger than it ([#475](https://github.com/mfontanini/presenterm/issues/475)).
|
||||
* Add support for wikilinks ([#448](https://github.com/mfontanini/presenterm/issues/448)).
|
||||
|
||||
## Fixes
|
||||
|
||||
* Don't get stuck if tmux doesn't passthrough ([#456](https://github.com/mfontanini/presenterm/issues/456)).
|
||||
* Don't squash image if terminal's font aspect ratio is not 2:1 ([#446](https://github.com/mfontanini/presenterm/issues/446)).
|
||||
* Fail if `--config-file` points to non existent file ([#474](https://github.com/mfontanini/presenterm/issues/474)).
|
||||
* Use right script name for kotlin files when executing ([#462](https://github.com/mfontanini/presenterm/issues/462)).
|
||||
* Respect lists that start at non 1 indexes ([#459](https://github.com/mfontanini/presenterm/issues/459)).
|
||||
* Jump to right slide on code attribute change ([#478](https://github.com/mfontanini/presenterm/issues/478)).
|
||||
|
||||
## Improvements
|
||||
|
||||
* Remove `result` return type from builder fns that don't need it ([#465](https://github.com/mfontanini/presenterm/issues/465)).
|
||||
* Refactor theme code ([#463](https://github.com/mfontanini/presenterm/issues/463)).
|
||||
* Restructure `terminal` code and add test for margins/layouts ([#443](https://github.com/mfontanini/presenterm/issues/443)).
|
||||
* Use `fastrand` instead of `rand` ([#441](https://github.com/mfontanini/presenterm/issues/441)).
|
||||
* Avoid cloning strings when styling them ([#440](https://github.com/mfontanini/presenterm/issues/440)).
|
||||
|
||||
# v0.10.1 - 2025-02-14
|
||||
|
||||
## Fixes
|
||||
|
||||
* Don't error out if `options` in front matter doesn't include `auto_render_languages` ([#454](https://github.com/mfontanini/presenterm/pull/454)).
|
||||
* Bump sixel-rs to 0.4.1 to fix build in aarch64 and riscv64 ([#452](https://github.com/mfontanini/presenterm/pull/452)) - thanks @Xeonacid.
|
||||
|
||||
# v0.10.0 - 2025-02-02
|
||||
|
||||
## New features
|
||||
|
||||
* Support for presentation speaker notes ([#389](https://github.com/mfontanini/presenterm/issues/389)) ([#419](https://github.com/mfontanini/presenterm/issues/419)) ([#421](https://github.com/mfontanini/presenterm/issues/421)) ([#425](https://github.com/mfontanini/presenterm/issues/425)) - thanks @dmackdev.
|
||||
* Add support for colored text via inline `span` HTML tags ([#390](https://github.com/mfontanini/presenterm/issues/390)).
|
||||
* Add a color palette in themes to allow reusing colors across the theme and using predefined colors inside `span` tags ([#427](https://github.com/mfontanini/presenterm/issues/427)).
|
||||
* Add support for github/gitlab style markdown alerts ([#423](https://github.com/mfontanini/presenterm/issues/423)) ([#430](https://github.com/mfontanini/presenterm/issues/430)).
|
||||
* Allow using `+image` on code blocks to consume their output as an image ([#429](https://github.com/mfontanini/presenterm/issues/429)).
|
||||
* Allow multiline comment commands ([#424](https://github.com/mfontanini/presenterm/issues/424)).
|
||||
* Allow auto rendering mermaid/typst/latex code blocks ([#418](https://github.com/mfontanini/presenterm/issues/418)).
|
||||
* Allow capping max columns on presentation ([#417](https://github.com/mfontanini/presenterm/issues/417)).
|
||||
* Automatically detect kitty support, including when running inside tmux ([#406](https://github.com/mfontanini/presenterm/issues/406)).
|
||||
* Use kitty image protocol in ghostty ([#405](https://github.com/mfontanini/presenterm/issues/405)).
|
||||
* Force color output in rust, c, and c++ compiler executions ([#401](https://github.com/mfontanini/presenterm/issues/401)).
|
||||
* Add graphql code highlighting ([#385](https://github.com/mfontanini/presenterm/issues/385)) - thanks @GV14982.
|
||||
* Add tcl code highlighting ([#387](https://github.com/mfontanini/presenterm/issues/387)) - thanks @jtplaarj.
|
||||
* Add Haskell executor ([#414](https://github.com/mfontanini/presenterm/issues/414)) - thanks @feature-not-a-bug.
|
||||
* Add C# to code executors ([#399](https://github.com/mfontanini/presenterm/issues/399)) - thanks @giggio.
|
||||
* Add R to executors ([#393](https://github.com/mfontanini/presenterm/issues/393)) - thanks @jonocarroll.
|
||||
|
||||
## Fixes
|
||||
|
||||
* Check for `term_program` before `term` to determine emulator ([#420](https://github.com/mfontanini/presenterm/issues/420)).
|
||||
* Allow jumping back to column in column layout ([#396](https://github.com/mfontanini/presenterm/issues/396)).
|
||||
* Ignore comments that start with `vim:` prefix ([#395](https://github.com/mfontanini/presenterm/issues/395)).
|
||||
* Respect `+no_background` on a `+exec_replace` block ([#383](https://github.com/mfontanini/presenterm/issues/383)).
|
||||
|
||||
## Docs
|
||||
|
||||
* Document tmux active session bug ([#402](https://github.com/mfontanini/presenterm/issues/402)).
|
||||
* Add notes on running `bat` directly ([#397](https://github.com/mfontanini/presenterm/issues/397)).
|
||||
|
||||
# v0.9.0 - 2024-10-06
|
||||
|
||||
## Breaking changes
|
||||
|
||||
* Default themes now no longer use a progress bar based footer. Instead they use indicator of the current page number
|
||||
and the total number of pages. If you'd like to preserve the old behavior, you can override the theme by using
|
||||
`footer.style = progress_bar` in [your
|
||||
theme](https://mfontanini.github.io/presenterm/guides/themes.html#setting-themes).
|
||||
* Links that include a title (e.g. `[my title](http://example.com)`) now have their title rendered as well. Removing a
|
||||
link's title will make it look the same as they used to.
|
||||
|
||||
## New features
|
||||
|
||||
* Use "template" footer in built-in themes ([#358](https://github.com/mfontanini/presenterm/issues/358)).
|
||||
* Allow including external code snippets ([#328](https://github.com/mfontanini/presenterm/issues/328))
|
||||
([#372](https://github.com/mfontanini/presenterm/issues/372)).
|
||||
* Add `+no_background` property to remove background from code blocks
|
||||
([#363](https://github.com/mfontanini/presenterm/issues/363))
|
||||
([#368](https://github.com/mfontanini/presenterm/issues/368)).
|
||||
* Show colored output from snippet execution output ([#316](https://github.com/mfontanini/presenterm/issues/316)).
|
||||
* Style markdown inside block quotes ([#350](https://github.com/mfontanini/presenterm/issues/350))
|
||||
([#351](https://github.com/mfontanini/presenterm/issues/351)).
|
||||
* Allow using all intro slide variables in footer template
|
||||
([#338](https://github.com/mfontanini/presenterm/issues/338)).
|
||||
* Include hidden line prefix in executors file ([#337](https://github.com/mfontanini/presenterm/issues/337)).
|
||||
* Show link labels and titles ([#334](https://github.com/mfontanini/presenterm/issues/334)).
|
||||
* Add `+exec_replace` which executes snippets and replaces them with their output
|
||||
([#330](https://github.com/mfontanini/presenterm/issues/330))
|
||||
([#371](https://github.com/mfontanini/presenterm/issues/371)).
|
||||
* Always show snippet execution bar ([#329](https://github.com/mfontanini/presenterm/issues/329)).
|
||||
* Handle suspend signal (SIGTSTP) ([#318](https://github.com/mfontanini/presenterm/issues/318)).
|
||||
* Allow closing with `q` ([#321](https://github.com/mfontanini/presenterm/issues/321)).
|
||||
* Add event, location, and date labels in intro slide ([#317](https://github.com/mfontanini/presenterm/issues/317)).
|
||||
* Use transparent background in mermaid charts ([#314](https://github.com/mfontanini/presenterm/issues/314)).
|
||||
* Add `+acquire_terminal` to acquire the terminal when running snippets
|
||||
([#366](https://github.com/mfontanini/presenterm/issues/366))
|
||||
([#376](https://github.com/mfontanini/presenterm/pull/376)).
|
||||
* Add PHP executor ([#332](https://github.com/mfontanini/presenterm/issues/332)).
|
||||
* Add Racket syntax highlighting ([#367](https://github.com/mfontanini/presenterm/issues/367)).
|
||||
* Add TOML highlighting ([#361](https://github.com/mfontanini/presenterm/issues/361)).
|
||||
|
||||
## Fixes
|
||||
|
||||
* Wrap code snippets if they don't fit in terminal ([#320](https://github.com/mfontanini/presenterm/issues/320)).
|
||||
* Allow list-themes/acknowledgements to run without path ([#359](https://github.com/mfontanini/presenterm/issues/359)).
|
||||
* Translate tabs in code snippets to 4 spaces ([#356](https://github.com/mfontanini/presenterm/issues/356)).
|
||||
* Add padding to right of code block wrapped lines ([#354](https://github.com/mfontanini/presenterm/issues/354)).
|
||||
* Don't wrap code snippet separator line ([#353](https://github.com/mfontanini/presenterm/issues/353)).
|
||||
* Show block quote prefix when wrapping ([#352](https://github.com/mfontanini/presenterm/issues/352)).
|
||||
* Don't crash on code block with only hidden-line-prefixed lines
|
||||
([#347](https://github.com/mfontanini/presenterm/issues/347)).
|
||||
* Canonicalize resources path ([#333](https://github.com/mfontanini/presenterm/issues/333)).
|
||||
* Execute script relative to current working directory ([#323](https://github.com/mfontanini/presenterm/issues/323)).
|
||||
* Support rendering mermaid charts on windows ([#319](https://github.com/mfontanini/presenterm/issues/319)).
|
||||
|
||||
## Improvements
|
||||
|
||||
* Add example on how column layouts and pauses interact ([#348](https://github.com/mfontanini/presenterm/issues/348)).
|
||||
* Rename `jump_to_vertical_center` -> `jump_to_middle` in docs
|
||||
([#342](https://github.com/mfontanini/presenterm/issues/342)).
|
||||
* Document `all` snippet highlighting keyword ([#335](https://github.com/mfontanini/presenterm/issues/335)).
|
||||
|
||||
# v0.8.0 - 2024-07-29
|
||||
|
||||
## Breaking changes
|
||||
|
880
Cargo.lock
generated
880
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
43
Cargo.toml
43
Cargo.toml
@ -4,48 +4,47 @@ authors = ["Matias Fontanini"]
|
||||
description = "A terminal slideshow presentation tool"
|
||||
repository = "https://github.com/mfontanini/presenterm"
|
||||
license = "BSD-2-Clause"
|
||||
version = "0.8.0"
|
||||
version = "0.13.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
base64 = "0.22"
|
||||
bincode = "1.3"
|
||||
clap = { version = "4.4", features = ["derive", "string"] }
|
||||
comrak = { version = "0.26", default-features = false }
|
||||
crossterm = { version = "0.27", features = ["serde"] }
|
||||
directories = "5.0"
|
||||
comrak = { version = "0.36", default-features = false }
|
||||
crossterm = { version = "0.28", features = ["serde"] }
|
||||
directories = "6.0"
|
||||
hex = "0.4"
|
||||
fastrand = "2.3"
|
||||
flate2 = "1.0"
|
||||
image = { version = "0.25", features = ["gif", "jpeg", "rayon", "png", "webp"], default-features = false }
|
||||
sixel-rs = { version = "0.3.3", optional = true }
|
||||
image = { version = "0.25", features = ["gif", "jpeg", "png"], default-features = false }
|
||||
sixel-rs = { version = "0.4.1", optional = true }
|
||||
merge-struct = "0.1.0"
|
||||
itertools = "0.13"
|
||||
itertools = "0.14"
|
||||
once_cell = "1.19"
|
||||
rand = "0.8.5"
|
||||
schemars = "0.8"
|
||||
semver = "1.0"
|
||||
schemars = { version = "0.8", optional = true }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_yaml = "0.9"
|
||||
serde_json = "1.0"
|
||||
serde_with = "3.6"
|
||||
strum = { version = "0.26", features = ["derive"] }
|
||||
tempfile = "3.10"
|
||||
console = "0.15.8"
|
||||
thiserror = "1"
|
||||
unicode-width = "0.1"
|
||||
syntect = { version = "5.2", features = ["parsing", "default-themes", "regex-onig", "plist-load"], default-features = false }
|
||||
socket2 = "0.5.8"
|
||||
strum = { version = "0.27", features = ["derive"] }
|
||||
tempfile = { version = "3.10", default-features = false }
|
||||
tl = "0.7"
|
||||
thiserror = "2"
|
||||
unicode-width = "0.2"
|
||||
os_pipe = "1.1.5"
|
||||
|
||||
[dependencies.syntect]
|
||||
version = "5.2"
|
||||
default-features = false
|
||||
features = ["parsing", "default-themes", "regex-onig", "plist-load"]
|
||||
libc = "0.2"
|
||||
vte = "0.15"
|
||||
|
||||
[dev-dependencies]
|
||||
rstest = { version = "0.21", default-features = false }
|
||||
rstest = { version = "0.25", default-features = false }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
sixel = ["sixel-rs"]
|
||||
json-schema = ["dep:schemars"]
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 0
|
||||
|
96
README.md
96
README.md
@ -16,8 +16,9 @@ presenterm
|
||||
[scoop-package]: https://scoop.sh/#/apps?q=presenterm&id=a462290f824b50f180afbaa6d8c7c1e6e0952e3a
|
||||
|
||||
_presenterm_ lets you create presentations in markdown format and run them from your terminal, with support for image
|
||||
and animated gifs, highly customizable themes, code highlighting, exporting presentations into PDF format, and
|
||||
plenty of other features. This is how the [demo presentation](/examples/demo.md) looks like:
|
||||
and animated gifs, highly customizable themes, code highlighting, exporting presentations into PDF format, and plenty of
|
||||
other features. This is how the [demo presentation](/examples/demo.md) looks like when running in the [kitty
|
||||
terminal](https://sw.kovidgoyal.net/kitty/):
|
||||
|
||||

|
||||
|
||||
@ -25,51 +26,68 @@ Check the rest of the example presentations in the [examples directory](/example
|
||||
|
||||
# Documentation
|
||||
|
||||
Visit the [documentation][guide-introduction] to get started.
|
||||
Visit the [documentation][docs-introduction] to get started.
|
||||
|
||||
# Features
|
||||
|
||||
* Define your presentation in a single markdown file.
|
||||
* [Images and animated gifs][guide-images] on terminals like _kitty_, _iterm2_, and _wezterm_.
|
||||
* [Customizeable themes][guide-themes] including colors, margins, layout (left/center aligned content), footer for every
|
||||
slide, etc. Several [built-in themes][guide-builtin-themes] can give your presentation the look you want without
|
||||
* [Images and animated gifs][docs-images] on terminals like _kitty_, _iterm2_, and _wezterm_.
|
||||
* [Customizable themes][docs-themes] including colors, margins, layout (left/center aligned content), footer for every
|
||||
slide, etc. Several [built-in themes][docs-builtin-themes] can give your presentation the look you want without
|
||||
having to define your own.
|
||||
* Code highlighting for a [wide list of programming languages][guide-code-highlight].
|
||||
* [Selective/dynamic][guide-selective-highlight] code highlighting that only highlights portions of code at a time.
|
||||
* [Column layouts][guide-layout].
|
||||
* [mermaid graph rendering](guide-mermaid).
|
||||
* [_LaTeX_ and _typst_ formula rendering][guide-latex].
|
||||
* [Introduction slide][guide-intro-slide] that displays the presentation title and your name.
|
||||
* [Slide titles][guide-slide-titles].
|
||||
* [Snippet execution][guide-code-execute] for various programming languages.
|
||||
* [Export presentations to PDF][guide-pdf-export].
|
||||
* [Pause][guide-pauses] portions of your slides.
|
||||
* [Custom key bindings][guide-key-bindings].
|
||||
* [Automatically reload your presentation][guide-hot-reload] every time it changes for a fast development loop.
|
||||
* Code highlighting for a [wide list of programming languages][docs-code-highlight].
|
||||
* [Font sizes][docs-font-sizes] for terminals that support them.
|
||||
* [Selective/dynamic][docs-selective-highlight] code highlighting that only highlights portions of code at a time.
|
||||
* [Column layouts][docs-layout].
|
||||
* [mermaid graph rendering][docs-mermaid].
|
||||
* [_LaTeX_ and _typst_ formula rendering][docs-latex].
|
||||
* [Introduction slide][docs-intro-slide] that displays the presentation title and your name.
|
||||
* [Slide titles][docs-slide-titles].
|
||||
* [Snippet execution][docs-code-execute] for various programming languages.
|
||||
* [Export presentations to PDF][docs-pdf-export].
|
||||
* [Slide transitions][docs-slide-transitions].
|
||||
* [Pause][docs-pauses] portions of your slides.
|
||||
* [Custom key bindings][docs-key-bindings].
|
||||
* [Automatically reload your presentation][docs-hot-reload] every time it changes for a fast development loop.
|
||||
* [Define speaker notes][docs-speaker-notes] to aid you during presentations.
|
||||
|
||||
See the [introduction page][guide-introduction] to learn more.
|
||||
See the [introduction page][docs-introduction] to learn more.
|
||||
|
||||
# Presenterm in action
|
||||
|
||||
Here are some talks and demos that feature _presenterm_:
|
||||
|
||||
- [Bringing Terminal Aesthetics to the Web With Rust][bringing-terminal-aesthetics] by [Orhun Parmaksız][orhun-github]
|
||||
- [7 Rust Terminal Tools That You Should Use][rust-terminal-tools] by [Orhun Parmaksız][orhun-github]
|
||||
- [Renaissance of Terminal User Interfaces with Rust][renaissance-tui] by [Orhun Parmaksız][orhun-github]
|
||||
|
||||
Gave a talk using _presenterm_? We would love to feature it here! Open a PR or issue to get it added.
|
||||
|
||||
<!-- links -->
|
||||
|
||||
[guide-introduction]: https://mfontanini.github.io/presenterm/
|
||||
[guide-installation]: https://mfontanini.github.io/presenterm/guides/installation.html
|
||||
[guide-basics]: https://mfontanini.github.io/presenterm/guides/basics.html
|
||||
[guide-intro-slide]: https://mfontanini.github.io/presenterm/guides/basics.html#introduction-slide
|
||||
[guide-slide-titles]: https://mfontanini.github.io/presenterm/guides/basics.html#slide-titles
|
||||
[guide-pauses]: https://mfontanini.github.io/presenterm/guides/basics.html#pauses
|
||||
[guide-images]: https://mfontanini.github.io/presenterm/guides/basics.html#images
|
||||
[guide-themes]: https://mfontanini.github.io/presenterm/guides/themes.html
|
||||
[guide-builtin-themes]: https://mfontanini.github.io/presenterm/guides/themes.html#built-in-themes
|
||||
[guide-code-highlight]: https://mfontanini.github.io/presenterm/guides/code-highlight.html
|
||||
[guide-code-execute]: https://mfontanini.github.io/presenterm/guides/code-highlight.html#executing-code
|
||||
[guide-selective-highlight]: https://mfontanini.github.io/presenterm/guides/code-highlight.html#selective-highlighting
|
||||
[guide-layout]: https://mfontanini.github.io/presenterm/guides/layout.html
|
||||
[guide-mermaid]: https://mfontanini.github.io/presenterm/guides/mermaid.html
|
||||
[guide-latex]: https://mfontanini.github.io/presenterm/guides/latex.html
|
||||
[guide-pdf-export]: https://mfontanini.github.io/presenterm/guides/pdf-export.html
|
||||
[guide-key-bindings]: https://mfontanini.github.io/presenterm/guides/configuration.html#key-bindings
|
||||
[guide-hot-reload]: https://mfontanini.github.io/presenterm/guides/basics.html#hot-reload
|
||||
[docs-introduction]: https://mfontanini.github.io/presenterm/
|
||||
[docs-basics]: https://mfontanini.github.io/presenterm/features/introduction.html
|
||||
[docs-intro-slide]: https://mfontanini.github.io/presenterm/features/introduction.html#introduction-slide
|
||||
[docs-slide-titles]: https://mfontanini.github.io/presenterm/features/introduction.html#slide-titles
|
||||
[docs-font-sizes]: https://mfontanini.github.io/presenterm/features/introduction.html#font-sizes
|
||||
[docs-pauses]: https://mfontanini.github.io/presenterm/features/commands.html#pauses
|
||||
[docs-images]: https://mfontanini.github.io/presenterm/features/images.html
|
||||
[docs-themes]: https://mfontanini.github.io/presenterm/features/themes/introduction.html
|
||||
[docs-builtin-themes]: https://mfontanini.github.io/presenterm/features/themes/introduction.html#built-in-themes
|
||||
[docs-code-highlight]: https://mfontanini.github.io/presenterm/features/code/highlighting.html
|
||||
[docs-code-execute]: https://mfontanini.github.io/presenterm/features/code/execution.html
|
||||
[docs-selective-highlight]: https://mfontanini.github.io/presenterm/features/code/highlighting.html#selective-highlighting
|
||||
[docs-slide-transitions]: https://mfontanini.github.io/presenterm/features/slide-transitons.html
|
||||
[docs-layout]: https://mfontanini.github.io/presenterm/features/layout.html
|
||||
[docs-mermaid]: https://mfontanini.github.io/presenterm/features/code/mermaid.html
|
||||
[docs-latex]: https://mfontanini.github.io/presenterm/features/code/latex.html
|
||||
[docs-pdf-export]: https://mfontanini.github.io/presenterm/features/pdf-export.html
|
||||
[docs-key-bindings]: https://mfontanini.github.io/presenterm/configuration/settings.html#key-bindings
|
||||
[docs-hot-reload]: https://mfontanini.github.io/presenterm/features/introduction.html#hot-reload
|
||||
[docs-speaker-notes]: https://mfontanini.github.io/presenterm/features/speaker-notes.html
|
||||
[bat]: https://github.com/sharkdp/bat
|
||||
[syntect]: https://github.com/trishume/syntect
|
||||
|
||||
|
||||
[bringing-terminal-aesthetics]: https://www.youtube.com/watch?v=iepbyYrF_YQ
|
||||
[rust-terminal-tools]: https://www.youtube.com/watch?v=ATiKwUiqnAU
|
||||
[renaissance-tui]: https://www.youtube.com/watch?v=hWG51Mc1DlM
|
||||
[orhun-github]: https://github.com/orhun
|
||||
|
@ -14,6 +14,9 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"export": {
|
||||
"$ref": "#/definitions/ExportConfig"
|
||||
},
|
||||
"mermaid": {
|
||||
"$ref": "#/definitions/MermaidConfig"
|
||||
},
|
||||
@ -23,6 +26,19 @@
|
||||
"snippet": {
|
||||
"$ref": "#/definitions/SnippetConfig"
|
||||
},
|
||||
"speaker_notes": {
|
||||
"$ref": "#/definitions/SpeakerNotesConfig"
|
||||
},
|
||||
"transition": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/SlideTransitionConfig"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"typst": {
|
||||
"$ref": "#/definitions/TypstConfig"
|
||||
}
|
||||
@ -40,6 +56,44 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"incremental_lists": {
|
||||
"description": "The configuration for lists when incremental lists are enabled.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/IncrementalListsConfig"
|
||||
}
|
||||
]
|
||||
},
|
||||
"max_columns": {
|
||||
"description": "A max width in columns that the presentation must always be capped to.",
|
||||
"default": 65535,
|
||||
"type": "integer",
|
||||
"format": "uint16",
|
||||
"minimum": 0.0
|
||||
},
|
||||
"max_columns_alignment": {
|
||||
"description": "The alignment the presentation should have if `max_columns` is set and the terminal is larger than that.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/MaxColumnsAlignment"
|
||||
}
|
||||
]
|
||||
},
|
||||
"max_rows": {
|
||||
"description": "A max height in rows that the presentation must always be capped to.",
|
||||
"default": 65535,
|
||||
"type": "integer",
|
||||
"format": "uint16",
|
||||
"minimum": 0.0
|
||||
},
|
||||
"max_rows_alignment": {
|
||||
"description": "The alignment the presentation should have if `max_rows` is set and the terminal is larger than that.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/MaxRowsAlignment"
|
||||
}
|
||||
]
|
||||
},
|
||||
"terminal_font_size": {
|
||||
"description": "Override the terminal font size when in windows or when using sixel.",
|
||||
"default": 16,
|
||||
@ -65,6 +119,55 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ExportConfig": {
|
||||
"description": "The export configuration.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dimensions": {
|
||||
"description": "The dimensions to use for presentation exports.",
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ExportDimensionsConfig"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"pauses": {
|
||||
"description": "Whether pauses should create new slides.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/PauseExportPolicy"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ExportDimensionsConfig": {
|
||||
"description": "The dimensions to use for presentation exports.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"columns",
|
||||
"rows"
|
||||
],
|
||||
"properties": {
|
||||
"columns": {
|
||||
"description": "The number of columns.",
|
||||
"type": "integer",
|
||||
"format": "uint16",
|
||||
"minimum": 0.0
|
||||
},
|
||||
"rows": {
|
||||
"description": "The number of rows.",
|
||||
"type": "integer",
|
||||
"format": "uint16",
|
||||
"minimum": 0.0
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ImageProtocol": {
|
||||
"oneOf": [
|
||||
{
|
||||
@ -111,6 +214,29 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"IncrementalListsConfig": {
|
||||
"description": "The configuration for lists when incremental lists are enabled.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pause_after": {
|
||||
"description": "Whether to pause after a list ends.",
|
||||
"default": null,
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"pause_before": {
|
||||
"description": "Whether to pause before a list begins.",
|
||||
"default": null,
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"KeyBinding": {
|
||||
"type": "string"
|
||||
},
|
||||
@ -194,6 +320,13 @@
|
||||
"$ref": "#/definitions/KeyBinding"
|
||||
}
|
||||
},
|
||||
"suspend": {
|
||||
"description": "The key binding to suspend the application.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/KeyBinding"
|
||||
}
|
||||
},
|
||||
"toggle_bindings": {
|
||||
"description": "The key binding to toggle the key bindings modal.",
|
||||
"type": "array",
|
||||
@ -240,9 +373,68 @@
|
||||
"filename": {
|
||||
"description": "The filename to use for the snippet input file.",
|
||||
"type": "string"
|
||||
},
|
||||
"hidden_line_prefix": {
|
||||
"description": "The prefix to use to hide lines visually but still execute them.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"MaxColumnsAlignment": {
|
||||
"description": "The alignment to use when `defaults.max_columns` is set.",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "Align the presentation to the left.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"left"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Align the presentation on the center.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"center"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Align the presentation to the right.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"right"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"MaxRowsAlignment": {
|
||||
"description": "The alignment to use when `defaults.max_rows` is set.",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "Align the presentation to the top.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"top"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Align the presentation on the center.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"center"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Align the presentation to the bottom.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"bottom"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"MermaidConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@ -259,6 +451,13 @@
|
||||
"OptionsConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"auto_render_languages": {
|
||||
"description": "Assume snippets for these languages contain `+render` and render them automatically.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/SnippetLanguage"
|
||||
}
|
||||
},
|
||||
"command_prefix": {
|
||||
"description": "The prefix to use for commands.",
|
||||
"type": [
|
||||
@ -304,6 +503,108 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PauseExportPolicy": {
|
||||
"description": "The policy for pauses when exporting.",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "Whether to ignore pauses.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ignore"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Create a new slide when a pause is found.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"new_slide"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"SlideTransitionConfig": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"animation"
|
||||
],
|
||||
"properties": {
|
||||
"animation": {
|
||||
"description": "The slide transition style.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/SlideTransitionStyleConfig"
|
||||
}
|
||||
]
|
||||
},
|
||||
"duration_millis": {
|
||||
"description": "The amount of time to take to perform the transition.",
|
||||
"default": 1000,
|
||||
"type": "integer",
|
||||
"format": "uint16",
|
||||
"minimum": 0.0
|
||||
},
|
||||
"frames": {
|
||||
"description": "The number of frames in a transition.",
|
||||
"default": 30,
|
||||
"type": "integer",
|
||||
"format": "uint",
|
||||
"minimum": 0.0
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SlideTransitionStyleConfig": {
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "Slide horizontally.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"style"
|
||||
],
|
||||
"properties": {
|
||||
"style": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"slide_horizontal"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"description": "Fade the new slide into the previous one.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"style"
|
||||
],
|
||||
"properties": {
|
||||
"style": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"fade"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"description": "Collapse the current slide into the center of the screen.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"style"
|
||||
],
|
||||
"properties": {
|
||||
"style": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"collapse_horizontal"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"SnippetConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@ -315,6 +616,14 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"exec_replace": {
|
||||
"description": "The properties for snippet execution.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/SnippetExecReplaceConfig"
|
||||
}
|
||||
]
|
||||
},
|
||||
"render": {
|
||||
"description": "The properties for snippet auto rendering.",
|
||||
"allOf": [
|
||||
@ -346,6 +655,105 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SnippetExecReplaceConfig": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"enable"
|
||||
],
|
||||
"properties": {
|
||||
"enable": {
|
||||
"description": "Whether to enable snippet replace-executions, which automatically run code snippets without the user's intervention.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SnippetLanguage": {
|
||||
"description": "The language of a code snippet.",
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Ada",
|
||||
"Asp",
|
||||
"Awk",
|
||||
"Bash",
|
||||
"BatchFile",
|
||||
"C",
|
||||
"CMake",
|
||||
"Crontab",
|
||||
"CSharp",
|
||||
"Clojure",
|
||||
"Cpp",
|
||||
"Css",
|
||||
"DLang",
|
||||
"Diff",
|
||||
"Docker",
|
||||
"Dotenv",
|
||||
"Elixir",
|
||||
"Elm",
|
||||
"Erlang",
|
||||
"File",
|
||||
"Fish",
|
||||
"Go",
|
||||
"GraphQL",
|
||||
"Haskell",
|
||||
"Html",
|
||||
"Java",
|
||||
"JavaScript",
|
||||
"Json",
|
||||
"Julia",
|
||||
"Kotlin",
|
||||
"Latex",
|
||||
"Lua",
|
||||
"Makefile",
|
||||
"Mermaid",
|
||||
"Markdown",
|
||||
"Nix",
|
||||
"Nushell",
|
||||
"OCaml",
|
||||
"Perl",
|
||||
"Php",
|
||||
"Protobuf",
|
||||
"Puppet",
|
||||
"Python",
|
||||
"R",
|
||||
"Racket",
|
||||
"Ruby",
|
||||
"Rust",
|
||||
"RustScript",
|
||||
"Scala",
|
||||
"Shell",
|
||||
"Sql",
|
||||
"Swift",
|
||||
"Svelte",
|
||||
"Tcl",
|
||||
"Terraform",
|
||||
"Toml",
|
||||
"TypeScript",
|
||||
"Typst",
|
||||
"Xml",
|
||||
"Yaml",
|
||||
"Verilog",
|
||||
"Vue",
|
||||
"Zig",
|
||||
"Zsh"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"Unknown"
|
||||
],
|
||||
"properties": {
|
||||
"Unknown": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"SnippetRenderConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@ -359,6 +767,27 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SpeakerNotesConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"always_publish": {
|
||||
"description": "Whether to always publish speaker notes.",
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"listen_address": {
|
||||
"description": "The address in which to listen for speaker note events.",
|
||||
"default": "127.255.255.255:59418",
|
||||
"type": "string"
|
||||
},
|
||||
"publish_address": {
|
||||
"description": "The address in which to publish speaker notes events.",
|
||||
"default": "127.255.255.255:59418",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"TypstConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
@ -1,3 +1,4 @@
|
||||
---
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/mfontanini/presenterm/master/config-file-schema.json
|
||||
defaults:
|
||||
# override the terminal font size when in windows or when using sixel.
|
||||
@ -40,10 +41,24 @@ snippet:
|
||||
# enable code snippet execution. Use at your own risk!
|
||||
enable: true
|
||||
|
||||
exec_replace:
|
||||
# enable code snippet automatic execution + replacing the snippet with its output. Use at your own risk!
|
||||
enable: true
|
||||
|
||||
render:
|
||||
# the number of threads to use when rendering `+render` code snippets.
|
||||
threads: 2
|
||||
|
||||
speaker_notes:
|
||||
# The endpoint to listen for speaker note events.
|
||||
listen_address: "127.0.0.1:59418"
|
||||
|
||||
# The endpoint to publish speaker note events.
|
||||
publish_address: "127.0.0.1:59418"
|
||||
|
||||
# Whether to always publish speaker notes even when `--publish-speaker-notes` is not set.
|
||||
always_publish: false
|
||||
|
||||
bindings:
|
||||
# the keys that cause the presentation to move forwards.
|
||||
next: ["l", "j", "<right>", "<page_down>", "<down>", " "]
|
||||
@ -82,4 +97,7 @@ bindings:
|
||||
close_modal: ["<esc>"]
|
||||
|
||||
# the key binding to close the application.
|
||||
exit: ["<c-c>"]
|
||||
exit: ["<c-c>", "q"]
|
||||
|
||||
# the key binding to suspend the application.
|
||||
suspend: ["<c-z>"]
|
||||
|
@ -7,12 +7,18 @@ title = "presenterm documentation"
|
||||
|
||||
[preprocessor]
|
||||
|
||||
[preprocessor.catppuccin]
|
||||
assets_version = "2.1.0"
|
||||
[preprocessor.alerts]
|
||||
|
||||
[output]
|
||||
|
||||
[output.html]
|
||||
additional-css = ["./theme/catppuccin.css", "./theme/catppuccin-admonish.css"]
|
||||
git-repository-url = "https://github.com/mfontanini/presenterm"
|
||||
default-theme = "machiatto"
|
||||
default-theme = "navy"
|
||||
|
||||
# Redirects for broken links after 02/02/2025 restructuring.
|
||||
[output.html.redirect]
|
||||
"/guides/basics.html" = "../features/introduction.html"
|
||||
"/guides/installation.html" = "../install.html"
|
||||
"/guides/code-highlight.html" = "../features/code/highlighting.html"
|
||||
"/guides/mermaid.html" = "../features/code/mermaid.html"
|
||||
|
||||
|
@ -2,17 +2,25 @@
|
||||
|
||||
[Introduction](./introduction.md)
|
||||
|
||||
# Guides
|
||||
# Docs
|
||||
|
||||
- [Installation](./guides/installation.md)
|
||||
- [Basics](./guides/basics.md)
|
||||
- [Themes](./guides/themes.md)
|
||||
- [Layout](./guides/layout.md)
|
||||
- [Configuration](./guides/configuration.md)
|
||||
- [Code highlighting](./guides/code-highlight.md)
|
||||
- [PDF export](./guides/pdf-export.md)
|
||||
- [Mermaid](./guides/mermaid.md)
|
||||
- [LaTeX and typst](./guides/latex.md)
|
||||
- [Install](./install.md)
|
||||
- [Features](./features/introduction.md)
|
||||
- [Images](./features/images.md).
|
||||
- [Commands](./features/commands.md).
|
||||
- [Layout](./features/layout.md).
|
||||
- [Code](./features/code/highlighting.md)
|
||||
- [Execution](./features/code/execution.md)
|
||||
- [Mermaid diagrams](./features/code/mermaid.md)
|
||||
- [LaTeX and typst](./features/code/latex.md)
|
||||
- [Themes](./features/themes/introduction.md)
|
||||
- [Definition](./features/themes/definition.md)
|
||||
- [PDF export](./features/pdf-export.md)
|
||||
- [Slide transitions](./features/slide-transitions.md)
|
||||
- [Speaker notes](./features/speaker-notes.md)
|
||||
- [Configuration](./configuration/introduction.md)
|
||||
- [Options](./configuration/options.md)
|
||||
- [Settings](./configuration/settings.md)
|
||||
|
||||
# Internals
|
||||
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 282 KiB |
Binary file not shown.
Before Width: | Height: | Size: 462 KiB After Width: | Height: | Size: 655 KiB |
BIN
docs/src/assets/example-footer.png
Normal file
BIN
docs/src/assets/example-footer.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 64 KiB |
28
docs/src/configuration/introduction.md
Normal file
28
docs/src/configuration/introduction.md
Normal file
@ -0,0 +1,28 @@
|
||||
# Configuration
|
||||
|
||||
_presenterm_ allows you to customize its behavior via a configuration file. This file is stored, along with all of your
|
||||
custom themes, in the following directories:
|
||||
|
||||
* `$XDG_CONFIG_HOME/presenterm/` if that environment variable is defined, otherwise:
|
||||
* `~/.config/presenterm/` in Linux.
|
||||
* `~/Library/Application Support/presenterm/` in macOS.
|
||||
* `~/AppData/Roaming/presenterm/config/` in Windows.
|
||||
|
||||
The configuration file will be looked up automatically in the directories above under the name `config.yaml`. e.g. on
|
||||
Linux you should create it under `~/.config/presenterm/config.yaml`. You can also specify a custom path to this file
|
||||
when running _presenterm_ via the `--config-file` parameter.
|
||||
|
||||
A [sample configuration file](https://github.com/mfontanini/presenterm/blob/master/config.sample.yaml) is provided in
|
||||
the repository that you can use as a base.
|
||||
|
||||
# Configuration schema
|
||||
|
||||
A JSON schema that defines the configuration file's schema is available to be used with YAML language servers such as
|
||||
[yaml-language-server](https://github.com/redhat-developer/yaml-language-server).
|
||||
|
||||
Include the following line at the beginning of your configuration file to have your editor pull in autocompletion
|
||||
suggestions and docs automatically:
|
||||
|
||||
```yaml
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/mfontanini/presenterm/master/config-file-schema.json
|
||||
```
|
165
docs/src/configuration/options.md
Normal file
165
docs/src/configuration/options.md
Normal file
@ -0,0 +1,165 @@
|
||||
# Options
|
||||
|
||||
Options are special configuration parameters that can be set either in the configuration file under the `options` key,
|
||||
or in a presentation's front matter under the same key. This last one allows you to customize a single presentation so
|
||||
that it acts in a particular way. This can also be useful if you'd like to share the source files for your presentation
|
||||
with other people.
|
||||
|
||||
The supported configuration options are currently the following:
|
||||
|
||||
## implicit_slide_ends
|
||||
|
||||
This option removes the need to use `<!-- end_slide -->` in between slides and instead assumes that if you use a slide
|
||||
title, then you're implying that the previous slide ended. For example, the following presentation:
|
||||
|
||||
```markdown
|
||||
---
|
||||
options:
|
||||
implicit_slide_ends: true
|
||||
---
|
||||
|
||||
Tasty vegetables
|
||||
================
|
||||
|
||||
* Potato
|
||||
|
||||
Awful vegetables
|
||||
================
|
||||
|
||||
* Lettuce
|
||||
```
|
||||
|
||||
Is equivalent to this "vanilla" one that doesn't use implicit slide ends.
|
||||
|
||||
```markdown
|
||||
Tasty vegetables
|
||||
================
|
||||
|
||||
* Potato
|
||||
|
||||
<!-- end_slide -->
|
||||
|
||||
Awful vegetables
|
||||
================
|
||||
|
||||
* Lettuce
|
||||
```
|
||||
|
||||
## end_slide_shorthand
|
||||
|
||||
This option allows using thematic breaks (`---`) as a delimiter between slides. When enabling this option, you can still
|
||||
use `<!-- end_slide -->` but any thematic break will also be considered a slide terminator.
|
||||
|
||||
```
|
||||
---
|
||||
options:
|
||||
end_slide_shorthand: true
|
||||
---
|
||||
|
||||
this is a slide
|
||||
|
||||
---------------------
|
||||
|
||||
this is another slide
|
||||
```
|
||||
|
||||
## command_prefix
|
||||
|
||||
Because _presenterm_ uses HTML comments to represent commands, it is necessary to make some assumptions on _what_ is a
|
||||
command and what isn't. The current heuristic is:
|
||||
|
||||
* If an HTML comment is laid out on a single line, it is assumed to be a command. This means if you want to use a real
|
||||
HTML comment like `<!-- remember to say "potato" here -->`, this will raise an error.
|
||||
* If an HTML comment is multi-line, then it is assumed to be a comment and it can have anything inside it. This means
|
||||
you can't have a multi-line comment that contains a command like `pause` inside.
|
||||
|
||||
Depending on how you use HTML comments personally, this may be limiting to you: you cannot use any single line comments
|
||||
that are not commands. To get around this, the `command_prefix` option lets you configure a prefix that must be set in
|
||||
all commands for them to be configured as such. Any single line comment that doesn't start with this prefix will not be
|
||||
considered a command.
|
||||
|
||||
For example:
|
||||
|
||||
```
|
||||
---
|
||||
options:
|
||||
command_prefix: "cmd:"
|
||||
---
|
||||
|
||||
<!-- remember to say "potato here" -->
|
||||
|
||||
Tasty vegetables
|
||||
================
|
||||
|
||||
* Potato
|
||||
|
||||
<!-- cmd:pause -->
|
||||
|
||||
**That's it!**
|
||||
```
|
||||
|
||||
In the example above, the first comment is ignored because it doesn't start with "cmd:" and the second one is processed
|
||||
because it does.
|
||||
|
||||
## incremental_lists
|
||||
|
||||
If you'd like all bullet points in all lists to show up with pauses in between you can enable the `incremental_lists`
|
||||
option:
|
||||
|
||||
```
|
||||
---
|
||||
options:
|
||||
incremental_lists: true
|
||||
---
|
||||
|
||||
* pauses
|
||||
* in
|
||||
* between
|
||||
```
|
||||
|
||||
Keep in mind if you only want specific bullet points to show up with pauses in between, you can use the
|
||||
[`incremental_lists` comment command](../features/commands.md#incremental-lists).
|
||||
|
||||
## strict_front_matter_parsing
|
||||
|
||||
This option tells _presenterm_ you don't care about extra parameters in presentation's front matter. This can be useful
|
||||
if you're trying to load a presentation made for another tool. The following presentation would only be successfully
|
||||
loaded if you set `strict_front_matter_parsing` to `false` in your configuration file:
|
||||
|
||||
```markdown
|
||||
---
|
||||
potato: 42
|
||||
---
|
||||
|
||||
# Hi
|
||||
```
|
||||
|
||||
## image_attributes_prefix
|
||||
|
||||
The [image size](../features/images.md#image-size) prefix (by default `image:`) can be configured to be anything you
|
||||
would want in case you don't like the default one. For example, if you'd like to set the image size by simply doing
|
||||
`` you would need to set:
|
||||
|
||||
```yaml
|
||||
---
|
||||
options:
|
||||
image_attributes_prefix: ""
|
||||
---
|
||||
|
||||

|
||||
```
|
||||
|
||||
## auto_render_languages
|
||||
|
||||
This option allows indicating a list of languages for which the `+render` attribute can be omitted in their code
|
||||
snippets and will be implicitly considered to be set. This can be used for languages like `mermaid` so that graphs are
|
||||
always automatically rendered without the need to specify `+render` everywhere.
|
||||
|
||||
```yaml
|
||||
---
|
||||
options:
|
||||
auto_render_languages:
|
||||
- mermaid
|
||||
---
|
||||
```
|
||||
|
305
docs/src/configuration/settings.md
Normal file
305
docs/src/configuration/settings.md
Normal file
@ -0,0 +1,305 @@
|
||||
# Settings
|
||||
|
||||
As opposed to options, the rest of these settings **can only be configured via the configuration file**.
|
||||
|
||||
## Default theme
|
||||
|
||||
The default theme can be configured only via the config file. When this is set, every presentation that doesn't set a
|
||||
theme explicitly will use this one:
|
||||
|
||||
```yaml
|
||||
defaults:
|
||||
theme: light
|
||||
```
|
||||
|
||||
## Terminal font size
|
||||
|
||||
This is a parameter that lets you explicitly set the terminal font size in use. This should not be used unless you are
|
||||
in Windows, given there's no (easy) way to get the terminal window size so we use this to figure out how large the
|
||||
window is and resize images properly. Some terminals on other platforms may also have this issue, but that should not be
|
||||
as common.
|
||||
|
||||
If you are on Windows or you notice images show up larger/smaller than they should, you can adjust this setting in your
|
||||
config file:
|
||||
|
||||
```yaml
|
||||
defaults:
|
||||
terminal_font_size: 16
|
||||
```
|
||||
|
||||
## Preferred image protocol
|
||||
|
||||
By default _presenterm_ will try to detect which image protocol to use based on the terminal you are using. In case
|
||||
detection for some reason fails in your setup or you'd like to force a different protocol to be used, you can explicitly
|
||||
set this via the `--image-protocol` parameter or the configuration key `defaults.image_protocol`:
|
||||
|
||||
```yaml
|
||||
defaults:
|
||||
image_protocol: kitty-local
|
||||
```
|
||||
|
||||
Possible values are:
|
||||
* `auto`: try to detect it automatically (default).
|
||||
* `kitty-local`: use the kitty protocol in "local" mode, meaning both _presenterm_ and the terminal run in the same host
|
||||
and can share the filesystem to communicate.
|
||||
* `kitty-remote`: use the kitty protocol in "remote" mode, meaning _presenterm_ and the terminal run in different hosts
|
||||
and therefore can only communicate via terminal escape codes.
|
||||
* `iterm2`: use the iterm2 protocol.
|
||||
* `sixel`: use the sixel protocol. Note that this requires compiling _presenterm_ using the `--features sixel` flag.
|
||||
|
||||
## Maximum presentation width
|
||||
|
||||
The `max_columns` property can be set to specify the maximum number of columns that the presentation will stretch to. If
|
||||
your terminal is larger than that, the presentation will stick to that size and will be centered, preventing it from
|
||||
looking too stretched.
|
||||
|
||||
```yaml
|
||||
defaults:
|
||||
max_columns: 100
|
||||
```
|
||||
|
||||
If you would like your presentation to be left or right aligned instead of centered when the terminal is too wide, you
|
||||
can use the `max_columns_alignment` key:
|
||||
|
||||
```yaml
|
||||
defaults:
|
||||
max_columns: 100
|
||||
# Valid values: left, center, right
|
||||
max_columns_alignment: left
|
||||
```
|
||||
|
||||
## Maximum presentation height
|
||||
|
||||
The `max_rows` and `max_rows_alignment` properties are analogous to `max_columns*` to allow capping the maximum number
|
||||
of rows:
|
||||
|
||||
```yaml
|
||||
defaults:
|
||||
max_rows: 100
|
||||
# Valid values: top, center, bottom
|
||||
max_rows_alignment: left
|
||||
```
|
||||
|
||||
## Incremental lists behavior
|
||||
|
||||
By default, [incremental lists](../features/commands.md) will pause before and after a list. If you would like to change
|
||||
this behavior, use the `defaults.incremental_lists` key:
|
||||
|
||||
```yaml
|
||||
defaults:
|
||||
incremental_lists:
|
||||
# The defaults, change to false if desired.
|
||||
pause_before: true
|
||||
pause_after: true
|
||||
```
|
||||
|
||||
# Slide transitions
|
||||
|
||||
Slide transitions allow animating your presentation every time you move from a slide to the next/previous one. The
|
||||
configuration for slide transitions is the following:
|
||||
|
||||
```yaml
|
||||
transition:
|
||||
# how long the transition should last.
|
||||
duration_millis: 750
|
||||
|
||||
# how many frames should be rendered during the transition
|
||||
frames: 45
|
||||
|
||||
# the animation to use
|
||||
animation:
|
||||
style: <style_name>
|
||||
```
|
||||
|
||||
See the [slide transitions page](../features/slide-transitions.md) for more information on which animation styles are
|
||||
supported.
|
||||
|
||||
# Key bindings
|
||||
|
||||
Key bindings that _presenterm_ uses can be manually configured in the config file via the `bindings` key. The following
|
||||
is the default configuration:
|
||||
|
||||
```yaml
|
||||
bindings:
|
||||
# the keys that cause the presentation to move forwards.
|
||||
next: ["l", "j", "<right>", "<page_down>", "<down>", " "]
|
||||
|
||||
# the keys that cause the presentation to move backwards.
|
||||
previous: ["h", "k", "<left>", "<page_up>", "<up>"]
|
||||
|
||||
# the keys that cause the presentation to move "fast" to the next slide. this will ignore:
|
||||
#
|
||||
# * Pauses.
|
||||
# * Dynamic code highlights.
|
||||
# * Slide transitions, if enabled.
|
||||
next_fast: ["n"]
|
||||
|
||||
# same as `next_fast` but jumps fast to the previous slide.
|
||||
previous_fast: ["p"]
|
||||
|
||||
# the key binding to jump to the first slide.
|
||||
first_slide: ["gg"]
|
||||
|
||||
# the key binding to jump to the last slide.
|
||||
last_slide: ["G"]
|
||||
|
||||
# the key binding to jump to a specific slide.
|
||||
go_to_slide: ["<number>G"]
|
||||
|
||||
# the key binding to execute a piece of shell code.
|
||||
execute_code: ["<c-e>"]
|
||||
|
||||
# the key binding to reload the presentation.
|
||||
reload: ["<c-r>"]
|
||||
|
||||
# the key binding to toggle the slide index modal.
|
||||
toggle_slide_index: ["<c-p>"]
|
||||
|
||||
# the key binding to toggle the key bindings modal.
|
||||
toggle_bindings: ["?"]
|
||||
|
||||
# the key binding to close the currently open modal.
|
||||
close_modal: ["<esc>"]
|
||||
|
||||
# the key binding to close the application.
|
||||
exit: ["<c-c>", "q"]
|
||||
|
||||
# the key binding to suspend the application.
|
||||
suspend: ["<c-z>"]
|
||||
```
|
||||
|
||||
You can choose to override any of them. Keep in mind these are overrides so if for example you change `next`, the
|
||||
default won't apply anymore and only what you've defined will be used.
|
||||
|
||||
# Snippet configurations
|
||||
|
||||
The configurations that affect code snippets in presentations.
|
||||
|
||||
## Snippet execution
|
||||
|
||||
[Snippet execution](../features/code/execution.md#executing-code-blocks) is disabled by default for security reasons.
|
||||
Besides passing in the `-x` command line parameter every time you run _presenterm_, you can also configure this globally
|
||||
for all presentations by setting:
|
||||
|
||||
```yaml
|
||||
snippet:
|
||||
exec:
|
||||
enable: true
|
||||
```
|
||||
|
||||
**Use this at your own risk**, especially if you're running someone else's presentations!
|
||||
|
||||
## Snippet execution + replace
|
||||
|
||||
[Snippet execution + replace](../features/code/execution.md#executing-and-replacing) is disabled by default for security
|
||||
reasons. Similar to `+exec`, this can be enabled by passing in the `-X` command line parameter or configuring it
|
||||
globally by setting:
|
||||
|
||||
```yaml
|
||||
snippet:
|
||||
exec_replace:
|
||||
enable: true
|
||||
```
|
||||
|
||||
**Use this at your own risk**. This will cause _presenterm_ to execute code without user intervention so don't blindly
|
||||
enable this and open a presentation unless you trust its origin!
|
||||
|
||||
## Custom snippet executors
|
||||
|
||||
If _presenterm_ doesn't support executing code snippets for your language of choice, please [create an
|
||||
issue](https://github.com/mfontanini/presenterm/issues/new)! Alternatively, you can configure this locally yourself by
|
||||
setting:
|
||||
|
||||
```yaml
|
||||
snippet:
|
||||
exec:
|
||||
custom:
|
||||
# The keys should be the language identifier you'd use in a code block.
|
||||
c++:
|
||||
# The name of the file that will be created with your snippet's contents.
|
||||
filename: "snippet.cpp"
|
||||
|
||||
# A list of environment variables that should be set before building/running your code.
|
||||
environment:
|
||||
MY_FAVORITE_ENVIRONMENT_VAR: foo
|
||||
|
||||
# A prefix that indicates a line that starts with it should not be visible but should be executed if the
|
||||
# snippet is marked with `+exec`.
|
||||
hidden_line_prefix: "/// "
|
||||
|
||||
# A list of commands that will be ran one by one in the same directory as the snippet is in.
|
||||
commands:
|
||||
# Compile if first
|
||||
- ["g++", "-std=c++20", "snippet.cpp", "-o", "snippet"]
|
||||
# Now run it
|
||||
- ["./snippet"]
|
||||
```
|
||||
|
||||
The output of all commands will be included in the code snippet execution output so if a command (like the `g++`
|
||||
invocation) was to emit any output, make sure to use whatever flags are needed to mute its output.
|
||||
|
||||
Also note that you can override built-in executors in case you want to run them differently (e.g. use `c++23` in the
|
||||
example above).
|
||||
|
||||
See more examples in the [executors.yaml](https://github.com/mfontanini/presenterm/blob/master/executors.yaml) file
|
||||
which defines all of the built-in executors.
|
||||
|
||||
## Snippet rendering threads
|
||||
|
||||
Because some `+render` code blocks can take some time to be rendered into an image, especially if you're using
|
||||
[mermaid](https://mermaid.js.org/) charts, this is run asychronously. The number of threads used to render these, which
|
||||
defaults to 2, can be configured by setting:
|
||||
|
||||
```yaml
|
||||
snippet:
|
||||
render:
|
||||
threads: 2
|
||||
```
|
||||
|
||||
## Mermaid scaling
|
||||
|
||||
[mermaid](https://mermaid.js.org/) graphs will use a default scaling of `2` when invoking the mermaid CLI. If you'd like
|
||||
to change this use:
|
||||
|
||||
|
||||
```yaml
|
||||
mermaid:
|
||||
scale: 2
|
||||
```
|
||||
|
||||
## Enabling speaker note publishing
|
||||
|
||||
If you don't want to run _presenterm_ with `--publish-speaker-notes` every time you want to publish speaker notes, you
|
||||
can set the `speaker_notes.always_publish` attribute to `true`.
|
||||
|
||||
```yaml
|
||||
speaker_notes:
|
||||
always_publish: true
|
||||
```
|
||||
|
||||
# Presentation exports
|
||||
|
||||
The configurations that affect PDF exports.
|
||||
|
||||
## PDF export size
|
||||
|
||||
The size of exported PDFs can be configured via the `export.dimensions` key:
|
||||
|
||||
```yaml
|
||||
export:
|
||||
dimensions:
|
||||
columns: 80
|
||||
rows: 30
|
||||
```
|
||||
|
||||
See [the PDF export page](../features/pdf-export.md) for more information.
|
||||
|
||||
## Pause behavior
|
||||
|
||||
By default pauses will be ignored in generated PDF files. If instead you'd like every pause to generate a new page in
|
||||
the export, set the `export.pauses` attribute:
|
||||
|
||||
```yaml
|
||||
export:
|
||||
pauses: new_slide
|
||||
```
|
125
docs/src/features/code/execution.md
Normal file
125
docs/src/features/code/execution.md
Normal file
@ -0,0 +1,125 @@
|
||||
# Snippet execution
|
||||
|
||||
## Executing code blocks
|
||||
|
||||
Annotating a code block with a `+exec` attribute will make it executable. Pressing `control+e` when viewing a slide that
|
||||
contains an executable block, the code in the snippet will be executed and the output of the execution will be displayed
|
||||
on a box below it. The code execution is stateful so if you switch to another slide and then go back, you will still see
|
||||
the output.
|
||||
|
||||
~~~markdown
|
||||
```bash +exec
|
||||
echo hello world
|
||||
```
|
||||
~~~
|
||||
|
||||
Code execution **must be explicitly enabled** by using either:
|
||||
|
||||
* The `-x` command line parameter when running _presenterm_.
|
||||
* Setting the `snippet.exec.enable` property to `true` in your [_presenterm_ config
|
||||
file](../../configuration/settings.md#snippet-execution).
|
||||
|
||||
Refer to [the table in the highlighting page](highlighting.md#code-highlighting) for the list of languages for which
|
||||
code execution is supported.
|
||||
|
||||
---
|
||||
|
||||
[](https://asciinema.org/a/BbAY817esxagCgPtnKUwgYnHr)
|
||||
|
||||
> [!warning]
|
||||
> Run code in presentations at your own risk! Especially if you're running someone else's presentation. Don't blindly
|
||||
> enable snippet execution!
|
||||
|
||||
## Executing and replacing
|
||||
|
||||
Similar to `+exec`, `+exec_replace` causes a snippet to be executable but:
|
||||
|
||||
* Execution happens automatically without user intervention.
|
||||
* The snippet will be automatically replaced with its execution output.
|
||||
|
||||
This can be useful to run programs that generate some form of ASCII art that you'd like to generate dynamically.
|
||||
|
||||
[](https://asciinema.org/a/hklQARZKb5sP5mavL4cGgbYXD)
|
||||
|
||||
Because of the risk involved in `+exec_replace`, where code gets automatically executed when running a presentation,
|
||||
this requires users to explicitly opt in to it. This can be done by either passing in the `-X` command line parameter
|
||||
or setting the `snippet.exec_replace.enable` flag in your configuration file to `true`.
|
||||
|
||||
## Code to image conversions
|
||||
|
||||
The `+image` attribute behaves like `+exec_replace` but also assumes the output of the executed snippet will be an
|
||||
image, and it will render it as such. For this to work, the code **must only emit an image in jpg/png formats** and
|
||||
nothing else.
|
||||
|
||||
For example, this would render the demo presentation's image:
|
||||
|
||||
~~~markdown
|
||||
```bash +image
|
||||
cat examples/doge.png
|
||||
```
|
||||
~~~
|
||||
|
||||
This attribute carries the same risks as `+exec_replace` and therefore needs to be enabled via the same flags.
|
||||
|
||||
## Executing snippets that need a TTY
|
||||
|
||||
If you're trying to execute a program like `top` that needs to run on a TTY as it renders text, clears the screen, etc,
|
||||
you can use the `+acquire_terminal` modifier on a code already marked as executable with `+exec`. Executing snippets
|
||||
tagged with these two attributes will cause _presenterm_ to suspend execution, the snippet will be invoked giving it the
|
||||
raw terminal to do whatever it needs, and upon its completion _presenterm_ will resume its execution.
|
||||
|
||||
[](https://asciinema.org/a/AHfuJorCNRR8ZEnfwQSDR5vPT)
|
||||
|
||||
## Styled execution output
|
||||
|
||||
Snippets that generate output which contains escape codes that change the colors or styling of the text will be parsed
|
||||
and displayed respecting those styles. Do note that you may need to force certain tools to use colored output as they
|
||||
will likely not use it by default.
|
||||
|
||||
For example, to get colored output when invoking `ls` you can use:
|
||||
|
||||
~~~markdown
|
||||
```bash +exec
|
||||
ls /tmp --color=always
|
||||
```
|
||||
~~~
|
||||
|
||||
The parameter or way to enable this will depend on the tool being invoked.
|
||||
|
||||
## Hiding code lines
|
||||
|
||||
When you mark a code snippet as executable via the `+exec` flag, you may not be interested in showing _all the lines_ to
|
||||
your audience, as some of them may not be necessary to convey your point. For example, you may want to hide imports,
|
||||
non-essential functions, initialization of certain variables, etc. For this purpose, _presenterm_ supports a prefix
|
||||
under certain programming languages that let you indicate a line should be executed when running the code but should not
|
||||
be displayed in the presentation.
|
||||
|
||||
For example, in the following code snippet only the print statement will be displayed but the entire snippet will be
|
||||
ran:
|
||||
|
||||
~~~markdown
|
||||
```rust
|
||||
# fn main() {
|
||||
println!("Hello world!");
|
||||
# }
|
||||
```
|
||||
~~~
|
||||
|
||||
Rather than blindly relying on a prefix that may have a meaning in a language, prefixes are chosen on a per language
|
||||
basis. The languages that are supported and their prefix is:
|
||||
|
||||
* rust: `# `.
|
||||
* python/bash/fish/shell/zsh/kotlin/java/javascript/typescript/c/c++/go: `/// `.
|
||||
|
||||
This means that any line in a rust code snippet that starts with `# ` will be hidden, whereas all lines in, say, a
|
||||
golang code snippet that starts with a `/// ` will be hidden.
|
||||
|
||||
## Pre-rendering
|
||||
|
||||
Some languages support pre-rendering. This means the code block is transformed into something else when the presentation
|
||||
is loaded. The languages that currently support this are _mermaid_, _LaTeX_, and _typst_ where the contents of the code
|
||||
block is transformed into an image, allowing you to define formulas as text in your presentation. This can be done by
|
||||
using the `+render` attribute on a code block.
|
||||
|
||||
See the [LaTeX and typst](latex.md) and [mermaid](mermaid.md) docs for more information.
|
||||
|
180
docs/src/features/code/highlighting.md
Normal file
180
docs/src/features/code/highlighting.md
Normal file
@ -0,0 +1,180 @@
|
||||
# Code highlighting
|
||||
|
||||
Code highlighting is supported for the following languages:
|
||||
|
||||
| Language | Execution support |
|
||||
| -----------|-------------------|
|
||||
| ada | |
|
||||
| asp | |
|
||||
| awk | |
|
||||
| bash | ✓ |
|
||||
| batchfile | |
|
||||
| C | ✓ |
|
||||
| cmake | |
|
||||
| crontab | |
|
||||
| C# | ✓ |
|
||||
| clojure | |
|
||||
| C++ | ✓ |
|
||||
| CSS | |
|
||||
| D | |
|
||||
| diff | |
|
||||
| docker | |
|
||||
| dotenv | |
|
||||
| elixir | |
|
||||
| elm | |
|
||||
| erlang | |
|
||||
| fish | ✓ |
|
||||
| go | ✓ |
|
||||
| haskell | ✓ |
|
||||
| HTML | |
|
||||
| java | ✓ |
|
||||
| javascript | ✓ |
|
||||
| json | |
|
||||
| julia | ✓ |
|
||||
| kotlin | ✓ |
|
||||
| latex | |
|
||||
| lua | ✓ |
|
||||
| makefile | |
|
||||
| markdown | |
|
||||
| nix | |
|
||||
| ocaml | |
|
||||
| perl | ✓ |
|
||||
| php | ✓ |
|
||||
| protobuf | |
|
||||
| puppet | |
|
||||
| python | ✓ |
|
||||
| R | ✓ |
|
||||
| ruby | ✓ |
|
||||
| rust | ✓ |
|
||||
| scala | |
|
||||
| shell | ✓ |
|
||||
| sql | |
|
||||
| swift | |
|
||||
| svelte | |
|
||||
| tcl | |
|
||||
| toml | |
|
||||
| terraform | |
|
||||
| typescript | |
|
||||
| xml | |
|
||||
| yaml | |
|
||||
| vue | |
|
||||
| zig | |
|
||||
| zsh | ✓ |
|
||||
|
||||
Other languages that are supported are:
|
||||
|
||||
* nushell, for which highlighting isn't supported but execution is.
|
||||
* rust-script, which is highlighted as rust but is executed via the [rust-script](https://rust-script.org/) tool,
|
||||
which lets you specify dependencies in your snippet.
|
||||
|
||||
If there's a language that is not in this list and you would like it to be supported, please [create an
|
||||
issue](https://github.com/mfontanini/presenterm/issues/new). If you'd also like code execution support, provide details
|
||||
on how to compile (if necessary) and run snippets for that language. You can also configure how to run code snippet for
|
||||
a language locally in your [config file](../../configuration/settings.md#custom-snippet-executors).
|
||||
|
||||
## Enabling line numbers
|
||||
|
||||
If you would like line numbers to be shown on the left of a code block use the `+line_numbers` switch after specifying
|
||||
the language in a code block:
|
||||
|
||||
~~~markdown
|
||||
```rust +line_numbers
|
||||
fn hello_world() {
|
||||
println!("Hello world");
|
||||
}
|
||||
```
|
||||
~~~
|
||||
|
||||
## Selective highlighting
|
||||
|
||||
By default, the entire code block will be syntax-highlighted. If instead you only wanted a subset of it to be
|
||||
highlighted, you can use braces and a list of either individual lines, or line ranges that you'd want to highlight.
|
||||
|
||||
~~~markdown
|
||||
```rust {1,3,5-7}
|
||||
fn potato() -> u32 { // 1: highlighted
|
||||
// 2: not highlighted
|
||||
println!("Hello world"); // 3: highlighted
|
||||
let mut q = 42; // 4: not highlighted
|
||||
q = q * 1337; // 5: highlighted
|
||||
q // 6: highlighted
|
||||
} // 7: highlighted
|
||||
```
|
||||
~~~
|
||||
|
||||
## Dynamic highlighting
|
||||
|
||||
Similar to the syntax used for selective highlighting, dynamic highlighting will change which lines of the code in a
|
||||
code block are highlighted every time you move to the next/previous slide.
|
||||
|
||||
This is achieved by using the separator `|` to indicate what sections of the code will be highlighted at a given time.
|
||||
You can also use `all` to highlight all lines for a particular frame.
|
||||
|
||||
~~~markdown
|
||||
```rust {1,3|5-7}
|
||||
fn potato() -> u32 {
|
||||
|
||||
println!("Hello world");
|
||||
let mut q = 42;
|
||||
q = q * 1337;
|
||||
q
|
||||
}
|
||||
```
|
||||
~~~
|
||||
|
||||
In this example, lines 1 and 3 will be highlighted initially. Then once you press a key to move to the next slide, lines
|
||||
1 and 3 will no longer be highlighted and instead lines 5 through 7 will. This allows you to create more dynamic
|
||||
presentations where you can display sections of the code to explain something specific about each of them.
|
||||
|
||||
See this real example of how this looks like.
|
||||
|
||||
[](https://asciinema.org/a/iCf4f6how1Ux3H8GNzksFUczI)
|
||||
|
||||
## Including external code snippets
|
||||
|
||||
The `file` snippet type can be used to specify an external code snippet that will be included and highlighted as usual.
|
||||
|
||||
~~~markdown
|
||||
```file +exec +line_numbers
|
||||
path: snippet.rs
|
||||
language: rust
|
||||
```
|
||||
~~~
|
||||
|
||||
If you'd like to include only a subset of the file, you can use the optional fields `start_line` and `end_line`:
|
||||
|
||||
~~~markdown
|
||||
```file +exec +line_numbers
|
||||
path: snippet.rs
|
||||
language: rust
|
||||
# Only shot lines 5-10
|
||||
start_line: 5
|
||||
end_line: 10
|
||||
```
|
||||
~~~
|
||||
|
||||
## Showing a snippet without a background
|
||||
|
||||
Using the `+no_background` flag will cause the snippet to have no background. This is useful when combining it with the
|
||||
`+exec_replace` flag described further down.
|
||||
|
||||
## Adding highlighting syntaxes for new languages
|
||||
|
||||
_presenterm_ uses the syntaxes supported by [bat](https://github.com/sharkdp/bat) to highlight code snippets, so any
|
||||
languages supported by _bat_ natively can be added to _presenterm_ easily. Please create a ticket or use
|
||||
[this](https://github.com/mfontanini/presenterm/pull/385) as a reference to submit a pull request to make a syntax
|
||||
officially supported by _presenterm_ as well.
|
||||
|
||||
If a language isn't natively supported by _bat_ but you'd like to use it, you can follow
|
||||
[this guide in the bat docs](https://github.com/sharkdp/bat#adding-new-syntaxes--language-definitions) and
|
||||
invoke _bat_ directly in a presentation:
|
||||
|
||||
~~~markdown
|
||||
```bash +exec_replace
|
||||
bat --color always script.py
|
||||
```
|
||||
~~~
|
||||
|
||||
> [!note]
|
||||
> Check the [code execution docs](execution.md#executing-and-replacing) for more details on how to allow the tool to run
|
||||
> `exec_replace` blocks.
|
@ -1,26 +1,40 @@
|
||||
## LaTeX and typst
|
||||
# LaTeX and typst
|
||||
|
||||
`latex` and `typst` code blocks can be marked with the `+render` attribute (see [highlighting](code-highlight.html)) to
|
||||
have them rendered into images when the presentation is loaded. This allows you to define formulas in text rather than
|
||||
having to define them somewhere else, transform them into an image, and them embed it.
|
||||
`latex` and `typst` code blocks can be marked with the `+render` attribute (see [highlighting](highlighting.md)) to have
|
||||
them rendered into images when the presentation is loaded. This allows you to define formulas in text rather than having
|
||||
to define them somewhere else, transform them into an image, and them embed it.
|
||||
|
||||
### Dependencies
|
||||
For example, the following presentation:
|
||||
|
||||
#### typst
|
||||
~~~
|
||||
# Formulas
|
||||
|
||||
```latex +render
|
||||
\[ \sum_{n=1}^{\infty} 2^{-n} = 1 \]
|
||||
```
|
||||
~~~
|
||||
|
||||
Would be rendered like this:
|
||||
|
||||

|
||||
|
||||
## Dependencies
|
||||
|
||||
### typst
|
||||
|
||||
The engine used to render both of these languages is [typst](https://github.com/typst/typst). _typst_ is easy to
|
||||
install, lightweight, and boilerplate free as compared to _LaTeX_.
|
||||
|
||||
#### pandoc
|
||||
### pandoc
|
||||
|
||||
For _LaTeX_ code rendering both _typst_ and [pandoc](https://github.com/jgm/pandoc) are required. How this works is the
|
||||
_LaTeX_ code you write gets transformed into _typst_ code via _pandoc_ and then rendered by using _typst_. This lets us:
|
||||
|
||||
For _LaTeX_ code rendering, besides _typst_ you will need to install [pandoc](https://github.com/jgm/pandoc). How this
|
||||
works is the _LaTeX_ code you write gets transformed into _typst_ code via _pandoc_ and then rendered by using _typst_.
|
||||
This lets us:
|
||||
* Have the same look/feel on generated formulas for both languages.
|
||||
* Avoid having to write lots of boilerplate _LaTeX_ to make rendering for that language work.
|
||||
* Have the same logic to render formulas for both languages, except with a small preparation step for _LaTeX_.
|
||||
|
||||
### Controlling PPI
|
||||
## Controlling PPI
|
||||
|
||||
_presenterm_ lets you define how many Pixels Per Inch (PPI) you want in the generated images. This is important because
|
||||
as opposed to images that you manually include in your presentation, where you control the exact dimensions, the images
|
||||
@ -28,7 +42,7 @@ generated on the fly will have a fixed size. Configuring the PPI used during the
|
||||
higher the PPI, the larger the generated images will be.
|
||||
|
||||
Because as opposed to most configurations this is a very environment-specific config, the PPI parameter is not part of
|
||||
the theme definition but is instead has to be set in [_presenterm_'s config file](configuration.html):
|
||||
the theme definition but is instead has to be set in _presenterm_'s [config file](../../configuration/introduction.md):
|
||||
|
||||
```yaml
|
||||
typst:
|
||||
@ -37,7 +51,17 @@ typst:
|
||||
|
||||
The default is 300 so adjust it and see what works for you.
|
||||
|
||||
### Controlling the image size
|
||||
## Image paths
|
||||
|
||||
If you're including an image inside a _typst_ snippet, you must:
|
||||
|
||||
* Use absolute paths, e.g. `#image("/image1.png")`.
|
||||
* Place the image in the same or a sub path of the path where the presentation is. That is, if your presentation file is
|
||||
at `/tmp/foo/presentation.md`, you can place images in `/tmp/foo`, and `/tmp/foo/bar` but not under `/tmp/bar`. This is
|
||||
because of the absolute path rule above: the path will be considered to be relative to the presentation file's
|
||||
directory.
|
||||
|
||||
## Controlling the image size
|
||||
|
||||
You can also set the generated image's size on a per code snippet basis by using the `+width` modifier which specifies
|
||||
the width of the image as a percentage of the terminal size.
|
||||
@ -48,7 +72,7 @@ $f(x) = x + 1$
|
||||
```
|
||||
~~~
|
||||
|
||||
### Customizations
|
||||
## Customizations
|
||||
|
||||
The colors and margin of the generated images can be defined in your theme:
|
||||
|
||||
@ -62,19 +86,3 @@ typst:
|
||||
horizontal_margin: 2
|
||||
vertical_margin: 2
|
||||
```
|
||||
|
||||
## Example
|
||||
|
||||
The following example:
|
||||
|
||||
~~~
|
||||
# Formulas
|
||||
|
||||
```latex +render
|
||||
\[ \sum_{n=1}^{\infty} 2^{-n} = 1 \]
|
||||
```
|
||||
~~~
|
||||
|
||||
Is rendered like this:
|
||||
|
||||

|
@ -1,4 +1,4 @@
|
||||
# Mermaid
|
||||
## Mermaid
|
||||
|
||||
[mermaid](https://mermaid.js.org/) snippets can be converted into images automatically in any code snippet tagged with
|
||||
the `mermaid` language and a `+render` tag:
|
||||
@ -15,11 +15,11 @@ sequenceDiagram
|
||||
|
||||
Note that because the mermaid CLI will spin up a browser under the hood, this may not work in all environments and can
|
||||
also be a bit slow (e.g. ~2 seconds to generate every image). Mermaid graphs are rendered asynchronously by a number of
|
||||
threads that can be configured in the [configuration file](configuration.html#snippet-rendering-threads). This
|
||||
configuration value currently defaults to 2.
|
||||
threads that can be configured in the [configuration file](../../configuration/settings.md#snippet-rendering-threads).
|
||||
This configuration value currently defaults to 2.
|
||||
|
||||
The size of the rendered image can be configured by changing:
|
||||
* The `mermaid.scale` [configuration parameter](configuration.html#mermaid-scaling).
|
||||
* The `mermaid.scale` [configuration parameter](../../configuration/settings.md#mermaid-scaling).
|
||||
* Using the `+width:<number>%` attribute in the code snippet.
|
||||
|
||||
For example, this diagram will take up 50% of the width of the window and will preserve its aspect ratio:
|
||||
@ -35,3 +35,16 @@ sequenceDiagram
|
||||
It is recommended to change the `mermaid.scale` parameter until images look big enough and then adjust on an image by
|
||||
image case if necessary using the `+width` attribute. Otherwise, using a small scale and then scaling via `+width` may
|
||||
cause the image to become blurry.
|
||||
|
||||
## Theme
|
||||
|
||||
The theme of the rendered mermaid diagrams can be changed through the following
|
||||
[theme](../themes/introduction.md#mermaid) parameters:
|
||||
|
||||
* `mermaid.background` the background color passed to the CLI (e.g., `transparent`, `red`, `#F0F0F0`).
|
||||
* `mermaid.theme` the [mermaid theme](https://mermaid.js.org/config/theming.html#available-themes) to use.
|
||||
|
||||
## Always render diagrams
|
||||
|
||||
If you don't want to use `+render` every time, you can configure which languages get this automatically via the [config
|
||||
file](../../configuration/settings.md#auto_render_languages).
|
123
docs/src/features/commands.md
Normal file
123
docs/src/features/commands.md
Normal file
@ -0,0 +1,123 @@
|
||||
# Comment commands
|
||||
|
||||
_presenterm_ uses "comment commands" in the form of HTML comments to let the user specify certain behaviors that can't
|
||||
be specified by vanilla markdown.
|
||||
|
||||
## Pauses
|
||||
|
||||
Pauses allow the sections of the content in your slide to only show up when you advance in your presentation. That is,
|
||||
only after you press, say, the right arrow will a section of the slide show up. This can be done by the `pause` comment
|
||||
command:
|
||||
|
||||
```html
|
||||
<!-- pause -->
|
||||
```
|
||||
|
||||
## Font size
|
||||
|
||||
The font size can be changed by using the `font_size` command:
|
||||
|
||||
```html
|
||||
<!-- font_size: 2 -->
|
||||
```
|
||||
|
||||
This causes the remainder of the slide to use the font size specified. The font size can range from 1 to 7, 1 being the
|
||||
default.
|
||||
|
||||
> ![note]
|
||||
> This is currently only supported in the [_kitty_](https://sw.kovidgoyal.net/kitty/) terminal and only as of version
|
||||
> 0.40.0. See the notes on font sizes on the [introduction page](introduction.md#font-sizes) for more information on
|
||||
> this.
|
||||
|
||||
## Jumping to the vertical center
|
||||
|
||||
The command `jump_to_middle` lets you jump to the middle of the page vertically. This is useful in combination
|
||||
with slide titles to create separator slides:
|
||||
|
||||
```markdown
|
||||
blablabla
|
||||
|
||||
<!-- end_slide -->
|
||||
|
||||
<!-- jump_to_middle -->
|
||||
|
||||
Farming potatoes
|
||||
===
|
||||
|
||||
<!-- end_slide -->
|
||||
```
|
||||
|
||||
This will create a slide with the text "Farming potatoes" in the center, rendered using the slide title style.
|
||||
|
||||
## Explicit new lines
|
||||
|
||||
The `newline`/`new_line` and `newlines`/`new_lines` commands allow you to explicitly create new lines. Because markdown
|
||||
ignores multiple line breaks in a row, this is useful to create some spacing where necessary:
|
||||
|
||||
```markdown
|
||||
hi
|
||||
|
||||
<!-- new_lines: 10 -->
|
||||
|
||||
mom
|
||||
|
||||
<!-- new_line -->
|
||||
|
||||
bye
|
||||
```
|
||||
|
||||
## Incremental lists
|
||||
|
||||
Using `<!-- pause -->` in between each bullet point a list is a bit tedious so instead you can use the
|
||||
`incremental_lists` command to tell _presenterm_ that **until the end of the current slide** you want each individual
|
||||
bullet point to appear only after you move to the next slide:
|
||||
|
||||
```markdown
|
||||
<!-- incremental_lists: true -->
|
||||
|
||||
* this
|
||||
* appears
|
||||
* one after
|
||||
* the other
|
||||
|
||||
<!-- incremental_lists: false -->
|
||||
|
||||
* this appears
|
||||
* all at once
|
||||
```
|
||||
|
||||
## No footer
|
||||
|
||||
If you don't want the footer to show up in some particular slide for some reason, you can use the `no_footer` command:
|
||||
|
||||
```html
|
||||
<!-- no_footer -->
|
||||
```
|
||||
|
||||
## Skip slide
|
||||
|
||||
If you don't want a specific slide to be included in the presentation use the `skip_slide` command:
|
||||
|
||||
```html
|
||||
<!-- skip_slide -->
|
||||
```
|
||||
|
||||
## Text alignment
|
||||
|
||||
The text alignment for the remainder of the slide can be configured via the `alignment` command, which can use values:
|
||||
`left`, `center`, and `right`:
|
||||
|
||||
```markdown
|
||||
<!-- alignment: left -->
|
||||
|
||||
left alignment, the default
|
||||
|
||||
<!-- alignment: center -->
|
||||
|
||||
centered
|
||||
|
||||
<!-- alignment: right -->
|
||||
|
||||
right aligned
|
||||
```
|
||||
|
58
docs/src/features/images.md
Normal file
58
docs/src/features/images.md
Normal file
@ -0,0 +1,58 @@
|
||||
# Images
|
||||
|
||||
Images are supported and will render in your terminal as long as it supports either the [iterm2 image
|
||||
protocol](https://iterm2.com/documentation-images.html), the [kitty graphics
|
||||
protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/), or [sixel](https://saitoha.github.io/libsixel/). Some of
|
||||
the terminals where at least one of these is supported are:
|
||||
|
||||
* [kitty](https://sw.kovidgoyal.net/kitty/)
|
||||
* [iterm2](https://iterm2.com/)
|
||||
* [WezTerm](https://wezfurlong.org/wezterm/index.html)
|
||||
* [foot](https://codeberg.org/dnkl/foot)
|
||||
|
||||
Sixel support is experimental so it needs to be explicitly enabled via the `sixel` configuration flag:
|
||||
|
||||
```bash
|
||||
cargo build --release --features sixel
|
||||
```
|
||||
|
||||
> [!note]
|
||||
> This feature flag is only needed if your terminal emulator _only_ supports sixel. Many terminals support the kitty or
|
||||
> iterm2 protocols so using this flag is often not required to get images to render successfully.
|
||||
|
||||
---
|
||||
|
||||
Things you should know when using image tags in your presentation's markdown are:
|
||||
* Image paths are relative to your presentation path. That is a tag like `` will be looked up at
|
||||
`$PRESENTATION_DIRECTORY/food/potato.png`.
|
||||
* Images will be rendered by default in their original size. That is, if your terminal is 300x200px and your image is
|
||||
200x100px, it will take up 66% of your horizontal space and 50% of your vertical space.
|
||||
* The exception to the point above is if the image does not fit in your terminal, it will be resized accordingly while
|
||||
preserving the aspect ratio.
|
||||
* If your terminal does not support any of the graphics protocol above, images will be rendered using ascii blocks. It
|
||||
ain't great but it's something!
|
||||
|
||||
## tmux
|
||||
|
||||
If you're using tmux, you will need to enable the [allow-passthrough
|
||||
option](https://github.com/tmux/tmux/wiki/FAQ#what-is-the-passthrough-escape-sequence-and-how-do-i-use-it) for images to
|
||||
work correctly.
|
||||
|
||||
## Image size
|
||||
|
||||
The size of each image can be set by using the `image:width` or `image:w` attributes in the image tag. For example, the
|
||||
following will cause the image to take up 50% of the terminal width:
|
||||
|
||||
```markdown
|
||||

|
||||
```
|
||||
|
||||
The image will always be scaled to preserve its aspect ratio and it will not be allowed to overflow vertically nor
|
||||
horizontally.
|
||||
|
||||
## Protocol detection
|
||||
|
||||
By default the image protocol to be used will be automatically detected. In cases where this detection fails, you can
|
||||
set it manually via the `--image-protocol` parameter or by setting it in the [config
|
||||
file](../configuration/settings.md#preferred-image-protocol).
|
||||
|
182
docs/src/features/introduction.md
Normal file
182
docs/src/features/introduction.md
Normal file
@ -0,0 +1,182 @@
|
||||
# Introduction
|
||||
|
||||
This guide teaches you how to use _presenterm_. At this point you should have already installed _presenterm_, otherwise
|
||||
visit the [installation](../install.md) guide to get started.
|
||||
|
||||
## Quick start
|
||||
|
||||
Download the demo presentation and run it using:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/mfontanini/presenterm.git
|
||||
cd presenterm
|
||||
presenterm examples/demo.md
|
||||
```
|
||||
|
||||
# Presentations
|
||||
|
||||
A presentation in _presenterm_ is a single markdown file. Every slide in the presentation file is delimited by a line
|
||||
that contains a single HTML comment:
|
||||
|
||||
```html
|
||||
<!-- end_slide -->
|
||||
```
|
||||
|
||||
Presentations can contain most commonly used markdown elements such as ordered and unordered lists, headings, formatted
|
||||
text (**bold**, _italics_, ~strikethrough~, `inline code`, etc), code blocks, block quotes, tables, etc.
|
||||
|
||||
## Introduction slide
|
||||
|
||||
By setting a front matter at the beginning of your presentation you can configure the title, sub title, author and other
|
||||
metadata about your presentation. Doing so will cause _presenterm_ to create an introduction slide:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: "My _first_ **presentation**"
|
||||
sub_title: (in presenterm!)
|
||||
author: Myself
|
||||
---
|
||||
```
|
||||
|
||||
All of these attributes are optional and should be avoided if an introduction slide is not needed. Note that the `title`
|
||||
key can contain arbitrary markdown so you can use bold, italics, `<span>` tags, etc.
|
||||
|
||||
### Multiple authors
|
||||
|
||||
If you're creating a presentation in which there's multiple authors, you can use the `authors` key instead of `author`
|
||||
and list them all this way:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: Our first presentation
|
||||
authors:
|
||||
- Me
|
||||
- You
|
||||
---
|
||||
```
|
||||
|
||||
## Slide titles
|
||||
|
||||
Any [setext header](https://spec.commonmark.org/0.30/#setext-headings) will be considered to be a slide title and will
|
||||
be rendered in a more slide-title-looking way. By default this means it will be centered, some vertical padding will be
|
||||
added and the text color will be different.
|
||||
|
||||
~~~markdown
|
||||
Hello
|
||||
===
|
||||
~~~
|
||||
|
||||
> [!note]
|
||||
> See the [themes](themes/introduction.md) section on how to customize the looks of slide titles and any other element
|
||||
> in a presentation.
|
||||
|
||||
## Ending slides
|
||||
|
||||
While other applications use a thematic break (`---`) to mark the end of a slide, _presenterm_ uses a special
|
||||
`end_slide` HTML comment:
|
||||
|
||||
```html
|
||||
<!-- end_slide -->
|
||||
```
|
||||
|
||||
This makes the end of a slide more explicit and easy to spot while you're editing your presentation. See the
|
||||
[configuration](../configuration/options.md#implicit_slide_ends) if you want to customize this behavior.
|
||||
|
||||
If you really would prefer to use thematic breaks (`---`) to delimit slides, you can do that by enabling the
|
||||
[`end_slide_shorthand`](../configuration/options.md#end_slide_shorthand) options.
|
||||
|
||||
## Colored text
|
||||
|
||||
`span` HTML tags can be used to provide foreground and/or background colors to text. There's currently two ways to
|
||||
specify colors:
|
||||
|
||||
* Via the `style` attribute, in which only the CSS attributes `color` and `background-color` can be used to set the
|
||||
foreground and background colors respectively. Colors used in both CSS attributes can refer to
|
||||
[theme palette colors](themes/definition.md#color-palette) by using the `palette:<name>` or `p:<name` syntaxes.
|
||||
* Via the `class` attribute, which must point to a class defined in the [theme
|
||||
palette](themes/definition.md#color-palette). Classes allow configuring foreground/background color combinations to be
|
||||
used across your presentation.
|
||||
|
||||
For example, the following will use `ff0000` as the foreground color and whatever the active theme's palette defines as
|
||||
`foo`:
|
||||
|
||||
```markdown
|
||||
<span style="color: #ff0000; background-color: palette:foo">colored text!</span>
|
||||
```
|
||||
|
||||
Alternatively, can you can define a class that contains a foreground/background color combination in your theme's
|
||||
palette and use it:
|
||||
|
||||
```markdown
|
||||
<span class="my_class">colored text!</span>
|
||||
```
|
||||
|
||||
> [!note]
|
||||
> Keep in mind **only `span` tags are supported**.
|
||||
|
||||
## Font sizes
|
||||
|
||||
The [_kitty_](https://sw.kovidgoyal.net/kitty/) terminal added in version 0.40.0 support for a new protocol that allows
|
||||
TUIs to specify the font size to be used when printing text. _presenterm_ is one of the first applications supports this
|
||||
protocol in various places:
|
||||
|
||||
* Themes can specify it in the presentation title in the introduction slide, in slide titles, and in headers by using
|
||||
the `font_size` property. All built in themes currently set font size to 2 (1 is the default) for these elements.
|
||||
* Explicitly by using the `font_size` comment command:
|
||||
|
||||
```markdown
|
||||
# Normal text
|
||||
|
||||
<!-- font_size: 2 -->
|
||||
|
||||
# Larger text
|
||||
```
|
||||
|
||||
Terminal support for this feature is verified when _presenterm_ starts and any attempt to change the font size, be it
|
||||
via the theme or via the comment command, will be ignored if it's not supported.
|
||||
|
||||
# Key bindings
|
||||
|
||||
Navigation within a presentation should be intuitive: jumping to the next/previous slide can be done by using the arrow
|
||||
keys, _hjkl_, and page up/down keys.
|
||||
|
||||
Besides this:
|
||||
|
||||
* Jumping to the first slide: `gg`.
|
||||
* Jumping to the last slide: `G`.
|
||||
* Jumping to a specific slide: `<slide-number>G`.
|
||||
* Exit the presentation: `<ctrl>c`.
|
||||
|
||||
You can check all the configured keybindings by pressing `?` while running _presenterm_.
|
||||
|
||||
## Configuring key bindings
|
||||
|
||||
If you don't like the default key bindings, you can override them in the [configuration
|
||||
file](../configuration/settings.md#key-bindings).
|
||||
|
||||
# Modals
|
||||
|
||||
_presenterm_ currently has 2 modals that can provide some information while running the application. Modals can be
|
||||
toggled using some key combination and can be hidden using the escape key by default, but these can be configured via
|
||||
the [configuration file key bindings](../configuration/settings.md#key-bindings).
|
||||
|
||||
## Slide index modal
|
||||
|
||||
This modal can be toggled by default using `control+p` and lets you see an index that contains a row for every slide in
|
||||
the presentation, including its title and slide index. This allows you to find a slide you're trying to jump to
|
||||
quicklier rather than scanning through each of them.
|
||||
|
||||
[](https://asciinema.org/a/1VgRxVIEyLrMmq6OZ3oKx4PGi)
|
||||
|
||||
## Key bindings modal
|
||||
|
||||
The key bindings modal displays the key bindings for each of the supported actions and can be opened by pressing `?`.
|
||||
|
||||
# Hot reload
|
||||
|
||||
Unless you run in presentation mode by passing in the `--present` parameter, _presenterm_ will automatically reload your
|
||||
presentation file every time you save it. _presenterm_ will also automatically detect which specific slide was modified
|
||||
and jump to it so you don't have to be jumping back and forth between the source markdown and the presentation to see
|
||||
how the changes look like.
|
||||
|
||||
[](https://asciinema.org/a/bu9ITs8KhaQK5OdDWnPwUYKu3)
|
@ -1,4 +1,4 @@
|
||||
## Layouts
|
||||
# Layouts
|
||||
|
||||
_presenterm_ currently supports a column layout that lets you split parts of your slides into column. This allows you to
|
||||
put text on one side, and code/images on the other, or really organize markdown into columns in any way you want.
|
||||
@ -6,7 +6,7 @@ put text on one side, and code/images on the other, or really organize markdown
|
||||
This is done by using commands, just like `pause` and `end_slide`, in the form of HTML comments. This section describes
|
||||
how to use those.
|
||||
|
||||
### Wait, why not HTML?
|
||||
## Wait, why not HTML?
|
||||
|
||||
While markdown _can_ contain HTML tags (beyond comments!) and we _could_ represent this using divs with alignment, I
|
||||
don't really want to:
|
||||
@ -17,12 +17,12 @@ don't really want to:
|
||||
|
||||
Because of this, _presenterm_ doesn't let you use HTML and instead has a custom way of specifying column layouts.
|
||||
|
||||
### Column layout
|
||||
## Column layout
|
||||
|
||||
The way to specify column layouts is by first creating a layout, and then telling _presenterm_ you want to enter each of
|
||||
the column in it as you write your presentation.
|
||||
|
||||
#### Defining layouts
|
||||
### Defining layouts
|
||||
|
||||
Defining a layout is done via the `column_layout` command, in the form of an HTML comment:
|
||||
|
||||
@ -39,7 +39,7 @@ This defines a layout with 2 columns where:
|
||||
You can use any number of columns and with as many units you want on each of them. This lets you decide how to structure
|
||||
the presentation in a fairly straightforward way.
|
||||
|
||||
#### Using columns
|
||||
### Using columns
|
||||
|
||||
Once a layout is defined, you just need to specify that you want to enter a column before writing any text to it by
|
||||
using the `column` command:
|
||||
@ -54,7 +54,7 @@ Now all the markdown you write will be placed on the first column until you eith
|
||||
* The slide ends.
|
||||
* You jump into another column by using the `column` command again.
|
||||
|
||||
### Example
|
||||
## Example
|
||||
|
||||
The following example puts all of this together by defining 2 columns, one with some code and bullet points, another one
|
||||
with an image, and some extra text at the bottom that's not tied to any columns.
|
||||
@ -95,7 +95,7 @@ This would render the following way:
|
||||
|
||||

|
||||
|
||||
### Other uses
|
||||
## Other uses
|
||||
|
||||
Besides organizing your slides into columns, you can use column layouts to center a piece of your slide. For example, if
|
||||
you want a certain portion of your slide to be centered, you could define a column layout like `[1, 3, 1]` and then only
|
35
docs/src/features/pdf-export.md
Normal file
35
docs/src/features/pdf-export.md
Normal file
@ -0,0 +1,35 @@
|
||||
# Exporting presentations in PDF format
|
||||
|
||||
Presentations can be converted into PDF by using [weasyprint](https://pypi.org/project/weasyprint/). Follow their
|
||||
[installation instructions](https://doc.courtbouillon.org/weasyprint/stable/first_steps.html) since it may require you
|
||||
to install extra dependencies for the tool to work.
|
||||
|
||||
> [!note]
|
||||
> If you were using _presenterm-export_ before it was deprecated, that tool already required _weasyprint_ so it is
|
||||
> already installed in whatever virtual env you were using and there's nothing to be done.
|
||||
|
||||
|
||||
After you've installed _weasyprint_, run _presenterm_ with the `--export-pdf` parameter to generate the output PDF:
|
||||
|
||||
```bash
|
||||
presenterm --export-pdf examples/demo.md
|
||||
```
|
||||
|
||||
The output PDF will be placed in `examples/demo.pdf`. Alternatively you can use the `--output` flag to specify where you
|
||||
want the output file to be written to.
|
||||
|
||||
> [!note]
|
||||
> If you're using a separate virtual env to install _weasyprint_ just make sure you activate it before running
|
||||
> _presenterm_ with the `--export-pdf` parameter.
|
||||
|
||||
## PDF page size
|
||||
|
||||
By default, the size of each page in the generated PDF will depend on the size of your terminal.
|
||||
|
||||
If you would like to instead configure the dimensions by hand, set the `export.dimensions` key in the configuration file
|
||||
as described in the [settings page](../configuration/settings.md#pdf-export-size).
|
||||
|
||||
## Pause behavior
|
||||
|
||||
See the [settings page](../configuration/settings.md#pause-behavior) to learn how to configure the behavior of pauses in
|
||||
generated PDFs.
|
24
docs/src/features/slide-transitions.md
Normal file
24
docs/src/features/slide-transitions.md
Normal file
@ -0,0 +1,24 @@
|
||||
# Slide transitions
|
||||
|
||||
Slide transitions allow animating your presentation every time you move from a slide to the next/previous one. See the
|
||||
[configuration page](../configuration/settings.md) to learn how to configure transitions.
|
||||
|
||||
The following animations are supported:
|
||||
|
||||
## `fade`
|
||||
|
||||
Fade the current slide into the next one.
|
||||
|
||||
[](https://asciinema.org/a/RvxLw0FHOopjdF4ixWbCkWuSw)
|
||||
|
||||
## `slide_horizontal`
|
||||
|
||||
Slide horizontally to the next/previous slide.
|
||||
|
||||
[](https://asciinema.org/a/T43ttxPWZ8TsM2auTqNZSWrmZ)
|
||||
|
||||
## `collapse_horizontal`
|
||||
|
||||
Collapse the current slide into the center of the screen horizontally.
|
||||
|
||||
[](https://asciinema.org/a/VB8i3kGMvbkbiYYPpaZJUl2dW)
|
72
docs/src/features/speaker-notes.md
Normal file
72
docs/src/features/speaker-notes.md
Normal file
@ -0,0 +1,72 @@
|
||||
## Speaker notes
|
||||
|
||||
Starting on version 0.10.0, _presenterm_ allows presentations to define speaker notes. The way this works is:
|
||||
|
||||
* You start an instance of _presenterm_ using the `--publish-speaker-notes` parameter. This will be the main instance in
|
||||
which you will present like you usually do.
|
||||
* Another instance should be started using the `--listen-speaker-notes` parameter. This instance will only display
|
||||
speaker notes in the presentation and will automatically change slides whenever the main instance does so.
|
||||
|
||||
For example:
|
||||
|
||||
```bash
|
||||
# Start the main instance
|
||||
presenterm demo.md --publish-speaker-notes
|
||||
|
||||
# In another shell: start the speaker notes instance
|
||||
presenterm demo.md --listen-speaker-notes
|
||||
```
|
||||
|
||||
[](https://asciinema.org/a/ETusvlmHuHrcLKzwa0CMQRX2J)
|
||||
|
||||
See the [speaker notes example](https://github.com/mfontanini/presenterm/blob/master/examples/speaker-notes.md) for more
|
||||
information.
|
||||
|
||||
### Defining speaker notes
|
||||
|
||||
In order to define speaker notes you can use the `speaker_notes` comment command:
|
||||
|
||||
```markdown
|
||||
Normal text
|
||||
|
||||
<!-- speaker_note: this is a speaker note -->
|
||||
|
||||
More text
|
||||
```
|
||||
|
||||
When running this two instance setup, the main one will show "normal text" and "more text", whereas the second one will
|
||||
only show "this is a speaker note" on that slide.
|
||||
|
||||
### Multiline speaker notes
|
||||
|
||||
You can use multiline speaker notes by using the appropriate YAML syntax:
|
||||
|
||||
```yaml
|
||||
<!--
|
||||
speaker_note: |
|
||||
something
|
||||
something else
|
||||
-->
|
||||
```
|
||||
|
||||
### Multiple instances
|
||||
|
||||
On Linux and Windows, you can run multiple instances in publish mode and multiple instances in listen mode at the same
|
||||
time. Each instance will only listen to events for the presentation it was started on.
|
||||
|
||||
On Mac this is not supported and only a single listener can be used at a time.
|
||||
|
||||
### Enabling publishing by default
|
||||
|
||||
You can use the `speaker_notes.always_publish` key in your config file to always publish speaker notes. This means you
|
||||
will only ever need to use `--listen-speaker-notes` and you will never need to use `--publish-speaker-notes`:
|
||||
|
||||
```yaml
|
||||
speaker_notes:
|
||||
always_publish: true
|
||||
```
|
||||
|
||||
### Internals
|
||||
|
||||
This uses UDP sockets on localhost to communicate between instances. The main instance sends events every time a slide
|
||||
is shown and the listener instances listen to them and displays the speaker notes for that specific slide.
|
@ -1,108 +1,9 @@
|
||||
## Themes
|
||||
# Theme definition
|
||||
|
||||
Themes are defined in the form of yaml files. A few built-in themes are defined in the [themes][builtin-themes]
|
||||
directory, but others can be created and referenced directly in every presentation.
|
||||
This section goes through the structure of the theme files. Have a look at some of the [existing
|
||||
themes](https://github.com/mfontanini/presenterm/tree/master/themes) to have an idea of how to structure themes.
|
||||
|
||||
### Setting themes
|
||||
|
||||
There's various ways of setting the theme you want in your presentation:
|
||||
|
||||
#### CLI
|
||||
|
||||
Passing in the `--theme` parameter when running _presenterm_ to select one of the built-in themes.
|
||||
|
||||
#### Within the presentation
|
||||
|
||||
The presentation's markdown file can contain a front matter that specifies the theme to use. This comes in 3 flavors:
|
||||
|
||||
##### By name
|
||||
|
||||
Using a built-in theme name makes your presentation use that one regardless of what the default or what the `--theme`
|
||||
option specifies:
|
||||
|
||||
```yaml
|
||||
---
|
||||
theme:
|
||||
name: dark
|
||||
---
|
||||
```
|
||||
|
||||
##### By path
|
||||
|
||||
You can define a theme file in yaml format somewhere in your filesystem and reference it within the presentation:
|
||||
|
||||
```yaml
|
||||
---
|
||||
theme:
|
||||
path: /home/me/Documents/epic-theme.yaml
|
||||
---
|
||||
```
|
||||
|
||||
##### Overrides
|
||||
|
||||
You can partially/completely override the theme in use from within the presentation:
|
||||
|
||||
```yaml
|
||||
---
|
||||
theme:
|
||||
override:
|
||||
default:
|
||||
colors:
|
||||
foreground: "beeeff"
|
||||
---
|
||||
```
|
||||
|
||||
This lets you:
|
||||
|
||||
1. Create a unique style for your presentation without having to go through the process of taking an existing theme,
|
||||
copying somewhere, and changing it when you only expect to use it for that one presentation.
|
||||
2. Iterate quickly on styles given overrides are reloaded whenever you save your presentation file.
|
||||
|
||||
## Built-in themes
|
||||
|
||||
A few built-in themes are bundled with the application binary, meaning you don't need to have any external files
|
||||
available to use them. These are packed as part of the [build process][build-rs] as a binary blob and are decoded on
|
||||
demand only when used.
|
||||
|
||||
Currently, the following themes are supported:
|
||||
|
||||
* `dark`: A dark theme.
|
||||
* `light`: A light theme.
|
||||
* `tokyonight-storm`: A theme inspired by the colors used in [toyonight](https://github.com/folke/tokyonight.nvim).
|
||||
* A set of themes based on the [catppuccin](https://github.com/catppuccin/catppuccin) color palette:
|
||||
* `catppuccin-latte`
|
||||
* `catppuccin-frappe`
|
||||
* `catppuccin-macchiato`
|
||||
* `catppuccin-mocha`
|
||||
* `terminal-dark`: A theme that uses your terminals color and looks best if your terminal uses a dark color scheme. This
|
||||
means if your terminal background is e.g. transparent, or uses an image, the presentation will inherit that.
|
||||
* `terminal-light`: The same as `terminal-dark` but works best if your terminal uses a light color scheme.
|
||||
|
||||
### Trying out built-in themes
|
||||
|
||||
All built-in themes can be tested by using the `--list-themes` parameter:
|
||||
|
||||
```shell
|
||||
presenterm --list-themes
|
||||
```
|
||||
|
||||
This will run a presentation where the same content is rendered using a different theme in each slide:
|
||||
|
||||
[](https://asciinema.org/a/zeV1QloyrLkfBp6rNltvX7Lle)
|
||||
|
||||
## Loading custom themes
|
||||
|
||||
On startup, _presenterm_ will look into the `themes` directory under the [configuration directory](configuration.html)
|
||||
(e.g. `~/.config/presenterm/themes` in Linux) and will load any `.yaml` file as a theme and make it available as if it
|
||||
was a built-in theme. This means you can use it as an argument to the `--theme` parameter, use it in the `theme.name`
|
||||
property in a presentation's front matter, etc.
|
||||
|
||||
## Theme definition
|
||||
|
||||
This section goes through the structure of the theme files. Have a look at some of the [existing themes][builtin-themes]
|
||||
to have an idea of how to structure themes.
|
||||
|
||||
### Root elements
|
||||
## Root elements
|
||||
|
||||
The root attributes on the theme yaml files specify either:
|
||||
|
||||
@ -110,7 +11,7 @@ The root attributes on the theme yaml files specify either:
|
||||
etc.
|
||||
* A default to be applied as a fallback if no specific style is specified for a particular element.
|
||||
|
||||
### Alignment
|
||||
## Alignment
|
||||
|
||||
_presenterm_ uses the notion of alignment, just like you would have in a GUI editor, to align text to the left, center,
|
||||
or right. You probably want most elements to be aligned left, _some_ to be aligned on the center, and probably none to
|
||||
@ -122,14 +23,14 @@ The following elements support alignment:
|
||||
* The title, subtitle, and author elements in the intro slide.
|
||||
* Tables.
|
||||
|
||||
#### Left/right alignment
|
||||
### Left/right alignment
|
||||
|
||||
Left and right alignments take a margin property which specifies the number of columns to keep between the text and the
|
||||
left/right terminal screen borders.
|
||||
|
||||
The margin can be specified in two ways:
|
||||
|
||||
##### Fixed
|
||||
#### Fixed
|
||||
|
||||
A specific number of characters regardless of the terminal size.
|
||||
|
||||
@ -139,7 +40,7 @@ margin:
|
||||
fixed: 5
|
||||
```
|
||||
|
||||
##### Percent
|
||||
#### Percent
|
||||
|
||||
A percentage over the total number of columns in the terminal.
|
||||
|
||||
@ -152,7 +53,7 @@ margin:
|
||||
Percent alignment tends to look a bit nicer as it won't change the presentation's look as much when the terminal size
|
||||
changes.
|
||||
|
||||
#### Center alignment
|
||||
### Center alignment
|
||||
|
||||
Center alignment has 2 properties:
|
||||
* `minimum_size` which specifies the minimum size you want that element to have. This is normally useful for code blocks
|
||||
@ -161,7 +62,7 @@ Center alignment has 2 properties:
|
||||
alignment. This doesn't play very well with `minimum_size` but in isolation it specifies the minimum number of columns
|
||||
you want to the left and right of your text.
|
||||
|
||||
### Colors
|
||||
## Colors
|
||||
|
||||
Every element can have its own background/foreground color using hex notation:
|
||||
|
||||
@ -172,7 +73,7 @@ default:
|
||||
background: "00ff00"
|
||||
```
|
||||
|
||||
### Default style
|
||||
## Default style
|
||||
|
||||
The default style specifies:
|
||||
|
||||
@ -188,7 +89,7 @@ default:
|
||||
background: "040312"
|
||||
```
|
||||
|
||||
### Intro slide
|
||||
## Intro slide
|
||||
|
||||
The introductory slide will be rendered if you specify a title, subtitle, or author in the presentation's front matter.
|
||||
This lets you have a less markdown-looking introductory slide that stands out so that it doesn't end up looking too
|
||||
@ -221,20 +122,79 @@ intro_slide:
|
||||
positioning: below_title
|
||||
```
|
||||
|
||||
### Footer
|
||||
## Footer
|
||||
|
||||
The footer currently comes in 3 flavors:
|
||||
|
||||
#### None
|
||||
### Template footers
|
||||
|
||||
No footer at all!
|
||||
A template footer lets you put text on the left, center and/or right of the screen. The template strings
|
||||
can reference `{current_slide}` and `{total_slides}` which will be replaced with the current and total number of slides.
|
||||
|
||||
Besides those special variables, any of the attributes defined in the front matter can also be used:
|
||||
|
||||
* `title`.
|
||||
* `sub_title`.
|
||||
* `event`.
|
||||
* `location`.
|
||||
* `date`.
|
||||
* `author`.
|
||||
|
||||
Strings used in template footers can contain arbitrary markdown, including `span` tags that let you use colored text. A
|
||||
`height` attribute allows specifying how tall, in terminal rows, the footer is. The text in the footer will always be
|
||||
placed at the center of the footer area. The default footer height is 2.
|
||||
|
||||
```yaml
|
||||
footer:
|
||||
style: empty
|
||||
style: template
|
||||
left: "My **name** is {author}"
|
||||
center: "_@myhandle_"
|
||||
right: "{current_slide} / {total_slides}"
|
||||
height: 3
|
||||
```
|
||||
|
||||
#### Progress bar
|
||||
Do note that:
|
||||
|
||||
* Only existing attributes in the front matter can be referenced. That is, if you use `{date}` but the `date` isn't set,
|
||||
an error will be shown.
|
||||
* Similarly, referencing unsupported variables (e.g. `{potato}`) will cause an error to be displayed. If you'd like the
|
||||
`{}` characters to be used in contexts where you don't want to reference a variable, you will need to escape them by
|
||||
using another brace. e.g. `{{potato}} farms` will be displayed as `{potato} farms`.
|
||||
|
||||
#### Footer images
|
||||
|
||||
Besides text, images can also be used in the left/center/right positions. This can be done by specifying an `image` key
|
||||
under each of those attributes:
|
||||
|
||||
```yaml
|
||||
footer:
|
||||
style: template
|
||||
left:
|
||||
image: potato.png
|
||||
center:
|
||||
image: banana.png
|
||||
right:
|
||||
image: apple.png
|
||||
# The height of the footer to adjust image sizes
|
||||
height: 5
|
||||
```
|
||||
|
||||
Images will be looked up:
|
||||
|
||||
* First, relative to the presentation file just like any other image.
|
||||
* If the image is not found, it will be looked up relative to the themes directory. e.g. `~/.config/presenterm/themes`.
|
||||
This allows you to define a custom theme in your themes directory that points to a local image within that same
|
||||
location.
|
||||
|
||||
Images will preserve their aspect ratio and expand vertically to take up as many terminal rows as `footer.height`
|
||||
specifies. This parameter should be adjusted accordingly if taller-than-wider images are used in a footer.
|
||||
|
||||
See the [footer example](https://github.com/mfontanini/presenterm/blob/master/examples/footer.md) as a showcase of how a
|
||||
footer can contain images and colored text.
|
||||
|
||||

|
||||
|
||||
### Progress bar footers
|
||||
|
||||
A progress bar that will advance as you move in your presentation. This will by default use a block-looking character to
|
||||
draw the progress bar but you can customize it:
|
||||
@ -247,21 +207,17 @@ footer:
|
||||
character: 🚀
|
||||
```
|
||||
|
||||
#### Template
|
||||
### None
|
||||
|
||||
A template footer that lets you put something on the left, center and/or right of the screen. The template strings have
|
||||
access to `{author}` as specified in the front matter, `{current_slide}` and `{total_slides}` which will point to the
|
||||
current and total number of slides:
|
||||
No footer at all!
|
||||
|
||||
```yaml
|
||||
footer:
|
||||
style: template
|
||||
left: "My name is {author}"
|
||||
center: @myhandle
|
||||
right: "{current_slide} / {total_slides}"
|
||||
style: empty
|
||||
```
|
||||
|
||||
### Slide title
|
||||
|
||||
## Slide title
|
||||
|
||||
Slide titles, as specified by using a setext header, has the following properties:
|
||||
* `padding_top` which specifies the number of rows you want as padding before the text.
|
||||
@ -275,7 +231,7 @@ slide_title:
|
||||
separator: true
|
||||
```
|
||||
|
||||
### Headings
|
||||
## Headings
|
||||
|
||||
Every header type (h1 through h6) can have its own style composed of:
|
||||
* The prefix you want to use.
|
||||
@ -293,7 +249,7 @@ headings:
|
||||
foreground: "rgb_(168,223,142)"
|
||||
```
|
||||
|
||||
### Code blocks
|
||||
## Code blocks
|
||||
|
||||
The syntax highlighting for code blocks is done via the [syntect](https://github.com/trishume/syntect) crate. The list
|
||||
of all the supported built-in _syntect_ themes is the following:
|
||||
@ -324,10 +280,10 @@ code:
|
||||
#### Custom highlighting themes
|
||||
|
||||
Besides the built-in highlighting themes, you can drop any `.tmTheme` theme in the `themes/highlighting` directory under
|
||||
your [configuration directory](configuration.html) (e.g. `~/.config/presenterm/themes/highlighting` in Linux) and they
|
||||
will be loaded automatically when _presenterm_ starts.
|
||||
your [configuration directory](../../configuration/introduction.md) (e.g. `~/.config/presenterm/themes/highlighting` in
|
||||
Linux) and they will be loaded automatically when _presenterm_ starts.
|
||||
|
||||
### Block quotes
|
||||
## Block quotes
|
||||
|
||||
For block quotes you can specify a string to use as a prefix in every line of quoted text:
|
||||
|
||||
@ -336,6 +292,103 @@ block_quote:
|
||||
prefix: "▍ "
|
||||
```
|
||||
|
||||
<!-- links -->
|
||||
[builtin-themes]: https://github.com/mfontanini/presenterm/tree/master/themes
|
||||
[build-rs]: https://github.com/mfontanini/presenterm/blob/master/build.rs
|
||||
## Mermaid
|
||||
|
||||
The [mermaid](https://mermaid.js.org/) graphs can be customized using the following parameters:
|
||||
|
||||
* `mermaid.background` the background color passed to the CLI (e.g., `transparent`, `red`, `#F0F0F0`).
|
||||
* `mermaid.theme` the [mermaid theme](https://mermaid.js.org/config/theming.html#available-themes) to use.
|
||||
|
||||
```yaml
|
||||
mermaid:
|
||||
background: transparent
|
||||
theme: dark
|
||||
```
|
||||
|
||||
## Alerts
|
||||
|
||||
GitHub style markdown alerts can be styled by setting the `alert` key:
|
||||
|
||||
```yaml
|
||||
alert:
|
||||
# the base colors used in all text in an alert
|
||||
base_colors:
|
||||
foreground: red
|
||||
background: black
|
||||
|
||||
# the prefix used in every line in the alert
|
||||
prefix: "▍ "
|
||||
|
||||
# the style for each alert type
|
||||
styles:
|
||||
note:
|
||||
color: blue
|
||||
title: Note
|
||||
icon: I
|
||||
tip:
|
||||
color: green
|
||||
title: Tip
|
||||
icon: T
|
||||
important:
|
||||
color: cyan
|
||||
title: Important
|
||||
icon: I
|
||||
warning:
|
||||
color: orange
|
||||
title: Warning
|
||||
icon: W
|
||||
caution:
|
||||
color: red
|
||||
title: Caution
|
||||
icon: C
|
||||
```
|
||||
|
||||
## Extending themes
|
||||
|
||||
Custom themes can extend other custom or built in themes. This means it will inherit all the properties of the theme
|
||||
being extended by default.
|
||||
|
||||
For example:
|
||||
|
||||
```yaml
|
||||
extends: dark
|
||||
default:
|
||||
colors:
|
||||
background: "000000"
|
||||
```
|
||||
|
||||
This theme extends the built in _dark_ theme and overrides the background color. This is useful if you find yourself
|
||||
_almost_ liking a built in theme but there's only some properties you don't like.
|
||||
|
||||
## Color palette
|
||||
|
||||
Every theme can define a color palette, which includes a list of pre-defined colors and a list of background/foreground
|
||||
pairs called "classes". Colors and classes can be used when styling text via `<span>` HTML tags, whereas colors can also
|
||||
be used inside themes to avoid duplicating the same colors all over the theme definition.
|
||||
|
||||
A palette can de defined as follows:
|
||||
|
||||
```yaml
|
||||
palette:
|
||||
colors:
|
||||
red: "f78ca2"
|
||||
purple: "986ee2"
|
||||
classes:
|
||||
foo:
|
||||
foreground: "ff0000"
|
||||
background: "00ff00"
|
||||
```
|
||||
|
||||
Any palette color can be referenced using either `palette:<name>` or `p:<name>`. This means now any part of the theme
|
||||
can use `p:red` and `p:purple` where a color is required.
|
||||
|
||||
Similarly, these colors can be used in `span` tags like:
|
||||
|
||||
```html
|
||||
<span style="color: palette:red">this is red</span>
|
||||
|
||||
<span class="foo">this is foo-colored</span>
|
||||
```
|
||||
|
||||
These colors can used anywhere in your presentation as well as in other places such as in
|
||||
[template footers](#template-footers) and [introduction slides](../introduction.md#introduction-slide).
|
102
docs/src/features/themes/introduction.md
Normal file
102
docs/src/features/themes/introduction.md
Normal file
@ -0,0 +1,102 @@
|
||||
# Themes
|
||||
|
||||
_presenterm_ tries to be as configurable as possible, allowing users to create presentations that look exactly how they
|
||||
want them to look like. The tool ships with a set of [built-in
|
||||
themes](https://github.com/mfontanini/presenterm/tree/master/themes) but users can be created by users in their local
|
||||
setup and imported in their presentations.
|
||||
|
||||
## Setting themes
|
||||
|
||||
There's various ways of setting the theme you want in your presentation:
|
||||
|
||||
### CLI
|
||||
|
||||
Passing in the `--theme` parameter when running _presenterm_ to select one of the built-in themes.
|
||||
|
||||
### Within the presentation
|
||||
|
||||
The presentation's markdown file can contain a front matter that specifies the theme to use. This comes in 3 flavors:
|
||||
|
||||
#### By name
|
||||
|
||||
Using a built-in theme name makes your presentation use that one regardless of what the default or what the `--theme`
|
||||
option specifies:
|
||||
|
||||
```yaml
|
||||
---
|
||||
theme:
|
||||
name: dark
|
||||
---
|
||||
```
|
||||
|
||||
#### By path
|
||||
|
||||
You can define a theme file in yaml format somewhere in your filesystem and reference it within the presentation:
|
||||
|
||||
```yaml
|
||||
---
|
||||
theme:
|
||||
path: /home/me/Documents/epic-theme.yaml
|
||||
---
|
||||
```
|
||||
|
||||
#### Overrides
|
||||
|
||||
You can partially/completely override the theme in use from within the presentation:
|
||||
|
||||
```yaml
|
||||
---
|
||||
theme:
|
||||
override:
|
||||
default:
|
||||
colors:
|
||||
foreground: "beeeff"
|
||||
---
|
||||
```
|
||||
|
||||
This lets you:
|
||||
|
||||
1. Create a unique style for your presentation without having to go through the process of taking an existing theme,
|
||||
copying somewhere, and changing it when you only expect to use it for that one presentation.
|
||||
2. Iterate quickly on styles given overrides are reloaded whenever you save your presentation file.
|
||||
|
||||
# Built-in themes
|
||||
|
||||
A few built-in themes are bundled with the application binary, meaning you don't need to have any external files
|
||||
available to use them. These are packed as part of the [build
|
||||
process](https://github.com/mfontanini/presenterm/blob/master/build.rs) as a binary blob and are decoded on demand only
|
||||
when used.
|
||||
|
||||
Currently, the following themes are supported:
|
||||
|
||||
* A set of themes based on the [catppuccin](https://github.com/catppuccin/catppuccin) color palette:
|
||||
* `catppuccin-latte`
|
||||
* `catppuccin-frappe`
|
||||
* `catppuccin-macchiato`
|
||||
* `catppuccin-mocha`
|
||||
* `dark`: A dark theme.
|
||||
* `gruvbox`: A theme inspired by the colors used in [gruvbox](https://github.com/morhetz/gruvbox).
|
||||
* `light`: A light theme.
|
||||
* `terminal-dark`: A theme that uses your terminals color and looks best if your terminal uses a dark color scheme. This
|
||||
means if your terminal background is e.g. transparent, or uses an image, the presentation will inherit that.
|
||||
* `terminal-light`: The same as `terminal-dark` but works best if your terminal uses a light color scheme.
|
||||
* `tokyonight-storm`: A theme inspired by the colors used in [toyonight](https://github.com/folke/tokyonight.nvim).
|
||||
|
||||
## Trying out built-in themes
|
||||
|
||||
All built-in themes can be tested by using the `--list-themes` parameter:
|
||||
|
||||
```bash
|
||||
presenterm --list-themes
|
||||
```
|
||||
|
||||
This will run a presentation where the same content is rendered using a different theme in each slide:
|
||||
|
||||
[](https://asciinema.org/a/zeV1QloyrLkfBp6rNltvX7Lle)
|
||||
|
||||
# Loading custom themes
|
||||
|
||||
On startup, _presenterm_ will look into the `themes` directory under the [configuration
|
||||
directory](../../configuration/introduction.md) (e.g. `~/.config/presenterm/themes` in Linux) and will load any `.yaml`
|
||||
file as a theme and make it available as if it was a built-in theme. This means you can use it as an argument to the
|
||||
`--theme` parameter, use it in the `theme.name` property in a presentation's front matter, etc.
|
@ -1,252 +0,0 @@
|
||||
## Introduction
|
||||
|
||||
This guide teaches you how to use _presenterm_. At this point you should have already installed _presenterm_, otherwise
|
||||
visit the [installation](installation.html) guide to get started.
|
||||
|
||||
### Quick start
|
||||
|
||||
Download the demo presentation and run it using:
|
||||
|
||||
```shell
|
||||
git clone https://github.com/mfontanini/presenterm.git
|
||||
cd presenterm
|
||||
presenterm examples/demo.md
|
||||
```
|
||||
|
||||
## Presentations
|
||||
|
||||
A presentation in _presenterm_ is a single markdown file. Every slide in the presentation file is delimited by a line
|
||||
that contains a single HTML comment:
|
||||
|
||||
```html
|
||||
<!-- end_slide -->
|
||||
```
|
||||
|
||||
Presentations can contain most commonly used markdown elements such as ordered and unordered lists, headings, formatted
|
||||
text (**bold**, _italics_, ~strikethrough~, `inline code`, etc), code blocks, block quotes, tables, etc.
|
||||
|
||||
### Images
|
||||
|
||||

|
||||
|
||||
Images are supported and will render in your terminal as long as it supports either the [iterm2 image
|
||||
protocol](https://iterm2.com/documentation-images.html), the [kitty graphics
|
||||
protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/), or [sixel](https://saitoha.github.io/libsixel/). Some of
|
||||
the terminals where at least one of these is supported are:
|
||||
|
||||
* kitty
|
||||
* iterm2
|
||||
* wezterm
|
||||
* foot
|
||||
|
||||
Note that sixel support is experimental so it needs to be explicitly enabled via the `sixel` configuration flag:
|
||||
|
||||
```shell
|
||||
cargo build --release --features sixel
|
||||
```
|
||||
|
||||
> **Note**: this feature flag is only needed if your terminal emulator only supports sixel. Many terminals support the
|
||||
> kitty or iterm2 protocols so this isn't necessary.
|
||||
|
||||
---
|
||||
|
||||
Things you should know when using image tags in your presentation's markdown are:
|
||||
* Image paths are relative to your presentation path. That is a tag like `` will be looked up at
|
||||
`$PRESENTATION_DIRECTORY/food/potato.png`.
|
||||
* Images will be rendered by default in their original size. That is, if your terminal is 300x200px and your image is
|
||||
200x100px, it will take up 66% of your horizontal space and 50% of your vertical space.
|
||||
* The exception to the point above is if the image does not fit in your terminal, it will be resized accordingly while
|
||||
preserving the aspect ratio.
|
||||
* If your terminal does not support any of the graphics protocol above, images will be rendered using ascii blocks. It
|
||||
ain't great but it's something!
|
||||
|
||||
#### Image size
|
||||
|
||||
The size of each image can be set by using the `image:width` or `image:w` attributes in the image tag. For example, the
|
||||
following will cause the image to take up 50% of the terminal width:
|
||||
|
||||
```markdown
|
||||

|
||||
```
|
||||
|
||||
The image will always be scaled to preserve its aspect ratio and it will not be allowed to overflow vertically nor
|
||||
horizontally.
|
||||
|
||||
#### Protocol detection
|
||||
|
||||
By default the image protocol to be used will be automatically detected. In cases where this detection fails (e.g. when
|
||||
running inside `tmux`), you can set it manually via the `--image-protocol` parameter or by setting it in the [config
|
||||
file](configuration.html#preferred-image-protocol).
|
||||
|
||||
## Extensions
|
||||
|
||||
Besides the standard markdown elements, _presenterm_ supports a few extensions.
|
||||
|
||||
### Introduction slide
|
||||
|
||||
By setting a front matter at the beginning of your presentation, you can configure the title, sub title, and author of
|
||||
your presentation and implicitly create an introduction slide:
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: My first presentation
|
||||
sub_title: (in presenterm!)
|
||||
author: Myself
|
||||
---
|
||||
```
|
||||
|
||||
All of these attributes are optional so you're not forced to set them all.
|
||||
|
||||
#### Multiple authors
|
||||
|
||||
If you're creating a presentation in which there's multiple authors, you can use the `authors` key instead of `author`
|
||||
and list them all this way:
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: Our first presentation
|
||||
authors:
|
||||
- Me
|
||||
- You
|
||||
---
|
||||
```
|
||||
|
||||
### Slide titles
|
||||
|
||||
Any [setext header](https://spec.commonmark.org/0.30/#setext-headings) will be considered to be a slide title and will
|
||||
be rendered in a more slide-title-looking way. By default this means it will be centered, some vertical padding will be
|
||||
added and the text color will be different.
|
||||
|
||||
~~~
|
||||
Hello
|
||||
===
|
||||
~~~
|
||||
|
||||
> Note: see the [themes](themes.html) section on how to customize the looks of slide titles and any other element in a
|
||||
> presentation.
|
||||
|
||||
### Pauses
|
||||
|
||||
Pauses allow the sections of the content in your slide to only show up when you advance in your presentation. That is,
|
||||
only after you press, say, the right arrow will a section of the slide show up. This can be done by the `pause` comment
|
||||
command:
|
||||
|
||||
```html
|
||||
<!-- pause -->
|
||||
```
|
||||
|
||||
### Ending slides
|
||||
|
||||
While other applications use a thematic break (`---`) to mark the end of a slide, _presenterm_ uses a special
|
||||
`end_slide` HTML comment:
|
||||
|
||||
```html
|
||||
<!-- end_slide -->
|
||||
```
|
||||
|
||||
This makes the end of a slide more explicit and easy to spot while you're editing your presentation. See the
|
||||
[configuration](/docs/config.md#implicit_slide_ends) if you want to customize this behavior.
|
||||
|
||||
If you really would prefer to use thematic breaks (`---`) to delimit slides, you can do that by enabling the
|
||||
[`end_slide_shorthand`](configuration.html#end_slide_shorthand) options.
|
||||
|
||||
### Jumping to the vertical center
|
||||
|
||||
The command `jump_to_vertical_center` lets you jump to the middle of the page vertically. This is useful in combination
|
||||
with slide titles to create separator slides:
|
||||
|
||||
```markdown
|
||||
blablabla
|
||||
|
||||
<!-- end_slide -->
|
||||
|
||||
<!-- jump_to_middle -->
|
||||
|
||||
Farming potatoes
|
||||
===
|
||||
|
||||
<!-- end_slide -->
|
||||
```
|
||||
|
||||
This will create a slide with the text "Farming potatoes" in the center, rendered using the slide title style.
|
||||
|
||||
### Explicit new lines
|
||||
|
||||
The `newline`/`new_line` and `newlines`/`new_lines` commands allow you to explicitly create new lines. Because markdown
|
||||
ignores multiple line breaks in a row, this is useful to create some spacing where necessary:
|
||||
|
||||
```markdown
|
||||
hi
|
||||
|
||||
<!-- new_lines: 10 -->
|
||||
|
||||
mom
|
||||
|
||||
<!-- new_line -->
|
||||
|
||||
bye
|
||||
```
|
||||
|
||||
### Incremental lists
|
||||
|
||||
Using `<!-- pause -->` in between each bullet point a list is a bit tedious so instead you can use the
|
||||
`incremental_lists` command to tell _presenterm_ that **until the end of the current slide** you want each individual
|
||||
bullet point to appear only after you move to the next slide:
|
||||
|
||||
```markdown
|
||||
<!-- incremental_lists: true -->
|
||||
|
||||
* this
|
||||
* appears
|
||||
* one after
|
||||
* the other
|
||||
|
||||
<!-- incremental_lists: false -->
|
||||
|
||||
* this appears
|
||||
* all at once
|
||||
```
|
||||
|
||||
## Key bindings
|
||||
|
||||
Navigation within a presentation should be intuitive: jumping to the next/previous slide can be done by using the arrow
|
||||
keys, _hjkl_, and page up/down keys.
|
||||
|
||||
Besides this:
|
||||
|
||||
* Jumping to the first slide: `gg`.
|
||||
* Jumping to the last slide: `G`.
|
||||
* Jumping to a specific slide: `<slide-number>G`.
|
||||
* Exit the presentation: `<ctrl>c`.
|
||||
|
||||
### Configuring key bindings
|
||||
|
||||
If you don't like the default key bindings, you can override them in the [configuration
|
||||
file](configuration.html#key-bindings).
|
||||
|
||||
## Modals
|
||||
|
||||
_presenterm_ currently has 2 modals that can provide some information while running the application. Modals can be
|
||||
toggled using some key combination and can be hidden using the escape key by default, but these can be configured via
|
||||
the [configuration file key bindings](configuration.html#key-bindings).
|
||||
|
||||
### Slide index modal
|
||||
|
||||
This modal can be toggled by default using `control+p` and lets you see an index that contains a row for every slide in
|
||||
the presentation, including its title and slide index. This allows you to find a slide you're trying to jump to
|
||||
quicklier rather than scanning through each of them.
|
||||
|
||||
[](https://asciinema.org/a/1VgRxVIEyLrMmq6OZ3oKx4PGi)
|
||||
|
||||
### Key bindings modal
|
||||
|
||||
The key bindings modal displays the key bindings for each of the supported actions.
|
||||
|
||||
## Hot reload
|
||||
|
||||
Unless you run in presentation mode by passing in the `--present` parameter, _presenterm_ will automatically reload your
|
||||
presentation file every time you save it. _presenterm_ will also automatically detect which specific slide was modified
|
||||
and jump to it so you don't have to be jumping back and forth between the source markdown and the presentation to see
|
||||
how the changes look like.
|
||||
|
||||
[](https://asciinema.org/a/bu9ITs8KhaQK5OdDWnPwUYKu3)
|
@ -1,198 +0,0 @@
|
||||
## Code highlighting
|
||||
|
||||
Code highlighting is supported for the following languages:
|
||||
|
||||
* ada
|
||||
* asp
|
||||
* awk
|
||||
* bash
|
||||
* batchfile
|
||||
* C
|
||||
* cmake
|
||||
* crontab
|
||||
* C#
|
||||
* clojure
|
||||
* C++
|
||||
* CSS
|
||||
* D
|
||||
* diff
|
||||
* docker
|
||||
* dotenv
|
||||
* elixir
|
||||
* elm
|
||||
* erlang
|
||||
* go
|
||||
* haskell
|
||||
* HTML
|
||||
* java
|
||||
* javascript
|
||||
* json
|
||||
* kotlin
|
||||
* latex
|
||||
* lua
|
||||
* makefile
|
||||
* markdown
|
||||
* nix
|
||||
* ocaml
|
||||
* perl
|
||||
* php
|
||||
* protobuf
|
||||
* puppet
|
||||
* python
|
||||
* R
|
||||
* ruby
|
||||
* rust
|
||||
* scala
|
||||
* shell
|
||||
* sql
|
||||
* swift
|
||||
* svelte
|
||||
* terraform
|
||||
* typescript
|
||||
* xml
|
||||
* yaml
|
||||
* vue
|
||||
* zig
|
||||
|
||||
### Enabling line numbers
|
||||
|
||||
If you would like line numbers to be shown on the left of a code block use the `+line_numbers` switch after specifying
|
||||
the language in a code block:
|
||||
|
||||
~~~markdown
|
||||
```rust +line_numbers
|
||||
fn hello_world() {
|
||||
println!("Hello world");
|
||||
}
|
||||
```
|
||||
~~~
|
||||
|
||||
### Selective highlighting
|
||||
|
||||
By default, the entire code block will be syntax-highlighted. If instead you only wanted a subset of it to be
|
||||
highlighted, you can use braces and a list of either individual lines, or line ranges that you'd want to highlight.
|
||||
|
||||
~~~markdown
|
||||
```rust {1,3,5-7}
|
||||
fn potato() -> u32 { // 1: highlighted
|
||||
// 2: not highlighted
|
||||
println!("Hello world"); // 3: highlighted
|
||||
let mut q = 42; // 4: not highlighted
|
||||
q = q * 1337; // 5: highlighted
|
||||
q // 6: highlighted
|
||||
} // 7: highlighted
|
||||
```
|
||||
~~~
|
||||
|
||||
### Dynamic highlighting
|
||||
|
||||
Similar to the syntax used for selective highlighting, dynamic highlighting will change which lines of the code in a
|
||||
code block are highlighted every time you move to the next/previous slide.
|
||||
|
||||
This is achieved by using the separator `|` to indicate what sections of the code will be highlighted at a given time.
|
||||
|
||||
~~~markdown
|
||||
```rust {1,3|5-7}
|
||||
fn potato() -> u32 {
|
||||
|
||||
println!("Hello world");
|
||||
let mut q = 42;
|
||||
q = q * 1337;
|
||||
q
|
||||
}
|
||||
```
|
||||
~~~
|
||||
|
||||
In this example, lines 1 and 3 will be highlighted initially. Then once you press a key to move to the next slide, lines
|
||||
1 and 3 will no longer be highlighted and instead lines 5 through 7 will. This allows you to create more dynamic
|
||||
presentations where you can display sections of the code to explain something specific about each of them.
|
||||
|
||||
See this real example of how this looks like.
|
||||
|
||||
[](https://asciinema.org/a/iCf4f6how1Ux3H8GNzksFUczI)
|
||||
|
||||
### Executing code blocks
|
||||
|
||||
Annotating a code block with a `+exec` attribute will make it executable. Once you're in a slide that contains an
|
||||
executable block, press `control+e` to execute it. The output of the execution will be displayed on a box below the
|
||||
code. The code execution is stateful so if you switch to another slide and then go back, you will still see the output.
|
||||
|
||||
~~~markdown
|
||||
```bash +exec
|
||||
echo hello world
|
||||
```
|
||||
~~~
|
||||
|
||||
Code execution **must be explicitly enabled** by using either:
|
||||
|
||||
* The `-x` command line parameter when running _presenterm_.
|
||||
* Setting the `snippet.exec.enable` property to `true` in your [_presenterm_ config
|
||||
file](configuration.html#snippet-execution).
|
||||
|
||||
---
|
||||
|
||||
The list of languages that support execution are:
|
||||
|
||||
* bash
|
||||
* c++
|
||||
* c
|
||||
* fish
|
||||
* go
|
||||
* java
|
||||
* js
|
||||
* kotlin
|
||||
* lua
|
||||
* nushell
|
||||
* perl
|
||||
* python
|
||||
* ruby
|
||||
* rust-script
|
||||
* rust
|
||||
* sh
|
||||
* zsh
|
||||
|
||||
If there's a language that is not in this list and you would like it to be supported, please [create an
|
||||
issue](https://github.com/mfontanini/presenterm/issues/new) providing details on how to compile (if necessary) and run
|
||||
snippets for that language. You can also configure how to run code snippet for a language locally in your [config
|
||||
file](configuration.html#custom-snippet-executors).
|
||||
|
||||
[](https://asciinema.org/a/BbAY817esxagCgPtnKUwgYnHr)
|
||||
|
||||
> **Note**: because this is spawning a process and executing code, you should use this at your own risk.
|
||||
|
||||
### Hiding code lines
|
||||
|
||||
When you mark a code snippet as executable via the `+exec` flag, you may not be interested in showing _all the lines_ to
|
||||
your audience, as some of them may not be necessary to convey your point. For example, you may want to hide imports,
|
||||
non-essential functions, initialization of certain variables, etc. For this purpose, _presenterm_ supports a prefix
|
||||
under certain programming languages that let you indicate a line should be executed when running the code but should not
|
||||
be displayed in the presentation.
|
||||
|
||||
For example, in the following code snippet only the print statement will be displayed but the entire snippet will be
|
||||
ran:
|
||||
|
||||
~~~markdown
|
||||
```rust
|
||||
# fn main() {
|
||||
println!("Hello world!");
|
||||
# }
|
||||
```
|
||||
~~~
|
||||
|
||||
Rather than blindly relying on a prefix that may have a meaning in a language, prefixes are chosen on a per language
|
||||
basis. The languages that are supported and their prefix is:
|
||||
|
||||
* rust: `# `.
|
||||
* python/bash/fish/shell/zsh/kotlin/java/javascript/typescript/c/c++/go: `/// `.
|
||||
|
||||
This means that any line in a rust code snippet that starts with `# ` will be hidden, whereas all lines in, say, a
|
||||
golang code snippet that starts with a `/// ` will be hidden.
|
||||
|
||||
### Pre-rendering
|
||||
|
||||
Some languages support pre-rendering. This means the code block is transformed into something else when the presentation
|
||||
is loaded. The languages that currently support this are _LaTeX_ and _typst_ where the contents of the code block is
|
||||
transformed into an image, allowing you to define formulas as text in your presentation. This can be done by using the
|
||||
`+render` attribute on a code block.
|
||||
|
||||
See the [LaTeX and typst](latex.html) and [mermaid](mermaid.html) docs for more information.
|
@ -1,335 +0,0 @@
|
||||
## Configuration
|
||||
|
||||
_presenterm_ allows you to customize its behavior via a configuration file. This file is stored, along with all of your
|
||||
custom themes, in the following directories:
|
||||
|
||||
* `$XDG_CONFIG_HOME/presenterm/` if that environment variable is defined, otherwise:
|
||||
* `~/.config/presenterm/` in Linux.
|
||||
* `~/Library/Application Support/presenterm/` in macOS.
|
||||
* `~/AppData/Roaming/presenterm/config/` in Windows.
|
||||
|
||||
The configuration file will be looked up automatically in the directories above under the name `config.yaml`. e.g. on
|
||||
Linux you should create it under `~/.config/presenterm/config.yaml`. You can also specify a custom path to this file
|
||||
when running _presenterm_ via the `--config-path` parameter.
|
||||
|
||||
A [sample configuration file](https://github.com/mfontanini/presenterm/blob/master/config.sample.yaml) is provided in
|
||||
the repository that you can use as a base.
|
||||
|
||||
## Options
|
||||
|
||||
Options are special configuration parameters that can be set either in the configuration file under the `options` key,
|
||||
or in a presentation's front matter under the same key. This last one allows you to customize a single presentation so
|
||||
that it acts in a particular way. This can also be useful if you'd like to share the source files for your presentation
|
||||
with other people.
|
||||
|
||||
The supported configuration options are currently the following:
|
||||
|
||||
### implicit_slide_ends
|
||||
|
||||
This option removes the need to use `<!-- end_slide -->` in between slides and instead assumes that if you use a slide
|
||||
title, then you're implying that the previous slide ended. For example, the following presentation:
|
||||
|
||||
```
|
||||
---
|
||||
options:
|
||||
implicit_slide_ends: true
|
||||
---
|
||||
|
||||
Tasty vegetables
|
||||
================
|
||||
|
||||
* Potato
|
||||
|
||||
Awful vegetables
|
||||
================
|
||||
|
||||
* Lettuce
|
||||
```
|
||||
|
||||
Is equivalent to this "vanilla" one that doesn't use implicit slide ends.
|
||||
|
||||
```markdown
|
||||
Tasty vegetables
|
||||
================
|
||||
|
||||
* Potato
|
||||
|
||||
<!-- end_slide -->
|
||||
|
||||
Awful vegetables
|
||||
================
|
||||
|
||||
* Lettuce
|
||||
```
|
||||
|
||||
### end_slide_shorthand
|
||||
|
||||
This option allows using thematic breaks (`---`) as a delimiter between slides. When enabling this option, you can still
|
||||
use `<!-- end_slide -->` but any thematic break will also be considered a slide terminator.
|
||||
|
||||
```
|
||||
---
|
||||
options:
|
||||
end_slide_shorthand: true
|
||||
---
|
||||
|
||||
this is a slide
|
||||
|
||||
---------------------
|
||||
|
||||
this is another slide
|
||||
```
|
||||
|
||||
### command_prefix
|
||||
|
||||
Because _presenterm_ uses HTML comments to represent commands, it is necessary to make some assumptions on _what_ is a
|
||||
command and what isn't. The current heuristic is:
|
||||
|
||||
* If an HTML comment is laid out on a single line, it is assumed to be a command. This means if you want to use a real
|
||||
HTML comment like `<!-- remember to say "potato" here -->`, this will raise an error.
|
||||
* If an HTML comment is multi-line, then it is assumed to be a comment and it can have anything inside it. This means
|
||||
you can't have a multi-line comment that contains a command like `pause` inside.
|
||||
|
||||
Depending on how you use HTML comments personally, this may be limiting to you: you cannot use any single line comments
|
||||
that are not commands. To get around this, the `command_prefix` option lets you configure a prefix that must be set in
|
||||
all commands for them to be configured as such. Any single line comment that doesn't start with this prefix will not be
|
||||
considered a command.
|
||||
|
||||
For example:
|
||||
|
||||
```
|
||||
---
|
||||
options:
|
||||
command_prefix: "cmd:"
|
||||
---
|
||||
|
||||
<!-- remember to say "potato here" -->
|
||||
|
||||
Tasty vegetables
|
||||
================
|
||||
|
||||
* Potato
|
||||
|
||||
<!-- cmd:pause -->
|
||||
|
||||
**That's it!**
|
||||
```
|
||||
|
||||
In the example above, the first comment is ignored because it doesn't start with "cmd:" and the second one is processed
|
||||
because it does.
|
||||
|
||||
### incremental_lists
|
||||
|
||||
If you'd like all bullet points in all lists to show up with pauses in between you can enable the `incremental_lists`
|
||||
option:
|
||||
|
||||
```
|
||||
---
|
||||
options:
|
||||
incremental_lists: true
|
||||
---
|
||||
|
||||
* pauses
|
||||
* in
|
||||
* between
|
||||
```
|
||||
|
||||
Keep in mind if you only want specific bullet points to show up with pauses in between, you can use the
|
||||
[`incremental_lists` comment command](basics.html#incremental-lists).
|
||||
|
||||
### strict_front_matter_parsing
|
||||
|
||||
This option tells _presenterm_ you don't care about extra parameters in presentation's front matter. This can be useful
|
||||
if you're trying to load a presentation made for another tool. The following presentation would only be successfully
|
||||
loaded if you set `strict_front_matter_parsing` to `false` in your configuration file:
|
||||
|
||||
```markdown
|
||||
---
|
||||
potato: 42
|
||||
---
|
||||
|
||||
# Hi
|
||||
```
|
||||
|
||||
### image_attributes_prefix
|
||||
|
||||
The [image size](basics.html#image-size) prefix (by default `image:`) can be configured to be anything you would want in
|
||||
case you don't like the default one. For example, if you'd like to set the image size by simply doing
|
||||
`` you would need to set:
|
||||
|
||||
```
|
||||
---
|
||||
options:
|
||||
image_attributes_prefix: ""
|
||||
---
|
||||
|
||||

|
||||
```
|
||||
|
||||
## Defaults
|
||||
|
||||
Defaults **can only be configured via the configuration file**.
|
||||
|
||||
### Default theme
|
||||
|
||||
The default theme can be configured only via the config file. When this is set, every presentation that doesn't set a
|
||||
theme explicitly will use this one:
|
||||
|
||||
```yaml
|
||||
defaults:
|
||||
theme: light
|
||||
```
|
||||
|
||||
### Terminal font size
|
||||
|
||||
This is a parameter that lets you explicitly set the terminal font size in use. This should not be used unless you are
|
||||
in Windows, given there's no (easy) way to get the terminal window size so we use this to figure out how large the
|
||||
window is and resize images properly. Some terminals on other platforms may also have this issue, but that should not be
|
||||
as common.
|
||||
|
||||
If you are on Windows or you notice images show up larger/smaller than they should, you can adjust this setting in your
|
||||
config file:
|
||||
|
||||
```yaml
|
||||
defaults:
|
||||
terminal_font_size: 16
|
||||
```
|
||||
|
||||
### Preferred image protocol
|
||||
|
||||
By default _presenterm_ will try to detect which image protocol to use based on the terminal you are using. In some
|
||||
cases this may fail, for example when using `tmux`. In those cases, you can explicitly set this via the
|
||||
`--image-protocol` parameter or the configuration key `defaults.image_protocol`:
|
||||
|
||||
```yaml
|
||||
defaults:
|
||||
image_protocol: kitty-local
|
||||
```
|
||||
|
||||
Possible values are:
|
||||
* `auto`: try to detect it automatically (default).
|
||||
* `kitty-local`: use the kitty protocol in "local" mode, meaning both _presenterm_ and the terminal run in the same host
|
||||
and can share the filesystem to communicate.
|
||||
* `kitty-remote`: use the kitty protocol in "remote" mode, meaning _presenterm_ and the terminal run in different hosts
|
||||
and therefore can only communicate via terminal escape codes.
|
||||
* `iterm2`: use the iterm2 protocol.
|
||||
* `sixel`: use the sixel protocol. Note that this requires compiling _presenterm_ using the `--features sixel` flag.
|
||||
|
||||
## Key bindings
|
||||
|
||||
Key bindings that _presenterm_ uses can be manually configured in the config file via the `bindings` key. The following
|
||||
is the default configuration:
|
||||
|
||||
```yaml
|
||||
bindings:
|
||||
# the keys that cause the presentation to move forwards.
|
||||
next: ["l", "j", "<right>", "<page_down>", "<down>", " "]
|
||||
|
||||
# the keys that cause the presentation to move backwards.
|
||||
previous: ["h", "k", "<left>", "<page_up>", "<up>"]
|
||||
|
||||
# the key binding to jump to the first slide.
|
||||
first_slide: ["gg"]
|
||||
|
||||
# the key binding to jump to the last slide.
|
||||
last_slide: ["G"]
|
||||
|
||||
# the key binding to jump to a specific slide.
|
||||
go_to_slide: ["<number>G"]
|
||||
|
||||
# the key binding to execute a piece of shell code.
|
||||
execute_code: ["<c-e>"]
|
||||
|
||||
# the key binding to reload the presentation.
|
||||
reload: ["<c-r>"]
|
||||
|
||||
# the key binding to toggle the slide index modal.
|
||||
toggle_slide_index: ["<c-p>"]
|
||||
|
||||
# the key binding to toggle the key bindings modal.
|
||||
toggle_bindings: ["?"]
|
||||
|
||||
# the key binding to close the currently open modal.
|
||||
close_modal: ["<esc>"]
|
||||
|
||||
# the key binding to close the application.
|
||||
exit: ["<c-c>"]
|
||||
```
|
||||
|
||||
You can choose to override any of them. Keep in mind these are overrides so if for example you change `next`, the
|
||||
default won't apply anymore and only what you've defined will be used.
|
||||
|
||||
## Snippet configurations
|
||||
|
||||
### Snippet execution
|
||||
|
||||
Snippet execution is disabled by default for security reasons. Besides passing in the `-x` command line parameter every
|
||||
time you run _presenterm_, you can also configure this globally for all presentations by setting:
|
||||
|
||||
```yaml
|
||||
snippet:
|
||||
exec:
|
||||
enable: true
|
||||
```
|
||||
|
||||
**Use this at your own risk**, especially if you're running someone else's presentations!
|
||||
|
||||
### Custom snippet executors
|
||||
|
||||
If _presenterm_ doesn't support executing code snippets for your language of choice, please [create an
|
||||
issue](https://github.com/mfontanini/presenterm/issues/new)! Alternatively, you can configure this locally yourself by
|
||||
setting:
|
||||
|
||||
```yaml
|
||||
snippet:
|
||||
exec:
|
||||
custom:
|
||||
# The keys should be the language identifier you'd use in a code block.
|
||||
c++:
|
||||
# The name of the file that will be created with your snippet's contents.
|
||||
filename: "snippet.cpp"
|
||||
|
||||
# A list of environment variables that should be set before building/running your code.
|
||||
environment:
|
||||
MY_FAVORITE_ENVIRONMENT_VAR: foo
|
||||
|
||||
# A list of commands that will be ran one by one in the same directory as the snippet is in.
|
||||
commands:
|
||||
# Compile if first
|
||||
- ["g++", "-std=c++20", "snippet.cpp", "-o", "snippet"]
|
||||
# Now run it
|
||||
- ["./snippet"]
|
||||
```
|
||||
|
||||
The output of all commands will be included in the code snippet execution output so if a command (like the `g++`
|
||||
invocation) was to emit any output, make sure to use whatever flags are needed to mute its output.
|
||||
|
||||
Also note that you can override built-in executors in case you want to run them differently (e.g. use `c++23` in the
|
||||
example above).
|
||||
|
||||
See more examples in the [executors.yaml](https://github.com/mfontanini/presenterm/blob/master/executors.yaml) file
|
||||
which defines all of the built-in executors.
|
||||
|
||||
### Snippet rendering threads
|
||||
|
||||
Because some `+render` code blocks can take some time to be rendered into an image, especially if you're using
|
||||
[mermaid](https://mermaid.js.org/) charts, this is run asychronously. The number of threads used to render these, which
|
||||
defaults to 2, can be configured by setting:
|
||||
|
||||
```yaml
|
||||
snippet:
|
||||
render:
|
||||
threads: 2
|
||||
```
|
||||
|
||||
### Mermaid scaling
|
||||
|
||||
[mermaid](https://mermaid.js.org/) graphs will use a default scaling of `2` when invoking the mermaid CLI. If you'd like
|
||||
to change this use:
|
||||
|
||||
|
||||
```yaml
|
||||
mermaid:
|
||||
scale: 2
|
||||
```
|
||||
|
@ -1,31 +0,0 @@
|
||||
## PDF export
|
||||
|
||||
Presentations can be converted into PDF by using a [helper tool](https://github.com/mfontanini/presenterm-export). You
|
||||
can install it by running:
|
||||
|
||||
```shell
|
||||
pip install presenterm-export
|
||||
```
|
||||
|
||||
> **Note**: make sure that `presenterm-export` works by running `presenterm-export --version` before attempting to
|
||||
> generate a PDF file. If you get errors related to _weasyprint_, follow their [installation
|
||||
> instructions](https://doc.courtbouillon.org/weasyprint/stable/first_steps.html) to ensure you meet all of their
|
||||
> dependencies. This has otherwise caused issues in macOS.
|
||||
|
||||
The only external dependency you'll need is [tmux](https://github.com/tmux/tmux/). After you've installed both of these,
|
||||
simply run _presenterm_ with the `--export-pdf` parameter to generate the output PDF:
|
||||
|
||||
```shell
|
||||
presenterm --export-pdf examples/demo.md
|
||||
```
|
||||
|
||||
The output PDF will be placed in `examples/demo.pdf`. The size of each page will depend on the size of your terminal so
|
||||
make sure to adjust accordingly before running the command above.
|
||||
|
||||
> Note: if you're using a separate virtual env to install _presenterm-export_ just make sure you activate it before
|
||||
> running _presenterm_ with the `--export-pdf` parameter.
|
||||
|
||||
### How it works
|
||||
|
||||
The conversion into PDF format is pretty convoluted. If you'd like to learn more visit
|
||||
[presenterm-export](https://github.com/mfontanini/presenterm-export)'s repo.
|
@ -1,41 +1,41 @@
|
||||
## Installation
|
||||
# Installing _presenterm_
|
||||
|
||||
_presenterm_ works on Linux, macOS, and Windows and can be installed in different ways:
|
||||
|
||||
### Pre-built binaries (recommended)
|
||||
## Pre-built binaries (recommended)
|
||||
|
||||
The recommended way to install _presenterm_ is to download the latest pre-built version for
|
||||
your system from the [releases](https://github.com/mfontanini/presenterm/releases) page.
|
||||
|
||||
### Install via cargo
|
||||
## Install via cargo
|
||||
|
||||
Alternatively, download [rust](https://www.rust-lang.org/) and run:
|
||||
|
||||
```shell
|
||||
cargo install presenterm
|
||||
```bash
|
||||
cargo install --locked presenterm
|
||||
```
|
||||
|
||||
### Latest unreleased version
|
||||
## Latest unreleased version
|
||||
|
||||
To install from the latest source code run:
|
||||
|
||||
```shell
|
||||
```bash
|
||||
cargo install --git https://github.com/mfontanini/presenterm
|
||||
```
|
||||
|
||||
### macOS
|
||||
## macOS
|
||||
|
||||
Install the latest version in macOS via [brew](https://formulae.brew.sh/formula/presenterm) by running:
|
||||
|
||||
```shell
|
||||
```bash
|
||||
brew install presenterm
|
||||
```
|
||||
|
||||
### Nix
|
||||
## Nix
|
||||
|
||||
To install _presenterm_ using the Nix package manager run:
|
||||
|
||||
```shell
|
||||
```bash
|
||||
nix-env -iA nixos.presenterm # for nixos
|
||||
nix-env -iA nixpkgs.presenterm # for non-nixos
|
||||
```
|
||||
@ -56,13 +56,13 @@ nix run github:mfontanini/presenterm # to run from github repo
|
||||
```
|
||||
|
||||
For more information see
|
||||
[nixpkgs](https://search.nixos.org/packages?channel=unstable&show=presenterm&from=0&size=50&sort=relevance&type=packages&query=presenterm)
|
||||
[nixpkgs](https://search.nixos.org/packages?channel=unstable&show=presenterm&from=0&size=50&sort=relevance&type=packages&query=presenterm).
|
||||
|
||||
### Arch Linux
|
||||
## Arch Linux
|
||||
|
||||
_presenterm_ is available in the [official repositories](https://archlinux.org/packages/extra/x86_64/presenterm/). You can use [pacman](https://wiki.archlinux.org/title/pacman) to install as follows:
|
||||
|
||||
```shell
|
||||
```bash
|
||||
pacman -S presenterm
|
||||
```
|
||||
|
||||
@ -70,17 +70,17 @@ pacman -S presenterm
|
||||
|
||||
Alternatively, you can use any AUR helper to install the upstream binaries:
|
||||
|
||||
```shell
|
||||
```bash
|
||||
paru/yay -S presenterm-bin
|
||||
```
|
||||
|
||||
#### Building from git
|
||||
|
||||
```shell
|
||||
```bash
|
||||
paru/yay -S presenterm-git
|
||||
```
|
||||
|
||||
### Windows
|
||||
## Windows
|
||||
|
||||
Install the latest version in Scoop via [Scoop](https://scoop.sh/#/apps?q=presenterm&id=a462290f824b50f180afbaa6d8c7c1e6e0952e3a) by running:
|
||||
|
@ -1,10 +1,10 @@
|
||||
## presenterm
|
||||
# presenterm
|
||||
|
||||
[presenterm][github] lets you create presentations in markdown format and run them from your terminal, with support for
|
||||
image and animated gif support, highly customizable themes, code highlighting, exporting presentations into PDF format,
|
||||
and plenty of other features.
|
||||
|
||||
### Demo
|
||||
## Demo
|
||||
|
||||
This is how the [demo presentation][demo-source] looks like:
|
||||
|
||||
|
363
docs/theme/catppuccin-admonish.css
vendored
363
docs/theme/catppuccin-admonish.css
vendored
@ -1,363 +0,0 @@
|
||||
.mocha :is(.admonition):is(.admonish-hint, .admonish-important, .admonish-tip) {
|
||||
border-color: #f9e2af;
|
||||
}
|
||||
.mocha :is(.admonish-hint, .admonish-important, .admonish-tip) > :is(.admonition-title, summary.admonition-title) {
|
||||
background-color: rgba(249, 226, 175, 0.2);
|
||||
}
|
||||
.mocha :is(.admonish-hint, .admonish-important, .admonish-tip) > :is(.admonition-title, summary.admonition-title)::before {
|
||||
background-color: #f9e2af;
|
||||
}
|
||||
.mocha :is(.admonition):is(.admonish-abstract, .admonish-summary, .admonish-tldr) {
|
||||
border-color: #f2cdcd;
|
||||
}
|
||||
.mocha :is(.admonish-abstract, .admonish-summary, .admonish-tldr) > :is(.admonition-title, summary.admonition-title) {
|
||||
background-color: rgba(242, 205, 205, 0.2);
|
||||
}
|
||||
.mocha :is(.admonish-abstract, .admonish-summary, .admonish-tldr) > :is(.admonition-title, summary.admonition-title)::before {
|
||||
background-color: #f2cdcd;
|
||||
}
|
||||
.mocha :is(.admonition):is(.admonish-example) {
|
||||
border-color: #cba6f7;
|
||||
}
|
||||
.mocha :is(.admonish-example) > :is(.admonition-title, summary.admonition-title) {
|
||||
background-color: rgba(203, 166, 247, 0.2);
|
||||
}
|
||||
.mocha :is(.admonish-example) > :is(.admonition-title, summary.admonition-title)::before {
|
||||
background-color: #cba6f7;
|
||||
}
|
||||
.mocha :is(.admonition):is(.admonish-info, .admonish-todo) {
|
||||
border-color: #89dceb;
|
||||
}
|
||||
.mocha :is(.admonish-info, .admonish-todo) > :is(.admonition-title, summary.admonition-title) {
|
||||
background-color: rgba(137, 220, 235, 0.2);
|
||||
}
|
||||
.mocha :is(.admonish-info, .admonish-todo) > :is(.admonition-title, summary.admonition-title)::before {
|
||||
background-color: #89dceb;
|
||||
}
|
||||
.mocha :is(.admonition):is(.admonish-check, .admonish-done, .admonish-success) {
|
||||
border-color: #a6e3a1;
|
||||
}
|
||||
.mocha :is(.admonish-check, .admonish-done, .admonish-success) > :is(.admonition-title, summary.admonition-title) {
|
||||
background-color: rgba(166, 227, 161, 0.2);
|
||||
}
|
||||
.mocha :is(.admonish-check, .admonish-done, .admonish-success) > :is(.admonition-title, summary.admonition-title)::before {
|
||||
background-color: #a6e3a1;
|
||||
}
|
||||
.mocha :is(.admonition):is(.admonish-note) {
|
||||
border-color: #89b4fa;
|
||||
}
|
||||
.mocha :is(.admonish-note) > :is(.admonition-title, summary.admonition-title) {
|
||||
background-color: rgba(137, 180, 250, 0.2);
|
||||
}
|
||||
.mocha :is(.admonish-note) > :is(.admonition-title, summary.admonition-title)::before {
|
||||
background-color: #89b4fa;
|
||||
}
|
||||
.mocha :is(.admonition):is(.admonish-attention, .admonish-caution, .admonish-warning) {
|
||||
border-color: #fab387;
|
||||
}
|
||||
.mocha :is(.admonish-attention, .admonish-caution, .admonish-warning) > :is(.admonition-title, summary.admonition-title) {
|
||||
background-color: rgba(250, 179, 135, 0.2);
|
||||
}
|
||||
.mocha :is(.admonish-attention, .admonish-caution, .admonish-warning) > :is(.admonition-title, summary.admonition-title)::before {
|
||||
background-color: #fab387;
|
||||
}
|
||||
.mocha :is(.admonition):is(.admonish-faq, .admonish-help, .admonish-question) {
|
||||
border-color: #94e2d5;
|
||||
}
|
||||
.mocha :is(.admonish-faq, .admonish-help, .admonish-question) > :is(.admonition-title, summary.admonition-title) {
|
||||
background-color: rgba(148, 226, 213, 0.2);
|
||||
}
|
||||
.mocha :is(.admonish-faq, .admonish-help, .admonish-question) > :is(.admonition-title, summary.admonition-title)::before {
|
||||
background-color: #94e2d5;
|
||||
}
|
||||
.mocha :is(.admonition):is(.admonish-bug, .admonish-danger, .admonish-error, .admonish-fail, .admonish-failure, .admonish-missing) {
|
||||
border-color: #f38ba8;
|
||||
}
|
||||
.mocha :is(.admonish-bug, .admonish-danger, .admonish-error, .admonish-fail, .admonish-failure, .admonish-missing) > :is(.admonition-title, summary.admonition-title) {
|
||||
background-color: rgba(243, 139, 168, 0.2);
|
||||
}
|
||||
.mocha :is(.admonish-bug, .admonish-danger, .admonish-error, .admonish-fail, .admonish-failure, .admonish-missing) > :is(.admonition-title, summary.admonition-title)::before {
|
||||
background-color: #f38ba8;
|
||||
}
|
||||
.mocha :is(.admonition):is(.admonish-cite, .admonish-quote) {
|
||||
border-color: #f5c2e7;
|
||||
}
|
||||
.mocha :is(.admonish-cite, .admonish-quote) > :is(.admonition-title, summary.admonition-title) {
|
||||
background-color: rgba(245, 194, 231, 0.2);
|
||||
}
|
||||
.mocha :is(.admonish-cite, .admonish-quote) > :is(.admonition-title, summary.admonition-title)::before {
|
||||
background-color: #f5c2e7;
|
||||
}
|
||||
|
||||
.macchiato :is(.admonition):is(.admonish-hint, .admonish-important, .admonish-tip) {
|
||||
border-color: #eed49f;
|
||||
}
|
||||
.macchiato :is(.admonish-hint, .admonish-important, .admonish-tip) > :is(.admonition-title, summary.admonition-title) {
|
||||
background-color: rgba(238, 212, 159, 0.2);
|
||||
}
|
||||
.macchiato :is(.admonish-hint, .admonish-important, .admonish-tip) > :is(.admonition-title, summary.admonition-title)::before {
|
||||
background-color: #eed49f;
|
||||
}
|
||||
.macchiato :is(.admonition):is(.admonish-abstract, .admonish-summary, .admonish-tldr) {
|
||||
border-color: #f0c6c6;
|
||||
}
|
||||
.macchiato :is(.admonish-abstract, .admonish-summary, .admonish-tldr) > :is(.admonition-title, summary.admonition-title) {
|
||||
background-color: rgba(240, 198, 198, 0.2);
|
||||
}
|
||||
.macchiato :is(.admonish-abstract, .admonish-summary, .admonish-tldr) > :is(.admonition-title, summary.admonition-title)::before {
|
||||
background-color: #f0c6c6;
|
||||
}
|
||||
.macchiato :is(.admonition):is(.admonish-example) {
|
||||
border-color: #c6a0f6;
|
||||
}
|
||||
.macchiato :is(.admonish-example) > :is(.admonition-title, summary.admonition-title) {
|
||||
background-color: rgba(198, 160, 246, 0.2);
|
||||
}
|
||||
.macchiato :is(.admonish-example) > :is(.admonition-title, summary.admonition-title)::before {
|
||||
background-color: #c6a0f6;
|
||||
}
|
||||
.macchiato :is(.admonition):is(.admonish-info, .admonish-todo) {
|
||||
border-color: #91d7e3;
|
||||
}
|
||||
.macchiato :is(.admonish-info, .admonish-todo) > :is(.admonition-title, summary.admonition-title) {
|
||||
background-color: rgba(145, 215, 227, 0.2);
|
||||
}
|
||||
.macchiato :is(.admonish-info, .admonish-todo) > :is(.admonition-title, summary.admonition-title)::before {
|
||||
background-color: #91d7e3;
|
||||
}
|
||||
.macchiato :is(.admonition):is(.admonish-check, .admonish-done, .admonish-success) {
|
||||
border-color: #a6da95;
|
||||
}
|
||||
.macchiato :is(.admonish-check, .admonish-done, .admonish-success) > :is(.admonition-title, summary.admonition-title) {
|
||||
background-color: rgba(166, 218, 149, 0.2);
|
||||
}
|
||||
.macchiato :is(.admonish-check, .admonish-done, .admonish-success) > :is(.admonition-title, summary.admonition-title)::before {
|
||||
background-color: #a6da95;
|
||||
}
|
||||
.macchiato :is(.admonition):is(.admonish-note) {
|
||||
border-color: #8aadf4;
|
||||
}
|
||||
.macchiato :is(.admonish-note) > :is(.admonition-title, summary.admonition-title) {
|
||||
background-color: rgba(138, 173, 244, 0.2);
|
||||
}
|
||||
.macchiato :is(.admonish-note) > :is(.admonition-title, summary.admonition-title)::before {
|
||||
background-color: #8aadf4;
|
||||
}
|
||||
.macchiato :is(.admonition):is(.admonish-attention, .admonish-caution, .admonish-warning) {
|
||||
border-color: #f5a97f;
|
||||
}
|
||||
.macchiato :is(.admonish-attention, .admonish-caution, .admonish-warning) > :is(.admonition-title, summary.admonition-title) {
|
||||
background-color: rgba(245, 169, 127, 0.2);
|
||||
}
|
||||
.macchiato :is(.admonish-attention, .admonish-caution, .admonish-warning) > :is(.admonition-title, summary.admonition-title)::before {
|
||||
background-color: #f5a97f;
|
||||
}
|
||||
.macchiato :is(.admonition):is(.admonish-faq, .admonish-help, .admonish-question) {
|
||||
border-color: #8bd5ca;
|
||||
}
|
||||
.macchiato :is(.admonish-faq, .admonish-help, .admonish-question) > :is(.admonition-title, summary.admonition-title) {
|
||||
background-color: rgba(139, 213, 202, 0.2);
|
||||
}
|
||||
.macchiato :is(.admonish-faq, .admonish-help, .admonish-question) > :is(.admonition-title, summary.admonition-title)::before {
|
||||
background-color: #8bd5ca;
|
||||
}
|
||||
.macchiato :is(.admonition):is(.admonish-bug, .admonish-danger, .admonish-error, .admonish-fail, .admonish-failure, .admonish-missing) {
|
||||
border-color: #ed8796;
|
||||
}
|
||||
.macchiato :is(.admonish-bug, .admonish-danger, .admonish-error, .admonish-fail, .admonish-failure, .admonish-missing) > :is(.admonition-title, summary.admonition-title) {
|
||||
background-color: rgba(237, 135, 150, 0.2);
|
||||
}
|
||||
.macchiato :is(.admonish-bug, .admonish-danger, .admonish-error, .admonish-fail, .admonish-failure, .admonish-missing) > :is(.admonition-title, summary.admonition-title)::before {
|
||||
background-color: #ed8796;
|
||||
}
|
||||
.macchiato :is(.admonition):is(.admonish-cite, .admonish-quote) {
|
||||
border-color: #f5bde6;
|
||||
}
|
||||
.macchiato :is(.admonish-cite, .admonish-quote) > :is(.admonition-title, summary.admonition-title) {
|
||||
background-color: rgba(245, 189, 230, 0.2);
|
||||
}
|
||||
.macchiato :is(.admonish-cite, .admonish-quote) > :is(.admonition-title, summary.admonition-title)::before {
|
||||
background-color: #f5bde6;
|
||||
}
|
||||
|
||||
.frappe :is(.admonition):is(.admonish-hint, .admonish-important, .admonish-tip) {
|
||||
border-color: #e5c890;
|
||||
}
|
||||
.frappe :is(.admonish-hint, .admonish-important, .admonish-tip) > :is(.admonition-title, summary.admonition-title) {
|
||||
background-color: rgba(229, 200, 144, 0.2);
|
||||
}
|
||||
.frappe :is(.admonish-hint, .admonish-important, .admonish-tip) > :is(.admonition-title, summary.admonition-title)::before {
|
||||
background-color: #e5c890;
|
||||
}
|
||||
.frappe :is(.admonition):is(.admonish-abstract, .admonish-summary, .admonish-tldr) {
|
||||
border-color: #eebebe;
|
||||
}
|
||||
.frappe :is(.admonish-abstract, .admonish-summary, .admonish-tldr) > :is(.admonition-title, summary.admonition-title) {
|
||||
background-color: rgba(238, 190, 190, 0.2);
|
||||
}
|
||||
.frappe :is(.admonish-abstract, .admonish-summary, .admonish-tldr) > :is(.admonition-title, summary.admonition-title)::before {
|
||||
background-color: #eebebe;
|
||||
}
|
||||
.frappe :is(.admonition):is(.admonish-example) {
|
||||
border-color: #ca9ee6;
|
||||
}
|
||||
.frappe :is(.admonish-example) > :is(.admonition-title, summary.admonition-title) {
|
||||
background-color: rgba(202, 158, 230, 0.2);
|
||||
}
|
||||
.frappe :is(.admonish-example) > :is(.admonition-title, summary.admonition-title)::before {
|
||||
background-color: #ca9ee6;
|
||||
}
|
||||
.frappe :is(.admonition):is(.admonish-info, .admonish-todo) {
|
||||
border-color: #99d1db;
|
||||
}
|
||||
.frappe :is(.admonish-info, .admonish-todo) > :is(.admonition-title, summary.admonition-title) {
|
||||
background-color: rgba(153, 209, 219, 0.2);
|
||||
}
|
||||
.frappe :is(.admonish-info, .admonish-todo) > :is(.admonition-title, summary.admonition-title)::before {
|
||||
background-color: #99d1db;
|
||||
}
|
||||
.frappe :is(.admonition):is(.admonish-check, .admonish-done, .admonish-success) {
|
||||
border-color: #a6d189;
|
||||
}
|
||||
.frappe :is(.admonish-check, .admonish-done, .admonish-success) > :is(.admonition-title, summary.admonition-title) {
|
||||
background-color: rgba(166, 209, 137, 0.2);
|
||||
}
|
||||
.frappe :is(.admonish-check, .admonish-done, .admonish-success) > :is(.admonition-title, summary.admonition-title)::before {
|
||||
background-color: #a6d189;
|
||||
}
|
||||
.frappe :is(.admonition):is(.admonish-note) {
|
||||
border-color: #8caaee;
|
||||
}
|
||||
.frappe :is(.admonish-note) > :is(.admonition-title, summary.admonition-title) {
|
||||
background-color: rgba(140, 170, 238, 0.2);
|
||||
}
|
||||
.frappe :is(.admonish-note) > :is(.admonition-title, summary.admonition-title)::before {
|
||||
background-color: #8caaee;
|
||||
}
|
||||
.frappe :is(.admonition):is(.admonish-attention, .admonish-caution, .admonish-warning) {
|
||||
border-color: #ef9f76;
|
||||
}
|
||||
.frappe :is(.admonish-attention, .admonish-caution, .admonish-warning) > :is(.admonition-title, summary.admonition-title) {
|
||||
background-color: rgba(239, 159, 118, 0.2);
|
||||
}
|
||||
.frappe :is(.admonish-attention, .admonish-caution, .admonish-warning) > :is(.admonition-title, summary.admonition-title)::before {
|
||||
background-color: #ef9f76;
|
||||
}
|
||||
.frappe :is(.admonition):is(.admonish-faq, .admonish-help, .admonish-question) {
|
||||
border-color: #81c8be;
|
||||
}
|
||||
.frappe :is(.admonish-faq, .admonish-help, .admonish-question) > :is(.admonition-title, summary.admonition-title) {
|
||||
background-color: rgba(129, 200, 190, 0.2);
|
||||
}
|
||||
.frappe :is(.admonish-faq, .admonish-help, .admonish-question) > :is(.admonition-title, summary.admonition-title)::before {
|
||||
background-color: #81c8be;
|
||||
}
|
||||
.frappe :is(.admonition):is(.admonish-bug, .admonish-danger, .admonish-error, .admonish-fail, .admonish-failure, .admonish-missing) {
|
||||
border-color: #e78284;
|
||||
}
|
||||
.frappe :is(.admonish-bug, .admonish-danger, .admonish-error, .admonish-fail, .admonish-failure, .admonish-missing) > :is(.admonition-title, summary.admonition-title) {
|
||||
background-color: rgba(231, 130, 132, 0.2);
|
||||
}
|
||||
.frappe :is(.admonish-bug, .admonish-danger, .admonish-error, .admonish-fail, .admonish-failure, .admonish-missing) > :is(.admonition-title, summary.admonition-title)::before {
|
||||
background-color: #e78284;
|
||||
}
|
||||
.frappe :is(.admonition):is(.admonish-cite, .admonish-quote) {
|
||||
border-color: #f4b8e4;
|
||||
}
|
||||
.frappe :is(.admonish-cite, .admonish-quote) > :is(.admonition-title, summary.admonition-title) {
|
||||
background-color: rgba(244, 184, 228, 0.2);
|
||||
}
|
||||
.frappe :is(.admonish-cite, .admonish-quote) > :is(.admonition-title, summary.admonition-title)::before {
|
||||
background-color: #f4b8e4;
|
||||
}
|
||||
|
||||
.latte :is(.admonition):is(.admonish-hint, .admonish-important, .admonish-tip) {
|
||||
border-color: #df8e1d;
|
||||
}
|
||||
.latte :is(.admonish-hint, .admonish-important, .admonish-tip) > :is(.admonition-title, summary.admonition-title) {
|
||||
background-color: rgba(223, 142, 29, 0.2);
|
||||
}
|
||||
.latte :is(.admonish-hint, .admonish-important, .admonish-tip) > :is(.admonition-title, summary.admonition-title)::before {
|
||||
background-color: #df8e1d;
|
||||
}
|
||||
.latte :is(.admonition):is(.admonish-abstract, .admonish-summary, .admonish-tldr) {
|
||||
border-color: #dd7878;
|
||||
}
|
||||
.latte :is(.admonish-abstract, .admonish-summary, .admonish-tldr) > :is(.admonition-title, summary.admonition-title) {
|
||||
background-color: rgba(221, 120, 120, 0.2);
|
||||
}
|
||||
.latte :is(.admonish-abstract, .admonish-summary, .admonish-tldr) > :is(.admonition-title, summary.admonition-title)::before {
|
||||
background-color: #dd7878;
|
||||
}
|
||||
.latte :is(.admonition):is(.admonish-example) {
|
||||
border-color: #8839ef;
|
||||
}
|
||||
.latte :is(.admonish-example) > :is(.admonition-title, summary.admonition-title) {
|
||||
background-color: rgba(136, 57, 239, 0.2);
|
||||
}
|
||||
.latte :is(.admonish-example) > :is(.admonition-title, summary.admonition-title)::before {
|
||||
background-color: #8839ef;
|
||||
}
|
||||
.latte :is(.admonition):is(.admonish-info, .admonish-todo) {
|
||||
border-color: #04a5e5;
|
||||
}
|
||||
.latte :is(.admonish-info, .admonish-todo) > :is(.admonition-title, summary.admonition-title) {
|
||||
background-color: rgba(4, 165, 229, 0.2);
|
||||
}
|
||||
.latte :is(.admonish-info, .admonish-todo) > :is(.admonition-title, summary.admonition-title)::before {
|
||||
background-color: #04a5e5;
|
||||
}
|
||||
.latte :is(.admonition):is(.admonish-check, .admonish-done, .admonish-success) {
|
||||
border-color: #40a02b;
|
||||
}
|
||||
.latte :is(.admonish-check, .admonish-done, .admonish-success) > :is(.admonition-title, summary.admonition-title) {
|
||||
background-color: rgba(64, 160, 43, 0.2);
|
||||
}
|
||||
.latte :is(.admonish-check, .admonish-done, .admonish-success) > :is(.admonition-title, summary.admonition-title)::before {
|
||||
background-color: #40a02b;
|
||||
}
|
||||
.latte :is(.admonition):is(.admonish-note) {
|
||||
border-color: #1e66f5;
|
||||
}
|
||||
.latte :is(.admonish-note) > :is(.admonition-title, summary.admonition-title) {
|
||||
background-color: rgba(30, 102, 245, 0.2);
|
||||
}
|
||||
.latte :is(.admonish-note) > :is(.admonition-title, summary.admonition-title)::before {
|
||||
background-color: #1e66f5;
|
||||
}
|
||||
.latte :is(.admonition):is(.admonish-attention, .admonish-caution, .admonish-warning) {
|
||||
border-color: #fe640b;
|
||||
}
|
||||
.latte :is(.admonish-attention, .admonish-caution, .admonish-warning) > :is(.admonition-title, summary.admonition-title) {
|
||||
background-color: rgba(254, 100, 11, 0.2);
|
||||
}
|
||||
.latte :is(.admonish-attention, .admonish-caution, .admonish-warning) > :is(.admonition-title, summary.admonition-title)::before {
|
||||
background-color: #fe640b;
|
||||
}
|
||||
.latte :is(.admonition):is(.admonish-faq, .admonish-help, .admonish-question) {
|
||||
border-color: #179299;
|
||||
}
|
||||
.latte :is(.admonish-faq, .admonish-help, .admonish-question) > :is(.admonition-title, summary.admonition-title) {
|
||||
background-color: rgba(23, 146, 153, 0.2);
|
||||
}
|
||||
.latte :is(.admonish-faq, .admonish-help, .admonish-question) > :is(.admonition-title, summary.admonition-title)::before {
|
||||
background-color: #179299;
|
||||
}
|
||||
.latte :is(.admonition):is(.admonish-bug, .admonish-danger, .admonish-error, .admonish-fail, .admonish-failure, .admonish-missing) {
|
||||
border-color: #d20f39;
|
||||
}
|
||||
.latte :is(.admonish-bug, .admonish-danger, .admonish-error, .admonish-fail, .admonish-failure, .admonish-missing) > :is(.admonition-title, summary.admonition-title) {
|
||||
background-color: rgba(210, 15, 57, 0.2);
|
||||
}
|
||||
.latte :is(.admonish-bug, .admonish-danger, .admonish-error, .admonish-fail, .admonish-failure, .admonish-missing) > :is(.admonition-title, summary.admonition-title)::before {
|
||||
background-color: #d20f39;
|
||||
}
|
||||
.latte :is(.admonition):is(.admonish-cite, .admonish-quote) {
|
||||
border-color: #ea76cb;
|
||||
}
|
||||
.latte :is(.admonish-cite, .admonish-quote) > :is(.admonition-title, summary.admonition-title) {
|
||||
background-color: rgba(234, 118, 203, 0.2);
|
||||
}
|
||||
.latte :is(.admonish-cite, .admonish-quote) > :is(.admonition-title, summary.admonition-title)::before {
|
||||
background-color: #ea76cb;
|
||||
}
|
787
docs/theme/catppuccin.css
vendored
787
docs/theme/catppuccin.css
vendored
@ -1,787 +0,0 @@
|
||||
.mocha.hljs {
|
||||
color: #cdd6f4;
|
||||
background: #1e1e2e;
|
||||
}
|
||||
.mocha .hljs-keyword {
|
||||
color: #cba6f7;
|
||||
}
|
||||
.mocha .hljs-built_in {
|
||||
color: #f38ba8;
|
||||
}
|
||||
.mocha .hljs-type {
|
||||
color: #f9e2af;
|
||||
}
|
||||
.mocha .hljs-literal {
|
||||
color: #fab387;
|
||||
}
|
||||
.mocha .hljs-number {
|
||||
color: #fab387;
|
||||
}
|
||||
.mocha .hljs-operator {
|
||||
color: #94e2d5;
|
||||
}
|
||||
.mocha .hljs-punctuation {
|
||||
color: #bac2de;
|
||||
}
|
||||
.mocha .hljs-property {
|
||||
color: #94e2d5;
|
||||
}
|
||||
.mocha .hljs-regexp {
|
||||
color: #f5c2e7;
|
||||
}
|
||||
.mocha .hljs-string {
|
||||
color: #a6e3a1;
|
||||
}
|
||||
.mocha .hljs-char.escape_ {
|
||||
color: #a6e3a1;
|
||||
}
|
||||
.mocha .hljs-subst {
|
||||
color: #a6adc8;
|
||||
}
|
||||
.mocha .hljs-symbol {
|
||||
color: #f2cdcd;
|
||||
}
|
||||
.mocha .hljs-variable {
|
||||
color: #cba6f7;
|
||||
}
|
||||
.mocha .hljs-variable.language_ {
|
||||
color: #cba6f7;
|
||||
}
|
||||
.mocha .hljs-variable.constant_ {
|
||||
color: #fab387;
|
||||
}
|
||||
.mocha .hljs-title {
|
||||
color: #89b4fa;
|
||||
}
|
||||
.mocha .hljs-title.class_ {
|
||||
color: #f9e2af;
|
||||
}
|
||||
.mocha .hljs-title.function_ {
|
||||
color: #89b4fa;
|
||||
}
|
||||
.mocha .hljs-params {
|
||||
color: #cdd6f4;
|
||||
}
|
||||
.mocha .hljs-comment {
|
||||
color: #585b70;
|
||||
}
|
||||
.mocha .hljs-doctag {
|
||||
color: #f38ba8;
|
||||
}
|
||||
.mocha .hljs-meta {
|
||||
color: #fab387;
|
||||
}
|
||||
.mocha .hljs-section {
|
||||
color: #89b4fa;
|
||||
}
|
||||
.mocha .hljs-tag {
|
||||
color: #a6adc8;
|
||||
}
|
||||
.mocha .hljs-name {
|
||||
color: #cba6f7;
|
||||
}
|
||||
.mocha .hljs-attr {
|
||||
color: #89b4fa;
|
||||
}
|
||||
.mocha .hljs-attribute {
|
||||
color: #a6e3a1;
|
||||
}
|
||||
.mocha .hljs-bullet {
|
||||
color: #94e2d5;
|
||||
}
|
||||
.mocha .hljs-code {
|
||||
color: #a6e3a1;
|
||||
}
|
||||
.mocha .hljs-emphasis {
|
||||
color: #f38ba8;
|
||||
font-style: italic;
|
||||
}
|
||||
.mocha .hljs-strong {
|
||||
color: #f38ba8;
|
||||
font-weight: bold;
|
||||
}
|
||||
.mocha .hljs-formula {
|
||||
color: #94e2d5;
|
||||
}
|
||||
.mocha .hljs-link {
|
||||
color: #74c7ec;
|
||||
font-style: italic;
|
||||
}
|
||||
.mocha .hljs-quote {
|
||||
color: #a6e3a1;
|
||||
font-style: italic;
|
||||
}
|
||||
.mocha .hljs-selector-tag {
|
||||
color: #f9e2af;
|
||||
}
|
||||
.mocha .hljs-selector-id {
|
||||
color: #89b4fa;
|
||||
}
|
||||
.mocha .hljs-selector-class {
|
||||
color: #94e2d5;
|
||||
}
|
||||
.mocha .hljs-selector-attr {
|
||||
color: #cba6f7;
|
||||
}
|
||||
.mocha .hljs-selector-pseudo {
|
||||
color: #94e2d5;
|
||||
}
|
||||
.mocha .hljs-template-tag {
|
||||
color: #f2cdcd;
|
||||
}
|
||||
.mocha .hljs-template-variable {
|
||||
color: #f2cdcd;
|
||||
}
|
||||
.mocha .hljs-addition {
|
||||
color: #a6e3a1;
|
||||
background: rgba(166, 227, 161, 0.15);
|
||||
}
|
||||
.mocha .hljs-deletion {
|
||||
color: #f38ba8;
|
||||
background: rgba(243, 139, 168, 0.15);
|
||||
}
|
||||
.mocha code {
|
||||
color: #cdd6f4;
|
||||
background: #181825;
|
||||
}
|
||||
.mocha blockquote blockquote {
|
||||
border-top: 0.1em solid #585b70;
|
||||
border-bottom: 0.1em solid #585b70;
|
||||
}
|
||||
.mocha hr {
|
||||
color: #585b70;
|
||||
}
|
||||
.mocha del {
|
||||
color: #9399b2;
|
||||
}
|
||||
.mocha .ace_gutter {
|
||||
color: #7f849c;
|
||||
background: #181825;
|
||||
}
|
||||
.mocha .ace_gutter-active-line.ace_gutter-cell {
|
||||
color: #f5c2e7;
|
||||
background: #181825;
|
||||
}
|
||||
|
||||
.macchiato.hljs {
|
||||
color: #cad3f5;
|
||||
background: #24273a;
|
||||
}
|
||||
.macchiato .hljs-keyword {
|
||||
color: #c6a0f6;
|
||||
}
|
||||
.macchiato .hljs-built_in {
|
||||
color: #ed8796;
|
||||
}
|
||||
.macchiato .hljs-type {
|
||||
color: #eed49f;
|
||||
}
|
||||
.macchiato .hljs-literal {
|
||||
color: #f5a97f;
|
||||
}
|
||||
.macchiato .hljs-number {
|
||||
color: #f5a97f;
|
||||
}
|
||||
.macchiato .hljs-operator {
|
||||
color: #8bd5ca;
|
||||
}
|
||||
.macchiato .hljs-punctuation {
|
||||
color: #b8c0e0;
|
||||
}
|
||||
.macchiato .hljs-property {
|
||||
color: #8bd5ca;
|
||||
}
|
||||
.macchiato .hljs-regexp {
|
||||
color: #f5bde6;
|
||||
}
|
||||
.macchiato .hljs-string {
|
||||
color: #a6da95;
|
||||
}
|
||||
.macchiato .hljs-char.escape_ {
|
||||
color: #a6da95;
|
||||
}
|
||||
.macchiato .hljs-subst {
|
||||
color: #a5adcb;
|
||||
}
|
||||
.macchiato .hljs-symbol {
|
||||
color: #f0c6c6;
|
||||
}
|
||||
.macchiato .hljs-variable {
|
||||
color: #c6a0f6;
|
||||
}
|
||||
.macchiato .hljs-variable.language_ {
|
||||
color: #c6a0f6;
|
||||
}
|
||||
.macchiato .hljs-variable.constant_ {
|
||||
color: #f5a97f;
|
||||
}
|
||||
.macchiato .hljs-title {
|
||||
color: #8aadf4;
|
||||
}
|
||||
.macchiato .hljs-title.class_ {
|
||||
color: #eed49f;
|
||||
}
|
||||
.macchiato .hljs-title.function_ {
|
||||
color: #8aadf4;
|
||||
}
|
||||
.macchiato .hljs-params {
|
||||
color: #cad3f5;
|
||||
}
|
||||
.macchiato .hljs-comment {
|
||||
color: #5b6078;
|
||||
}
|
||||
.macchiato .hljs-doctag {
|
||||
color: #ed8796;
|
||||
}
|
||||
.macchiato .hljs-meta {
|
||||
color: #f5a97f;
|
||||
}
|
||||
.macchiato .hljs-section {
|
||||
color: #8aadf4;
|
||||
}
|
||||
.macchiato .hljs-tag {
|
||||
color: #a5adcb;
|
||||
}
|
||||
.macchiato .hljs-name {
|
||||
color: #c6a0f6;
|
||||
}
|
||||
.macchiato .hljs-attr {
|
||||
color: #8aadf4;
|
||||
}
|
||||
.macchiato .hljs-attribute {
|
||||
color: #a6da95;
|
||||
}
|
||||
.macchiato .hljs-bullet {
|
||||
color: #8bd5ca;
|
||||
}
|
||||
.macchiato .hljs-code {
|
||||
color: #a6da95;
|
||||
}
|
||||
.macchiato .hljs-emphasis {
|
||||
color: #ed8796;
|
||||
font-style: italic;
|
||||
}
|
||||
.macchiato .hljs-strong {
|
||||
color: #ed8796;
|
||||
font-weight: bold;
|
||||
}
|
||||
.macchiato .hljs-formula {
|
||||
color: #8bd5ca;
|
||||
}
|
||||
.macchiato .hljs-link {
|
||||
color: #7dc4e4;
|
||||
font-style: italic;
|
||||
}
|
||||
.macchiato .hljs-quote {
|
||||
color: #a6da95;
|
||||
font-style: italic;
|
||||
}
|
||||
.macchiato .hljs-selector-tag {
|
||||
color: #eed49f;
|
||||
}
|
||||
.macchiato .hljs-selector-id {
|
||||
color: #8aadf4;
|
||||
}
|
||||
.macchiato .hljs-selector-class {
|
||||
color: #8bd5ca;
|
||||
}
|
||||
.macchiato .hljs-selector-attr {
|
||||
color: #c6a0f6;
|
||||
}
|
||||
.macchiato .hljs-selector-pseudo {
|
||||
color: #8bd5ca;
|
||||
}
|
||||
.macchiato .hljs-template-tag {
|
||||
color: #f0c6c6;
|
||||
}
|
||||
.macchiato .hljs-template-variable {
|
||||
color: #f0c6c6;
|
||||
}
|
||||
.macchiato .hljs-addition {
|
||||
color: #a6da95;
|
||||
background: rgba(166, 218, 149, 0.15);
|
||||
}
|
||||
.macchiato .hljs-deletion {
|
||||
color: #ed8796;
|
||||
background: rgba(237, 135, 150, 0.15);
|
||||
}
|
||||
.macchiato code {
|
||||
color: #cad3f5;
|
||||
background: #1e2030;
|
||||
}
|
||||
.macchiato blockquote blockquote {
|
||||
border-top: 0.1em solid #5b6078;
|
||||
border-bottom: 0.1em solid #5b6078;
|
||||
}
|
||||
.macchiato hr {
|
||||
color: #5b6078;
|
||||
}
|
||||
.macchiato del {
|
||||
color: #939ab7;
|
||||
}
|
||||
.macchiato .ace_gutter {
|
||||
color: #8087a2;
|
||||
background: #1e2030;
|
||||
}
|
||||
.macchiato .ace_gutter-active-line.ace_gutter-cell {
|
||||
color: #f5bde6;
|
||||
background: #1e2030;
|
||||
}
|
||||
|
||||
.frappe.hljs {
|
||||
color: #c6d0f5;
|
||||
background: #303446;
|
||||
}
|
||||
.frappe .hljs-keyword {
|
||||
color: #ca9ee6;
|
||||
}
|
||||
.frappe .hljs-built_in {
|
||||
color: #e78284;
|
||||
}
|
||||
.frappe .hljs-type {
|
||||
color: #e5c890;
|
||||
}
|
||||
.frappe .hljs-literal {
|
||||
color: #ef9f76;
|
||||
}
|
||||
.frappe .hljs-number {
|
||||
color: #ef9f76;
|
||||
}
|
||||
.frappe .hljs-operator {
|
||||
color: #81c8be;
|
||||
}
|
||||
.frappe .hljs-punctuation {
|
||||
color: #b5bfe2;
|
||||
}
|
||||
.frappe .hljs-property {
|
||||
color: #81c8be;
|
||||
}
|
||||
.frappe .hljs-regexp {
|
||||
color: #f4b8e4;
|
||||
}
|
||||
.frappe .hljs-string {
|
||||
color: #a6d189;
|
||||
}
|
||||
.frappe .hljs-char.escape_ {
|
||||
color: #a6d189;
|
||||
}
|
||||
.frappe .hljs-subst {
|
||||
color: #a5adce;
|
||||
}
|
||||
.frappe .hljs-symbol {
|
||||
color: #eebebe;
|
||||
}
|
||||
.frappe .hljs-variable {
|
||||
color: #ca9ee6;
|
||||
}
|
||||
.frappe .hljs-variable.language_ {
|
||||
color: #ca9ee6;
|
||||
}
|
||||
.frappe .hljs-variable.constant_ {
|
||||
color: #ef9f76;
|
||||
}
|
||||
.frappe .hljs-title {
|
||||
color: #8caaee;
|
||||
}
|
||||
.frappe .hljs-title.class_ {
|
||||
color: #e5c890;
|
||||
}
|
||||
.frappe .hljs-title.function_ {
|
||||
color: #8caaee;
|
||||
}
|
||||
.frappe .hljs-params {
|
||||
color: #c6d0f5;
|
||||
}
|
||||
.frappe .hljs-comment {
|
||||
color: #626880;
|
||||
}
|
||||
.frappe .hljs-doctag {
|
||||
color: #e78284;
|
||||
}
|
||||
.frappe .hljs-meta {
|
||||
color: #ef9f76;
|
||||
}
|
||||
.frappe .hljs-section {
|
||||
color: #8caaee;
|
||||
}
|
||||
.frappe .hljs-tag {
|
||||
color: #a5adce;
|
||||
}
|
||||
.frappe .hljs-name {
|
||||
color: #ca9ee6;
|
||||
}
|
||||
.frappe .hljs-attr {
|
||||
color: #8caaee;
|
||||
}
|
||||
.frappe .hljs-attribute {
|
||||
color: #a6d189;
|
||||
}
|
||||
.frappe .hljs-bullet {
|
||||
color: #81c8be;
|
||||
}
|
||||
.frappe .hljs-code {
|
||||
color: #a6d189;
|
||||
}
|
||||
.frappe .hljs-emphasis {
|
||||
color: #e78284;
|
||||
font-style: italic;
|
||||
}
|
||||
.frappe .hljs-strong {
|
||||
color: #e78284;
|
||||
font-weight: bold;
|
||||
}
|
||||
.frappe .hljs-formula {
|
||||
color: #81c8be;
|
||||
}
|
||||
.frappe .hljs-link {
|
||||
color: #85c1dc;
|
||||
font-style: italic;
|
||||
}
|
||||
.frappe .hljs-quote {
|
||||
color: #a6d189;
|
||||
font-style: italic;
|
||||
}
|
||||
.frappe .hljs-selector-tag {
|
||||
color: #e5c890;
|
||||
}
|
||||
.frappe .hljs-selector-id {
|
||||
color: #8caaee;
|
||||
}
|
||||
.frappe .hljs-selector-class {
|
||||
color: #81c8be;
|
||||
}
|
||||
.frappe .hljs-selector-attr {
|
||||
color: #ca9ee6;
|
||||
}
|
||||
.frappe .hljs-selector-pseudo {
|
||||
color: #81c8be;
|
||||
}
|
||||
.frappe .hljs-template-tag {
|
||||
color: #eebebe;
|
||||
}
|
||||
.frappe .hljs-template-variable {
|
||||
color: #eebebe;
|
||||
}
|
||||
.frappe .hljs-addition {
|
||||
color: #a6d189;
|
||||
background: rgba(166, 209, 137, 0.15);
|
||||
}
|
||||
.frappe .hljs-deletion {
|
||||
color: #e78284;
|
||||
background: rgba(231, 130, 132, 0.15);
|
||||
}
|
||||
.frappe code {
|
||||
color: #c6d0f5;
|
||||
background: #292c3c;
|
||||
}
|
||||
.frappe blockquote blockquote {
|
||||
border-top: 0.1em solid #626880;
|
||||
border-bottom: 0.1em solid #626880;
|
||||
}
|
||||
.frappe hr {
|
||||
color: #626880;
|
||||
}
|
||||
.frappe del {
|
||||
color: #949cbb;
|
||||
}
|
||||
.frappe .ace_gutter {
|
||||
color: #838ba7;
|
||||
background: #292c3c;
|
||||
}
|
||||
.frappe .ace_gutter-active-line.ace_gutter-cell {
|
||||
color: #f4b8e4;
|
||||
background: #292c3c;
|
||||
}
|
||||
|
||||
.latte.hljs {
|
||||
color: #4c4f69;
|
||||
background: #eff1f5;
|
||||
}
|
||||
.latte .hljs-keyword {
|
||||
color: #8839ef;
|
||||
}
|
||||
.latte .hljs-built_in {
|
||||
color: #d20f39;
|
||||
}
|
||||
.latte .hljs-type {
|
||||
color: #df8e1d;
|
||||
}
|
||||
.latte .hljs-literal {
|
||||
color: #fe640b;
|
||||
}
|
||||
.latte .hljs-number {
|
||||
color: #fe640b;
|
||||
}
|
||||
.latte .hljs-operator {
|
||||
color: #179299;
|
||||
}
|
||||
.latte .hljs-punctuation {
|
||||
color: #5c5f77;
|
||||
}
|
||||
.latte .hljs-property {
|
||||
color: #179299;
|
||||
}
|
||||
.latte .hljs-regexp {
|
||||
color: #ea76cb;
|
||||
}
|
||||
.latte .hljs-string {
|
||||
color: #40a02b;
|
||||
}
|
||||
.latte .hljs-char.escape_ {
|
||||
color: #40a02b;
|
||||
}
|
||||
.latte .hljs-subst {
|
||||
color: #6c6f85;
|
||||
}
|
||||
.latte .hljs-symbol {
|
||||
color: #dd7878;
|
||||
}
|
||||
.latte .hljs-variable {
|
||||
color: #8839ef;
|
||||
}
|
||||
.latte .hljs-variable.language_ {
|
||||
color: #8839ef;
|
||||
}
|
||||
.latte .hljs-variable.constant_ {
|
||||
color: #fe640b;
|
||||
}
|
||||
.latte .hljs-title {
|
||||
color: #1e66f5;
|
||||
}
|
||||
.latte .hljs-title.class_ {
|
||||
color: #df8e1d;
|
||||
}
|
||||
.latte .hljs-title.function_ {
|
||||
color: #1e66f5;
|
||||
}
|
||||
.latte .hljs-params {
|
||||
color: #4c4f69;
|
||||
}
|
||||
.latte .hljs-comment {
|
||||
color: #acb0be;
|
||||
}
|
||||
.latte .hljs-doctag {
|
||||
color: #d20f39;
|
||||
}
|
||||
.latte .hljs-meta {
|
||||
color: #fe640b;
|
||||
}
|
||||
.latte .hljs-section {
|
||||
color: #1e66f5;
|
||||
}
|
||||
.latte .hljs-tag {
|
||||
color: #6c6f85;
|
||||
}
|
||||
.latte .hljs-name {
|
||||
color: #8839ef;
|
||||
}
|
||||
.latte .hljs-attr {
|
||||
color: #1e66f5;
|
||||
}
|
||||
.latte .hljs-attribute {
|
||||
color: #40a02b;
|
||||
}
|
||||
.latte .hljs-bullet {
|
||||
color: #179299;
|
||||
}
|
||||
.latte .hljs-code {
|
||||
color: #40a02b;
|
||||
}
|
||||
.latte .hljs-emphasis {
|
||||
color: #d20f39;
|
||||
font-style: italic;
|
||||
}
|
||||
.latte .hljs-strong {
|
||||
color: #d20f39;
|
||||
font-weight: bold;
|
||||
}
|
||||
.latte .hljs-formula {
|
||||
color: #179299;
|
||||
}
|
||||
.latte .hljs-link {
|
||||
color: #209fb5;
|
||||
font-style: italic;
|
||||
}
|
||||
.latte .hljs-quote {
|
||||
color: #40a02b;
|
||||
font-style: italic;
|
||||
}
|
||||
.latte .hljs-selector-tag {
|
||||
color: #df8e1d;
|
||||
}
|
||||
.latte .hljs-selector-id {
|
||||
color: #1e66f5;
|
||||
}
|
||||
.latte .hljs-selector-class {
|
||||
color: #179299;
|
||||
}
|
||||
.latte .hljs-selector-attr {
|
||||
color: #8839ef;
|
||||
}
|
||||
.latte .hljs-selector-pseudo {
|
||||
color: #179299;
|
||||
}
|
||||
.latte .hljs-template-tag {
|
||||
color: #dd7878;
|
||||
}
|
||||
.latte .hljs-template-variable {
|
||||
color: #dd7878;
|
||||
}
|
||||
.latte .hljs-addition {
|
||||
color: #40a02b;
|
||||
background: rgba(64, 160, 43, 0.15);
|
||||
}
|
||||
.latte .hljs-deletion {
|
||||
color: #d20f39;
|
||||
background: rgba(210, 15, 57, 0.15);
|
||||
}
|
||||
.latte code {
|
||||
color: #4c4f69;
|
||||
background: #e6e9ef;
|
||||
}
|
||||
.latte blockquote blockquote {
|
||||
border-top: 0.1em solid #acb0be;
|
||||
border-bottom: 0.1em solid #acb0be;
|
||||
}
|
||||
.latte hr {
|
||||
color: #acb0be;
|
||||
}
|
||||
.latte del {
|
||||
color: #7c7f93;
|
||||
}
|
||||
.latte .ace_gutter {
|
||||
color: #8c8fa1;
|
||||
background: #e6e9ef;
|
||||
}
|
||||
.latte .ace_gutter-active-line.ace_gutter-cell {
|
||||
color: #ea76cb;
|
||||
background: #e6e9ef;
|
||||
}
|
||||
|
||||
.mocha {
|
||||
--bg: #1e1e2e;
|
||||
--fg: #cdd6f4;
|
||||
--sidebar-bg: #181825;
|
||||
--sidebar-fg: #cdd6f4;
|
||||
--sidebar-non-existant: #6c7086;
|
||||
--sidebar-active: #89b4fa;
|
||||
--sidebar-spacer: #6c7086;
|
||||
--scrollbar: #6c7086;
|
||||
--icons: #6c7086;
|
||||
--icons-hover: #7f849c;
|
||||
--links: #89b4fa;
|
||||
--inline-code-color: #fab387;
|
||||
--theme-popup-bg: #181825;
|
||||
--theme-popup-border: #6c7086;
|
||||
--theme-hover: #6c7086;
|
||||
--quote-bg: #181825;
|
||||
--quote-border: #11111b;
|
||||
--table-border-color: #11111b;
|
||||
--table-header-bg: #181825;
|
||||
--table-alternate-bg: #181825;
|
||||
--searchbar-border-color: #11111b;
|
||||
--searchbar-bg: #181825;
|
||||
--searchbar-fg: #cdd6f4;
|
||||
--searchbar-shadow-color: #11111b;
|
||||
--searchresults-header-fg: #cdd6f4;
|
||||
--searchresults-border-color: #11111b;
|
||||
--searchresults-li-bg: #1e1e2e;
|
||||
--search-mark-bg: #fab387;
|
||||
--warning-border: #fab387;
|
||||
}
|
||||
|
||||
.macchiato {
|
||||
--bg: #24273a;
|
||||
--fg: #cad3f5;
|
||||
--sidebar-bg: #1e2030;
|
||||
--sidebar-fg: #cad3f5;
|
||||
--sidebar-non-existant: #6e738d;
|
||||
--sidebar-active: #8aadf4;
|
||||
--sidebar-spacer: #6e738d;
|
||||
--scrollbar: #6e738d;
|
||||
--icons: #6e738d;
|
||||
--icons-hover: #8087a2;
|
||||
--links: #8aadf4;
|
||||
--inline-code-color: #f5a97f;
|
||||
--theme-popup-bg: #1e2030;
|
||||
--theme-popup-border: #6e738d;
|
||||
--theme-hover: #6e738d;
|
||||
--quote-bg: #1e2030;
|
||||
--quote-border: #181926;
|
||||
--table-border-color: #181926;
|
||||
--table-header-bg: #1e2030;
|
||||
--table-alternate-bg: #1e2030;
|
||||
--searchbar-border-color: #181926;
|
||||
--searchbar-bg: #1e2030;
|
||||
--searchbar-fg: #cad3f5;
|
||||
--searchbar-shadow-color: #181926;
|
||||
--searchresults-header-fg: #cad3f5;
|
||||
--searchresults-border-color: #181926;
|
||||
--searchresults-li-bg: #24273a;
|
||||
--search-mark-bg: #f5a97f;
|
||||
--warning-border: #f5a97f;
|
||||
}
|
||||
|
||||
.frappe {
|
||||
--bg: #303446;
|
||||
--fg: #c6d0f5;
|
||||
--sidebar-bg: #292c3c;
|
||||
--sidebar-fg: #c6d0f5;
|
||||
--sidebar-non-existant: #737994;
|
||||
--sidebar-active: #8caaee;
|
||||
--sidebar-spacer: #737994;
|
||||
--scrollbar: #737994;
|
||||
--icons: #737994;
|
||||
--icons-hover: #838ba7;
|
||||
--links: #8caaee;
|
||||
--inline-code-color: #ef9f76;
|
||||
--theme-popup-bg: #292c3c;
|
||||
--theme-popup-border: #737994;
|
||||
--theme-hover: #737994;
|
||||
--quote-bg: #292c3c;
|
||||
--quote-border: #232634;
|
||||
--table-border-color: #232634;
|
||||
--table-header-bg: #292c3c;
|
||||
--table-alternate-bg: #292c3c;
|
||||
--searchbar-border-color: #232634;
|
||||
--searchbar-bg: #292c3c;
|
||||
--searchbar-fg: #c6d0f5;
|
||||
--searchbar-shadow-color: #232634;
|
||||
--searchresults-header-fg: #c6d0f5;
|
||||
--searchresults-border-color: #232634;
|
||||
--searchresults-li-bg: #303446;
|
||||
--search-mark-bg: #ef9f76;
|
||||
--warning-border: #ef9f76;
|
||||
}
|
||||
|
||||
.latte {
|
||||
--bg: #eff1f5;
|
||||
--fg: #4c4f69;
|
||||
--sidebar-bg: #e6e9ef;
|
||||
--sidebar-fg: #4c4f69;
|
||||
--sidebar-non-existant: #9ca0b0;
|
||||
--sidebar-active: #1e66f5;
|
||||
--sidebar-spacer: #9ca0b0;
|
||||
--scrollbar: #9ca0b0;
|
||||
--icons: #9ca0b0;
|
||||
--icons-hover: #8c8fa1;
|
||||
--links: #1e66f5;
|
||||
--inline-code-color: #fe640b;
|
||||
--theme-popup-bg: #e6e9ef;
|
||||
--theme-popup-border: #9ca0b0;
|
||||
--theme-hover: #9ca0b0;
|
||||
--quote-bg: #e6e9ef;
|
||||
--quote-border: #dce0e8;
|
||||
--table-border-color: #dce0e8;
|
||||
--table-header-bg: #e6e9ef;
|
||||
--table-alternate-bg: #e6e9ef;
|
||||
--searchbar-border-color: #dce0e8;
|
||||
--searchbar-bg: #e6e9ef;
|
||||
--searchbar-fg: #4c4f69;
|
||||
--searchbar-shadow-color: #dce0e8;
|
||||
--searchresults-header-fg: #4c4f69;
|
||||
--searchresults-border-color: #dce0e8;
|
||||
--searchresults-li-bg: #eff1f5;
|
||||
--search-mark-bg: #fe640b;
|
||||
--warning-border: #fe640b;
|
||||
}
|
349
docs/theme/index.hbs
vendored
349
docs/theme/index.hbs
vendored
@ -1,349 +0,0 @@
|
||||
<!DOCTYPE HTML>
|
||||
<html lang="{{ language }}" class="{{ default_theme }}" dir="{{ text_direction }}">
|
||||
<head>
|
||||
<!-- Book generated using mdBook -->
|
||||
<meta charset="UTF-8">
|
||||
<title>{{ title }}</title>
|
||||
{{#if is_print }}
|
||||
<meta name="robots" content="noindex">
|
||||
{{/if}}
|
||||
{{#if base_url}}
|
||||
<base href="{{ base_url }}">
|
||||
{{/if}}
|
||||
|
||||
|
||||
<!-- Custom HTML head -->
|
||||
{{> head}}
|
||||
|
||||
<meta name="description" content="{{ description }}">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
|
||||
{{#if favicon_svg}}
|
||||
<link rel="icon" href="{{ path_to_root }}favicon.svg">
|
||||
{{/if}}
|
||||
{{#if favicon_png}}
|
||||
<link rel="shortcut icon" href="{{ path_to_root }}favicon.png">
|
||||
{{/if}}
|
||||
<link rel="stylesheet" href="{{ path_to_root }}css/variables.css">
|
||||
<link rel="stylesheet" href="{{ path_to_root }}css/general.css">
|
||||
<link rel="stylesheet" href="{{ path_to_root }}css/chrome.css">
|
||||
{{#if print_enable}}
|
||||
<link rel="stylesheet" href="{{ path_to_root }}css/print.css" media="print">
|
||||
{{/if}}
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="stylesheet" href="{{ path_to_root }}FontAwesome/css/font-awesome.css">
|
||||
{{#if copy_fonts}}
|
||||
<link rel="stylesheet" href="{{ path_to_root }}fonts/fonts.css">
|
||||
{{/if}}
|
||||
|
||||
<!-- Highlight.js Stylesheets -->
|
||||
<link rel="stylesheet" href="{{ path_to_root }}highlight.css">
|
||||
<link rel="stylesheet" href="{{ path_to_root }}tomorrow-night.css">
|
||||
<link rel="stylesheet" href="{{ path_to_root }}ayu-highlight.css">
|
||||
|
||||
<!-- Custom theme stylesheets -->
|
||||
{{#each additional_css}}
|
||||
<link rel="stylesheet" href="{{ ../path_to_root }}{{ this }}">
|
||||
{{/each}}
|
||||
|
||||
{{#if mathjax_support}}
|
||||
<!-- MathJax -->
|
||||
<script async src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.1/MathJax.js?config=TeX-AMS-MML_HTMLorMML"></script>
|
||||
{{/if}}
|
||||
</head>
|
||||
<body class="sidebar-visible no-js">
|
||||
<div id="body-container">
|
||||
<!-- Provide site root to javascript -->
|
||||
<script>
|
||||
var path_to_root = "{{ path_to_root }}";
|
||||
var default_theme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "{{ preferred_dark_theme }}" : "{{ default_theme }}";
|
||||
</script>
|
||||
|
||||
<!-- Work around some values being stored in localStorage wrapped in quotes -->
|
||||
<script>
|
||||
try {
|
||||
var theme = localStorage.getItem('mdbook-theme');
|
||||
var sidebar = localStorage.getItem('mdbook-sidebar');
|
||||
|
||||
if (theme.startsWith('"') && theme.endsWith('"')) {
|
||||
localStorage.setItem('mdbook-theme', theme.slice(1, theme.length - 1));
|
||||
}
|
||||
|
||||
if (sidebar.startsWith('"') && sidebar.endsWith('"')) {
|
||||
localStorage.setItem('mdbook-sidebar', sidebar.slice(1, sidebar.length - 1));
|
||||
}
|
||||
} catch (e) { }
|
||||
</script>
|
||||
|
||||
<!-- Set the theme before any content is loaded, prevents flash -->
|
||||
<script>
|
||||
var theme;
|
||||
try { theme = localStorage.getItem('mdbook-theme'); } catch(e) { }
|
||||
if (theme === null || theme === undefined) { theme = default_theme; }
|
||||
var html = document.querySelector('html');
|
||||
html.classList.remove('{{ default_theme }}')
|
||||
html.classList.add(theme);
|
||||
var body = document.querySelector('body');
|
||||
body.classList.remove('no-js')
|
||||
body.classList.add('js');
|
||||
</script>
|
||||
|
||||
<input type="checkbox" id="sidebar-toggle-anchor" class="hidden">
|
||||
|
||||
<!-- Hide / unhide sidebar before it is displayed -->
|
||||
<script>
|
||||
var body = document.querySelector('body');
|
||||
var sidebar = null;
|
||||
var sidebar_toggle = document.getElementById("sidebar-toggle-anchor");
|
||||
if (document.body.clientWidth >= 1080) {
|
||||
try { sidebar = localStorage.getItem('mdbook-sidebar'); } catch(e) { }
|
||||
sidebar = sidebar || 'visible';
|
||||
} else {
|
||||
sidebar = 'hidden';
|
||||
}
|
||||
sidebar_toggle.checked = sidebar === 'visible';
|
||||
body.classList.remove('sidebar-visible');
|
||||
body.classList.add("sidebar-" + sidebar);
|
||||
</script>
|
||||
|
||||
<nav id="sidebar" class="sidebar" aria-label="Table of contents">
|
||||
<div class="sidebar-scrollbox">
|
||||
{{#toc}}{{/toc}}
|
||||
</div>
|
||||
<div id="sidebar-resize-handle" class="sidebar-resize-handle"></div>
|
||||
</nav>
|
||||
|
||||
<!-- Track and set sidebar scroll position -->
|
||||
<script>
|
||||
var sidebarScrollbox = document.querySelector('#sidebar .sidebar-scrollbox');
|
||||
sidebarScrollbox.addEventListener('click', function(e) {
|
||||
if (e.target.tagName === 'A') {
|
||||
sessionStorage.setItem('sidebar-scroll', sidebarScrollbox.scrollTop);
|
||||
}
|
||||
}, { passive: true });
|
||||
var sidebarScrollTop = sessionStorage.getItem('sidebar-scroll');
|
||||
sessionStorage.removeItem('sidebar-scroll');
|
||||
if (sidebarScrollTop) {
|
||||
// preserve sidebar scroll position when navigating via links within sidebar
|
||||
sidebarScrollbox.scrollTop = sidebarScrollTop;
|
||||
} else {
|
||||
// scroll sidebar to current active section when navigating via "next/previous chapter" buttons
|
||||
var activeSection = document.querySelector('#sidebar .active');
|
||||
if (activeSection) {
|
||||
activeSection.scrollIntoView({ block: 'center' });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div id="page-wrapper" class="page-wrapper">
|
||||
|
||||
<div class="page">
|
||||
{{> header}}
|
||||
<div id="menu-bar-hover-placeholder"></div>
|
||||
<div id="menu-bar" class="menu-bar sticky">
|
||||
<div class="left-buttons">
|
||||
<label id="sidebar-toggle" class="icon-button" for="sidebar-toggle-anchor" title="Toggle Table of Contents" aria-label="Toggle Table of Contents" aria-controls="sidebar">
|
||||
<i class="fa fa-bars"></i>
|
||||
</label>
|
||||
<button id="theme-toggle" class="icon-button" type="button" title="Change theme" aria-label="Change theme" aria-haspopup="true" aria-expanded="false" aria-controls="theme-list">
|
||||
<i class="fa fa-paint-brush"></i>
|
||||
</button>
|
||||
<ul id="theme-list" class="theme-popup" aria-label="Themes" role="menu">
|
||||
<li role="none"><button role="menuitem" class="theme" id="mocha">Mocha</button></li>
|
||||
<li role="none"><button role="menuitem" class="theme" id="macchiato">Macchiato</button></li>
|
||||
<li role="none"><button role="menuitem" class="theme" id="frappe">Frappé</button></li>
|
||||
<li role="none"><button role="menuitem" class="theme" id="latte">Latte</button></li>
|
||||
<li role="none"><button role="menuitem" class="theme" id="light">Light</button></li>
|
||||
<li role="none"><button role="menuitem" class="theme" id="rust">Rust</button></li>
|
||||
<li role="none"><button role="menuitem" class="theme" id="coal">Coal</button></li>
|
||||
<li role="none"><button role="menuitem" class="theme" id="navy">Navy</button></li>
|
||||
<li role="none"><button role="menuitem" class="theme" id="ayu">Ayu</button></li>
|
||||
|
||||
</ul>
|
||||
{{#if search_enabled}}
|
||||
<button id="search-toggle" class="icon-button" type="button" title="Search. (Shortkey: s)" aria-label="Toggle Searchbar" aria-expanded="false" aria-keyshortcuts="S" aria-controls="searchbar">
|
||||
<i class="fa fa-search"></i>
|
||||
</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<h1 class="menu-title">{{ book_title }}</h1>
|
||||
|
||||
<div class="right-buttons">
|
||||
{{#if print_enable}}
|
||||
<a href="{{ path_to_root }}print.html" title="Print this book" aria-label="Print this book">
|
||||
<i id="print-button" class="fa fa-print"></i>
|
||||
</a>
|
||||
{{/if}}
|
||||
{{#if git_repository_url}}
|
||||
<a href="{{git_repository_url}}" title="Git repository" aria-label="Git repository">
|
||||
<i id="git-repository-button" class="fa {{git_repository_icon}}"></i>
|
||||
</a>
|
||||
{{/if}}
|
||||
{{#if git_repository_edit_url}}
|
||||
<a href="{{git_repository_edit_url}}" title="Suggest an edit" aria-label="Suggest an edit">
|
||||
<i id="git-edit-button" class="fa fa-edit"></i>
|
||||
</a>
|
||||
{{/if}}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{#if search_enabled}}
|
||||
<div id="search-wrapper" class="hidden">
|
||||
<form id="searchbar-outer" class="searchbar-outer">
|
||||
<input type="search" id="searchbar" name="searchbar" placeholder="Search this book ..." aria-controls="searchresults-outer" aria-describedby="searchresults-header">
|
||||
</form>
|
||||
<div id="searchresults-outer" class="searchresults-outer hidden">
|
||||
<div id="searchresults-header" class="searchresults-header"></div>
|
||||
<ul id="searchresults">
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<!-- Apply ARIA attributes after the sidebar and the sidebar toggle button are added to the DOM -->
|
||||
<script>
|
||||
document.getElementById('sidebar-toggle').setAttribute('aria-expanded', sidebar === 'visible');
|
||||
document.getElementById('sidebar').setAttribute('aria-hidden', sidebar !== 'visible');
|
||||
Array.from(document.querySelectorAll('#sidebar a')).forEach(function(link) {
|
||||
link.setAttribute('tabIndex', sidebar === 'visible' ? 0 : -1);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div id="content" class="content">
|
||||
<main>
|
||||
{{{ content }}}
|
||||
</main>
|
||||
|
||||
<nav class="nav-wrapper" aria-label="Page navigation">
|
||||
<!-- Mobile navigation buttons -->
|
||||
{{#previous}}
|
||||
<a rel="prev" href="{{ path_to_root }}{{link}}" class="mobile-nav-chapters previous" title="Previous chapter" aria-label="Previous chapter" aria-keyshortcuts="Left">
|
||||
<i class="fa fa-angle-left"></i>
|
||||
</a>
|
||||
{{/previous}}
|
||||
|
||||
{{#next}}
|
||||
<a rel="next prefetch" href="{{ path_to_root }}{{link}}" class="mobile-nav-chapters next" title="Next chapter" aria-label="Next chapter" aria-keyshortcuts="Right">
|
||||
<i class="fa fa-angle-right"></i>
|
||||
</a>
|
||||
{{/next}}
|
||||
|
||||
<div style="clear: both"></div>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="nav-wide-wrapper" aria-label="Page navigation">
|
||||
{{#previous}}
|
||||
<a rel="prev" href="{{ path_to_root }}{{link}}" class="nav-chapters previous" title="Previous chapter" aria-label="Previous chapter" aria-keyshortcuts="Left">
|
||||
<i class="fa fa-angle-left"></i>
|
||||
</a>
|
||||
{{/previous}}
|
||||
|
||||
{{#next}}
|
||||
<a rel="next prefetch" href="{{ path_to_root }}{{link}}" class="nav-chapters next" title="Next chapter" aria-label="Next chapter" aria-keyshortcuts="Right">
|
||||
<i class="fa fa-angle-right"></i>
|
||||
</a>
|
||||
{{/next}}
|
||||
</nav>
|
||||
|
||||
</div>
|
||||
|
||||
{{#if live_reload_endpoint}}
|
||||
<!-- Livereload script (if served using the cli tool) -->
|
||||
<script>
|
||||
const wsProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsAddress = wsProtocol + "//" + location.host + "/" + "{{{live_reload_endpoint}}}";
|
||||
const socket = new WebSocket(wsAddress);
|
||||
socket.onmessage = function (event) {
|
||||
if (event.data === "reload") {
|
||||
socket.close();
|
||||
location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
window.onbeforeunload = function() {
|
||||
socket.close();
|
||||
}
|
||||
</script>
|
||||
{{/if}}
|
||||
|
||||
{{#if google_analytics}}
|
||||
<!-- Google Analytics Tag -->
|
||||
<script>
|
||||
var localAddrs = ["localhost", "127.0.0.1", ""];
|
||||
|
||||
// make sure we don't activate google analytics if the developer is
|
||||
// inspecting the book locally...
|
||||
if (localAddrs.indexOf(document.location.hostname) === -1) {
|
||||
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
|
||||
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
|
||||
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
|
||||
})(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
|
||||
|
||||
ga('create', '{{google_analytics}}', 'auto');
|
||||
ga('send', 'pageview');
|
||||
}
|
||||
</script>
|
||||
{{/if}}
|
||||
|
||||
{{#if playground_line_numbers}}
|
||||
<script>
|
||||
window.playground_line_numbers = true;
|
||||
</script>
|
||||
{{/if}}
|
||||
|
||||
{{#if playground_copyable}}
|
||||
<script>
|
||||
window.playground_copyable = true;
|
||||
</script>
|
||||
{{/if}}
|
||||
|
||||
{{#if playground_js}}
|
||||
<script src="{{ path_to_root }}ace.js"></script>
|
||||
<script src="{{ path_to_root }}editor.js"></script>
|
||||
<script src="{{ path_to_root }}mode-rust.js"></script>
|
||||
<script src="{{ path_to_root }}theme-dawn.js"></script>
|
||||
<script src="{{ path_to_root }}theme-tomorrow_night.js"></script>
|
||||
{{/if}}
|
||||
|
||||
{{#if search_js}}
|
||||
<script src="{{ path_to_root }}elasticlunr.min.js"></script>
|
||||
<script src="{{ path_to_root }}mark.min.js"></script>
|
||||
<script src="{{ path_to_root }}searcher.js"></script>
|
||||
{{/if}}
|
||||
|
||||
<script src="{{ path_to_root }}clipboard.min.js"></script>
|
||||
<script src="{{ path_to_root }}highlight.js"></script>
|
||||
<script src="{{ path_to_root }}book.js"></script>
|
||||
|
||||
<!-- Custom JS scripts -->
|
||||
{{#each additional_js}}
|
||||
<script src="{{ ../path_to_root }}{{this}}"></script>
|
||||
{{/each}}
|
||||
|
||||
{{#if is_print}}
|
||||
{{#if mathjax_support}}
|
||||
<script>
|
||||
window.addEventListener('load', function() {
|
||||
MathJax.Hub.Register.StartupHook('End', function() {
|
||||
window.setTimeout(window.print, 100);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{{else}}
|
||||
<script>
|
||||
window.addEventListener('load', function() {
|
||||
window.setTimeout(window.print, 100);
|
||||
});
|
||||
</script>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
@ -39,4 +39,21 @@ This example uses a template-style footer, which lets you place some text on the
|
||||
A few template variables, such as `current_slide` and `total_slides` can be used to reference properties of the
|
||||
presentation.
|
||||
|
||||
[](https://asciinema.org/a/DLpBDpCbEp5pSrNZ2Vh4mmIY1)
|
||||

|
||||
|
||||
# Columns
|
||||
|
||||
[Source](/examples/columns.md)
|
||||
|
||||
This example shows how column layouts and pauses interact with each other. Note that the image shows up as pixels
|
||||
because asciinema doesn't support these and it will otherwise look like a normal image if your terminal supports images.
|
||||
|
||||
[](https://asciinema.org/a/x2tTDt0BIesvOXeal3UpdzMHp)
|
||||
|
||||
# Speaker notes
|
||||
|
||||
[Source](/examples/speaker-notes.md)
|
||||
|
||||
This example shows how to use speaker notes.
|
||||
|
||||
[](https://asciinema.org/a/ETusvlmHuHrcLKzwa0CMQRX2J)
|
||||
|
25
examples/columns.md
Normal file
25
examples/columns.md
Normal file
@ -0,0 +1,25 @@
|
||||
# Columns and pauses
|
||||
|
||||
Columns and pauses can interact with each other in useful ways:
|
||||
|
||||
<!-- pause -->
|
||||
|
||||
<!-- column_layout: [1, 1] -->
|
||||
|
||||
<!-- column: 1 -->
|
||||
|
||||

|
||||
|
||||
After this pause, the text on the left will show up
|
||||
|
||||
<!-- pause -->
|
||||
|
||||
<!-- column: 0 -->
|
||||
|
||||
This is useful for various things:
|
||||
|
||||
<!-- incremental_lists: true -->
|
||||
* Lorem.
|
||||
* Ipsum.
|
||||
* Etcetera.
|
||||
|
194
examples/demo.md
194
examples/demo.md
@ -1,38 +1,34 @@
|
||||
---
|
||||
title: Introducing presenterm
|
||||
title: Introducing _presenterm_
|
||||
author: Matias
|
||||
---
|
||||
|
||||
Introduction slide
|
||||
Customizability
|
||||
---
|
||||
|
||||
An introduction slide can be defined by using a front matter at the beginning of the markdown file:
|
||||
_presenterm_ allows configuring almost anything about your presentation:
|
||||
|
||||
* The colors used.
|
||||
* Layouts.
|
||||
* Footers, including images in the footer.
|
||||
|
||||
<!-- pause -->
|
||||
|
||||
This is an example on how to configure a footer:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: My presentation title
|
||||
sub_title: An optional subtitle
|
||||
author: Your name which will appear somewhere in the bottom
|
||||
---
|
||||
```
|
||||
footer:
|
||||
style: template
|
||||
left:
|
||||
image: doge.png
|
||||
center: '<span class="noice">Colored</span> _footer_'
|
||||
right: "{current_slide} / {total_slides}"
|
||||
height: 5
|
||||
|
||||
The slide's theme can also be configured in the front matter:
|
||||
|
||||
```yaml
|
||||
---
|
||||
theme:
|
||||
# Specify it by name for built-in themes
|
||||
name: my-favorite-theme
|
||||
|
||||
# Otherwise specify the path for it
|
||||
path: /home/myself/themes/epic.yaml
|
||||
|
||||
# Or override parts of the theme right here
|
||||
override:
|
||||
default:
|
||||
colors:
|
||||
foreground: white
|
||||
---
|
||||
palette:
|
||||
classes:
|
||||
noice:
|
||||
foreground: red
|
||||
```
|
||||
|
||||
<!-- end_slide -->
|
||||
@ -40,58 +36,27 @@ theme:
|
||||
Headers
|
||||
---
|
||||
|
||||
Using commonmark setext headers allows you to set titles for your slides (like seen above!):
|
||||
Markdown headers can be used to set slide titles like:
|
||||
|
||||
```
|
||||
```markdown
|
||||
Headers
|
||||
---
|
||||
-------
|
||||
```
|
||||
|
||||
# Other headers
|
||||
# Headers
|
||||
|
||||
All other header types are simply treated as headers within your slide.
|
||||
Each header type can be styled differently.
|
||||
|
||||
## Subheaders
|
||||
|
||||
### And more
|
||||
|
||||
<!-- end_slide -->
|
||||
|
||||
Slide commands
|
||||
---
|
||||
|
||||
Certain commands in the form of HTML comments can be used:
|
||||
|
||||
# Ending slides
|
||||
|
||||
In order to end a single slide, use:
|
||||
|
||||
```html
|
||||
<!-- end_slide -->
|
||||
```
|
||||
|
||||
# Creating pauses
|
||||
|
||||
Slides can be paused by using the `pause` command:
|
||||
|
||||
```html
|
||||
<!-- pause -->
|
||||
```
|
||||
|
||||
This allows you to:
|
||||
|
||||
<!-- pause -->
|
||||
* Create suspense.
|
||||
<!-- pause -->
|
||||
* Have more interactive presentations.
|
||||
<!-- pause -->
|
||||
* Possibly more!
|
||||
|
||||
<!-- end_slide -->
|
||||
|
||||
Code highlighting
|
||||
---
|
||||
|
||||
Code highlighting is enabled for code blocks that include the most commonly used programming languages:
|
||||
Highlight code in 50+ programming languages:
|
||||
|
||||
```rust
|
||||
// Rust
|
||||
@ -106,22 +71,25 @@ def greet() -> str:
|
||||
return "hi mom"
|
||||
```
|
||||
|
||||
```cpp
|
||||
<!-- pause -->
|
||||
|
||||
-------
|
||||
|
||||
Code snippets can have different styles including no background:
|
||||
|
||||
```cpp +no_background +line_numbers
|
||||
// C++
|
||||
string greet() {
|
||||
return "hi mom";
|
||||
}
|
||||
```
|
||||
|
||||
And many more!
|
||||
|
||||
<!-- end_slide -->
|
||||
|
||||
Dynamic code highlighting
|
||||
---
|
||||
|
||||
Select specific subsets of lines to be highlighted dynamically as you move to the next slide. Optionally enable line
|
||||
numbers to make it easier to specify which lines you're referring to!
|
||||
Dynamically highlight different subsets of lines:
|
||||
|
||||
```rust {1-4|6-10|all} +line_numbers
|
||||
#[derive(Clone, Debug)]
|
||||
@ -141,12 +109,11 @@ impl Person {
|
||||
Snippet execution
|
||||
---
|
||||
|
||||
Code snippets can be executed:
|
||||
Code snippets can be executed on demand:
|
||||
|
||||
* For various languages, including compiled ones.
|
||||
* Their output is shown in real time.
|
||||
* Unimportant lines can be hidden so they don't clutter what you're trying to convey.
|
||||
* By default by pressing `<ctrl-e>`.
|
||||
* For 20+ languages, including compiled ones.
|
||||
* Display their output in real time.
|
||||
* Comment out unimportant lines to hide them.
|
||||
|
||||
```rust +exec
|
||||
# use std::thread::sleep;
|
||||
@ -165,12 +132,17 @@ fn main() {
|
||||
Images
|
||||
---
|
||||
|
||||
Image rendering is supported as long as you're using iterm2, your terminal supports
|
||||
the kitty graphics protocol (such as the kitty terminal itself!), or the sixel format.
|
||||
Images and animated gifs are supported in terminals such as:
|
||||
|
||||
* Include images in your slides by using ``.
|
||||
* Images will be rendered in **their original size**.
|
||||
* If they're too big they will be scaled down to fit the screen.
|
||||
* kitty
|
||||
* iterm2
|
||||
* wezterm
|
||||
* ghostty
|
||||
* Any sixel enabled terminal
|
||||
|
||||
<!-- column_layout: [1, 3, 1] -->
|
||||
|
||||
<!-- column: 1 -->
|
||||
|
||||

|
||||
|
||||
@ -181,13 +153,15 @@ _Picture by Alexis Bailey / CC BY-NC 4.0_
|
||||
Column layouts
|
||||
---
|
||||
|
||||
<!-- column_layout: [2, 1] -->
|
||||
<!-- column_layout: [7, 3] -->
|
||||
|
||||
<!-- column: 0 -->
|
||||
|
||||
Column layouts let you organize content into columns.
|
||||
Use column layouts to structure your presentation:
|
||||
|
||||
Here you can place code:
|
||||
* Define the number of columns.
|
||||
* Adjust column widths as needed.
|
||||
* Write content into every column.
|
||||
|
||||
```rust
|
||||
fn potato() -> u32 {
|
||||
@ -195,21 +169,15 @@ fn potato() -> u32 {
|
||||
}
|
||||
```
|
||||
|
||||
Plus pretty much anything else:
|
||||
* Bullet points.
|
||||
* Images.
|
||||
* _more_!
|
||||
|
||||
<!-- column: 1 -->
|
||||
|
||||

|
||||
|
||||
_Picture by Alexis Bailey / CC BY-NC 4.0_
|
||||
|
||||
<!-- reset_layout -->
|
||||
|
||||
Because we just reset the layout, this text is now below both of the columns. Code and any other element will now look
|
||||
like it usually does:
|
||||
---
|
||||
|
||||
Layouts can be reset at any time.
|
||||
|
||||
```python
|
||||
print("Hello world!")
|
||||
@ -220,28 +188,23 @@ print("Hello world!")
|
||||
Text formatting
|
||||
---
|
||||
|
||||
Text formatting works as expected:
|
||||
Text formatting works including:
|
||||
|
||||
* **This is bold text**.
|
||||
* _This is italics_.
|
||||
* **This is bold _and this is bold and italic_**.
|
||||
* ~This is strikethrough text.~
|
||||
* Inline code `is also supported`.
|
||||
* Links look like this [](https://example.com/)
|
||||
* **Bold text**.
|
||||
* _Italics_.
|
||||
* **_Bold and italic_**.
|
||||
* ~Strikethrough~.
|
||||
* `Inline code`.
|
||||
* Links [](https://example.com/)
|
||||
* <span style="color: red">Colored</span> text.
|
||||
* <span style="color: blue; background-color: black">Background color</span> can be changed too.
|
||||
|
||||
<!-- end_slide -->
|
||||
|
||||
Other elements
|
||||
More markdown
|
||||
---
|
||||
|
||||
Other elements supported are:
|
||||
|
||||
# Tables
|
||||
|
||||
| Name | Taste |
|
||||
| ------ | ------ |
|
||||
| Potato | Great |
|
||||
| Carrot | Yuck |
|
||||
Other markdown elements supported are:
|
||||
|
||||
# Block quotes
|
||||
|
||||
@ -249,8 +212,21 @@ Other elements supported are:
|
||||
> et exercitationem deleniti et quia maiores a cumque enim et
|
||||
> aspernatur nesciunt sed adipisci quis.
|
||||
|
||||
# Thematic breaks
|
||||
# Alerts
|
||||
|
||||
A horizontal line by using `---`.
|
||||
> [!caution]
|
||||
> Github style alerts
|
||||
|
||||
# Tables
|
||||
|
||||
| Name | Taste |
|
||||
| ------ | ------ |
|
||||
| Potato | Great |
|
||||
| Carrot | Yuck |
|
||||
|
||||
<!-- end_slide -->
|
||||
|
||||
<!-- jump_to_middle -->
|
||||
|
||||
The end
|
||||
---
|
||||
|
@ -3,9 +3,15 @@ theme:
|
||||
override:
|
||||
footer:
|
||||
style: template
|
||||
left: "@myhandle"
|
||||
center: "Introduction to footer styling"
|
||||
left:
|
||||
image: doge.png
|
||||
center: '**Introduction** to <span class="noice">footer</span> _styling_'
|
||||
right: "{current_slide} / {total_slides}"
|
||||
height: 5
|
||||
palette:
|
||||
classes:
|
||||
noice:
|
||||
foreground: red
|
||||
---
|
||||
|
||||
First slide
|
||||
|
43
examples/speaker-notes.md
Normal file
43
examples/speaker-notes.md
Normal file
@ -0,0 +1,43 @@
|
||||
Speaker Notes
|
||||
===
|
||||
|
||||
`presenterm` supports speaker notes.
|
||||
|
||||
You can use the following HTML comment throughout your presentation markdown file:
|
||||
|
||||
```markdown
|
||||
<!-- speaker_note: Your speaker note goes here. -->
|
||||
```
|
||||
|
||||
<!-- speaker_note: This is a speaker note from slide 1. -->
|
||||
|
||||
And you can run a separate instance of `presenterm` to view them.
|
||||
|
||||
<!-- speaker_note: You can use multiple speaker notes within each slide and interleave them with other markdown. -->
|
||||
|
||||
<!-- end_slide -->
|
||||
|
||||
Usage
|
||||
===
|
||||
Run the following two commands in separate terminals.
|
||||
|
||||
<!-- speaker_note: This is a speaker note from slide 2. -->
|
||||
|
||||
The `--publish-speaker-notes` argument will render your actual presentation as normal, without speaker notes:
|
||||
|
||||
```
|
||||
presenterm --publish-speaker-notes examples/speaker-notes.md
|
||||
```
|
||||
|
||||
The `--listen-speaker-notes` argument will render only the speaker notes for the current slide being shown in the actual
|
||||
presentation:
|
||||
|
||||
```
|
||||
presenterm --listen-speaker-notes examples/speaker-notes.md
|
||||
```
|
||||
|
||||
<!-- speaker_note: Demonstrate changing slides in the actual presentation. -->
|
||||
|
||||
As you change slides in your actual presentation, the speaker notes presentation slide will automatically navigate to the correct slide.
|
||||
|
||||
<!-- speaker_note: Isn't that cool? -->
|
@ -1,73 +1,110 @@
|
||||
---
|
||||
bash:
|
||||
filename: script.sh
|
||||
commands:
|
||||
- ["bash", "script.sh"]
|
||||
- ["bash", "$pwd/script.sh"]
|
||||
hidden_line_prefix: "/// "
|
||||
c++:
|
||||
filename: snippet.cpp
|
||||
commands:
|
||||
- ["g++", "-std=c++20", "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", "snippet.c", "-o", "$pwd/snippet"]
|
||||
- ["gcc", "$pwd/snippet.c", "-fdiagnostics-color=always", "-o", "$pwd/snippet"]
|
||||
- ["$pwd/snippet"]
|
||||
hidden_line_prefix: "/// "
|
||||
fish:
|
||||
filename: script.fish
|
||||
commands:
|
||||
- ["fish", "script.fish"]
|
||||
- ["fish", "$pwd/script.fish"]
|
||||
hidden_line_prefix: "/// "
|
||||
go:
|
||||
filename: snippet.go
|
||||
environment:
|
||||
GO11MODULE: off
|
||||
commands:
|
||||
- ["go", "run", "snippet.go"]
|
||||
- ["go", "run", "$pwd/snippet.go"]
|
||||
hidden_line_prefix: "/// "
|
||||
haskell:
|
||||
filename: snippet.hs
|
||||
commands:
|
||||
- ["runhaskell", "-w", "$pwd/snippet.hs"]
|
||||
java:
|
||||
filename: Snippet.java
|
||||
commands:
|
||||
- ["java", "Snippet.java"]
|
||||
- ["java", "$pwd/Snippet.java"]
|
||||
hidden_line_prefix: "/// "
|
||||
js:
|
||||
filename: snippet.js
|
||||
commands:
|
||||
- ["node", "snippet.js"]
|
||||
- ["node", "$pwd/snippet.js"]
|
||||
hidden_line_prefix: "/// "
|
||||
julia:
|
||||
filename: snippet.jl
|
||||
commands:
|
||||
- ["julia", "$pwd/snippet.jl"]
|
||||
hidden_line_prefix: "/// "
|
||||
kotlin:
|
||||
filename: snippet.kts
|
||||
commands:
|
||||
- ["kotlinc", "-script", "script.kts"]
|
||||
- ["kotlinc", "-script", "$pwd/snippet.kts"]
|
||||
hidden_line_prefix: "/// "
|
||||
lua:
|
||||
filename: snippet.lua
|
||||
commands:
|
||||
- ["lua", "snippet.lua"]
|
||||
- ["lua", "$pwd/snippet.lua"]
|
||||
nushell:
|
||||
filename: snippet.nu
|
||||
commands:
|
||||
- ["nu", "snippet.nu"]
|
||||
- ["nu", "$pwd/snippet.nu"]
|
||||
perl:
|
||||
filename: snippet.pl
|
||||
commands:
|
||||
- ["perl", "snippet.pl"]
|
||||
- ["perl", "$pwd/snippet.pl"]
|
||||
php:
|
||||
filename: snippet.php
|
||||
commands:
|
||||
- ["php", "-f", "$pwd/snippet.php"]
|
||||
hidden_line_prefix: "/// "
|
||||
python:
|
||||
filename: snippet.py
|
||||
commands:
|
||||
- ["python", "-u", "snippet.py"]
|
||||
- ["python", "-u", "$pwd/snippet.py"]
|
||||
hidden_line_prefix: "/// "
|
||||
r:
|
||||
filename: snippet.R
|
||||
commands:
|
||||
- ["Rscript", "$pwd/snippet.R"]
|
||||
ruby:
|
||||
filename: snippet.rb
|
||||
commands:
|
||||
- ["ruby", "snippet.rb"]
|
||||
- ["ruby", "$pwd/snippet.rb"]
|
||||
rust-script:
|
||||
filename: snippet.rs
|
||||
commands:
|
||||
- ["rust-script", "snippet.rs"]
|
||||
- ["rust-script", "--debug", "$pwd/snippet.rs"]
|
||||
hidden_line_prefix: "# "
|
||||
rust:
|
||||
filename: snippet.rs
|
||||
commands:
|
||||
- ["rustc", "--crate-name", "presenterm_snippet", "snippet.rs", "-o", "$pwd/snippet"]
|
||||
- ["rustc", "--crate-name", "presenterm_snippet", "$pwd/snippet.rs", "-o", "$pwd/snippet", "--color", "always"]
|
||||
- ["$pwd/snippet"]
|
||||
hidden_line_prefix: "# "
|
||||
sh:
|
||||
filename: script.sh
|
||||
commands:
|
||||
- ["sh", "script.sh"]
|
||||
- ["sh", "$pwd/script.sh"]
|
||||
hidden_line_prefix: "/// "
|
||||
zsh:
|
||||
filename: script.sh
|
||||
commands:
|
||||
- ["zsh", "script.sh"]
|
||||
- ["zsh", "$pwd/script.sh"]
|
||||
hidden_line_prefix: "/// "
|
||||
csharp:
|
||||
filename: snippet.cs
|
||||
commands:
|
||||
- ["dotnet-script", "$pwd/snippet.cs"]
|
||||
hidden_line_prefix: "/// "
|
||||
|
@ -1,10 +1,12 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
script_dir=$(dirname "$0")
|
||||
root_dir="${script_dir}/../"
|
||||
|
||||
current_schema=$(mktemp)
|
||||
cargo run -q -- --generate-config-file-schema >"$current_schema"
|
||||
cargo run --features json-schema -q -- --generate-config-file-schema >"$current_schema"
|
||||
|
||||
diff=$(diff --color=always -u "${root_dir}/config-file-schema.json" "$current_schema")
|
||||
if [ $? -ne 0 ]; then
|
||||
|
@ -1,15 +1,18 @@
|
||||
//! Code execution.
|
||||
|
||||
use super::snippet::{SnippetExec, SnippetRepr};
|
||||
use crate::{
|
||||
custom::LanguageSnippetExecutionConfig,
|
||||
markdown::elements::{Snippet, SnippetLanguage},
|
||||
code::snippet::{Snippet, SnippetLanguage},
|
||||
config::LanguageSnippetExecutionConfig,
|
||||
};
|
||||
use once_cell::sync::Lazy;
|
||||
use os_pipe::PipeReader;
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
fmt::{self, Debug},
|
||||
fs::File,
|
||||
io::{self, BufRead, BufReader, Write},
|
||||
io::{self, BufRead, BufReader, Read, Write},
|
||||
path::{Path, PathBuf},
|
||||
process::{self, Child, Stdio},
|
||||
sync::{Arc, Mutex},
|
||||
thread,
|
||||
@ -17,17 +20,18 @@ use std::{
|
||||
use tempfile::TempDir;
|
||||
|
||||
static EXECUTORS: Lazy<BTreeMap<SnippetLanguage, LanguageSnippetExecutionConfig>> =
|
||||
Lazy::new(|| serde_yaml::from_slice(include_bytes!("../executors.yaml")).expect("executors.yaml is broken"));
|
||||
Lazy::new(|| serde_yaml::from_slice(include_bytes!("../../executors.yaml")).expect("executors.yaml is broken"));
|
||||
|
||||
/// Allows executing code.
|
||||
#[derive(Debug)]
|
||||
pub struct SnippetExecutor {
|
||||
executors: BTreeMap<SnippetLanguage, LanguageSnippetExecutionConfig>,
|
||||
cwd: PathBuf,
|
||||
}
|
||||
|
||||
impl SnippetExecutor {
|
||||
pub fn new(
|
||||
custom_executors: BTreeMap<SnippetLanguage, LanguageSnippetExecutionConfig>,
|
||||
cwd: PathBuf,
|
||||
) -> Result<Self, InvalidSnippetConfig> {
|
||||
let mut executors = EXECUTORS.clone();
|
||||
executors.extend(custom_executors);
|
||||
@ -44,44 +48,95 @@ impl SnippetExecutor {
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Self { executors })
|
||||
Ok(Self { executors, cwd })
|
||||
}
|
||||
|
||||
pub(crate) fn is_execution_supported(&self, language: &SnippetLanguage) -> bool {
|
||||
self.executors.contains_key(language)
|
||||
}
|
||||
|
||||
/// Execute a piece of code.
|
||||
pub(crate) fn execute(&self, code: &Snippet) -> Result<ExecutionHandle, CodeExecuteError> {
|
||||
if !code.attributes.execute {
|
||||
return Err(CodeExecuteError::NotExecutableCode);
|
||||
}
|
||||
let Some(config) = self.executors.get(&code.language) else {
|
||||
return Err(CodeExecuteError::UnsupportedExecution);
|
||||
/// Execute a piece of code asynchronously.
|
||||
pub(crate) fn execute_async(&self, snippet: &Snippet) -> Result<ExecutionHandle, CodeExecuteError> {
|
||||
let config = self.language_config(snippet)?;
|
||||
let script_dir = Self::write_snippet(snippet, config)?;
|
||||
let state: Arc<Mutex<ExecutionState>> = Default::default();
|
||||
let output_type = match snippet.attributes.representation {
|
||||
SnippetRepr::Image => OutputType::Binary,
|
||||
_ => OutputType::Lines,
|
||||
};
|
||||
Self::execute_lang(config, code.executable_contents().as_bytes())
|
||||
let reader_handle = CommandsRunner::spawn(
|
||||
state.clone(),
|
||||
script_dir,
|
||||
config.commands.clone(),
|
||||
config.environment.clone(),
|
||||
self.cwd.to_path_buf(),
|
||||
output_type,
|
||||
);
|
||||
let handle = ExecutionHandle { state, reader_handle };
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
fn execute_lang(config: &LanguageSnippetExecutionConfig, code: &[u8]) -> Result<ExecutionHandle, CodeExecuteError> {
|
||||
/// Executes a piece of code synchronously.
|
||||
pub(crate) fn execute_sync(&self, snippet: &Snippet) -> Result<(), CodeExecuteError> {
|
||||
let config = self.language_config(snippet)?;
|
||||
let script_dir = Self::write_snippet(snippet, config)?;
|
||||
let script_dir_path = script_dir.path().to_string_lossy();
|
||||
for mut commands in 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(&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));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn hidden_line_prefix(&self, language: &SnippetLanguage) -> Option<&str> {
|
||||
self.executors.get(language).and_then(|lang| lang.hidden_line_prefix.as_deref())
|
||||
}
|
||||
|
||||
fn language_config(&self, snippet: &Snippet) -> Result<&LanguageSnippetExecutionConfig, CodeExecuteError> {
|
||||
let is_executable = !matches!(snippet.attributes.execution, SnippetExec::None);
|
||||
let is_exec_replace = matches!(snippet.attributes.representation, SnippetRepr::ExecReplace);
|
||||
if !is_executable && !is_exec_replace {
|
||||
return Err(CodeExecuteError::NotExecutableCode);
|
||||
}
|
||||
self.executors.get(&snippet.language).ok_or(CodeExecuteError::UnsupportedExecution)
|
||||
}
|
||||
|
||||
fn write_snippet(snippet: &Snippet, config: &LanguageSnippetExecutionConfig) -> Result<TempDir, CodeExecuteError> {
|
||||
let hide_prefix = config.hidden_line_prefix.as_deref();
|
||||
let code = snippet.executable_contents(hide_prefix);
|
||||
let script_dir =
|
||||
tempfile::Builder::default().prefix(".presenterm").tempdir().map_err(CodeExecuteError::TempDir)?;
|
||||
let snippet_path = script_dir.path().join(&config.filename);
|
||||
{
|
||||
let mut snippet_file = File::create(snippet_path).map_err(CodeExecuteError::TempDir)?;
|
||||
snippet_file.write_all(code).map_err(CodeExecuteError::TempDir)?;
|
||||
}
|
||||
|
||||
let state: Arc<Mutex<ExecutionState>> = Default::default();
|
||||
let reader_handle =
|
||||
CommandsRunner::spawn(state.clone(), script_dir, config.commands.clone(), config.environment.clone());
|
||||
let handle = ExecutionHandle { state, reader_handle };
|
||||
Ok(handle)
|
||||
let mut snippet_file = File::create(snippet_path).map_err(CodeExecuteError::TempDir)?;
|
||||
snippet_file.write_all(code.as_bytes()).map_err(CodeExecuteError::TempDir)?;
|
||||
Ok(script_dir)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SnippetExecutor {
|
||||
fn default() -> Self {
|
||||
Self::new(Default::default()).expect("initialization failed")
|
||||
Self::new(Default::default(), PathBuf::from("./")).expect("initialization failed")
|
||||
}
|
||||
}
|
||||
|
||||
impl Debug for SnippetExecutor {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "SnippetExecutor {{ .. }}")
|
||||
}
|
||||
}
|
||||
|
||||
@ -107,6 +162,12 @@ pub(crate) enum CodeExecuteError {
|
||||
|
||||
#[error("error creating pipe: {0}")]
|
||||
Pipe(io::Error),
|
||||
|
||||
#[error("error waiting for process to run: {0}")]
|
||||
Waiting(io::Error),
|
||||
|
||||
#[error("error running process: {0}")]
|
||||
Running(String),
|
||||
}
|
||||
|
||||
/// A handle for the execution of a piece of code.
|
||||
@ -129,15 +190,17 @@ impl CommandsRunner {
|
||||
script_directory: TempDir,
|
||||
commands: Vec<Vec<String>>,
|
||||
env: HashMap<String, String>,
|
||||
cwd: PathBuf,
|
||||
output_type: OutputType,
|
||||
) -> thread::JoinHandle<()> {
|
||||
let reader = Self { state, script_directory };
|
||||
thread::spawn(|| reader.run(commands, env))
|
||||
thread::spawn(move || reader.run(commands, env, cwd, output_type))
|
||||
}
|
||||
|
||||
fn run(self, commands: Vec<Vec<String>>, env: HashMap<String, String>) {
|
||||
fn run(self, commands: Vec<Vec<String>>, env: HashMap<String, String>, cwd: PathBuf, output_type: OutputType) {
|
||||
let mut last_result = true;
|
||||
for command in commands {
|
||||
last_result = self.run_command(command, &env);
|
||||
last_result = self.run_command(command, &env, &cwd, output_type);
|
||||
if !last_result {
|
||||
break;
|
||||
}
|
||||
@ -149,17 +212,23 @@ impl CommandsRunner {
|
||||
self.state.lock().unwrap().status = status;
|
||||
}
|
||||
|
||||
fn run_command(&self, command: Vec<String>, env: &HashMap<String, String>) -> bool {
|
||||
let (mut child, reader) = match self.launch_process(command, env) {
|
||||
fn run_command(
|
||||
&self,
|
||||
command: Vec<String>,
|
||||
env: &HashMap<String, String>,
|
||||
cwd: &Path,
|
||||
output_type: OutputType,
|
||||
) -> bool {
|
||||
let (mut child, reader) = match self.launch_process(command, env, cwd) {
|
||||
Ok(inner) => inner,
|
||||
Err(e) => {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
state.status = ProcessStatus::Failure;
|
||||
state.output.push(e.to_string());
|
||||
state.output.extend(e.to_string().into_bytes());
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let _ = Self::process_output(self.state.clone(), reader);
|
||||
let _ = Self::process_output(self.state.clone(), reader, output_type);
|
||||
|
||||
match child.wait() {
|
||||
Ok(code) => code.success(),
|
||||
@ -171,17 +240,19 @@ impl CommandsRunner {
|
||||
&self,
|
||||
mut commands: Vec<String>,
|
||||
env: &HashMap<String, String>,
|
||||
cwd: &Path,
|
||||
) -> Result<(Child, PipeReader), CodeExecuteError> {
|
||||
let (reader, writer) = os_pipe::pipe().map_err(CodeExecuteError::Pipe)?;
|
||||
let writer_clone = writer.try_clone().map_err(CodeExecuteError::Pipe)?;
|
||||
let script_dir = self.script_directory.path().to_string_lossy();
|
||||
for command in &mut commands {
|
||||
*command = command.replace("$pwd", &self.script_directory.path().to_string_lossy());
|
||||
*command = command.replace("$pwd", &script_dir);
|
||||
}
|
||||
let (command, args) = commands.split_first().expect("no commands");
|
||||
let child = process::Command::new(command)
|
||||
.args(args)
|
||||
.envs(env)
|
||||
.current_dir(self.script_directory.path())
|
||||
.current_dir(cwd)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(writer)
|
||||
.stderr(writer_clone)
|
||||
@ -190,24 +261,41 @@ impl CommandsRunner {
|
||||
Ok((child, reader))
|
||||
}
|
||||
|
||||
fn process_output(state: Arc<Mutex<ExecutionState>>, reader: os_pipe::PipeReader) -> io::Result<()> {
|
||||
let reader = BufReader::new(reader);
|
||||
for line in reader.lines() {
|
||||
let mut line = line?;
|
||||
if line.contains('\t') {
|
||||
line = line.replace('\t', " ");
|
||||
fn process_output(
|
||||
state: Arc<Mutex<ExecutionState>>,
|
||||
mut reader: os_pipe::PipeReader,
|
||||
output_type: OutputType,
|
||||
) -> io::Result<()> {
|
||||
match output_type {
|
||||
OutputType::Lines => {
|
||||
let reader = BufReader::new(reader);
|
||||
for line in reader.lines() {
|
||||
let mut state = state.lock().unwrap();
|
||||
state.output.extend(line?.into_bytes());
|
||||
state.output.push(b'\n');
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
OutputType::Binary => {
|
||||
let mut buffer = Vec::new();
|
||||
reader.read_to_end(&mut buffer)?;
|
||||
state.lock().unwrap().output.extend(buffer);
|
||||
Ok(())
|
||||
}
|
||||
// TODO: consider not locking per line...
|
||||
state.lock().unwrap().output.push(line);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum OutputType {
|
||||
Lines,
|
||||
Binary,
|
||||
}
|
||||
|
||||
/// The state of the execution of a process.
|
||||
#[derive(Clone, Default, Debug)]
|
||||
pub(crate) struct ExecutionState {
|
||||
pub(crate) output: Vec<String>,
|
||||
pub(crate) output: Vec<u8>,
|
||||
pub(crate) status: ProcessStatus,
|
||||
}
|
||||
|
||||
@ -230,7 +318,7 @@ impl ProcessStatus {
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::markdown::elements::SnippetAttributes;
|
||||
use crate::code::snippet::SnippetAttributes;
|
||||
|
||||
#[test]
|
||||
fn shell_code_execution() {
|
||||
@ -241,9 +329,9 @@ echo 'bye'"
|
||||
let code = Snippet {
|
||||
contents,
|
||||
language: SnippetLanguage::Shell,
|
||||
attributes: SnippetAttributes { execute: true, ..Default::default() },
|
||||
attributes: SnippetAttributes { execution: SnippetExec::Exec, ..Default::default() },
|
||||
};
|
||||
let handle = SnippetExecutor::default().execute(&code).expect("execution failed");
|
||||
let handle = SnippetExecutor::default().execute_async(&code).expect("execution failed");
|
||||
let state = loop {
|
||||
let state = handle.state.lock().unwrap();
|
||||
if state.status.is_finished() {
|
||||
@ -251,8 +339,8 @@ echo 'bye'"
|
||||
}
|
||||
};
|
||||
|
||||
let expected_lines = vec!["hello world", "bye"];
|
||||
assert_eq!(state.output, expected_lines);
|
||||
let expected = b"hello world\nbye\n";
|
||||
assert_eq!(state.output, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -261,9 +349,9 @@ echo 'bye'"
|
||||
let code = Snippet {
|
||||
contents,
|
||||
language: SnippetLanguage::Shell,
|
||||
attributes: SnippetAttributes { execute: false, ..Default::default() },
|
||||
attributes: SnippetAttributes { execution: SnippetExec::None, ..Default::default() },
|
||||
};
|
||||
let result = SnippetExecutor::default().execute(&code);
|
||||
let result = SnippetExecutor::default().execute_async(&code);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
@ -277,9 +365,9 @@ echo 'hello world'
|
||||
let code = Snippet {
|
||||
contents,
|
||||
language: SnippetLanguage::Shell,
|
||||
attributes: SnippetAttributes { execute: true, ..Default::default() },
|
||||
attributes: SnippetAttributes { execution: SnippetExec::Exec, ..Default::default() },
|
||||
};
|
||||
let handle = SnippetExecutor::default().execute(&code).expect("execution failed");
|
||||
let handle = SnippetExecutor::default().execute_async(&code).expect("execution failed");
|
||||
let state = loop {
|
||||
let state = handle.state.lock().unwrap();
|
||||
if state.status.is_finished() {
|
||||
@ -287,8 +375,8 @@ echo 'hello world'
|
||||
}
|
||||
};
|
||||
|
||||
let expected_lines = vec!["This message redirects to stderr", "hello world"];
|
||||
assert_eq!(state.output, expected_lines);
|
||||
let expected = b"This message redirects to stderr\nhello world\n";
|
||||
assert_eq!(state.output, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -302,9 +390,9 @@ echo 'hello world'
|
||||
let code = Snippet {
|
||||
contents,
|
||||
language: SnippetLanguage::Shell,
|
||||
attributes: SnippetAttributes { execute: true, ..Default::default() },
|
||||
attributes: SnippetAttributes { execution: SnippetExec::Exec, ..Default::default() },
|
||||
};
|
||||
let handle = SnippetExecutor::default().execute(&code).expect("execution failed");
|
||||
let handle = SnippetExecutor::default().execute_async(&code).expect("execution failed");
|
||||
let state = loop {
|
||||
let state = handle.state.lock().unwrap();
|
||||
if state.status.is_finished() {
|
||||
@ -312,13 +400,12 @@ echo 'hello world'
|
||||
}
|
||||
};
|
||||
|
||||
let expected_lines =
|
||||
vec!["this line was hidden", "this line was hidden and contains another prefix /// ", "hello world"];
|
||||
assert_eq!(state.output, expected_lines);
|
||||
let expected = b"this line was hidden\nthis line was hidden and contains another prefix /// \nhello world\n";
|
||||
assert_eq!(state.output, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn built_in_executors() {
|
||||
SnippetExecutor::new(Default::default()).expect("invalid default executors");
|
||||
SnippetExecutor::new(Default::default(), PathBuf::from("./")).expect("invalid default executors");
|
||||
}
|
||||
}
|
@ -1,24 +1,20 @@
|
||||
use crate::{markdown::elements::SnippetLanguage, theme::CodeBlockStyle};
|
||||
use crossterm::{
|
||||
style::{SetBackgroundColor, SetForegroundColor},
|
||||
QueueableCommand,
|
||||
use crate::{
|
||||
code::snippet::SnippetLanguage,
|
||||
markdown::{
|
||||
elements::{Line, Text},
|
||||
text_style::{Color, TextStyle},
|
||||
},
|
||||
theme::CodeBlockStyle,
|
||||
};
|
||||
use flate2::read::ZlibDecoder;
|
||||
use once_cell::sync::Lazy;
|
||||
use serde::Deserialize;
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::BTreeMap,
|
||||
fs,
|
||||
io::{self, Write},
|
||||
path::Path,
|
||||
rc::Rc,
|
||||
};
|
||||
use std::{cell::RefCell, collections::BTreeMap, fs, path::Path, rc::Rc};
|
||||
use syntect::{
|
||||
LoadingError,
|
||||
easy::HighlightLines,
|
||||
highlighting::{Style, Theme, ThemeSet},
|
||||
parsing::SyntaxSet,
|
||||
LoadingError,
|
||||
};
|
||||
|
||||
static SYNTAX_SET: Lazy<SyntaxSet> = Lazy::new(|| {
|
||||
@ -44,16 +40,16 @@ pub struct HighlightThemeSet {
|
||||
|
||||
impl HighlightThemeSet {
|
||||
/// Construct a new highlighter using the given [syntect] theme name.
|
||||
pub fn load_by_name(&self, name: &str) -> Option<CodeHighlighter> {
|
||||
pub fn load_by_name(&self, name: &str) -> Option<SnippetHighlighter> {
|
||||
let mut themes = self.themes.borrow_mut();
|
||||
// Check if we already loaded this one.
|
||||
if let Some(theme) = themes.get(name).cloned() {
|
||||
Some(CodeHighlighter { theme })
|
||||
Some(SnippetHighlighter { theme })
|
||||
}
|
||||
// Otherwise try to deserialize it from bat's themes
|
||||
else if let Some(theme) = self.deserialize_bat_theme(name) {
|
||||
themes.insert(name.into(), theme.clone());
|
||||
Some(CodeHighlighter { theme })
|
||||
Some(SnippetHighlighter { theme })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@ -89,13 +85,13 @@ impl Default for HighlightThemeSet {
|
||||
}
|
||||
}
|
||||
|
||||
/// A code highlighter.
|
||||
/// A snippet highlighter.
|
||||
#[derive(Clone)]
|
||||
pub struct CodeHighlighter {
|
||||
pub(crate) struct SnippetHighlighter {
|
||||
theme: Rc<Theme>,
|
||||
}
|
||||
|
||||
impl CodeHighlighter {
|
||||
impl SnippetHighlighter {
|
||||
/// Create a highlighter for a specific language.
|
||||
pub(crate) fn language_highlighter(&self, language: &SnippetLanguage) -> LanguageHighlighter {
|
||||
let extension = Self::language_extension(language);
|
||||
@ -126,13 +122,16 @@ impl CodeHighlighter {
|
||||
Elixir => "ex",
|
||||
Elm => "elm",
|
||||
Erlang => "erl",
|
||||
File => "txt",
|
||||
Fish => "fish",
|
||||
Go => "go",
|
||||
GraphQL => "graphql",
|
||||
Haskell => "hs",
|
||||
Html => "html",
|
||||
Java => "java",
|
||||
JavaScript => "js",
|
||||
Json => "json",
|
||||
Julia => "jl",
|
||||
Kotlin => "kt",
|
||||
Latex => "tex",
|
||||
Lua => "lua",
|
||||
@ -148,6 +147,7 @@ impl CodeHighlighter {
|
||||
Puppet => "pp",
|
||||
Python => "py",
|
||||
R => "r",
|
||||
Racket => "rkt",
|
||||
Ruby => "rb",
|
||||
Rust => "rs",
|
||||
RustScript => "rs",
|
||||
@ -156,11 +156,14 @@ impl CodeHighlighter {
|
||||
Sql => "sql",
|
||||
Swift => "swift",
|
||||
Svelte => "svelte",
|
||||
Tcl => "tcl",
|
||||
Terraform => "tf",
|
||||
Toml => "toml",
|
||||
TypeScript => "ts",
|
||||
Typst => "txt",
|
||||
// default to plain text so we get the same look&feel
|
||||
Unknown(_) => "txt",
|
||||
Verilog => "v",
|
||||
Vue => "vue",
|
||||
Xml => "xml",
|
||||
Yaml => "yaml",
|
||||
@ -170,7 +173,7 @@ impl CodeHighlighter {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CodeHighlighter {
|
||||
impl Default for SnippetHighlighter {
|
||||
fn default() -> Self {
|
||||
let themes = HighlightThemeSet::default();
|
||||
themes.load_by_name("base16-eighties.dark").expect("default theme not found")
|
||||
@ -181,51 +184,42 @@ pub(crate) struct LanguageHighlighter<'a> {
|
||||
highlighter: HighlightLines<'a>,
|
||||
}
|
||||
|
||||
impl<'a> LanguageHighlighter<'a> {
|
||||
pub(crate) fn highlight_line(&mut self, line: &str, block_style: &CodeBlockStyle) -> String {
|
||||
self.style_line(line).map(|s| s.apply_style(block_style)).collect()
|
||||
impl LanguageHighlighter<'_> {
|
||||
pub(crate) fn highlight_line(&mut self, line: &str, block_style: &CodeBlockStyle) -> Line {
|
||||
self.style_line(line, block_style)
|
||||
}
|
||||
|
||||
pub(crate) fn style_line<'b>(&mut self, line: &'b str) -> impl Iterator<Item = StyledTokens<'b>> {
|
||||
self.highlighter
|
||||
pub(crate) fn style_line(&mut self, line: &str, block_style: &CodeBlockStyle) -> Line {
|
||||
let texts: Vec<_> = self
|
||||
.highlighter
|
||||
.highlight_line(line, &SYNTAX_SET)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|(style, tokens)| StyledTokens { style, tokens })
|
||||
.map(|(style, tokens)| StyledTokens::new(style, tokens, block_style).apply_style())
|
||||
.collect();
|
||||
Line(texts)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct StyledTokens<'a> {
|
||||
pub(crate) style: Style,
|
||||
pub(crate) style: TextStyle,
|
||||
pub(crate) tokens: &'a str,
|
||||
}
|
||||
|
||||
impl<'a> StyledTokens<'a> {
|
||||
pub(crate) fn apply_style(&self, block_style: &CodeBlockStyle) -> String {
|
||||
let has_background = block_style.background.unwrap_or(true);
|
||||
let background = has_background.then_some(to_ansi_color(self.style.background)).flatten();
|
||||
let foreground = to_ansi_color(self.style.foreground);
|
||||
pub(crate) fn new(style: Style, tokens: &'a str, block_style: &CodeBlockStyle) -> Self {
|
||||
let has_background = block_style.background;
|
||||
let background = has_background.then_some(parse_color(style.background)).flatten();
|
||||
let foreground = parse_color(style.foreground);
|
||||
let mut style = TextStyle::default();
|
||||
style.colors.background = background;
|
||||
style.colors.foreground = foreground;
|
||||
Self { style, tokens }
|
||||
}
|
||||
|
||||
// We do this conversion manually as crossterm will reset the color after styling, and we
|
||||
// want to "keep it open" so that padding also uses this background color.
|
||||
//
|
||||
// Note: these unwraps shouldn't happen as this is an in-memory writer so there's no
|
||||
// fallible IO here.
|
||||
let mut cursor = io::BufWriter::new(Vec::new());
|
||||
if let Some(color) = background {
|
||||
cursor.queue(SetBackgroundColor(color)).unwrap();
|
||||
}
|
||||
if let Some(color) = foreground {
|
||||
cursor.queue(SetForegroundColor(color)).unwrap();
|
||||
}
|
||||
// syntect likes its input to contain \n but we don't want them as we pad text with extra
|
||||
// " " at the end so we get rid of them here.
|
||||
for chunk in self.tokens.split('\n') {
|
||||
cursor.write_all(chunk.as_bytes()).unwrap();
|
||||
}
|
||||
|
||||
cursor.flush().unwrap();
|
||||
String::from_utf8(cursor.into_inner().unwrap()).unwrap()
|
||||
pub(crate) fn apply_style(&self) -> Text {
|
||||
let text: String = self.tokens.split('\n').collect();
|
||||
Text::new(text, self.style)
|
||||
}
|
||||
}
|
||||
|
||||
@ -235,8 +229,7 @@ impl<'a> StyledTokens<'a> {
|
||||
pub struct ThemeNotFound;
|
||||
|
||||
// This code has been adapted from bat's: https://github.com/sharkdp/bat
|
||||
fn to_ansi_color(color: syntect::highlighting::Color) -> Option<crossterm::style::Color> {
|
||||
use crossterm::style::Color;
|
||||
fn parse_color(color: syntect::highlighting::Color) -> Option<Color> {
|
||||
if color.a == 0 {
|
||||
Some(match color.r {
|
||||
0x00 => Color::Black,
|
||||
@ -247,12 +240,12 @@ fn to_ansi_color(color: syntect::highlighting::Color) -> Option<crossterm::style
|
||||
0x05 => Color::DarkMagenta,
|
||||
0x06 => Color::DarkCyan,
|
||||
0x07 => Color::Grey,
|
||||
n => Color::AnsiValue(n),
|
||||
n => Color::from_ansi(n)?,
|
||||
})
|
||||
} else if color.a == 1 {
|
||||
None
|
||||
} else {
|
||||
Some(Color::Rgb { r: color.r, g: color.g, b: color.b })
|
||||
Some(Color::new(color.r, color.g, color.b))
|
||||
}
|
||||
}
|
||||
|
||||
@ -265,7 +258,7 @@ mod test {
|
||||
#[test]
|
||||
fn language_extensions_exist() {
|
||||
for language in SnippetLanguage::iter() {
|
||||
let extension = CodeHighlighter::language_extension(&language);
|
||||
let extension = SnippetHighlighter::language_extension(&language);
|
||||
let syntax = SYNTAX_SET.find_syntax_by_extension(extension);
|
||||
assert!(syntax.is_some(), "extension {extension} for {language:?} not found");
|
||||
}
|
||||
@ -273,7 +266,7 @@ mod test {
|
||||
|
||||
#[test]
|
||||
fn default_highlighter() {
|
||||
CodeHighlighter::default();
|
||||
SnippetHighlighter::default();
|
||||
}
|
||||
|
||||
#[test]
|
4
src/code/mod.rs
Normal file
4
src/code/mod.rs
Normal file
@ -0,0 +1,4 @@
|
||||
pub(crate) mod execute;
|
||||
pub(crate) mod highlighting;
|
||||
pub(crate) mod padding;
|
||||
pub(crate) mod snippet;
|
@ -6,7 +6,7 @@ pub(crate) struct NumberPadder {
|
||||
|
||||
impl NumberPadder {
|
||||
pub(crate) fn new(upper_bound: usize) -> Self {
|
||||
let width = upper_bound.ilog10() as usize + 1;
|
||||
let width = upper_bound.checked_ilog10().map(|log| log as usize + 1).unwrap_or_default();
|
||||
Self { width }
|
||||
}
|
||||
|
||||
@ -37,4 +37,9 @@ mod test {
|
||||
let rendered: Vec<_> = numbers.iter().map(|n| padder.pad_right(*n)).collect();
|
||||
assert_eq!(rendered, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_count() {
|
||||
NumberPadder::new(0);
|
||||
}
|
||||
}
|
856
src/code/snippet.rs
Normal file
856
src/code/snippet.rs
Normal file
@ -0,0 +1,856 @@
|
||||
use super::{
|
||||
highlighting::{LanguageHighlighter, StyledTokens},
|
||||
padding::NumberPadder,
|
||||
};
|
||||
use crate::{
|
||||
markdown::{
|
||||
elements::{Percent, PercentParseError},
|
||||
text::{WeightedLine, WeightedText},
|
||||
text_style::{Color, TextStyle},
|
||||
},
|
||||
presentation::ChunkMutator,
|
||||
render::{
|
||||
operation::{AsRenderOperations, BlockLine, RenderOperation},
|
||||
properties::WindowSize,
|
||||
},
|
||||
theme::{Alignment, CodeBlockStyle},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use std::{cell::RefCell, convert::Infallible, fmt::Write, ops::Range, path::PathBuf, rc::Rc, str::FromStr};
|
||||
use strum::{EnumDiscriminants, EnumIter};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
pub(crate) struct SnippetSplitter<'a> {
|
||||
style: &'a CodeBlockStyle,
|
||||
hidden_line_prefix: Option<&'a str>,
|
||||
}
|
||||
|
||||
impl<'a> SnippetSplitter<'a> {
|
||||
pub(crate) fn new(style: &'a CodeBlockStyle, hidden_line_prefix: Option<&'a str>) -> Self {
|
||||
Self { style, hidden_line_prefix }
|
||||
}
|
||||
|
||||
pub(crate) fn split(&self, code: &Snippet) -> Vec<SnippetLine> {
|
||||
let mut lines = Vec::new();
|
||||
let horizontal_padding = self.style.padding.horizontal;
|
||||
let vertical_padding = self.style.padding.vertical;
|
||||
if vertical_padding > 0 {
|
||||
lines.push(SnippetLine::empty());
|
||||
}
|
||||
self.push_lines(code, horizontal_padding, &mut lines);
|
||||
if vertical_padding > 0 {
|
||||
lines.push(SnippetLine::empty());
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
fn push_lines(&self, code: &Snippet, horizontal_padding: u8, lines: &mut Vec<SnippetLine>) {
|
||||
if code.contents.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let padding = " ".repeat(horizontal_padding as usize);
|
||||
let padder = NumberPadder::new(code.visible_lines(self.hidden_line_prefix).count());
|
||||
for (index, line) in code.visible_lines(self.hidden_line_prefix).enumerate() {
|
||||
let mut line = line.replace('\t', " ");
|
||||
let mut prefix = padding.clone();
|
||||
if code.attributes.line_numbers {
|
||||
let line_number = index + 1;
|
||||
prefix.push_str(&padder.pad_right(line_number));
|
||||
prefix.push(' ');
|
||||
}
|
||||
line.push('\n');
|
||||
let line_number = Some(index as u16 + 1);
|
||||
lines.push(SnippetLine { prefix, code: line, right_padding_length: padding.len() as u16, line_number });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct SnippetLine {
|
||||
pub(crate) prefix: String,
|
||||
pub(crate) code: String,
|
||||
pub(crate) right_padding_length: u16,
|
||||
pub(crate) line_number: Option<u16>,
|
||||
}
|
||||
|
||||
impl SnippetLine {
|
||||
pub(crate) fn empty() -> Self {
|
||||
Self { prefix: String::new(), code: "\n".into(), right_padding_length: 0, line_number: None }
|
||||
}
|
||||
|
||||
pub(crate) fn width(&self) -> usize {
|
||||
self.prefix.width() + self.code.width() + self.right_padding_length as usize
|
||||
}
|
||||
|
||||
pub(crate) fn highlight(
|
||||
&self,
|
||||
code_highlighter: &mut LanguageHighlighter,
|
||||
block_style: &CodeBlockStyle,
|
||||
font_size: u8,
|
||||
) -> WeightedLine {
|
||||
let mut line = code_highlighter.highlight_line(&self.code, block_style);
|
||||
line.apply_style(&TextStyle::default().size(font_size));
|
||||
line.into()
|
||||
}
|
||||
|
||||
pub(crate) fn dim(&self, dim_style: &TextStyle) -> WeightedLine {
|
||||
let output = vec![StyledTokens { style: *dim_style, tokens: &self.code }.apply_style()];
|
||||
output.into()
|
||||
}
|
||||
|
||||
pub(crate) fn dim_prefix(&self, dim_style: &TextStyle) -> WeightedText {
|
||||
let text = StyledTokens { style: *dim_style, tokens: &self.prefix }.apply_style();
|
||||
text.into()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct HighlightContext {
|
||||
pub(crate) groups: Vec<HighlightGroup>,
|
||||
pub(crate) current: usize,
|
||||
pub(crate) block_length: u16,
|
||||
pub(crate) alignment: Alignment,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct HighlightedLine {
|
||||
pub(crate) prefix: WeightedText,
|
||||
pub(crate) right_padding_length: u16,
|
||||
pub(crate) highlighted: WeightedLine,
|
||||
pub(crate) not_highlighted: WeightedLine,
|
||||
pub(crate) line_number: Option<u16>,
|
||||
pub(crate) context: Rc<RefCell<HighlightContext>>,
|
||||
pub(crate) block_color: Option<Color>,
|
||||
}
|
||||
|
||||
impl AsRenderOperations for HighlightedLine {
|
||||
fn as_render_operations(&self, _: &WindowSize) -> Vec<RenderOperation> {
|
||||
let context = self.context.borrow();
|
||||
let group = &context.groups[context.current];
|
||||
let needs_highlight = self.line_number.map(|number| group.contains(number)).unwrap_or_default();
|
||||
// TODO: Cow<str>?
|
||||
let text = match needs_highlight {
|
||||
true => self.highlighted.clone(),
|
||||
false => self.not_highlighted.clone(),
|
||||
};
|
||||
vec![
|
||||
RenderOperation::RenderBlockLine(BlockLine {
|
||||
prefix: self.prefix.clone(),
|
||||
right_padding_length: self.right_padding_length,
|
||||
repeat_prefix_on_wrap: false,
|
||||
text,
|
||||
block_length: context.block_length,
|
||||
alignment: context.alignment,
|
||||
block_color: self.block_color,
|
||||
}),
|
||||
RenderOperation::RenderLineBreak,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct HighlightMutator {
|
||||
context: Rc<RefCell<HighlightContext>>,
|
||||
}
|
||||
|
||||
impl HighlightMutator {
|
||||
pub(crate) fn new(context: Rc<RefCell<HighlightContext>>) -> Self {
|
||||
Self { context }
|
||||
}
|
||||
}
|
||||
|
||||
impl ChunkMutator for HighlightMutator {
|
||||
fn mutate_next(&self) -> bool {
|
||||
let mut context = self.context.borrow_mut();
|
||||
if context.current == context.groups.len() - 1 {
|
||||
false
|
||||
} else {
|
||||
context.current += 1;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn mutate_previous(&self) -> bool {
|
||||
let mut context = self.context.borrow_mut();
|
||||
if context.current == 0 {
|
||||
false
|
||||
} else {
|
||||
context.current -= 1;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn reset_mutations(&self) {
|
||||
self.context.borrow_mut().current = 0;
|
||||
}
|
||||
|
||||
fn apply_all_mutations(&self) {
|
||||
let mut context = self.context.borrow_mut();
|
||||
context.current = context.groups.len() - 1;
|
||||
}
|
||||
|
||||
fn mutations(&self) -> (usize, usize) {
|
||||
let context = self.context.borrow();
|
||||
(context.current, context.groups.len())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) type ParseResult<T> = Result<T, SnippetBlockParseError>;
|
||||
|
||||
pub(crate) struct SnippetParser;
|
||||
|
||||
impl SnippetParser {
|
||||
pub(crate) fn parse(info: String, code: String) -> ParseResult<Snippet> {
|
||||
let (language, attributes) = Self::parse_block_info(&info)?;
|
||||
let code = Snippet { contents: code, language, attributes };
|
||||
Ok(code)
|
||||
}
|
||||
|
||||
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) {
|
||||
return Err(SnippetBlockParseError::NotRenderSnippet("width"));
|
||||
}
|
||||
Ok((language, attributes))
|
||||
}
|
||||
|
||||
fn parse_language(input: &str) -> (SnippetLanguage, &str) {
|
||||
let token = Self::next_identifier(input);
|
||||
// this always returns `Ok` given we fall back to `Unknown` if we don't know the language.
|
||||
let language = token.parse().expect("language parsing");
|
||||
let rest = &input[token.len()..];
|
||||
(language, rest)
|
||||
}
|
||||
|
||||
fn parse_attributes(mut input: &str) -> ParseResult<SnippetAttributes> {
|
||||
let mut attributes = SnippetAttributes::default();
|
||||
let mut processed_attributes = Vec::new();
|
||||
while let (Some(attribute), rest) = Self::parse_attribute(input)? {
|
||||
let discriminant = SnippetAttributeDiscriminants::from(&attribute);
|
||||
if processed_attributes.contains(&discriminant) {
|
||||
return Err(SnippetBlockParseError::DuplicateAttribute("duplicate attribute"));
|
||||
}
|
||||
use SnippetAttribute::*;
|
||||
match attribute {
|
||||
ExecReplace | Image | Render if attributes.representation != SnippetRepr::Snippet => {
|
||||
return Err(SnippetBlockParseError::MultipleRepresentation);
|
||||
}
|
||||
LineNumbers => attributes.line_numbers = true,
|
||||
Exec => {
|
||||
if attributes.execution != SnippetExec::AcquireTerminal {
|
||||
attributes.execution = SnippetExec::Exec;
|
||||
}
|
||||
}
|
||||
ExecReplace => {
|
||||
attributes.representation = SnippetRepr::ExecReplace;
|
||||
attributes.execution = SnippetExec::Exec;
|
||||
}
|
||||
Image => {
|
||||
attributes.representation = SnippetRepr::Image;
|
||||
attributes.execution = SnippetExec::Exec;
|
||||
}
|
||||
Render => attributes.representation = SnippetRepr::Render,
|
||||
AcquireTerminal => attributes.execution = SnippetExec::AcquireTerminal,
|
||||
NoBackground => attributes.no_background = true,
|
||||
HighlightedLines(lines) => attributes.highlight_groups = lines,
|
||||
Width(width) => attributes.width = Some(width),
|
||||
};
|
||||
processed_attributes.push(discriminant);
|
||||
input = rest;
|
||||
}
|
||||
if attributes.highlight_groups.is_empty() {
|
||||
attributes.highlight_groups.push(HighlightGroup::new(vec![Highlight::All]));
|
||||
}
|
||||
Ok(attributes)
|
||||
}
|
||||
|
||||
fn parse_attribute(input: &str) -> ParseResult<(Option<SnippetAttribute>, &str)> {
|
||||
let input = Self::skip_whitespace(input);
|
||||
let (attribute, input) = match input.chars().next() {
|
||||
Some('+') => {
|
||||
let token = Self::next_identifier(&input[1..]);
|
||||
let attribute = match token {
|
||||
"line_numbers" => SnippetAttribute::LineNumbers,
|
||||
"exec" => SnippetAttribute::Exec,
|
||||
"exec_replace" => SnippetAttribute::ExecReplace,
|
||||
"image" => SnippetAttribute::Image,
|
||||
"render" => SnippetAttribute::Render,
|
||||
"no_background" => SnippetAttribute::NoBackground,
|
||||
"acquire_terminal" => SnippetAttribute::AcquireTerminal,
|
||||
token if token.starts_with("width:") => {
|
||||
let value = input.split_once("+width:").unwrap().1;
|
||||
let (width, input) = Self::parse_width(value)?;
|
||||
return Ok((Some(SnippetAttribute::Width(width)), input));
|
||||
}
|
||||
_ => return Err(SnippetBlockParseError::InvalidToken(Self::next_identifier(input).into())),
|
||||
};
|
||||
(Some(attribute), &input[token.len() + 1..])
|
||||
}
|
||||
Some('{') => {
|
||||
let (lines, input) = Self::parse_highlight_groups(&input[1..])?;
|
||||
(Some(SnippetAttribute::HighlightedLines(lines)), input)
|
||||
}
|
||||
Some(_) => return Err(SnippetBlockParseError::InvalidToken(Self::next_identifier(input).into())),
|
||||
None => (None, input),
|
||||
};
|
||||
Ok((attribute, input))
|
||||
}
|
||||
|
||||
fn parse_highlight_groups(input: &str) -> ParseResult<(Vec<HighlightGroup>, &str)> {
|
||||
use SnippetBlockParseError::InvalidHighlightedLines;
|
||||
let Some((head, tail)) = input.split_once('}') else {
|
||||
return Err(InvalidHighlightedLines("no enclosing '}'".into()));
|
||||
};
|
||||
let head = head.trim();
|
||||
if head.is_empty() {
|
||||
return Ok((Vec::new(), tail));
|
||||
}
|
||||
|
||||
let mut highlight_groups = Vec::new();
|
||||
for group in head.split('|') {
|
||||
let group = Self::parse_highlight_group(group)?;
|
||||
highlight_groups.push(group);
|
||||
}
|
||||
Ok((highlight_groups, tail))
|
||||
}
|
||||
|
||||
fn parse_highlight_group(input: &str) -> ParseResult<HighlightGroup> {
|
||||
let mut highlights = Vec::new();
|
||||
for piece in input.split(',') {
|
||||
let piece = piece.trim();
|
||||
if piece == "all" {
|
||||
highlights.push(Highlight::All);
|
||||
continue;
|
||||
}
|
||||
match piece.split_once('-') {
|
||||
Some((left, right)) => {
|
||||
let left = Self::parse_number(left)?;
|
||||
let right = Self::parse_number(right)?;
|
||||
let right = right.checked_add(1).ok_or_else(|| {
|
||||
SnippetBlockParseError::InvalidHighlightedLines(format!("{right} is too large"))
|
||||
})?;
|
||||
highlights.push(Highlight::Range(left..right));
|
||||
}
|
||||
None => {
|
||||
let number = Self::parse_number(piece)?;
|
||||
highlights.push(Highlight::Single(number));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(HighlightGroup::new(highlights))
|
||||
}
|
||||
|
||||
fn parse_number(input: &str) -> ParseResult<u16> {
|
||||
input
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|_| SnippetBlockParseError::InvalidHighlightedLines(format!("not a number: '{input}'")))
|
||||
}
|
||||
|
||||
fn parse_width(input: &str) -> ParseResult<(Percent, &str)> {
|
||||
let end_index = input.find(' ').unwrap_or(input.len());
|
||||
let value = input[0..end_index].parse().map_err(SnippetBlockParseError::InvalidWidth)?;
|
||||
Ok((value, &input[end_index..]))
|
||||
}
|
||||
|
||||
fn skip_whitespace(input: &str) -> &str {
|
||||
input.trim_start_matches(' ')
|
||||
}
|
||||
|
||||
fn next_identifier(input: &str) -> &str {
|
||||
match input.split_once(' ') {
|
||||
Some((token, _)) => token,
|
||||
None => input,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum SnippetBlockParseError {
|
||||
#[error("invalid code attribute: {0}")]
|
||||
InvalidToken(String),
|
||||
|
||||
#[error("invalid highlighted lines: {0}")]
|
||||
InvalidHighlightedLines(String),
|
||||
|
||||
#[error("invalid width: {0}")]
|
||||
InvalidWidth(PercentParseError),
|
||||
|
||||
#[error("duplicate attribute: {0}")]
|
||||
DuplicateAttribute(&'static str),
|
||||
|
||||
#[error("+exec_replace +image and +render can't be used together ")]
|
||||
MultipleRepresentation,
|
||||
|
||||
#[error("attribute {0} can only be set in +render blocks")]
|
||||
NotRenderSnippet(&'static str),
|
||||
}
|
||||
|
||||
#[derive(EnumDiscriminants)]
|
||||
enum SnippetAttribute {
|
||||
LineNumbers,
|
||||
Exec,
|
||||
ExecReplace,
|
||||
Image,
|
||||
Render,
|
||||
HighlightedLines(Vec<HighlightGroup>),
|
||||
Width(Percent),
|
||||
NoBackground,
|
||||
AcquireTerminal,
|
||||
}
|
||||
|
||||
/// A code snippet.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct Snippet {
|
||||
/// The snippet itself.
|
||||
pub(crate) contents: String,
|
||||
|
||||
/// The programming language this snippet is written in.
|
||||
pub(crate) language: SnippetLanguage,
|
||||
|
||||
/// The attributes used for snippet.
|
||||
pub(crate) attributes: SnippetAttributes,
|
||||
}
|
||||
|
||||
impl Snippet {
|
||||
pub(crate) fn visible_lines<'a, 'b>(
|
||||
&'a self,
|
||||
hidden_line_prefix: Option<&'b str>,
|
||||
) -> impl Iterator<Item = &'a str> + 'b
|
||||
where
|
||||
'a: 'b,
|
||||
{
|
||||
self.contents.lines().filter(move |line| !hidden_line_prefix.is_some_and(|prefix| line.starts_with(prefix)))
|
||||
}
|
||||
|
||||
pub(crate) fn executable_contents(&self, hidden_line_prefix: Option<&str>) -> String {
|
||||
if let Some(prefix) = hidden_line_prefix {
|
||||
self.contents.lines().fold(String::new(), |mut output, line| {
|
||||
let line = line.strip_prefix(prefix).unwrap_or(line);
|
||||
let _ = writeln!(output, "{line}");
|
||||
output
|
||||
})
|
||||
} else {
|
||||
self.contents.to_owned()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The language of a code snippet.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, EnumIter, PartialOrd, Ord)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
pub enum SnippetLanguage {
|
||||
Ada,
|
||||
Asp,
|
||||
Awk,
|
||||
Bash,
|
||||
BatchFile,
|
||||
C,
|
||||
CMake,
|
||||
Crontab,
|
||||
CSharp,
|
||||
Clojure,
|
||||
Cpp,
|
||||
Css,
|
||||
DLang,
|
||||
Diff,
|
||||
Docker,
|
||||
Dotenv,
|
||||
Elixir,
|
||||
Elm,
|
||||
Erlang,
|
||||
File,
|
||||
Fish,
|
||||
Go,
|
||||
GraphQL,
|
||||
Haskell,
|
||||
Html,
|
||||
Java,
|
||||
JavaScript,
|
||||
Json,
|
||||
Julia,
|
||||
Kotlin,
|
||||
Latex,
|
||||
Lua,
|
||||
Makefile,
|
||||
Mermaid,
|
||||
Markdown,
|
||||
Nix,
|
||||
Nushell,
|
||||
OCaml,
|
||||
Perl,
|
||||
Php,
|
||||
Protobuf,
|
||||
Puppet,
|
||||
Python,
|
||||
R,
|
||||
Racket,
|
||||
Ruby,
|
||||
Rust,
|
||||
RustScript,
|
||||
Scala,
|
||||
Shell,
|
||||
Sql,
|
||||
Swift,
|
||||
Svelte,
|
||||
Tcl,
|
||||
Terraform,
|
||||
Toml,
|
||||
TypeScript,
|
||||
Typst,
|
||||
Unknown(String),
|
||||
Xml,
|
||||
Yaml,
|
||||
Verilog,
|
||||
Vue,
|
||||
Zig,
|
||||
Zsh,
|
||||
}
|
||||
|
||||
crate::utils::impl_deserialize_from_str!(SnippetLanguage);
|
||||
|
||||
impl FromStr for SnippetLanguage {
|
||||
type Err = Infallible;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
use SnippetLanguage::*;
|
||||
let language = match s {
|
||||
"ada" => Ada,
|
||||
"asp" => Asp,
|
||||
"awk" => Awk,
|
||||
"bash" => Bash,
|
||||
"c" => C,
|
||||
"cmake" => CMake,
|
||||
"crontab" => Crontab,
|
||||
"csharp" => CSharp,
|
||||
"clojure" => Clojure,
|
||||
"cpp" | "c++" => Cpp,
|
||||
"css" => Css,
|
||||
"d" => DLang,
|
||||
"diff" => Diff,
|
||||
"docker" => Docker,
|
||||
"dotenv" => Dotenv,
|
||||
"elixir" => Elixir,
|
||||
"elm" => Elm,
|
||||
"erlang" => Erlang,
|
||||
"file" => File,
|
||||
"fish" => Fish,
|
||||
"go" => Go,
|
||||
"graphql" => GraphQL,
|
||||
"haskell" => Haskell,
|
||||
"html" => Html,
|
||||
"java" => Java,
|
||||
"javascript" | "js" => JavaScript,
|
||||
"json" => Json,
|
||||
"julia" => Julia,
|
||||
"kotlin" => Kotlin,
|
||||
"latex" => Latex,
|
||||
"lua" => Lua,
|
||||
"make" => Makefile,
|
||||
"markdown" => Markdown,
|
||||
"mermaid" => Mermaid,
|
||||
"nix" => Nix,
|
||||
"nushell" | "nu" => Nushell,
|
||||
"ocaml" => OCaml,
|
||||
"perl" => Perl,
|
||||
"php" => Php,
|
||||
"protobuf" => Protobuf,
|
||||
"puppet" => Puppet,
|
||||
"python" => Python,
|
||||
"r" => R,
|
||||
"racket" => Racket,
|
||||
"ruby" => Ruby,
|
||||
"rust" => Rust,
|
||||
"rust-script" => RustScript,
|
||||
"scala" => Scala,
|
||||
"shell" | "sh" => Shell,
|
||||
"sql" => Sql,
|
||||
"svelte" => Svelte,
|
||||
"swift" => Swift,
|
||||
"tcl" => Tcl,
|
||||
"terraform" => Terraform,
|
||||
"toml" => Toml,
|
||||
"typescript" | "ts" => TypeScript,
|
||||
"typst" => Typst,
|
||||
"xml" => Xml,
|
||||
"yaml" => Yaml,
|
||||
"verilog" => Verilog,
|
||||
"vue" => Vue,
|
||||
"zig" => Zig,
|
||||
"zsh" => Zsh,
|
||||
other => Unknown(other.to_string()),
|
||||
};
|
||||
Ok(language)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
|
||||
/// Whether the snippet should show line numbers.
|
||||
pub(crate) line_numbers: bool,
|
||||
|
||||
/// The groups of lines to highlight.
|
||||
pub(crate) highlight_groups: Vec<HighlightGroup>,
|
||||
|
||||
/// The width of the generated image.
|
||||
///
|
||||
/// Only valid for +render snippets.
|
||||
pub(crate) width: Option<Percent>,
|
||||
|
||||
/// Whether to add no background to a snippet.
|
||||
pub(crate) no_background: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) enum SnippetRepr {
|
||||
#[default]
|
||||
Snippet,
|
||||
Image,
|
||||
Render,
|
||||
ExecReplace,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) enum SnippetExec {
|
||||
#[default]
|
||||
None,
|
||||
Exec,
|
||||
AcquireTerminal,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct HighlightGroup(Vec<Highlight>);
|
||||
|
||||
impl HighlightGroup {
|
||||
pub(crate) fn new(highlights: Vec<Highlight>) -> Self {
|
||||
Self(highlights)
|
||||
}
|
||||
|
||||
pub(crate) fn contains(&self, line_number: u16) -> bool {
|
||||
for higlight in &self.0 {
|
||||
match higlight {
|
||||
Highlight::All => return true,
|
||||
Highlight::Single(number) if number == &line_number => return true,
|
||||
Highlight::Range(range) if range.contains(&line_number) => return true,
|
||||
_ => continue,
|
||||
};
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// A highlighted set of lines
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Highlight {
|
||||
All,
|
||||
Single(u16),
|
||||
Range(Range<u16>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ExternalFile {
|
||||
pub(crate) path: PathBuf,
|
||||
pub(crate) language: SnippetLanguage,
|
||||
pub(crate) start_line: Option<usize>,
|
||||
pub(crate) end_line: Option<usize>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use Highlight::*;
|
||||
use rstest::rstest;
|
||||
|
||||
fn parse_language(input: &str) -> SnippetLanguage {
|
||||
let (language, _) = SnippetParser::parse_block_info(input).expect("parse failed");
|
||||
language
|
||||
}
|
||||
|
||||
fn try_parse_attributes(input: &str) -> Result<SnippetAttributes, SnippetBlockParseError> {
|
||||
let (_, attributes) = SnippetParser::parse_block_info(input)?;
|
||||
Ok(attributes)
|
||||
}
|
||||
|
||||
fn parse_attributes(input: &str) -> SnippetAttributes {
|
||||
try_parse_attributes(input).expect("parse failed")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_with_line_numbers() {
|
||||
let total_lines = 11;
|
||||
let input_lines = "hi\n".repeat(total_lines);
|
||||
let code = Snippet {
|
||||
contents: input_lines,
|
||||
language: SnippetLanguage::Unknown("".to_string()),
|
||||
attributes: SnippetAttributes { line_numbers: true, ..Default::default() },
|
||||
};
|
||||
let lines = SnippetSplitter::new(&Default::default(), None).split(&code);
|
||||
assert_eq!(lines.len(), total_lines);
|
||||
|
||||
let mut lines = lines.into_iter().enumerate();
|
||||
// 0..=9
|
||||
for (index, line) in lines.by_ref().take(9) {
|
||||
let line_number = index + 1;
|
||||
assert_eq!(&line.prefix, &format!(" {line_number} "));
|
||||
}
|
||||
// 10..
|
||||
for (index, line) in lines {
|
||||
let line_number = index + 1;
|
||||
assert_eq!(&line.prefix, &format!("{line_number} "));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_language() {
|
||||
assert_eq!(parse_language("potato"), SnippetLanguage::Unknown("potato".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_attributes() {
|
||||
assert_eq!(parse_language("rust"), SnippetLanguage::Rust);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_attribute() {
|
||||
let attributes = parse_attributes("bash +exec");
|
||||
assert_eq!(attributes.execution, SnippetExec::Exec);
|
||||
assert!(!attributes.line_numbers);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_attributes() {
|
||||
let attributes = parse_attributes("bash +exec +line_numbers");
|
||||
assert_eq!(attributes.execution, SnippetExec::Exec);
|
||||
assert!(attributes.line_numbers);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acquire_terminal() {
|
||||
let attributes = parse_attributes("bash +acquire_terminal +exec");
|
||||
assert_eq!(attributes.execution, SnippetExec::AcquireTerminal);
|
||||
assert_eq!(attributes.representation, SnippetRepr::Snippet);
|
||||
assert!(!attributes.line_numbers);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image() {
|
||||
let attributes = parse_attributes("bash +image +exec");
|
||||
assert_eq!(attributes.execution, SnippetExec::Exec);
|
||||
assert_eq!(attributes.representation, SnippetRepr::Image);
|
||||
assert!(!attributes.line_numbers);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_attributes() {
|
||||
SnippetParser::parse_block_info("bash +potato").unwrap_err();
|
||||
SnippetParser::parse_block_info("bash potato").unwrap_err();
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::no_end("{")]
|
||||
#[case::number_no_end("{42")]
|
||||
#[case::comma_nothing("{42,")]
|
||||
#[case::brace_comma("{,}")]
|
||||
#[case::range_no_end("{42-")]
|
||||
#[case::range_end("{42-}")]
|
||||
#[case::too_many_ranges("{42-3-5}")]
|
||||
#[case::range_comma("{42-,")]
|
||||
#[case::too_large("{65536}")]
|
||||
#[case::too_large_end("{1-65536}")]
|
||||
fn invalid_line_highlights(#[case] input: &str) {
|
||||
let input = format!("bash {input}");
|
||||
SnippetParser::parse_block_info(&input).expect_err("parsed successfully");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlight_none() {
|
||||
let attributes = parse_attributes("bash {}");
|
||||
assert_eq!(attributes.highlight_groups, &[HighlightGroup::new(vec![Highlight::All])]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlight_specific_lines() {
|
||||
let attributes = parse_attributes("bash { 1, 2 , 3 }");
|
||||
assert_eq!(attributes.highlight_groups, &[HighlightGroup::new(vec![Single(1), Single(2), Single(3)])]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlight_line_range() {
|
||||
let attributes = parse_attributes("bash { 1, 2-4,6 , all , 10 - 12 }");
|
||||
assert_eq!(
|
||||
attributes.highlight_groups,
|
||||
&[HighlightGroup::new(vec![Single(1), Range(2..5), Single(6), All, Range(10..13)])]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_groups() {
|
||||
let attributes = parse_attributes("bash {1-3,5 |6-9}");
|
||||
assert_eq!(attributes.highlight_groups.len(), 2);
|
||||
assert_eq!(attributes.highlight_groups[0], HighlightGroup::new(vec![Range(1..4), Single(5)]));
|
||||
assert_eq!(attributes.highlight_groups[1], HighlightGroup::new(vec![Range(6..10)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_width() {
|
||||
let attributes = parse_attributes("mermaid +width:50% +render");
|
||||
assert_eq!(attributes.representation, SnippetRepr::Render);
|
||||
assert_eq!(attributes.width, Some(Percent(50)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_width() {
|
||||
try_parse_attributes("mermaid +width:50%% +render").expect_err("parse succeeded");
|
||||
try_parse_attributes("mermaid +width: +render").expect_err("parse succeeded");
|
||||
try_parse_attributes("mermaid +width:50%").expect_err("parse succeeded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_visible_lines() {
|
||||
let contents = r##"# fn main() {
|
||||
println!("Hello world");
|
||||
# // The prefix is # .
|
||||
# }
|
||||
"##
|
||||
.to_string();
|
||||
|
||||
let expected = vec!["println!(\"Hello world\");"];
|
||||
let code = Snippet { contents, language: SnippetLanguage::Rust, attributes: Default::default() };
|
||||
assert_eq!(expected, code.visible_lines(Some("# ")).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_executable_contents() {
|
||||
let contents = r##"# fn main() {
|
||||
println!("Hello world");
|
||||
# // The prefix is # .
|
||||
# }
|
||||
"##
|
||||
.to_string();
|
||||
|
||||
let expected = r##"fn main() {
|
||||
println!("Hello world");
|
||||
// The prefix is # .
|
||||
}
|
||||
"##
|
||||
.to_string();
|
||||
|
||||
let code = Snippet { contents, language: SnippetLanguage::Rust, attributes: Default::default() };
|
||||
assert_eq!(expected, code.executable_contents(Some("# ")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tabs_in_snippet() {
|
||||
let snippet = Snippet { contents: "\thi".into(), language: SnippetLanguage::C, attributes: Default::default() };
|
||||
let lines = SnippetSplitter::new(&Default::default(), None).split(&snippet);
|
||||
assert_eq!(lines[0].code, " hi\n");
|
||||
}
|
||||
}
|
@ -1,17 +1,15 @@
|
||||
use super::source::{Command, CommandDiscriminants};
|
||||
use crate::custom::KeyBindingsConfig;
|
||||
use crossterm::event::{poll, read, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||
use schemars::JsonSchema;
|
||||
use serde_with::DeserializeFromStr;
|
||||
use super::listener::{Command, CommandDiscriminants};
|
||||
use crate::config::KeyBindingsConfig;
|
||||
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, poll, read};
|
||||
use std::{fmt, io, iter, mem, str::FromStr, time::Duration};
|
||||
|
||||
/// A user input handler.
|
||||
pub struct UserInput {
|
||||
/// A keyboard command listener.
|
||||
pub struct KeyboardListener {
|
||||
bindings: CommandKeyBindings,
|
||||
events: Vec<KeyEvent>,
|
||||
}
|
||||
|
||||
impl UserInput {
|
||||
impl KeyboardListener {
|
||||
pub fn new(bindings: CommandKeyBindings) -> Self {
|
||||
Self { bindings, events: Vec::new() }
|
||||
}
|
||||
@ -90,6 +88,7 @@ impl CommandKeyBindings {
|
||||
}
|
||||
RenderAsyncOperations => Command::RenderAsyncOperations,
|
||||
Exit => Command::Exit,
|
||||
Suspend => Command::Suspend,
|
||||
Reload => Command::Reload,
|
||||
HardReload => Command::HardReload,
|
||||
ToggleSlideIndex => Command::ToggleSlideIndex,
|
||||
@ -133,6 +132,7 @@ impl TryFrom<KeyBindingsConfig> for CommandKeyBindings {
|
||||
.chain(zip(CommandDiscriminants::LastSlide, config.last_slide))
|
||||
.chain(zip(CommandDiscriminants::GoToSlide, config.go_to_slide))
|
||||
.chain(zip(CommandDiscriminants::Exit, config.exit))
|
||||
.chain(zip(CommandDiscriminants::Suspend, config.suspend))
|
||||
.chain(zip(CommandDiscriminants::HardReload, config.reload))
|
||||
.chain(zip(CommandDiscriminants::ToggleSlideIndex, config.toggle_slide_index))
|
||||
.chain(zip(CommandDiscriminants::ToggleKeyBindingsConfig, config.toggle_bindings))
|
||||
@ -160,8 +160,11 @@ enum BindingMatch {
|
||||
None,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeserializeFromStr, JsonSchema)]
|
||||
pub struct KeyBinding(#[schemars(with = "String")] Vec<KeyMatcher>);
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
pub struct KeyBinding(#[cfg_attr(feature = "json-schema", schemars(with = "String"))] Vec<KeyMatcher>);
|
||||
|
||||
crate::utils::impl_deserialize_from_str!(KeyBinding);
|
||||
|
||||
impl KeyBinding {
|
||||
fn match_events(&self, mut events: &[KeyEvent]) -> BindingMatch {
|
@ -1,40 +1,45 @@
|
||||
use super::{
|
||||
fs::PresentationFileWatcher,
|
||||
user::{CommandKeyBindings, KeyBindingsValidationError, UserInput},
|
||||
keyboard::{CommandKeyBindings, KeyBindingsValidationError, KeyboardListener},
|
||||
speaker_notes::{SpeakerNotesEvent, SpeakerNotesEventListener},
|
||||
};
|
||||
use crate::custom::KeyBindingsConfig;
|
||||
use crate::{config::KeyBindingsConfig, presenter::PresentationError};
|
||||
use serde::Deserialize;
|
||||
use std::{io, path::PathBuf, time::Duration};
|
||||
use std::time::Duration;
|
||||
use strum::EnumDiscriminants;
|
||||
|
||||
/// The source of commands.
|
||||
///
|
||||
/// This expects user commands as well as watches over the presentation file to reload if it that
|
||||
/// happens.
|
||||
pub struct CommandSource {
|
||||
watcher: PresentationFileWatcher,
|
||||
user_input: UserInput,
|
||||
/// A command listener that allows polling all command sources in a single place.
|
||||
pub struct CommandListener {
|
||||
keyboard: KeyboardListener,
|
||||
speaker_notes_event_listener: Option<SpeakerNotesEventListener>,
|
||||
}
|
||||
|
||||
impl CommandSource {
|
||||
impl CommandListener {
|
||||
/// Create a new command source over the given presentation path.
|
||||
pub fn new<P: Into<PathBuf>>(
|
||||
presentation_path: P,
|
||||
pub fn new(
|
||||
config: KeyBindingsConfig,
|
||||
speaker_notes_event_listener: Option<SpeakerNotesEventListener>,
|
||||
) -> Result<Self, KeyBindingsValidationError> {
|
||||
let watcher = PresentationFileWatcher::new(presentation_path);
|
||||
let bindings = CommandKeyBindings::try_from(config)?;
|
||||
Ok(Self { watcher, user_input: UserInput::new(bindings) })
|
||||
Ok(Self { keyboard: KeyboardListener::new(bindings), speaker_notes_event_listener })
|
||||
}
|
||||
|
||||
/// Try to get the next command.
|
||||
///
|
||||
/// This attempts to get a command and returns `Ok(None)` on timeout.
|
||||
pub(crate) fn try_next_command(&mut self) -> io::Result<Option<Command>> {
|
||||
if let Some(command) = self.user_input.poll_next_command(Duration::from_millis(250))? {
|
||||
return Ok(Some(command));
|
||||
};
|
||||
if self.watcher.has_modifications()? { Ok(Some(Command::Reload)) } else { Ok(None) }
|
||||
pub(crate) fn try_next_command(&mut self) -> Result<Option<Command>, PresentationError> {
|
||||
if let Some(receiver) = &self.speaker_notes_event_listener {
|
||||
if let Some(msg) = receiver.try_recv()? {
|
||||
let command = match msg {
|
||||
SpeakerNotesEvent::GoToSlide { slide } => Command::GoToSlide(slide),
|
||||
SpeakerNotesEvent::Exit => Command::Exit,
|
||||
};
|
||||
return Ok(Some(command));
|
||||
}
|
||||
}
|
||||
match self.keyboard.poll_next_command(Duration::from_millis(100))? {
|
||||
Some(command) => Ok(Some(command)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -74,6 +79,9 @@ pub(crate) enum Command {
|
||||
/// Exit the presentation.
|
||||
Exit,
|
||||
|
||||
/// Suspend the presentation.
|
||||
Suspend,
|
||||
|
||||
/// The presentation has changed and needs to be reloaded.
|
||||
Reload,
|
||||
|
3
src/commands/mod.rs
Normal file
3
src/commands/mod.rs
Normal file
@ -0,0 +1,3 @@
|
||||
pub(crate) mod keyboard;
|
||||
pub(crate) mod listener;
|
||||
pub(crate) mod speaker_notes;
|
115
src/commands/speaker_notes.rs
Normal file
115
src/commands/speaker_notes.rs
Normal file
@ -0,0 +1,115 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use socket2::{Domain, Protocol, Socket, Type};
|
||||
use std::{
|
||||
io,
|
||||
net::{SocketAddr, UdpSocket},
|
||||
path::PathBuf,
|
||||
};
|
||||
|
||||
pub struct SpeakerNotesEventPublisher {
|
||||
socket: UdpSocket,
|
||||
presentation_path: PathBuf,
|
||||
}
|
||||
|
||||
impl SpeakerNotesEventPublisher {
|
||||
pub fn new(address: SocketAddr, presentation_path: PathBuf) -> io::Result<Self> {
|
||||
let socket = UdpSocket::bind("127.0.0.1:0")?;
|
||||
socket.set_broadcast(true)?;
|
||||
socket.connect(address)?;
|
||||
Ok(Self { socket, presentation_path })
|
||||
}
|
||||
|
||||
pub(crate) fn send(&self, event: SpeakerNotesEvent) -> io::Result<()> {
|
||||
// Wrap this event in an envelope that contains the presentation path so listeners can
|
||||
// ignore unrelated events.
|
||||
let envelope = SpeakerNotesEventEnvelope { event, presentation_path: self.presentation_path.clone() };
|
||||
let data = serde_json::to_string(&envelope).expect("serialization failed");
|
||||
match self.socket.send(data.as_bytes()) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) if e.kind() == io::ErrorKind::ConnectionRefused => Ok(()),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SpeakerNotesEventListener {
|
||||
socket: UdpSocket,
|
||||
presentation_path: PathBuf,
|
||||
}
|
||||
|
||||
impl SpeakerNotesEventListener {
|
||||
pub fn new(address: SocketAddr, presentation_path: PathBuf) -> io::Result<Self> {
|
||||
let s = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP))?;
|
||||
// Use SO_REUSEADDR so we can have multiple listeners on the same port.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
s.set_reuse_address(true)?;
|
||||
// Don't block so we can listen to the keyboard and this socket at the same time.
|
||||
s.set_nonblocking(true)?;
|
||||
s.bind(&address.into())?;
|
||||
Ok(Self { socket: s.into(), presentation_path })
|
||||
}
|
||||
|
||||
pub(crate) fn try_recv(&self) -> io::Result<Option<SpeakerNotesEvent>> {
|
||||
let mut buffer = [0; 1024];
|
||||
let bytes_read = match self.socket.recv(&mut buffer) {
|
||||
Ok(bytes_read) => bytes_read,
|
||||
Err(e) if e.kind() == io::ErrorKind::WouldBlock => return Ok(None),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
// Ignore garbage. Odds are this is someone else sending garbage rather than presenterm
|
||||
// itself.
|
||||
let Ok(envelope) = serde_json::from_slice::<SpeakerNotesEventEnvelope>(&buffer[0..bytes_read]) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if envelope.presentation_path == self.presentation_path { Ok(Some(envelope.event)) } else { Ok(None) }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "command")]
|
||||
pub(crate) enum SpeakerNotesEvent {
|
||||
GoToSlide { slide: u32 },
|
||||
Exit,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
struct SpeakerNotesEventEnvelope {
|
||||
presentation_path: PathBuf,
|
||||
event: SpeakerNotesEvent,
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{default_speaker_notes_listen_address, default_speaker_notes_publish_address};
|
||||
use std::{thread::sleep, time::Duration};
|
||||
|
||||
fn make_listener(path: PathBuf) -> SpeakerNotesEventListener {
|
||||
SpeakerNotesEventListener::new(default_speaker_notes_listen_address(), path).expect("building listener")
|
||||
}
|
||||
|
||||
fn make_publisher(path: PathBuf) -> SpeakerNotesEventPublisher {
|
||||
SpeakerNotesEventPublisher::new(default_speaker_notes_publish_address(), path).expect("building publisher")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bind_multiple() {
|
||||
let _l1 = make_listener("".into());
|
||||
let _l2 = make_listener("".into());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multicast() {
|
||||
let path = PathBuf::from("/tmp/test.md");
|
||||
let l1 = make_listener(path.clone());
|
||||
let l2 = make_listener(path.clone());
|
||||
let publisher = make_publisher(path);
|
||||
let event = SpeakerNotesEvent::Exit;
|
||||
publisher.send(event.clone()).expect("send failed");
|
||||
sleep(Duration::from_millis(100));
|
||||
|
||||
assert_eq!(l1.try_recv().expect("recv first failed"), Some(event.clone()));
|
||||
assert_eq!(l2.try_recv().expect("recv second failed"), Some(event));
|
||||
}
|
||||
}
|
@ -1,23 +1,26 @@
|
||||
use crate::{
|
||||
input::user::KeyBinding,
|
||||
markdown::elements::SnippetLanguage,
|
||||
media::{emulator::TerminalEmulator, kitty::KittyMode},
|
||||
GraphicsMode,
|
||||
code::snippet::SnippetLanguage,
|
||||
commands::keyboard::KeyBinding,
|
||||
terminal::{
|
||||
GraphicsMode, capabilities::TerminalCapabilities, emulator::TerminalEmulator,
|
||||
image::protocols::kitty::KittyMode,
|
||||
},
|
||||
};
|
||||
use clap::ValueEnum;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
fs, io,
|
||||
net::{IpAddr, Ipv4Addr, SocketAddr},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Config {
|
||||
/// The default configuration for the presentation.
|
||||
#[serde(default)]
|
||||
#[doc = "The default configuration for the presentation."]
|
||||
pub defaults: DefaultsConfig,
|
||||
|
||||
#[serde(default)]
|
||||
@ -34,6 +37,15 @@ pub struct Config {
|
||||
|
||||
#[serde(default)]
|
||||
pub snippet: SnippetConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub speaker_notes: SpeakerNotesConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub export: ExportConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub transition: Option<SlideTransitionConfig>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@ -41,7 +53,7 @@ impl Config {
|
||||
pub fn load(path: &Path) -> Result<Self, ConfigLoadError> {
|
||||
let contents = match fs::read_to_string(path) {
|
||||
Ok(contents) => contents,
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Self::default()),
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => return Err(ConfigLoadError::NotFound),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let config = serde_yaml::from_str(&contents)?;
|
||||
@ -54,19 +66,23 @@ pub enum ConfigLoadError {
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] io::Error),
|
||||
|
||||
#[error("config file not found")]
|
||||
NotFound,
|
||||
|
||||
#[error("invalid configuration: {0}")]
|
||||
Invalid(#[from] serde_yaml::Error),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema)]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DefaultsConfig {
|
||||
/// The theme to use by default in every presentation unless overridden.
|
||||
pub theme: Option<String>,
|
||||
|
||||
/// Override the terminal font size when in windows or when using sixel.
|
||||
#[serde(default = "default_font_size")]
|
||||
#[validate(range(min = 1))]
|
||||
#[serde(default = "default_terminal_font_size")]
|
||||
#[cfg_attr(feature = "json-schema", validate(range(min = 1)))]
|
||||
pub terminal_font_size: u8,
|
||||
|
||||
/// The image protocol to use.
|
||||
@ -76,24 +92,98 @@ pub struct DefaultsConfig {
|
||||
/// Validate that the presentation does not overflow the terminal screen.
|
||||
#[serde(default)]
|
||||
pub validate_overflows: ValidateOverflows,
|
||||
|
||||
/// A max width in columns that the presentation must always be capped to.
|
||||
#[serde(default = "default_u16_max")]
|
||||
pub max_columns: u16,
|
||||
|
||||
/// The alignment the presentation should have if `max_columns` is set and the terminal is
|
||||
/// larger than that.
|
||||
#[serde(default)]
|
||||
pub max_columns_alignment: MaxColumnsAlignment,
|
||||
|
||||
/// A max height in rows that the presentation must always be capped to.
|
||||
#[serde(default = "default_u16_max")]
|
||||
pub max_rows: u16,
|
||||
|
||||
/// The alignment the presentation should have if `max_rows` is set and the terminal is
|
||||
/// larger than that.
|
||||
#[serde(default)]
|
||||
pub max_rows_alignment: MaxRowsAlignment,
|
||||
|
||||
/// The configuration for lists when incremental lists are enabled.
|
||||
#[serde(default)]
|
||||
pub incremental_lists: IncrementalListsConfig,
|
||||
}
|
||||
|
||||
impl Default for DefaultsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
theme: Default::default(),
|
||||
terminal_font_size: default_font_size(),
|
||||
terminal_font_size: default_terminal_font_size(),
|
||||
image_protocol: Default::default(),
|
||||
validate_overflows: Default::default(),
|
||||
max_columns: default_u16_max(),
|
||||
max_columns_alignment: Default::default(),
|
||||
max_rows: default_u16_max(),
|
||||
max_rows_alignment: Default::default(),
|
||||
incremental_lists: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_font_size() -> u8 {
|
||||
/// The configuration for lists when incremental lists are enabled.
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct IncrementalListsConfig {
|
||||
/// Whether to pause before a list begins.
|
||||
#[serde(default)]
|
||||
pub pause_before: Option<bool>,
|
||||
|
||||
/// Whether to pause after a list ends.
|
||||
#[serde(default)]
|
||||
pub pause_after: Option<bool>,
|
||||
}
|
||||
|
||||
fn default_terminal_font_size() -> u8 {
|
||||
16
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
|
||||
/// The alignment to use when `defaults.max_columns` is set.
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MaxColumnsAlignment {
|
||||
/// Align the presentation to the left.
|
||||
Left,
|
||||
|
||||
/// Align the presentation on the center.
|
||||
#[default]
|
||||
Center,
|
||||
|
||||
/// Align the presentation to the right.
|
||||
Right,
|
||||
}
|
||||
|
||||
/// The alignment to use when `defaults.max_rows` is set.
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MaxRowsAlignment {
|
||||
/// Align the presentation to the top.
|
||||
Top,
|
||||
|
||||
/// Align the presentation on the center.
|
||||
#[default]
|
||||
Center,
|
||||
|
||||
/// Align the presentation to the bottom.
|
||||
Bottom,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ValidateOverflows {
|
||||
#[default]
|
||||
@ -103,7 +193,8 @@ pub enum ValidateOverflows {
|
||||
WhenDeveloping,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct OptionsConfig {
|
||||
/// Whether slides are automatically terminated when a slide title is found.
|
||||
@ -123,21 +214,31 @@ pub struct OptionsConfig {
|
||||
|
||||
/// Whether to be strict about parsing the presentation's front matter.
|
||||
pub strict_front_matter_parsing: Option<bool>,
|
||||
|
||||
/// Assume snippets for these languages contain `+render` and render them automatically.
|
||||
#[serde(default)]
|
||||
pub auto_render_languages: Vec<SnippetLanguage>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SnippetConfig {
|
||||
/// The properties for snippet execution.
|
||||
#[serde(default)]
|
||||
pub exec: SnippetExecConfig,
|
||||
|
||||
/// The properties for snippet execution.
|
||||
#[serde(default)]
|
||||
pub exec_replace: SnippetExecReplaceConfig,
|
||||
|
||||
/// The properties for snippet auto rendering.
|
||||
#[serde(default)]
|
||||
pub render: SnippetRenderConfig,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SnippetExecConfig {
|
||||
/// Whether to enable snippet execution.
|
||||
@ -148,7 +249,17 @@ pub struct SnippetExecConfig {
|
||||
pub custom: BTreeMap<SnippetLanguage, LanguageSnippetExecutionConfig>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema)]
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SnippetExecReplaceConfig {
|
||||
/// Whether to enable snippet replace-executions, which automatically run code snippets without
|
||||
/// the user's intervention.
|
||||
pub enable: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SnippetRenderConfig {
|
||||
/// The number of threads to use when rendering.
|
||||
@ -166,7 +277,8 @@ pub(crate) fn default_snippet_render_threads() -> usize {
|
||||
2
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema)]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TypstConfig {
|
||||
/// The pixels per inch when rendering latex/typst formulas.
|
||||
@ -184,7 +296,8 @@ pub(crate) fn default_typst_ppi() -> u32 {
|
||||
300
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema)]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MermaidConfig {
|
||||
/// The scaling parameter to be used in the mermaid CLI.
|
||||
@ -202,8 +315,13 @@ pub(crate) fn default_mermaid_scale() -> u32 {
|
||||
2
|
||||
}
|
||||
|
||||
pub(crate) fn default_u16_max() -> u16 {
|
||||
u16::MAX
|
||||
}
|
||||
|
||||
/// The snippet execution configuration for a specific programming language.
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema)]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
pub struct LanguageSnippetExecutionConfig {
|
||||
/// The filename to use for the snippet input file.
|
||||
pub filename: String,
|
||||
@ -214,9 +332,13 @@ pub struct LanguageSnippetExecutionConfig {
|
||||
|
||||
/// The commands to be run when executing snippets for this programming language.
|
||||
pub commands: Vec<Vec<String>>,
|
||||
|
||||
/// The prefix to use to hide lines visually but still execute them.
|
||||
pub hidden_line_prefix: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, ValueEnum, JsonSchema)]
|
||||
#[derive(Clone, Debug, Default, Deserialize, ValueEnum)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ImageProtocol {
|
||||
/// Automatically detect the best image protocol to use.
|
||||
@ -255,10 +377,10 @@ impl TryFrom<&ImageProtocol> for GraphicsMode {
|
||||
}
|
||||
ImageProtocol::Iterm2 => GraphicsMode::Iterm2,
|
||||
ImageProtocol::KittyLocal => {
|
||||
GraphicsMode::Kitty { mode: KittyMode::Local, inside_tmux: TerminalEmulator::is_inside_tmux() }
|
||||
GraphicsMode::Kitty { mode: KittyMode::Local, inside_tmux: TerminalCapabilities::is_inside_tmux() }
|
||||
}
|
||||
ImageProtocol::KittyRemote => {
|
||||
GraphicsMode::Kitty { mode: KittyMode::Remote, inside_tmux: TerminalEmulator::is_inside_tmux() }
|
||||
GraphicsMode::Kitty { mode: KittyMode::Remote, inside_tmux: TerminalCapabilities::is_inside_tmux() }
|
||||
}
|
||||
ImageProtocol::AsciiBlocks => GraphicsMode::AsciiBlocks,
|
||||
#[cfg(feature = "sixel")]
|
||||
@ -270,7 +392,8 @@ impl TryFrom<&ImageProtocol> for GraphicsMode {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema)]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct KeyBindingsConfig {
|
||||
/// The keys that cause the presentation to move forwards.
|
||||
@ -330,6 +453,10 @@ pub struct KeyBindingsConfig {
|
||||
/// The key binding to close the application.
|
||||
#[serde(default = "default_exit_bindings")]
|
||||
pub(crate) exit: Vec<KeyBinding>,
|
||||
|
||||
/// The key binding to suspend the application.
|
||||
#[serde(default = "default_suspend_bindings")]
|
||||
pub(crate) suspend: Vec<KeyBinding>,
|
||||
}
|
||||
|
||||
impl Default for KeyBindingsConfig {
|
||||
@ -348,10 +475,108 @@ impl Default for KeyBindingsConfig {
|
||||
toggle_bindings: default_toggle_bindings_modal_bindings(),
|
||||
close_modal: default_close_modal_bindings(),
|
||||
exit: default_exit_bindings(),
|
||||
suspend: default_suspend_bindings(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SpeakerNotesConfig {
|
||||
/// The address in which to listen for speaker note events.
|
||||
#[serde(default = "default_speaker_notes_listen_address")]
|
||||
pub listen_address: SocketAddr,
|
||||
|
||||
/// The address in which to publish speaker notes events.
|
||||
#[serde(default = "default_speaker_notes_publish_address")]
|
||||
pub publish_address: SocketAddr,
|
||||
|
||||
/// Whether to always publish speaker notes.
|
||||
#[serde(default)]
|
||||
pub always_publish: bool,
|
||||
}
|
||||
|
||||
impl Default for SpeakerNotesConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
listen_address: default_speaker_notes_listen_address(),
|
||||
publish_address: default_speaker_notes_publish_address(),
|
||||
always_publish: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The export configuration.
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ExportConfig {
|
||||
/// The dimensions to use for presentation exports.
|
||||
pub dimensions: Option<ExportDimensionsConfig>,
|
||||
|
||||
/// Whether pauses should create new slides.
|
||||
#[serde(default)]
|
||||
pub pauses: PauseExportPolicy,
|
||||
}
|
||||
|
||||
/// The policy for pauses when exporting.
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(deny_unknown_fields, rename_all = "snake_case")]
|
||||
pub enum PauseExportPolicy {
|
||||
/// Whether to ignore pauses.
|
||||
#[default]
|
||||
Ignore,
|
||||
|
||||
/// Create a new slide when a pause is found.
|
||||
NewSlide,
|
||||
}
|
||||
|
||||
/// The dimensions to use for presentation exports.
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ExportDimensionsConfig {
|
||||
/// The number of rows.
|
||||
pub rows: u16,
|
||||
|
||||
/// The number of columns.
|
||||
pub columns: u16,
|
||||
}
|
||||
|
||||
// The slide transition configuration.
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(tag = "style", deny_unknown_fields)]
|
||||
pub struct SlideTransitionConfig {
|
||||
/// The amount of time to take to perform the transition.
|
||||
#[serde(default = "default_transition_duration_millis")]
|
||||
pub duration_millis: u16,
|
||||
|
||||
/// The number of frames in a transition.
|
||||
#[serde(default = "default_transition_frames")]
|
||||
pub frames: usize,
|
||||
|
||||
/// The slide transition style.
|
||||
pub animation: SlideTransitionStyleConfig,
|
||||
}
|
||||
|
||||
// The slide transition style configuration.
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(tag = "style", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum SlideTransitionStyleConfig {
|
||||
/// Slide horizontally.
|
||||
SlideHorizontal,
|
||||
|
||||
/// Fade the new slide into the previous one.
|
||||
Fade,
|
||||
|
||||
/// Collapse the current slide into the center of the screen.
|
||||
CollapseHorizontal,
|
||||
}
|
||||
|
||||
fn make_keybindings<const N: usize>(raw_bindings: [&str; N]) -> Vec<KeyBinding> {
|
||||
let mut bindings = Vec::new();
|
||||
for binding in raw_bindings {
|
||||
@ -409,17 +634,54 @@ fn default_close_modal_bindings() -> Vec<KeyBinding> {
|
||||
}
|
||||
|
||||
fn default_exit_bindings() -> Vec<KeyBinding> {
|
||||
make_keybindings(["<c-c>"])
|
||||
make_keybindings(["<c-c>", "q"])
|
||||
}
|
||||
|
||||
fn default_suspend_bindings() -> Vec<KeyBinding> {
|
||||
make_keybindings(["<c-z>"])
|
||||
}
|
||||
|
||||
fn default_transition_duration_millis() -> u16 {
|
||||
1000
|
||||
}
|
||||
|
||||
fn default_transition_frames() -> usize {
|
||||
30
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn default_speaker_notes_listen_address() -> SocketAddr {
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 255, 255, 255)), 59418)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub(crate) fn default_speaker_notes_listen_address() -> SocketAddr {
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 59418)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub(crate) fn default_speaker_notes_publish_address() -> SocketAddr {
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 255, 255, 255)), 59418)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) fn default_speaker_notes_publish_address() -> SocketAddr {
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 59418)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::input::user::CommandKeyBindings;
|
||||
use crate::commands::keyboard::CommandKeyBindings;
|
||||
|
||||
#[test]
|
||||
fn default_bindings() {
|
||||
let config = KeyBindingsConfig::default();
|
||||
CommandKeyBindings::try_from(config).expect("construction failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_options_serde() {
|
||||
serde_yaml::from_str::<'_, OptionsConfig>("implicit_slide_ends: true").expect("failed to parse");
|
||||
}
|
||||
}
|
51
src/demo.rs
51
src/demo.rs
@ -1,16 +1,20 @@
|
||||
use crate::{
|
||||
execute::SnippetExecutor,
|
||||
input::{
|
||||
source::Command,
|
||||
user::{CommandKeyBindings, UserInput},
|
||||
ImageRegistry, MarkdownParser, PresentationBuilderOptions, Resources, ThemeOptions, Themes, ThirdPartyRender,
|
||||
code::execute::SnippetExecutor,
|
||||
commands::{
|
||||
keyboard::{CommandKeyBindings, KeyboardListener},
|
||||
listener::Command,
|
||||
},
|
||||
markdown::elements::MarkdownElement,
|
||||
presentation::Presentation,
|
||||
processing::builder::{BuildError, PresentationBuilder},
|
||||
render::{draw::TerminalDrawer, terminal::TerminalWrite},
|
||||
ImageRegistry, MarkdownParser, PresentationBuilderOptions, PresentationTheme, Resources, Themes, ThirdPartyRender,
|
||||
presentation::{
|
||||
Presentation,
|
||||
builder::{BuildError, PresentationBuilder},
|
||||
},
|
||||
render::TerminalDrawer,
|
||||
terminal::emulator::TerminalEmulator,
|
||||
theme::raw::PresentationTheme,
|
||||
};
|
||||
use std::{io, rc::Rc};
|
||||
use std::{io, sync::Arc};
|
||||
|
||||
const PRESENTATION: &str = r#"
|
||||
# Header 1
|
||||
@ -37,16 +41,16 @@ fn greet(name: &str) -> String {
|
||||
<!-- end_slide -->
|
||||
"#;
|
||||
|
||||
pub struct ThemesDemo<W: TerminalWrite> {
|
||||
pub struct ThemesDemo {
|
||||
themes: Themes,
|
||||
input: UserInput,
|
||||
drawer: TerminalDrawer<W>,
|
||||
input: KeyboardListener,
|
||||
drawer: TerminalDrawer,
|
||||
}
|
||||
|
||||
impl<W: TerminalWrite> ThemesDemo<W> {
|
||||
pub fn new(themes: Themes, bindings: CommandKeyBindings, writer: W) -> io::Result<Self> {
|
||||
let input = UserInput::new(bindings);
|
||||
let drawer = TerminalDrawer::new(writer, Default::default(), 1)?;
|
||||
impl ThemesDemo {
|
||||
pub fn new(themes: Themes, bindings: CommandKeyBindings) -> io::Result<Self> {
|
||||
let input = KeyboardListener::new(bindings);
|
||||
let drawer = TerminalDrawer::new(Default::default(), Default::default())?;
|
||||
Ok(Self { themes, input, drawer })
|
||||
}
|
||||
|
||||
@ -62,7 +66,7 @@ impl<W: TerminalWrite> ThemesDemo<W> {
|
||||
}
|
||||
let mut current = 0;
|
||||
loop {
|
||||
self.drawer.render_slide(&presentations[current])?;
|
||||
self.drawer.render_operations(presentations[current].current_slide().iter_visible_operations())?;
|
||||
|
||||
let command = self.next_command()?;
|
||||
match command {
|
||||
@ -99,21 +103,24 @@ impl<W: TerminalWrite> ThemesDemo<W> {
|
||||
theme: &PresentationTheme,
|
||||
) -> Result<Presentation, BuildError> {
|
||||
let image_registry = ImageRegistry::default();
|
||||
let mut resources = Resources::new("non_existent", image_registry.clone());
|
||||
let resources = Resources::new("non_existent", "non_existent", image_registry.clone());
|
||||
let mut third_party = ThirdPartyRender::default();
|
||||
let options = PresentationBuilderOptions::default();
|
||||
let executer = Rc::new(SnippetExecutor::default());
|
||||
let options = PresentationBuilderOptions {
|
||||
theme_options: ThemeOptions { font_size_supported: TerminalEmulator::capabilities().font_size },
|
||||
..Default::default()
|
||||
};
|
||||
let executer = Arc::new(SnippetExecutor::default());
|
||||
let bindings_config = Default::default();
|
||||
let builder = PresentationBuilder::new(
|
||||
theme,
|
||||
&mut resources,
|
||||
resources,
|
||||
&mut third_party,
|
||||
executer,
|
||||
&self.themes,
|
||||
image_registry,
|
||||
bindings_config,
|
||||
options,
|
||||
);
|
||||
)?;
|
||||
let mut elements = vec![MarkdownElement::SetexHeading { text: format!("theme: {theme_name}").into() }];
|
||||
elements.extend(base_elements.iter().cloned());
|
||||
builder.build(elements)
|
||||
|
420
src/export.rs
420
src/export.rs
@ -1,420 +0,0 @@
|
||||
use crate::{
|
||||
custom::KeyBindingsConfig,
|
||||
execute::SnippetExecutor,
|
||||
markdown::parse::ParseError,
|
||||
media::{
|
||||
image::{Image, ImageSource},
|
||||
printer::{ImageResource, ResourceProperties},
|
||||
},
|
||||
presentation::{AsRenderOperations, Presentation, RenderAsyncState, RenderOperation},
|
||||
processing::builder::{BuildError, PresentationBuilder, PresentationBuilderOptions, Themes},
|
||||
render::properties::WindowSize,
|
||||
third_party::ThirdPartyRender,
|
||||
tools::{ExecutionError, ThirdPartyTools},
|
||||
MarkdownParser, PresentationTheme, Resources,
|
||||
};
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use image::{codecs::png::PngEncoder, DynamicImage, ImageEncoder, ImageError};
|
||||
use semver::Version;
|
||||
use serde::Serialize;
|
||||
use std::{
|
||||
env, fs, io, iter,
|
||||
path::{Path, PathBuf},
|
||||
rc::Rc,
|
||||
thread::sleep,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
const MINIMUM_EXPORTER_VERSION: Version = Version::new(0, 2, 0);
|
||||
const ASYNC_RENDER_WAIT_COUNT: usize = 8;
|
||||
|
||||
/// Allows exporting presentations into PDF.
|
||||
pub struct Exporter<'a> {
|
||||
parser: MarkdownParser<'a>,
|
||||
default_theme: &'a PresentationTheme,
|
||||
resources: Resources,
|
||||
third_party: ThirdPartyRender,
|
||||
code_executor: Rc<SnippetExecutor>,
|
||||
themes: Themes,
|
||||
options: PresentationBuilderOptions,
|
||||
}
|
||||
|
||||
impl<'a> Exporter<'a> {
|
||||
/// Construct a new exporter.
|
||||
pub fn new(
|
||||
parser: MarkdownParser<'a>,
|
||||
default_theme: &'a PresentationTheme,
|
||||
resources: Resources,
|
||||
third_party: ThirdPartyRender,
|
||||
code_executor: Rc<SnippetExecutor>,
|
||||
themes: Themes,
|
||||
options: PresentationBuilderOptions,
|
||||
) -> Self {
|
||||
Self { parser, default_theme, resources, third_party, code_executor, themes, options }
|
||||
}
|
||||
|
||||
/// Export the given presentation into PDF.
|
||||
///
|
||||
/// This uses a separate `presenterm-export` tool.
|
||||
pub fn export_pdf(&mut self, presentation_path: &Path, extra_args: &[&str]) -> Result<(), ExportError> {
|
||||
Self::validate_exporter_version()?;
|
||||
|
||||
println!("Analyzing presentation...");
|
||||
let metadata = self.generate_metadata(presentation_path)?;
|
||||
println!("Invoking presenterm-export...");
|
||||
Self::execute_exporter(metadata, extra_args)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generate the metadata for the given presentation.
|
||||
pub fn generate_metadata(&mut self, presentation_path: &Path) -> Result<ExportMetadata, ExportError> {
|
||||
let content = fs::read_to_string(presentation_path).map_err(ExportError::ReadPresentation)?;
|
||||
let metadata = self.extract_metadata(&content, presentation_path)?;
|
||||
Ok(metadata)
|
||||
}
|
||||
|
||||
fn validate_exporter_version() -> Result<(), ExportError> {
|
||||
let result = ThirdPartyTools::presenterm_export(&["--version"]).run_and_capture_stdout();
|
||||
let version = match result {
|
||||
Ok(version) => String::from_utf8(version).expect("not utf8"),
|
||||
Err(ExecutionError::Execution { .. }) => return Err(ExportError::MinimumVersion),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let version = Version::parse(version.trim()).map_err(|_| ExportError::MinimumVersion)?;
|
||||
if version >= MINIMUM_EXPORTER_VERSION { Ok(()) } else { Err(ExportError::MinimumVersion) }
|
||||
}
|
||||
|
||||
/// Extract the metadata necessary to make an export.
|
||||
fn extract_metadata(&mut self, content: &str, path: &Path) -> Result<ExportMetadata, ExportError> {
|
||||
let elements = self.parser.parse(content)?;
|
||||
let path = path.canonicalize().expect("canonicalize");
|
||||
let mut presentation = PresentationBuilder::new(
|
||||
self.default_theme,
|
||||
&mut self.resources,
|
||||
&mut self.third_party,
|
||||
self.code_executor.clone(),
|
||||
&self.themes,
|
||||
Default::default(),
|
||||
KeyBindingsConfig::default(),
|
||||
self.options.clone(),
|
||||
)
|
||||
.build(elements)?;
|
||||
|
||||
let async_renders = Self::count_async_render_operations(&presentation);
|
||||
let images = Self::build_image_metadata(&mut presentation)?;
|
||||
Self::validate_theme_colors(&presentation)?;
|
||||
let commands = Self::build_capture_commands(presentation, async_renders);
|
||||
let metadata = ExportMetadata { commands, presentation_path: path, images };
|
||||
Ok(metadata)
|
||||
}
|
||||
|
||||
fn execute_exporter(metadata: ExportMetadata, extra_args: &[&str]) -> Result<(), ExportError> {
|
||||
let presenterm_path = env::current_exe().map_err(ExportError::Io)?;
|
||||
let presenterm_path = presenterm_path.display().to_string();
|
||||
let presentation_path = metadata.presentation_path.display().to_string();
|
||||
let metadata = serde_json::to_vec(&metadata).expect("serialization failed");
|
||||
let mut args = vec![&presenterm_path, "--enable-export-mode"];
|
||||
args.extend(extra_args);
|
||||
args.push(&presentation_path);
|
||||
ThirdPartyTools::presenterm_export(&args).stdin(metadata).run()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_capture_commands(mut presentation: Presentation, async_renders: usize) -> Vec<CaptureCommand> {
|
||||
let mut commands = Vec::new();
|
||||
let slide_chunks: Vec<_> = presentation.iter_slides().map(|slide| slide.iter_chunks().count()).collect();
|
||||
let mut next_slide = |commands: &mut Vec<CaptureCommand>| {
|
||||
commands.push(CaptureCommand::SendKeys { keys: "l" });
|
||||
commands.push(CaptureCommand::WaitForChange);
|
||||
presentation.jump_next();
|
||||
};
|
||||
commands.extend(iter::repeat(CaptureCommand::WaitForChange).take(ASYNC_RENDER_WAIT_COUNT * async_renders));
|
||||
for chunks in slide_chunks {
|
||||
for _ in 0..chunks - 1 {
|
||||
next_slide(&mut commands);
|
||||
}
|
||||
commands.push(CaptureCommand::Capture);
|
||||
next_slide(&mut commands);
|
||||
}
|
||||
commands
|
||||
}
|
||||
|
||||
fn count_async_render_operations(presentation: &Presentation) -> usize {
|
||||
presentation
|
||||
.iter_slides()
|
||||
.map(|slide| {
|
||||
slide.iter_visible_operations().filter(|op| matches!(op, RenderOperation::RenderAsync(_))).count()
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
fn build_image_metadata(presentation: &mut Presentation) -> Result<Vec<ImageMetadata>, ExportError> {
|
||||
let mut replacer = ImageReplacer::default();
|
||||
replacer.replace_presentation_images(presentation);
|
||||
|
||||
let mut positions = Vec::new();
|
||||
for image in replacer.images {
|
||||
let meta = match image.original.source {
|
||||
ImageSource::Filesystem(path) => {
|
||||
let path = Some(path.canonicalize().map_err(ExportError::Io)?);
|
||||
ImageMetadata { path, color: image.color, contents: None }
|
||||
}
|
||||
ImageSource::Generated => {
|
||||
let mut buffer = Vec::new();
|
||||
let dimensions = image.original.dimensions();
|
||||
let ImageResource::Ascii(resource) = image.original.resource.as_ref() else {
|
||||
panic!("not in ascii mode")
|
||||
};
|
||||
PngEncoder::new(&mut buffer).write_image(
|
||||
resource.as_bytes(),
|
||||
dimensions.0,
|
||||
dimensions.1,
|
||||
resource.color().into(),
|
||||
)?;
|
||||
let contents = Some(STANDARD.encode(buffer));
|
||||
ImageMetadata { path: None, color: image.color, contents }
|
||||
}
|
||||
};
|
||||
positions.push(meta);
|
||||
}
|
||||
Ok(positions)
|
||||
}
|
||||
|
||||
fn validate_theme_colors(presentation: &Presentation) -> Result<(), ExportError> {
|
||||
for slide in presentation.iter_slides() {
|
||||
for operation in slide.iter_visible_operations() {
|
||||
let RenderOperation::SetColors(colors) = operation else {
|
||||
continue;
|
||||
};
|
||||
// The PDF requires a specific theme to be set, as "no background" means "what the
|
||||
// browser uses" which is likely white and it will probably look terrible. It's
|
||||
// better to err early and let you choose a theme that contains _some_ color.
|
||||
if colors.background.is_none() {
|
||||
return Err(ExportError::UnsupportedColor("background"));
|
||||
}
|
||||
if colors.foreground.is_none() {
|
||||
return Err(ExportError::UnsupportedColor("foreground"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum ExportError {
|
||||
#[error("failed to read presentation: {0}")]
|
||||
ReadPresentation(io::Error),
|
||||
|
||||
#[error("failed to parse presentation: {0}")]
|
||||
ParsePresentation(#[from] ParseError),
|
||||
|
||||
#[error("failed to build presentation: {0}")]
|
||||
BuildPresentation(#[from] BuildError),
|
||||
|
||||
#[error("unsupported {0} color in theme")]
|
||||
UnsupportedColor(&'static str),
|
||||
|
||||
#[error("generating images: {0}")]
|
||||
GeneratingImages(#[from] ImageError),
|
||||
|
||||
#[error(transparent)]
|
||||
Execution(#[from] ExecutionError),
|
||||
|
||||
#[error("minimum presenterm-export version ({MINIMUM_EXPORTER_VERSION}) not met")]
|
||||
MinimumVersion,
|
||||
|
||||
#[error("io: {0}")]
|
||||
Io(io::Error),
|
||||
}
|
||||
|
||||
/// The metadata necessary to export a presentation.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ExportMetadata {
|
||||
presentation_path: PathBuf,
|
||||
images: Vec<ImageMetadata>,
|
||||
commands: Vec<CaptureCommand>,
|
||||
}
|
||||
|
||||
/// Metadata about an image.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
struct ImageMetadata {
|
||||
path: Option<PathBuf>,
|
||||
contents: Option<String>,
|
||||
color: u32,
|
||||
}
|
||||
|
||||
/// A command to whoever is capturing us indicating what to do.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "snake_case", tag = "type")]
|
||||
enum CaptureCommand {
|
||||
Capture,
|
||||
SendKeys { keys: &'static str },
|
||||
WaitForChange,
|
||||
}
|
||||
|
||||
struct ReplacedImage {
|
||||
original: Image,
|
||||
color: u32,
|
||||
}
|
||||
|
||||
pub(crate) struct ImageReplacer {
|
||||
next_color: u32,
|
||||
images: Vec<ReplacedImage>,
|
||||
}
|
||||
|
||||
impl ImageReplacer {
|
||||
pub(crate) fn replace_presentation_images(&mut self, presentation: &mut Presentation) {
|
||||
let callback = |operation: &mut RenderOperation| {
|
||||
match operation {
|
||||
RenderOperation::RenderImage(image, properties) => {
|
||||
let replacement = self.replace_image(image.clone());
|
||||
*operation = RenderOperation::RenderImage(replacement, properties.clone());
|
||||
}
|
||||
RenderOperation::RenderAsync(inner) => {
|
||||
loop {
|
||||
match inner.poll_state() {
|
||||
RenderAsyncState::NotStarted => return,
|
||||
RenderAsyncState::Rendering { .. } => {
|
||||
sleep(Duration::from_millis(200));
|
||||
continue;
|
||||
}
|
||||
RenderAsyncState::Rendered | RenderAsyncState::JustFinishedRendering => break,
|
||||
};
|
||||
}
|
||||
|
||||
let window_size = WindowSize { rows: 0, columns: 0, width: 0, height: 0 };
|
||||
let mut new_operations = Vec::new();
|
||||
for operation in inner.as_render_operations(&window_size) {
|
||||
if let RenderOperation::RenderImage(image, properties) = operation {
|
||||
let image = self.replace_image(image);
|
||||
new_operations.push(RenderOperation::RenderImage(image, properties));
|
||||
} else {
|
||||
new_operations.push(operation);
|
||||
}
|
||||
}
|
||||
// Replace this operation with a new operation that contains the replaced image
|
||||
// and any other unmodified operations.
|
||||
*operation = RenderOperation::RenderDynamic(Rc::new(RenderMany(new_operations)));
|
||||
}
|
||||
_ => (),
|
||||
};
|
||||
};
|
||||
|
||||
presentation.mutate_operations(callback);
|
||||
}
|
||||
|
||||
fn replace_image(&mut self, image: Image) -> Image {
|
||||
let dimensions = image.dimensions();
|
||||
let color = self.allocate_color();
|
||||
let rgb_color = Self::as_rgb(color);
|
||||
|
||||
let mut replacement = DynamicImage::new_rgb8(dimensions.0, dimensions.1);
|
||||
let buffer = replacement.as_mut_rgb8().expect("not rgb8");
|
||||
for pixel in buffer.pixels_mut() {
|
||||
pixel.0 = rgb_color;
|
||||
}
|
||||
self.images.push(ReplacedImage { original: image, color });
|
||||
|
||||
Image::new(ImageResource::Ascii(replacement.into()), ImageSource::Generated)
|
||||
}
|
||||
|
||||
fn allocate_color(&mut self) -> u32 {
|
||||
let color = self.next_color;
|
||||
self.next_color += 1;
|
||||
color
|
||||
}
|
||||
|
||||
fn as_rgb(color: u32) -> [u8; 3] {
|
||||
[(color >> 16) as u8, (color >> 8) as u8, (color & 0xff) as u8]
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ImageReplacer {
|
||||
fn default() -> Self {
|
||||
Self { next_color: 0xffbad3, images: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct RenderMany(Vec<RenderOperation>);
|
||||
|
||||
impl AsRenderOperations for RenderMany {
|
||||
fn as_render_operations(&self, _: &WindowSize) -> Vec<RenderOperation> {
|
||||
self.0.clone()
|
||||
}
|
||||
|
||||
fn diffable_content(&self) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::theme::PresentationThemeSet;
|
||||
use comrak::Arena;
|
||||
|
||||
fn extract_metadata(content: &str, path: &str) -> ExportMetadata {
|
||||
let arena = Arena::new();
|
||||
let parser = MarkdownParser::new(&arena);
|
||||
let theme = PresentationThemeSet::default().load_by_name("dark").unwrap();
|
||||
let resources = Resources::new("examples", Default::default());
|
||||
let third_party = ThirdPartyRender::default();
|
||||
let code_executor = Default::default();
|
||||
let themes = Themes::default();
|
||||
let options = PresentationBuilderOptions { allow_mutations: false, ..Default::default() };
|
||||
let mut exporter = Exporter::new(parser, &theme, resources, third_party, code_executor, themes, options);
|
||||
exporter.extract_metadata(content, Path::new(path)).expect("metadata extraction failed")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata() {
|
||||
let presentation = r"
|
||||
First
|
||||
|
||||
<!-- end_slide -->
|
||||
|
||||
hi
|
||||
<!-- pause -->
|
||||
mom
|
||||
|
||||
<!-- end_slide -->
|
||||
|
||||

|
||||
|
||||
<!-- end_slide -->
|
||||
|
||||
bye
|
||||
<!-- pause -->
|
||||
mom
|
||||
";
|
||||
|
||||
let meta = extract_metadata(presentation, "examples/demo.md");
|
||||
|
||||
use CaptureCommand::*;
|
||||
let expected_commands = vec![
|
||||
// First slide
|
||||
Capture,
|
||||
SendKeys { keys: "l" },
|
||||
WaitForChange,
|
||||
// Second slide...
|
||||
SendKeys { keys: "l" },
|
||||
WaitForChange,
|
||||
Capture,
|
||||
SendKeys { keys: "l" },
|
||||
WaitForChange,
|
||||
// Third slide...
|
||||
Capture,
|
||||
SendKeys { keys: "l" },
|
||||
WaitForChange,
|
||||
// Fourth slide...
|
||||
SendKeys { keys: "l" },
|
||||
WaitForChange,
|
||||
Capture,
|
||||
SendKeys { keys: "l" },
|
||||
WaitForChange,
|
||||
];
|
||||
assert_eq!(meta.commands, expected_commands);
|
||||
}
|
||||
}
|
313
src/export/exporter.rs
Normal file
313
src/export/exporter.rs
Normal file
@ -0,0 +1,313 @@
|
||||
use crate::{
|
||||
MarkdownParser, Resources,
|
||||
code::execute::SnippetExecutor,
|
||||
config::{KeyBindingsConfig, PauseExportPolicy},
|
||||
export::output::{ExportRenderer, OutputFormat},
|
||||
markdown::{parse::ParseError, text_style::Color},
|
||||
presentation::{
|
||||
Presentation,
|
||||
builder::{BuildError, PresentationBuilder, PresentationBuilderOptions, Themes},
|
||||
poller::{Poller, PollerCommand},
|
||||
},
|
||||
render::{
|
||||
RenderError,
|
||||
operation::{AsRenderOperations, PollableState, RenderOperation},
|
||||
properties::WindowSize,
|
||||
},
|
||||
theme::{ProcessingThemeError, raw::PresentationTheme},
|
||||
third_party::ThirdPartyRender,
|
||||
tools::{ExecutionError, ThirdPartyTools},
|
||||
};
|
||||
use crossterm::{
|
||||
cursor::{MoveToColumn, MoveToNextLine, MoveUp},
|
||||
execute,
|
||||
style::{Print, PrintStyledContent, Stylize},
|
||||
terminal::{Clear, ClearType},
|
||||
};
|
||||
use image::ImageError;
|
||||
use std::{
|
||||
fs, io,
|
||||
path::{Path, PathBuf},
|
||||
rc::Rc,
|
||||
sync::Arc,
|
||||
};
|
||||
use tempfile::TempDir;
|
||||
|
||||
pub enum OutputDirectory {
|
||||
Temporary(TempDir),
|
||||
External(PathBuf),
|
||||
}
|
||||
|
||||
impl OutputDirectory {
|
||||
pub fn temporary() -> io::Result<Self> {
|
||||
let dir = TempDir::with_suffix("presenterm")?;
|
||||
Ok(Self::Temporary(dir))
|
||||
}
|
||||
|
||||
pub fn external(path: PathBuf) -> io::Result<Self> {
|
||||
fs::create_dir_all(&path)?;
|
||||
Ok(Self::External(path))
|
||||
}
|
||||
|
||||
pub(crate) fn path(&self) -> &Path {
|
||||
match self {
|
||||
Self::Temporary(temp) => temp.path(),
|
||||
Self::External(path) => path,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Allows exporting presentations into PDF.
|
||||
pub struct Exporter<'a> {
|
||||
parser: MarkdownParser<'a>,
|
||||
default_theme: &'a PresentationTheme,
|
||||
resources: Resources,
|
||||
third_party: ThirdPartyRender,
|
||||
code_executor: Arc<SnippetExecutor>,
|
||||
themes: Themes,
|
||||
dimensions: WindowSize,
|
||||
options: PresentationBuilderOptions,
|
||||
}
|
||||
|
||||
impl<'a> Exporter<'a> {
|
||||
/// Construct a new exporter.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
parser: MarkdownParser<'a>,
|
||||
default_theme: &'a PresentationTheme,
|
||||
resources: Resources,
|
||||
third_party: ThirdPartyRender,
|
||||
code_executor: Arc<SnippetExecutor>,
|
||||
themes: Themes,
|
||||
mut options: PresentationBuilderOptions,
|
||||
mut dimensions: WindowSize,
|
||||
pause_policy: PauseExportPolicy,
|
||||
) -> Self {
|
||||
// We don't want dynamically highlighted code blocks.
|
||||
options.allow_mutations = false;
|
||||
options.theme_options.font_size_supported = true;
|
||||
options.pause_create_new_slide = match pause_policy {
|
||||
PauseExportPolicy::Ignore => false,
|
||||
PauseExportPolicy::NewSlide => true,
|
||||
};
|
||||
|
||||
// Make sure we have a 1:2 aspect ratio.
|
||||
let width = (0.5 * dimensions.columns as f64) / (dimensions.rows as f64 / dimensions.height as f64);
|
||||
dimensions.width = width as u16;
|
||||
|
||||
Self { parser, default_theme, resources, third_party, code_executor, themes, options, dimensions }
|
||||
}
|
||||
|
||||
fn build_renderer(
|
||||
&mut self,
|
||||
presentation_path: &Path,
|
||||
output_directory: OutputDirectory,
|
||||
renderer: OutputFormat,
|
||||
) -> Result<ExportRenderer, ExportError> {
|
||||
let content = fs::read_to_string(presentation_path).map_err(ExportError::ReadPresentation)?;
|
||||
let elements = self.parser.parse(&content)?;
|
||||
|
||||
let mut presentation = PresentationBuilder::new(
|
||||
self.default_theme,
|
||||
self.resources.clone(),
|
||||
&mut self.third_party,
|
||||
self.code_executor.clone(),
|
||||
&self.themes,
|
||||
Default::default(),
|
||||
KeyBindingsConfig::default(),
|
||||
self.options.clone(),
|
||||
)?
|
||||
.build(elements)?;
|
||||
Self::validate_theme_colors(&presentation)?;
|
||||
|
||||
let mut render = ExportRenderer::new(self.dimensions.clone(), output_directory, renderer);
|
||||
Self::log("waiting for images to be generated and code to be executed, if any...")?;
|
||||
Self::render_async_images(&mut presentation);
|
||||
|
||||
for (index, slide) in presentation.into_slides().into_iter().enumerate() {
|
||||
let index = index + 1;
|
||||
Self::log(&format!("processing slide {index}..."))?;
|
||||
render.process_slide(slide)?;
|
||||
}
|
||||
Self::log("invoking weasyprint...")?;
|
||||
|
||||
Ok(render)
|
||||
}
|
||||
|
||||
/// Export the given presentation into PDF.
|
||||
pub fn export_pdf(
|
||||
mut self,
|
||||
presentation_path: &Path,
|
||||
output_directory: OutputDirectory,
|
||||
output_path: Option<&Path>,
|
||||
) -> Result<(), ExportError> {
|
||||
println!(
|
||||
"exporting using rows={}, columns={}, width={}, height={}",
|
||||
self.dimensions.rows, self.dimensions.columns, self.dimensions.width, self.dimensions.height
|
||||
);
|
||||
|
||||
println!("checking for weasyprint...");
|
||||
Self::validate_weasyprint_exists()?;
|
||||
Self::log("weasyprint installation found")?;
|
||||
|
||||
let render = self.build_renderer(presentation_path, output_directory, OutputFormat::Pdf)?;
|
||||
|
||||
let pdf_path = match output_path {
|
||||
Some(path) => path.to_path_buf(),
|
||||
None => presentation_path.with_extension("pdf"),
|
||||
};
|
||||
|
||||
render.generate(&pdf_path)?;
|
||||
|
||||
execute!(
|
||||
io::stdout(),
|
||||
PrintStyledContent(
|
||||
format!("output file is at {}\n", pdf_path.display()).stylize().with(Color::Green.into())
|
||||
)
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Export the given presentation into HTML.
|
||||
pub fn export_html(
|
||||
mut self,
|
||||
presentation_path: &Path,
|
||||
output_directory: OutputDirectory,
|
||||
output_path: Option<&Path>,
|
||||
) -> Result<(), ExportError> {
|
||||
println!(
|
||||
"exporting using rows={}, columns={}, width={}, height={}",
|
||||
self.dimensions.rows, self.dimensions.columns, self.dimensions.width, self.dimensions.height
|
||||
);
|
||||
|
||||
let render = self.build_renderer(presentation_path, output_directory, OutputFormat::Html)?;
|
||||
|
||||
let output_path = match output_path {
|
||||
Some(path) => path.to_path_buf(),
|
||||
None => presentation_path.with_extension("html"),
|
||||
};
|
||||
|
||||
render.generate(&output_path)?;
|
||||
|
||||
execute!(
|
||||
io::stdout(),
|
||||
PrintStyledContent(
|
||||
format!("output file is at {}\n", output_path.display()).stylize().with(Color::Green.into())
|
||||
)
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_async_images(presentation: &mut Presentation) {
|
||||
let poller = Poller::launch();
|
||||
let mut pollables = Vec::new();
|
||||
for (index, slide) in presentation.iter_slides().enumerate() {
|
||||
for op in slide.iter_operations() {
|
||||
if let RenderOperation::RenderAsync(inner) = op {
|
||||
// Send a pollable to the poller and keep one for ourselves.
|
||||
poller.send(PollerCommand::Poll { pollable: inner.pollable(), slide: index });
|
||||
pollables.push(inner.pollable())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Poll until they're all done
|
||||
for mut pollable in pollables {
|
||||
while let PollableState::Unmodified | PollableState::Modified = pollable.poll() {}
|
||||
}
|
||||
|
||||
// Replace render asyncs with new operations that contains the replaced image
|
||||
// and any other unmodified operations.
|
||||
for slide in presentation.iter_slides_mut() {
|
||||
for op in slide.iter_operations_mut() {
|
||||
if let RenderOperation::RenderAsync(inner) = op {
|
||||
let window_size = WindowSize { rows: 0, columns: 0, width: 0, height: 0 };
|
||||
let new_operations = inner.as_render_operations(&window_size);
|
||||
*op = RenderOperation::RenderDynamic(Rc::new(RenderMany(new_operations)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_weasyprint_exists() -> Result<(), ExportError> {
|
||||
let result = ThirdPartyTools::weasyprint(&["--version"]).run_and_capture_stdout();
|
||||
match result {
|
||||
Ok(_) => Ok(()),
|
||||
Err(ExecutionError::Execution { .. }) => Err(ExportError::WeasyprintMissing),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_theme_colors(presentation: &Presentation) -> Result<(), ExportError> {
|
||||
for slide in presentation.iter_slides() {
|
||||
for operation in slide.iter_visible_operations() {
|
||||
let RenderOperation::SetColors(colors) = operation else {
|
||||
continue;
|
||||
};
|
||||
// The PDF requires a specific theme to be set, as "no background" means "what the
|
||||
// browser uses" which is likely white and it will probably look terrible. It's
|
||||
// better to err early and let you choose a theme that contains _some_ color.
|
||||
if colors.background.is_none() {
|
||||
return Err(ExportError::UnsupportedColor("background"));
|
||||
}
|
||||
if colors.foreground.is_none() {
|
||||
return Err(ExportError::UnsupportedColor("foreground"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn log(text: &str) -> io::Result<()> {
|
||||
execute!(
|
||||
io::stdout(),
|
||||
MoveUp(1),
|
||||
Clear(ClearType::CurrentLine),
|
||||
MoveToColumn(0),
|
||||
Print(text),
|
||||
MoveToNextLine(1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum ExportError {
|
||||
#[error("failed to read presentation: {0}")]
|
||||
ReadPresentation(io::Error),
|
||||
|
||||
#[error("failed to parse presentation: {0}")]
|
||||
ParsePresentation(#[from] ParseError),
|
||||
|
||||
#[error("failed to build presentation: {0}")]
|
||||
BuildPresentation(#[from] BuildError),
|
||||
|
||||
#[error("unsupported {0} color in theme")]
|
||||
UnsupportedColor(&'static str),
|
||||
|
||||
#[error("generating images: {0}")]
|
||||
GeneratingImages(#[from] ImageError),
|
||||
|
||||
#[error(transparent)]
|
||||
Execution(#[from] ExecutionError),
|
||||
|
||||
#[error("weasyprint not found")]
|
||||
WeasyprintMissing,
|
||||
|
||||
#[error("processing theme: {0}")]
|
||||
ProcessingTheme(#[from] ProcessingThemeError),
|
||||
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] io::Error),
|
||||
|
||||
#[error("render: {0}")]
|
||||
Render(#[from] RenderError),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct RenderMany(Vec<RenderOperation>);
|
||||
|
||||
impl AsRenderOperations for RenderMany {
|
||||
fn as_render_operations(&self, _: &WindowSize) -> Vec<RenderOperation> {
|
||||
self.0.clone()
|
||||
}
|
||||
}
|
122
src/export/html.rs
Normal file
122
src/export/html.rs
Normal file
@ -0,0 +1,122 @@
|
||||
use crate::markdown::text_style::{Color, TextAttribute, TextStyle};
|
||||
use std::{borrow::Cow, fmt};
|
||||
|
||||
pub(crate) enum HtmlText {
|
||||
Plain(String),
|
||||
Styled { text: String, style: String },
|
||||
}
|
||||
|
||||
impl HtmlText {
|
||||
pub(crate) fn new(text: &str, style: &TextStyle, font_size: FontSize) -> Self {
|
||||
if style == &TextStyle::default() {
|
||||
return Self::Plain(text.to_string());
|
||||
}
|
||||
let mut css_styles = Vec::new();
|
||||
let mut text_decorations = Vec::new();
|
||||
for attr in style.iter_attributes() {
|
||||
match attr {
|
||||
TextAttribute::Bold => css_styles.push(Cow::Borrowed("font-weight: bold")),
|
||||
TextAttribute::Italics => css_styles.push(Cow::Borrowed("font-style: italic")),
|
||||
TextAttribute::Strikethrough => text_decorations.push(Cow::Borrowed("line-through")),
|
||||
TextAttribute::Underlined => text_decorations.push(Cow::Borrowed("underline")),
|
||||
TextAttribute::ForegroundColor(color) => {
|
||||
let color = color_to_html(&color);
|
||||
css_styles.push(format!("color: {color}").into());
|
||||
}
|
||||
TextAttribute::BackgroundColor(color) => {
|
||||
let color = color_to_html(&color);
|
||||
css_styles.push(format!("background-color: {color}").into());
|
||||
}
|
||||
};
|
||||
}
|
||||
if !text_decorations.is_empty() {
|
||||
let text_decoration = text_decorations.join(" ");
|
||||
css_styles.push(format!("text-decoration: {text_decoration}").into());
|
||||
}
|
||||
if style.size > 1 {
|
||||
let font_size = font_size.scale(style.size);
|
||||
css_styles.push(format!("font-size: {font_size}").into());
|
||||
}
|
||||
let css_style = css_styles.join("; ");
|
||||
Self::Styled { text: text.to_string(), style: css_style }
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for HtmlText {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Plain(text) => write!(f, "{text}"),
|
||||
Self::Styled { text, style } => write!(f, "<span style=\"{style}\">{text}</span>"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) enum FontSize {
|
||||
Pixels(u16),
|
||||
}
|
||||
|
||||
impl FontSize {
|
||||
fn scale(&self, size: u8) -> String {
|
||||
match self {
|
||||
Self::Pixels(scale) => format!("{}px", scale * size as u16),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn color_to_html(color: &Color) -> String {
|
||||
match color {
|
||||
Color::Black => "#000000".into(),
|
||||
Color::DarkGrey => "#5a5a5a".into(),
|
||||
Color::Red => "#ff0000".into(),
|
||||
Color::DarkRed => "#8b0000".into(),
|
||||
Color::Green => "#00ff00".into(),
|
||||
Color::DarkGreen => "#006400".into(),
|
||||
Color::Yellow => "#ffff00".into(),
|
||||
Color::DarkYellow => "#8b8000".into(),
|
||||
Color::Blue => "#0000ff".into(),
|
||||
Color::DarkBlue => "#00008b".into(),
|
||||
Color::Magenta => "#ff00ff".into(),
|
||||
Color::DarkMagenta => "#8b008b".into(),
|
||||
Color::Cyan => "#00ffff".into(),
|
||||
Color::DarkCyan => "#008b8b".into(),
|
||||
Color::White => "#ffffff".into(),
|
||||
Color::Grey => "#808080".into(),
|
||||
Color::Rgb { r, g, b } => format!("#{r:02x}{g:02x}{b:02x}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
#[case::none(TextStyle::default(), "")]
|
||||
#[case::bold(TextStyle::default().bold(), "font-weight: bold")]
|
||||
#[case::italics(TextStyle::default().italics(), "font-style: italic")]
|
||||
#[case::bold_italics(TextStyle::default().bold().italics(), "font-weight: bold; font-style: italic")]
|
||||
#[case::strikethrough(TextStyle::default().strikethrough(), "text-decoration: line-through")]
|
||||
#[case::underlined(TextStyle::default().underlined(), "text-decoration: underline")]
|
||||
#[case::strikethrough_underlined(
|
||||
TextStyle::default().strikethrough().underlined(),
|
||||
"text-decoration: line-through underline"
|
||||
)]
|
||||
#[case::foreground_color(TextStyle::default().fg_color(Color::new(1,2,3)), "color: #010203")]
|
||||
#[case::background_color(TextStyle::default().bg_color(Color::new(1,2,3)), "background-color: #010203")]
|
||||
#[case::font_size(TextStyle::default().size(3), "font-size: 6px")]
|
||||
fn html_text(#[case] style: TextStyle, #[case] expected_style: &str) {
|
||||
let html_text = HtmlText::new("", &style, FontSize::Pixels(2));
|
||||
let style = match &html_text {
|
||||
HtmlText::Plain(_) => "",
|
||||
HtmlText::Styled { style, .. } => &style,
|
||||
};
|
||||
assert_eq!(style, expected_style);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_span() {
|
||||
let html_text = HtmlText::new("hi", &TextStyle::default().bold(), FontSize::Pixels(1));
|
||||
let rendered = html_text.to_string();
|
||||
assert_eq!(rendered, "<span style=\"font-weight: bold\">hi</span>");
|
||||
}
|
||||
}
|
3
src/export/mod.rs
Normal file
3
src/export/mod.rs
Normal file
@ -0,0 +1,3 @@
|
||||
pub mod exporter;
|
||||
pub(crate) mod html;
|
||||
pub(crate) mod output;
|
260
src/export/output.rs
Normal file
260
src/export/output.rs
Normal file
@ -0,0 +1,260 @@
|
||||
use super::{
|
||||
exporter::{ExportError, OutputDirectory},
|
||||
html::{FontSize, color_to_html},
|
||||
};
|
||||
use crate::{
|
||||
export::html::HtmlText,
|
||||
markdown::text_style::TextStyle,
|
||||
presentation::Slide,
|
||||
render::{engine::RenderEngine, properties::WindowSize},
|
||||
terminal::{
|
||||
image::printer::TerminalImage,
|
||||
virt::{TerminalGrid, VirtualTerminal},
|
||||
},
|
||||
tools::ThirdPartyTools,
|
||||
};
|
||||
use std::{
|
||||
fs, io,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
// A magical multiplier that converts a font size in pixels to a font width.
|
||||
//
|
||||
// There's probably something somewhere that specifies what the relationship
|
||||
// really is but I found this by trial and error an I'm okay with that.
|
||||
const FONT_SIZE_WIDTH: f64 = 0.605;
|
||||
|
||||
const FONT_SIZE: u16 = 10;
|
||||
const LINE_HEIGHT: u16 = 12;
|
||||
|
||||
struct HtmlSlide {
|
||||
rows: Vec<String>,
|
||||
background_color: Option<String>,
|
||||
}
|
||||
|
||||
impl HtmlSlide {
|
||||
fn new(grid: TerminalGrid) -> Result<Self, ExportError> {
|
||||
let mut rows = Vec::new();
|
||||
rows.push(String::from("<div class=\"container\">"));
|
||||
for (y, row) in grid.rows.into_iter().enumerate() {
|
||||
let mut finalized_row = "<div class=\"content-line\"><pre>".to_string();
|
||||
let mut current_style = row.first().map(|c| c.style).unwrap_or_default();
|
||||
let mut current_string = String::new();
|
||||
let mut x = 0;
|
||||
while x < row.len() {
|
||||
let c = row[x];
|
||||
if c.style != current_style {
|
||||
finalized_row.push_str(&Self::finalize_string(¤t_string, ¤t_style));
|
||||
current_string = String::new();
|
||||
current_style = c.style;
|
||||
}
|
||||
match c.character {
|
||||
'<' => current_string.push_str("<"),
|
||||
'>' => current_string.push_str(">"),
|
||||
other => current_string.push(other),
|
||||
}
|
||||
if let Some(image) = grid.images.get(&(y as u16, x as u16)) {
|
||||
let TerminalImage::Raw(raw_image) = image.image.image() else { panic!("not in raw image mode") };
|
||||
let image_contents = raw_image.to_inline_html();
|
||||
let width_pixels = (image.width_columns as f64 * FONT_SIZE as f64 * FONT_SIZE_WIDTH).ceil();
|
||||
let image_tag = format!(
|
||||
"<img width=\"{width_pixels}\" src=\"{image_contents}\" style=\"position: absolute\" />"
|
||||
);
|
||||
current_string.push_str(&image_tag);
|
||||
}
|
||||
x += c.style.size as usize;
|
||||
}
|
||||
if !current_string.is_empty() {
|
||||
finalized_row.push_str(&Self::finalize_string(¤t_string, ¤t_style));
|
||||
}
|
||||
finalized_row.push_str("</pre></div>");
|
||||
rows.push(finalized_row);
|
||||
}
|
||||
rows.push(String::from("</div>"));
|
||||
|
||||
Ok(HtmlSlide { rows, background_color: grid.background_color.as_ref().map(color_to_html) })
|
||||
}
|
||||
|
||||
fn finalize_string(s: &str, style: &TextStyle) -> String {
|
||||
HtmlText::new(s, style, FontSize::Pixels(FONT_SIZE)).to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct ContentManager {
|
||||
output_directory: OutputDirectory,
|
||||
}
|
||||
|
||||
impl ContentManager {
|
||||
pub(crate) fn new(output_directory: OutputDirectory) -> Self {
|
||||
Self { output_directory }
|
||||
}
|
||||
|
||||
fn persist_file(&self, name: &str, data: &[u8]) -> io::Result<PathBuf> {
|
||||
let path = self.output_directory.path().join(name);
|
||||
fs::write(&path, data)?;
|
||||
Ok(path)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) enum OutputFormat {
|
||||
Pdf,
|
||||
Html,
|
||||
}
|
||||
|
||||
pub(crate) struct ExportRenderer {
|
||||
content_manager: ContentManager,
|
||||
output_format: OutputFormat,
|
||||
dimensions: WindowSize,
|
||||
html_body: String,
|
||||
background_color: Option<String>,
|
||||
}
|
||||
|
||||
impl ExportRenderer {
|
||||
pub(crate) fn new(dimensions: WindowSize, output_directory: OutputDirectory, output_type: OutputFormat) -> Self {
|
||||
let image_manager = ContentManager::new(output_directory);
|
||||
Self {
|
||||
content_manager: image_manager,
|
||||
dimensions,
|
||||
html_body: "".to_string(),
|
||||
background_color: None,
|
||||
output_format: output_type,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn process_slide(&mut self, slide: Slide) -> Result<(), ExportError> {
|
||||
let mut terminal = VirtualTerminal::new(self.dimensions.clone(), Default::default());
|
||||
let engine = RenderEngine::new(&mut terminal, self.dimensions.clone(), Default::default());
|
||||
engine.render(slide.iter_operations())?;
|
||||
|
||||
let grid = terminal.into_contents();
|
||||
let slide = HtmlSlide::new(grid)?;
|
||||
if self.background_color.is_none() {
|
||||
self.background_color.clone_from(&slide.background_color);
|
||||
}
|
||||
for row in slide.rows {
|
||||
self.html_body.push_str(&row);
|
||||
self.html_body.push('\n');
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn generate(self, output_path: &Path) -> Result<(), ExportError> {
|
||||
let html_body = &self.html_body;
|
||||
let script = include_str!("script.js");
|
||||
let width = (self.dimensions.columns as f64 * FONT_SIZE as f64 * FONT_SIZE_WIDTH).ceil();
|
||||
let height = self.dimensions.rows * LINE_HEIGHT;
|
||||
let background_color = self.background_color.unwrap_or_else(|| "black".into());
|
||||
let container = match self.output_format {
|
||||
OutputFormat::Pdf => String::from("display: contents;"),
|
||||
OutputFormat::Html => String::from(
|
||||
"
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
",
|
||||
),
|
||||
};
|
||||
let css = format!(
|
||||
r"
|
||||
pre {{
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}}
|
||||
|
||||
span {{
|
||||
display: inline-block;
|
||||
}}
|
||||
|
||||
body {{
|
||||
margin: 0;
|
||||
font-size: {FONT_SIZE}px;
|
||||
line-height: {LINE_HEIGHT}px;
|
||||
width: {width}px;
|
||||
height: {height}px;
|
||||
transform-origin: top left;
|
||||
background-color: {background_color};
|
||||
}}
|
||||
|
||||
.container {{
|
||||
{container}
|
||||
}}
|
||||
|
||||
.content-line {{
|
||||
line-height: {LINE_HEIGHT}px;
|
||||
height: {LINE_HEIGHT}px;
|
||||
margin: 0px;
|
||||
width: {width}px;
|
||||
}}
|
||||
|
||||
.hidden {{
|
||||
display: none;
|
||||
}}
|
||||
|
||||
@page {{
|
||||
margin: 0;
|
||||
height: {height}px;
|
||||
width: {width}px;
|
||||
}}"
|
||||
);
|
||||
let html_script = match self.output_format {
|
||||
OutputFormat::Pdf => String::new(),
|
||||
OutputFormat::Html => {
|
||||
format!(
|
||||
"
|
||||
<script>
|
||||
let originalWidth = {width};
|
||||
let originalHeight = {height};
|
||||
{script}
|
||||
</script>"
|
||||
)
|
||||
}
|
||||
};
|
||||
let style = match self.output_format {
|
||||
OutputFormat::Pdf => String::new(),
|
||||
OutputFormat::Html => format!(
|
||||
"
|
||||
<head>
|
||||
<style>
|
||||
{css}
|
||||
</style>
|
||||
</head>
|
||||
"
|
||||
),
|
||||
};
|
||||
let html = format!(
|
||||
r"
|
||||
<html>
|
||||
{style}
|
||||
<body>
|
||||
{html_body}
|
||||
{html_script}
|
||||
</body>
|
||||
</html>"
|
||||
);
|
||||
|
||||
let html_path = self.content_manager.persist_file("index.html", html.as_bytes())?;
|
||||
let css_path = self.content_manager.persist_file("styles.css", css.as_bytes())?;
|
||||
|
||||
match self.output_format {
|
||||
OutputFormat::Pdf => {
|
||||
ThirdPartyTools::weasyprint(&[
|
||||
"-s",
|
||||
css_path.to_string_lossy().as_ref(),
|
||||
"--presentational-hints",
|
||||
"-e",
|
||||
"utf8",
|
||||
html_path.to_string_lossy().as_ref(),
|
||||
output_path.to_string_lossy().as_ref(),
|
||||
])
|
||||
.run()?;
|
||||
}
|
||||
OutputFormat::Html => {
|
||||
fs::write(output_path, html.as_bytes())?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
45
src/export/script.js
Normal file
45
src/export/script.js
Normal file
@ -0,0 +1,45 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const allLines = document.querySelectorAll('body > div');
|
||||
const pageBreakMarkers = document.querySelectorAll('.container');
|
||||
let currentPageIndex = 0;
|
||||
|
||||
|
||||
function showCurrentPage() {
|
||||
allLines.forEach((line) => {
|
||||
line.classList.add('hidden');
|
||||
});
|
||||
|
||||
allLines[currentPageIndex].classList.remove('hidden');
|
||||
}
|
||||
|
||||
|
||||
function scaler() {
|
||||
var w = document.documentElement.clientWidth;
|
||||
var h = document.documentElement.clientHeight;
|
||||
let widthScaledAmount= w/originalWidth;
|
||||
let heightScaledAmount= h/originalHeight;
|
||||
let scaledAmount = Math.min(widthScaledAmount, heightScaledAmount);
|
||||
document.querySelector("body").style.transform = `scale(${scaledAmount})`;
|
||||
}
|
||||
|
||||
function handleKeyPress(event) {
|
||||
if (event.key === 'ArrowLeft') {
|
||||
if (currentPageIndex > 0) {
|
||||
currentPageIndex--;
|
||||
showCurrentPage();
|
||||
}
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
if (currentPageIndex < pageBreakMarkers.length - 1) {
|
||||
currentPageIndex++;
|
||||
showCurrentPage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyPress);
|
||||
window.addEventListener("resize", scaler);
|
||||
|
||||
scaler();
|
||||
showCurrentPage();
|
||||
});
|
||||
|
@ -1,35 +0,0 @@
|
||||
use std::{fs, io, path::PathBuf, time::SystemTime};
|
||||
|
||||
/// Watchers the presentation's file.
|
||||
///
|
||||
/// This uses polling rather than something fancier like `inotify`. The latter turned out to make
|
||||
/// code too complex for little added gain. This instead keeps the last modified time for the given
|
||||
/// path and uses that to determine if it's changed.
|
||||
pub(crate) struct PresentationFileWatcher {
|
||||
path: PathBuf,
|
||||
last_modification: SystemTime,
|
||||
}
|
||||
|
||||
impl PresentationFileWatcher {
|
||||
/// Create a watcher over the given file path.
|
||||
pub(crate) fn new<P: Into<PathBuf>>(path: P) -> Self {
|
||||
let path = path.into();
|
||||
let last_modification = fs::metadata(&path).and_then(|m| m.modified()).unwrap_or(SystemTime::UNIX_EPOCH);
|
||||
Self { path, last_modification }
|
||||
}
|
||||
|
||||
/// Checker whether this file has modifications.
|
||||
pub(crate) fn has_modifications(&mut self) -> io::Result<bool> {
|
||||
let Ok(metadata) = fs::metadata(&self.path) else {
|
||||
// If the file no longer exists, it's technically changed since last time.
|
||||
return Ok(true);
|
||||
};
|
||||
let modified_time = metadata.modified()?;
|
||||
if modified_time > self.last_modification {
|
||||
self.last_modification = modified_time;
|
||||
Ok(true)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,3 +0,0 @@
|
||||
pub(crate) mod fs;
|
||||
pub(crate) mod source;
|
||||
pub(crate) mod user;
|
37
src/lib.rs
37
src/lib.rs
@ -1,37 +0,0 @@
|
||||
//! Presenterm: a terminal slideshow presentation tool.
|
||||
//!
|
||||
//! This is not meant to be used as a crate!
|
||||
|
||||
pub(crate) mod custom;
|
||||
pub(crate) mod demo;
|
||||
pub(crate) mod diff;
|
||||
pub(crate) mod execute;
|
||||
pub(crate) mod export;
|
||||
pub(crate) mod input;
|
||||
pub(crate) mod markdown;
|
||||
pub(crate) mod media;
|
||||
pub(crate) mod presentation;
|
||||
pub(crate) mod presenter;
|
||||
pub(crate) mod processing;
|
||||
pub(crate) mod render;
|
||||
pub(crate) mod resource;
|
||||
pub(crate) mod style;
|
||||
pub(crate) mod theme;
|
||||
pub(crate) mod third_party;
|
||||
pub(crate) mod tools;
|
||||
|
||||
pub use crate::{
|
||||
custom::{Config, ImageProtocol, ValidateOverflows},
|
||||
demo::ThemesDemo,
|
||||
execute::SnippetExecutor,
|
||||
export::{ExportError, Exporter},
|
||||
input::source::CommandSource,
|
||||
markdown::parse::MarkdownParser,
|
||||
media::{graphics::GraphicsMode, printer::ImagePrinter, register::ImageRegistry},
|
||||
presenter::{PresentMode, Presenter, PresenterOptions},
|
||||
processing::builder::{PresentationBuilderOptions, Themes},
|
||||
render::highlighting::{CodeHighlighter, HighlightThemeSet},
|
||||
resource::Resources,
|
||||
theme::{LoadThemeError, PresentationTheme, PresentationThemeSet},
|
||||
third_party::{ThirdPartyConfigs, ThirdPartyRender},
|
||||
};
|
474
src/main.rs
474
src/main.rs
@ -1,20 +1,62 @@
|
||||
use clap::{error::ErrorKind, CommandFactory, Parser};
|
||||
use comrak::Arena;
|
||||
use directories::ProjectDirs;
|
||||
use presenterm::{
|
||||
CommandSource, Config, Exporter, GraphicsMode, HighlightThemeSet, ImagePrinter, ImageProtocol, ImageRegistry,
|
||||
MarkdownParser, PresentMode, PresentationBuilderOptions, PresentationTheme, PresentationThemeSet, Presenter,
|
||||
PresenterOptions, Resources, SnippetExecutor, Themes, ThemesDemo, ThirdPartyConfigs, ThirdPartyRender,
|
||||
ValidateOverflows,
|
||||
use crate::{
|
||||
code::{execute::SnippetExecutor, highlighting::HighlightThemeSet},
|
||||
commands::listener::CommandListener,
|
||||
config::{Config, ImageProtocol, ValidateOverflows},
|
||||
demo::ThemesDemo,
|
||||
export::exporter::Exporter,
|
||||
markdown::parse::MarkdownParser,
|
||||
presentation::builder::{PresentationBuilderOptions, Themes},
|
||||
presenter::{PresentMode, Presenter, PresenterOptions},
|
||||
resource::Resources,
|
||||
terminal::{
|
||||
GraphicsMode,
|
||||
image::printer::{ImagePrinter, ImageRegistry},
|
||||
},
|
||||
theme::{raw::PresentationTheme, registry::PresentationThemeRegistry},
|
||||
third_party::{ThirdPartyConfigs, ThirdPartyRender},
|
||||
};
|
||||
use anyhow::anyhow;
|
||||
use clap::{CommandFactory, Parser, error::ErrorKind};
|
||||
use commands::speaker_notes::{SpeakerNotesEventListener, SpeakerNotesEventPublisher};
|
||||
use comrak::Arena;
|
||||
use config::ConfigLoadError;
|
||||
use crossterm::{
|
||||
execute,
|
||||
style::{PrintStyledContent, Stylize},
|
||||
};
|
||||
use directories::ProjectDirs;
|
||||
use export::exporter::OutputDirectory;
|
||||
use render::{engine::MaxSize, properties::WindowSize};
|
||||
use std::{
|
||||
env, io,
|
||||
env::{self, current_dir},
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
rc::Rc,
|
||||
sync::Arc,
|
||||
};
|
||||
use terminal::emulator::TerminalEmulator;
|
||||
use theme::ThemeOptions;
|
||||
|
||||
mod code;
|
||||
mod commands;
|
||||
mod config;
|
||||
mod demo;
|
||||
mod export;
|
||||
mod markdown;
|
||||
mod presentation;
|
||||
mod presenter;
|
||||
mod render;
|
||||
mod resource;
|
||||
mod terminal;
|
||||
mod theme;
|
||||
mod third_party;
|
||||
mod tools;
|
||||
mod transitions;
|
||||
mod ui;
|
||||
mod utils;
|
||||
|
||||
const DEFAULT_THEME: &str = "dark";
|
||||
const DEFAULT_EXPORT_PIXELS_PER_COLUMN: u16 = 20;
|
||||
const DEFAULT_EXPORT_PIXELS_PER_ROW: u16 = DEFAULT_EXPORT_PIXELS_PER_COLUMN * 2;
|
||||
|
||||
/// Run slideshows from your terminal.
|
||||
#[derive(Parser)]
|
||||
@ -26,21 +68,26 @@ struct Cli {
|
||||
path: Option<PathBuf>,
|
||||
|
||||
/// Export the presentation as a PDF rather than displaying it.
|
||||
#[clap(short, long)]
|
||||
#[clap(short, long, group = "export")]
|
||||
export_pdf: bool,
|
||||
|
||||
/// Generate the PDF metadata without generating the PDF itself.
|
||||
#[clap(long, hide = true)]
|
||||
generate_pdf_metadata: bool,
|
||||
/// Export the presentation as a HTML rather than displaying it.
|
||||
#[clap(long, group = "export")]
|
||||
export_html: bool,
|
||||
|
||||
/// The path in which to store temporary files used when exporting.
|
||||
#[clap(long, requires = "export")]
|
||||
export_temporary_path: Option<PathBuf>,
|
||||
|
||||
/// The output path for the exported PDF.
|
||||
#[clap(short = 'o', long = "output", requires = "export")]
|
||||
export_output: Option<PathBuf>,
|
||||
|
||||
/// Generate a JSON schema for the configuration file.
|
||||
#[clap(long)]
|
||||
#[cfg(feature = "json-schema")]
|
||||
generate_config_file_schema: bool,
|
||||
|
||||
/// Run in export mode.
|
||||
#[clap(long, hide = true)]
|
||||
enable_export_mode: bool,
|
||||
|
||||
/// Use presentation mode.
|
||||
#[clap(short, long, default_value_t = false)]
|
||||
present: bool,
|
||||
@ -50,9 +97,13 @@ struct Cli {
|
||||
theme: Option<String>,
|
||||
|
||||
/// List all supported themes.
|
||||
#[clap(long)]
|
||||
#[clap(long, group = "target")]
|
||||
list_themes: bool,
|
||||
|
||||
/// Print the theme in use.
|
||||
#[clap(long, group = "target")]
|
||||
current_theme: bool,
|
||||
|
||||
/// Display acknowledgements.
|
||||
#[clap(long, group = "target")]
|
||||
acknowledgements: bool,
|
||||
@ -69,9 +120,21 @@ struct Cli {
|
||||
#[clap(short = 'x', long)]
|
||||
enable_snippet_execution: bool,
|
||||
|
||||
/// Enable code snippet auto execution via `+exec_replace` blocks.
|
||||
#[clap(short = 'X', long)]
|
||||
enable_snippet_execution_replace: bool,
|
||||
|
||||
/// The path to the configuration file.
|
||||
#[clap(short, long)]
|
||||
config_file: Option<String>,
|
||||
|
||||
/// Whether to publish speaker notes to local listeners.
|
||||
#[clap(short = 'P', long, group = "speaker-notes")]
|
||||
publish_speaker_notes: bool,
|
||||
|
||||
/// Whether to listen for speaker notes.
|
||||
#[clap(short, long, group = "speaker-notes")]
|
||||
listen_speaker_notes: bool,
|
||||
}
|
||||
|
||||
fn create_splash() -> String {
|
||||
@ -92,85 +155,195 @@ fn create_splash() -> String {
|
||||
struct Customizations {
|
||||
config: Config,
|
||||
themes: Themes,
|
||||
themes_path: Option<PathBuf>,
|
||||
code_executor: SnippetExecutor,
|
||||
}
|
||||
|
||||
fn load_customizations(config_file_path: Option<PathBuf>) -> Result<Customizations, Box<dyn std::error::Error>> {
|
||||
let configs_path: PathBuf = match env::var("XDG_CONFIG_HOME") {
|
||||
Ok(path) => Path::new(&path).join("presenterm"),
|
||||
Err(_) => {
|
||||
let Some(project_dirs) = ProjectDirs::from("", "", "presenterm") else {
|
||||
return Ok(Default::default());
|
||||
};
|
||||
project_dirs.config_dir().into()
|
||||
}
|
||||
};
|
||||
let themes = load_themes(&configs_path)?;
|
||||
let config_file_path = config_file_path.unwrap_or_else(|| configs_path.join("config.yaml"));
|
||||
let config = Config::load(&config_file_path)?;
|
||||
let code_executor = SnippetExecutor::new(config.snippet.exec.custom.clone())?;
|
||||
Ok(Customizations { config, themes, code_executor })
|
||||
}
|
||||
impl Customizations {
|
||||
fn load(config_file_path: Option<PathBuf>, cwd: &Path) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let configs_path: PathBuf = match env::var("XDG_CONFIG_HOME") {
|
||||
Ok(path) => Path::new(&path).join("presenterm"),
|
||||
Err(_) => {
|
||||
let Some(project_dirs) = ProjectDirs::from("", "", "presenterm") else {
|
||||
return Ok(Default::default());
|
||||
};
|
||||
project_dirs.config_dir().into()
|
||||
}
|
||||
};
|
||||
let themes_path = configs_path.join("themes");
|
||||
let themes = Self::load_themes(&themes_path)?;
|
||||
let require_config_file = config_file_path.is_some();
|
||||
let config_file_path = config_file_path.unwrap_or_else(|| configs_path.join("config.yaml"));
|
||||
let config = match Config::load(&config_file_path) {
|
||||
Ok(config) => config,
|
||||
Err(ConfigLoadError::NotFound) if !require_config_file => Default::default(),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let code_executor = SnippetExecutor::new(config.snippet.exec.custom.clone(), cwd.to_path_buf())?;
|
||||
Ok(Customizations { config, themes, themes_path: Some(themes_path), code_executor })
|
||||
}
|
||||
|
||||
fn load_themes(config_path: &Path) -> Result<Themes, Box<dyn std::error::Error>> {
|
||||
let themes_path = config_path.join("themes");
|
||||
fn load_themes(themes_path: &Path) -> Result<Themes, Box<dyn std::error::Error>> {
|
||||
let mut highlight_themes = HighlightThemeSet::default();
|
||||
highlight_themes.register_from_directory(themes_path.join("highlighting"))?;
|
||||
|
||||
let mut highlight_themes = HighlightThemeSet::default();
|
||||
highlight_themes.register_from_directory(themes_path.join("highlighting"))?;
|
||||
let mut presentation_themes = PresentationThemeRegistry::default();
|
||||
presentation_themes.register_from_directory(themes_path)?;
|
||||
|
||||
let mut presentation_themes = PresentationThemeSet::default();
|
||||
presentation_themes.register_from_directory(&themes_path)?;
|
||||
|
||||
let themes = Themes { presentation: presentation_themes, highlight: highlight_themes };
|
||||
Ok(themes)
|
||||
}
|
||||
|
||||
fn display_acknowledgements() {
|
||||
let acknowledgements = include_bytes!("../bat/acknowledgements.txt");
|
||||
println!("{}", String::from_utf8_lossy(acknowledgements));
|
||||
}
|
||||
|
||||
fn make_builder_options(config: &Config, mode: &PresentMode, force_default_theme: bool) -> PresentationBuilderOptions {
|
||||
PresentationBuilderOptions {
|
||||
allow_mutations: !matches!(mode, PresentMode::Export),
|
||||
implicit_slide_ends: config.options.implicit_slide_ends.unwrap_or_default(),
|
||||
command_prefix: config.options.command_prefix.clone().unwrap_or_default(),
|
||||
image_attribute_prefix: config.options.image_attributes_prefix.clone().unwrap_or_else(|| "image:".to_string()),
|
||||
incremental_lists: config.options.incremental_lists.unwrap_or_default(),
|
||||
force_default_theme,
|
||||
end_slide_shorthand: config.options.end_slide_shorthand.unwrap_or_default(),
|
||||
print_modal_background: false,
|
||||
strict_front_matter_parsing: config.options.strict_front_matter_parsing.unwrap_or(true),
|
||||
enable_snippet_execution: config.snippet.exec.enable,
|
||||
let themes = Themes { presentation: presentation_themes, highlight: highlight_themes };
|
||||
Ok(themes)
|
||||
}
|
||||
}
|
||||
|
||||
fn load_default_theme(config: &Config, themes: &Themes, cli: &Cli) -> PresentationTheme {
|
||||
let default_theme_name =
|
||||
cli.theme.as_ref().or(config.defaults.theme.as_ref()).map(|s| s.as_str()).unwrap_or(DEFAULT_THEME);
|
||||
let Some(default_theme) = themes.presentation.load_by_name(default_theme_name) else {
|
||||
let valid_themes = themes.presentation.theme_names().join(", ");
|
||||
let error_message = format!("invalid theme name, valid themes are: {valid_themes}");
|
||||
Cli::command().error(ErrorKind::InvalidValue, error_message).exit();
|
||||
};
|
||||
default_theme
|
||||
struct CoreComponents {
|
||||
third_party: ThirdPartyRender,
|
||||
code_executor: Arc<SnippetExecutor>,
|
||||
resources: Resources,
|
||||
printer: Arc<ImagePrinter>,
|
||||
builder_options: PresentationBuilderOptions,
|
||||
themes: Themes,
|
||||
default_theme: PresentationTheme,
|
||||
config: Config,
|
||||
present_mode: PresentMode,
|
||||
graphics_mode: GraphicsMode,
|
||||
}
|
||||
|
||||
fn select_graphics_mode(cli: &Cli, config: &Config) -> GraphicsMode {
|
||||
if cli.enable_export_mode || cli.export_pdf || cli.generate_pdf_metadata {
|
||||
GraphicsMode::AsciiBlocks
|
||||
} else {
|
||||
let protocol = cli.image_protocol.as_ref().unwrap_or(&config.defaults.image_protocol);
|
||||
match GraphicsMode::try_from(protocol) {
|
||||
Ok(mode) => mode,
|
||||
Err(_) => {
|
||||
Cli::command().error(ErrorKind::InvalidValue, "sixel support was not enabled during compilation").exit()
|
||||
impl CoreComponents {
|
||||
fn new(cli: &Cli, path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let mut resources_path = path.parent().unwrap_or(Path::new("./")).to_path_buf();
|
||||
if resources_path == Path::new("") {
|
||||
resources_path = "./".into();
|
||||
}
|
||||
let resources_path = resources_path.canonicalize().unwrap_or(resources_path);
|
||||
|
||||
let Customizations { config, themes, code_executor, themes_path } =
|
||||
Customizations::load(cli.config_file.clone().map(PathBuf::from), &resources_path)?;
|
||||
|
||||
let default_theme = Self::load_default_theme(&config, &themes, cli);
|
||||
let force_default_theme = cli.theme.is_some();
|
||||
let present_mode = match (cli.present, cli.export_pdf) {
|
||||
(true, _) | (_, true) => PresentMode::Presentation,
|
||||
(false, false) => PresentMode::Development,
|
||||
};
|
||||
|
||||
let mut builder_options = Self::make_builder_options(&config, force_default_theme, cli.listen_speaker_notes);
|
||||
if cli.enable_snippet_execution {
|
||||
builder_options.enable_snippet_execution = true;
|
||||
}
|
||||
if cli.enable_snippet_execution_replace {
|
||||
builder_options.enable_snippet_execution_replace = true;
|
||||
}
|
||||
let graphics_mode = Self::select_graphics_mode(cli, &config);
|
||||
let printer = Arc::new(ImagePrinter::new(graphics_mode.clone())?);
|
||||
let registry = ImageRegistry::new(printer.clone());
|
||||
let resources = Resources::new(
|
||||
resources_path.clone(),
|
||||
themes_path.unwrap_or_else(|| resources_path.clone()),
|
||||
registry.clone(),
|
||||
);
|
||||
let third_party_config = ThirdPartyConfigs {
|
||||
typst_ppi: config.typst.ppi.to_string(),
|
||||
mermaid_scale: config.mermaid.scale.to_string(),
|
||||
threads: config.snippet.render.threads,
|
||||
};
|
||||
let third_party = ThirdPartyRender::new(third_party_config, registry, &resources_path);
|
||||
let code_executor = Arc::new(code_executor);
|
||||
Ok(Self {
|
||||
third_party,
|
||||
code_executor,
|
||||
resources,
|
||||
printer,
|
||||
builder_options,
|
||||
themes,
|
||||
default_theme,
|
||||
config,
|
||||
present_mode,
|
||||
graphics_mode,
|
||||
})
|
||||
}
|
||||
|
||||
fn make_builder_options(
|
||||
config: &Config,
|
||||
force_default_theme: bool,
|
||||
render_speaker_notes_only: bool,
|
||||
) -> PresentationBuilderOptions {
|
||||
PresentationBuilderOptions {
|
||||
allow_mutations: true,
|
||||
implicit_slide_ends: config.options.implicit_slide_ends.unwrap_or_default(),
|
||||
command_prefix: config.options.command_prefix.clone().unwrap_or_default(),
|
||||
image_attribute_prefix: config
|
||||
.options
|
||||
.image_attributes_prefix
|
||||
.clone()
|
||||
.unwrap_or_else(|| "image:".to_string()),
|
||||
incremental_lists: config.options.incremental_lists.unwrap_or_default(),
|
||||
force_default_theme,
|
||||
end_slide_shorthand: config.options.end_slide_shorthand.unwrap_or_default(),
|
||||
print_modal_background: false,
|
||||
strict_front_matter_parsing: config.options.strict_front_matter_parsing.unwrap_or(true),
|
||||
enable_snippet_execution: config.snippet.exec.enable,
|
||||
enable_snippet_execution_replace: config.snippet.exec_replace.enable,
|
||||
render_speaker_notes_only,
|
||||
auto_render_languages: config.options.auto_render_languages.clone(),
|
||||
theme_options: ThemeOptions { font_size_supported: TerminalEmulator::capabilities().font_size },
|
||||
pause_before_incremental_lists: config.defaults.incremental_lists.pause_before.unwrap_or(true),
|
||||
pause_after_incremental_lists: config.defaults.incremental_lists.pause_after.unwrap_or(true),
|
||||
pause_create_new_slide: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn select_graphics_mode(cli: &Cli, config: &Config) -> GraphicsMode {
|
||||
if cli.export_pdf | cli.export_html {
|
||||
GraphicsMode::Raw
|
||||
} else {
|
||||
let protocol = cli.image_protocol.as_ref().unwrap_or(&config.defaults.image_protocol);
|
||||
match GraphicsMode::try_from(protocol) {
|
||||
Ok(mode) => mode,
|
||||
Err(_) => Cli::command()
|
||||
.error(ErrorKind::InvalidValue, "sixel support was not enabled during compilation")
|
||||
.exit(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_default_theme(config: &Config, themes: &Themes, cli: &Cli) -> PresentationTheme {
|
||||
let default_theme_name =
|
||||
cli.theme.as_ref().or(config.defaults.theme.as_ref()).map(|s| s.as_str()).unwrap_or(DEFAULT_THEME);
|
||||
let Some(default_theme) = themes.presentation.load_by_name(default_theme_name) else {
|
||||
let valid_themes = themes.presentation.theme_names().join(", ");
|
||||
let error_message = format!("invalid theme name, valid themes are: {valid_themes}");
|
||||
Cli::command().error(ErrorKind::InvalidValue, error_message).exit();
|
||||
};
|
||||
default_theme
|
||||
}
|
||||
}
|
||||
|
||||
fn overflow_validation(mode: &PresentMode, config: &ValidateOverflows) -> bool {
|
||||
struct SpeakerNotesComponents {
|
||||
events_listener: Option<SpeakerNotesEventListener>,
|
||||
events_publisher: Option<SpeakerNotesEventPublisher>,
|
||||
}
|
||||
|
||||
impl SpeakerNotesComponents {
|
||||
fn new(cli: &Cli, config: &Config, path: &Path) -> anyhow::Result<Self> {
|
||||
let full_presentation_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
|
||||
let publish_speaker_notes =
|
||||
cli.publish_speaker_notes || (config.speaker_notes.always_publish && !cli.listen_speaker_notes);
|
||||
let events_publisher = publish_speaker_notes
|
||||
.then(|| {
|
||||
SpeakerNotesEventPublisher::new(config.speaker_notes.publish_address, full_presentation_path.clone())
|
||||
})
|
||||
.transpose()
|
||||
.map_err(|e| anyhow!("failed to create speaker notes publisher: {e}"))?;
|
||||
let events_listener = cli
|
||||
.listen_speaker_notes
|
||||
.then(|| SpeakerNotesEventListener::new(config.speaker_notes.listen_address, full_presentation_path))
|
||||
.transpose()
|
||||
.map_err(|e| anyhow!("failed to create speaker notes listener: {e}"))?;
|
||||
Ok(Self { events_listener, events_publisher })
|
||||
}
|
||||
}
|
||||
|
||||
fn overflow_validation_enabled(mode: &PresentMode, config: &ValidateOverflows) -> bool {
|
||||
match (config, mode) {
|
||||
(ValidateOverflows::Always, _) => true,
|
||||
(ValidateOverflows::Never, _) => false,
|
||||
@ -180,88 +353,111 @@ fn overflow_validation(mode: &PresentMode, config: &ValidateOverflows) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn run(mut cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
|
||||
fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
|
||||
#[cfg(feature = "json-schema")]
|
||||
if cli.generate_config_file_schema {
|
||||
let schema = schemars::schema_for!(Config);
|
||||
serde_json::to_writer_pretty(io::stdout(), &schema).map_err(|e| format!("failed to write schema: {e}"))?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Customizations { config, themes, code_executor } =
|
||||
load_customizations(cli.config_file.clone().map(PathBuf::from))?;
|
||||
|
||||
let default_theme = load_default_theme(&config, &themes, &cli);
|
||||
let force_default_theme = cli.theme.is_some();
|
||||
let mode = match (cli.present, cli.enable_export_mode) {
|
||||
(true, _) => PresentMode::Presentation,
|
||||
(false, true) => PresentMode::Export,
|
||||
(false, false) => PresentMode::Development,
|
||||
};
|
||||
let arena = Arena::new();
|
||||
let parser = MarkdownParser::new(&arena);
|
||||
if cli.acknowledgements {
|
||||
display_acknowledgements();
|
||||
let acknowledgements = include_bytes!("../bat/acknowledgements.txt");
|
||||
println!("{}", String::from_utf8_lossy(acknowledgements));
|
||||
return Ok(());
|
||||
} else if cli.list_themes {
|
||||
// Load this ahead of time so we don't do it when we're already in raw mode.
|
||||
TerminalEmulator::capabilities();
|
||||
let Customizations { config, themes, .. } =
|
||||
Customizations::load(cli.config_file.clone().map(PathBuf::from), ¤t_dir()?)?;
|
||||
let bindings = config.bindings.try_into()?;
|
||||
let demo = ThemesDemo::new(themes, bindings, io::stdout())?;
|
||||
let demo = ThemesDemo::new(themes, bindings)?;
|
||||
demo.run()?;
|
||||
return Ok(());
|
||||
} else if cli.current_theme {
|
||||
let Customizations { config, .. } =
|
||||
Customizations::load(cli.config_file.clone().map(PathBuf::from), ¤t_dir()?)?;
|
||||
let theme_name =
|
||||
cli.theme.as_ref().or(config.defaults.theme.as_ref()).map(|s| s.as_str()).unwrap_or(DEFAULT_THEME);
|
||||
println!("{theme_name}");
|
||||
return Ok(());
|
||||
}
|
||||
// Disable this so we don't mess things up when generating PDFs
|
||||
if cli.export_pdf {
|
||||
TerminalEmulator::disable_capability_detection();
|
||||
}
|
||||
|
||||
let path = cli.path.take().unwrap_or_else(|| {
|
||||
let Some(path) = cli.path.clone() else {
|
||||
Cli::command().error(ErrorKind::MissingRequiredArgument, "no path specified").exit();
|
||||
});
|
||||
let validate_overflows = overflow_validation(&mode, &config.defaults.validate_overflows) || cli.validate_overflows;
|
||||
let resources_path = path.parent().unwrap_or(Path::new("/"));
|
||||
let mut options = make_builder_options(&config, &mode, force_default_theme);
|
||||
if cli.enable_snippet_execution {
|
||||
options.enable_snippet_execution = true;
|
||||
}
|
||||
let graphics_mode = select_graphics_mode(&cli, &config);
|
||||
let printer = Arc::new(ImagePrinter::new(graphics_mode.clone())?);
|
||||
let registry = ImageRegistry(printer.clone());
|
||||
let resources = Resources::new(resources_path, registry.clone());
|
||||
let third_party_config = ThirdPartyConfigs {
|
||||
typst_ppi: config.typst.ppi.to_string(),
|
||||
mermaid_scale: config.mermaid.scale.to_string(),
|
||||
threads: config.snippet.render.threads,
|
||||
};
|
||||
let third_party = ThirdPartyRender::new(third_party_config, registry, resources_path);
|
||||
let code_executor = Rc::new(code_executor);
|
||||
if cli.export_pdf || cli.generate_pdf_metadata {
|
||||
let mut exporter =
|
||||
Exporter::new(parser, &default_theme, resources, third_party, code_executor, themes, options);
|
||||
let mut args = Vec::new();
|
||||
if let Some(theme) = cli.theme.as_ref() {
|
||||
args.extend(["--theme", theme]);
|
||||
}
|
||||
if let Some(path) = cli.config_file.as_ref() {
|
||||
args.extend(["--config-file", path]);
|
||||
}
|
||||
if cli.enable_snippet_execution {
|
||||
args.push("-x");
|
||||
}
|
||||
let CoreComponents {
|
||||
third_party,
|
||||
code_executor,
|
||||
resources,
|
||||
printer,
|
||||
mut builder_options,
|
||||
themes,
|
||||
default_theme,
|
||||
config,
|
||||
present_mode,
|
||||
graphics_mode,
|
||||
} = CoreComponents::new(&cli, &path)?;
|
||||
let arena = Arena::new();
|
||||
let parser = MarkdownParser::new(&arena);
|
||||
let validate_overflows =
|
||||
overflow_validation_enabled(&present_mode, &config.defaults.validate_overflows) || cli.validate_overflows;
|
||||
if cli.export_pdf || cli.export_html {
|
||||
let dimensions = match config.export.dimensions {
|
||||
Some(dimensions) => WindowSize {
|
||||
rows: dimensions.rows,
|
||||
columns: dimensions.columns,
|
||||
height: dimensions.rows * DEFAULT_EXPORT_PIXELS_PER_ROW,
|
||||
width: dimensions.columns * DEFAULT_EXPORT_PIXELS_PER_COLUMN,
|
||||
},
|
||||
None => WindowSize::current(config.defaults.terminal_font_size)?,
|
||||
};
|
||||
let exporter = Exporter::new(
|
||||
parser,
|
||||
&default_theme,
|
||||
resources,
|
||||
third_party,
|
||||
code_executor,
|
||||
themes,
|
||||
builder_options,
|
||||
dimensions,
|
||||
config.export.pauses,
|
||||
);
|
||||
let output_directory = match cli.export_temporary_path {
|
||||
Some(path) => OutputDirectory::external(path),
|
||||
None => OutputDirectory::temporary(),
|
||||
}?;
|
||||
if cli.export_pdf {
|
||||
exporter.export_pdf(&path, &args)?;
|
||||
exporter.export_pdf(&path, output_directory, cli.export_output.as_deref())?;
|
||||
} else {
|
||||
let meta = exporter.generate_metadata(&path)?;
|
||||
println!("{}", serde_json::to_string_pretty(&meta)?);
|
||||
exporter.export_html(&path, output_directory, cli.export_output.as_deref())?;
|
||||
}
|
||||
} else {
|
||||
let commands = CommandSource::new(&path, config.bindings.clone())?;
|
||||
options.print_modal_background = matches!(graphics_mode, GraphicsMode::Kitty { .. });
|
||||
let SpeakerNotesComponents { events_listener, events_publisher } =
|
||||
SpeakerNotesComponents::new(&cli, &config, &path)?;
|
||||
let command_listener = CommandListener::new(config.bindings.clone(), events_listener)?;
|
||||
|
||||
builder_options.print_modal_background = matches!(graphics_mode, GraphicsMode::Kitty { .. });
|
||||
let options = PresenterOptions {
|
||||
builder_options: options,
|
||||
mode,
|
||||
builder_options,
|
||||
mode: present_mode,
|
||||
font_size_fallback: config.defaults.terminal_font_size,
|
||||
bindings: config.bindings,
|
||||
validate_overflows,
|
||||
max_size: MaxSize {
|
||||
max_columns: config.defaults.max_columns,
|
||||
max_columns_alignment: config.defaults.max_columns_alignment,
|
||||
max_rows: config.defaults.max_rows,
|
||||
max_rows_alignment: config.defaults.max_rows_alignment,
|
||||
},
|
||||
transition: config.transition,
|
||||
};
|
||||
let presenter = Presenter::new(
|
||||
&default_theme,
|
||||
commands,
|
||||
command_listener,
|
||||
parser,
|
||||
resources,
|
||||
third_party,
|
||||
@ -269,6 +465,7 @@ fn run(mut cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
|
||||
themes,
|
||||
printer,
|
||||
options,
|
||||
events_publisher,
|
||||
);
|
||||
presenter.present(&path)?;
|
||||
}
|
||||
@ -278,7 +475,8 @@ fn run(mut cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
|
||||
fn main() {
|
||||
let cli = Cli::parse();
|
||||
if let Err(e) = run(cli) {
|
||||
eprintln!("{e}");
|
||||
let _ =
|
||||
execute!(io::stdout(), PrintStyledContent(format!("{e}\n").stylize().with(crossterm::style::Color::Red)));
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
@ -1,297 +0,0 @@
|
||||
use super::elements::{
|
||||
Highlight, HighlightGroup, Percent, PercentParseError, Snippet, SnippetAttributes, SnippetLanguage,
|
||||
};
|
||||
use comrak::nodes::NodeCodeBlock;
|
||||
use strum::EnumDiscriminants;
|
||||
|
||||
pub(crate) type ParseResult<T> = Result<T, CodeBlockParseError>;
|
||||
|
||||
pub(crate) struct CodeBlockParser;
|
||||
|
||||
impl CodeBlockParser {
|
||||
pub(crate) fn parse(code_block: &NodeCodeBlock) -> ParseResult<Snippet> {
|
||||
let (language, attributes) = Self::parse_block_info(&code_block.info)?;
|
||||
let code = Snippet { contents: code_block.literal.clone(), language, attributes };
|
||||
Ok(code)
|
||||
}
|
||||
|
||||
fn parse_block_info(input: &str) -> ParseResult<(SnippetLanguage, SnippetAttributes)> {
|
||||
let (language, input) = Self::parse_language(input);
|
||||
let attributes = Self::parse_attributes(input)?;
|
||||
if attributes.auto_render && !language.supports_auto_render() {
|
||||
return Err(CodeBlockParseError::UnsupportedAttribute(language, "rendering"));
|
||||
}
|
||||
if attributes.width.is_some() && !attributes.auto_render {
|
||||
return Err(CodeBlockParseError::NotRenderSnippet("width"));
|
||||
}
|
||||
Ok((language, attributes))
|
||||
}
|
||||
|
||||
fn parse_language(input: &str) -> (SnippetLanguage, &str) {
|
||||
let token = Self::next_identifier(input);
|
||||
// this always returns `Ok` given we fall back to `Unknown` if we don't know the language.
|
||||
let language = token.parse().expect("language parsing");
|
||||
let rest = &input[token.len()..];
|
||||
(language, rest)
|
||||
}
|
||||
|
||||
fn parse_attributes(mut input: &str) -> ParseResult<SnippetAttributes> {
|
||||
let mut attributes = SnippetAttributes::default();
|
||||
let mut processed_attributes = Vec::new();
|
||||
while let (Some(attribute), rest) = Self::parse_attribute(input)? {
|
||||
let discriminant = AttributeDiscriminants::from(&attribute);
|
||||
if processed_attributes.contains(&discriminant) {
|
||||
return Err(CodeBlockParseError::DuplicateAttribute("duplicate attribute"));
|
||||
}
|
||||
match attribute {
|
||||
Attribute::LineNumbers => attributes.line_numbers = true,
|
||||
Attribute::Exec => attributes.execute = true,
|
||||
Attribute::AutoRender => attributes.auto_render = true,
|
||||
Attribute::HighlightedLines(lines) => attributes.highlight_groups = lines,
|
||||
Attribute::Width(width) => attributes.width = Some(width),
|
||||
};
|
||||
processed_attributes.push(discriminant);
|
||||
input = rest;
|
||||
}
|
||||
if attributes.highlight_groups.is_empty() {
|
||||
attributes.highlight_groups.push(HighlightGroup::new(vec![Highlight::All]));
|
||||
}
|
||||
Ok(attributes)
|
||||
}
|
||||
|
||||
fn parse_attribute(input: &str) -> ParseResult<(Option<Attribute>, &str)> {
|
||||
let input = Self::skip_whitespace(input);
|
||||
let (attribute, input) = match input.chars().next() {
|
||||
Some('+') => {
|
||||
let token = Self::next_identifier(&input[1..]);
|
||||
let attribute = match token {
|
||||
"line_numbers" => Attribute::LineNumbers,
|
||||
"exec" => Attribute::Exec,
|
||||
"render" => Attribute::AutoRender,
|
||||
token if token.starts_with("width:") => {
|
||||
let value = input.split_once("+width:").unwrap().1;
|
||||
let (width, input) = Self::parse_width(value)?;
|
||||
return Ok((Some(Attribute::Width(width)), input));
|
||||
}
|
||||
_ => return Err(CodeBlockParseError::InvalidToken(Self::next_identifier(input).into())),
|
||||
};
|
||||
(Some(attribute), &input[token.len() + 1..])
|
||||
}
|
||||
Some('{') => {
|
||||
let (lines, input) = Self::parse_highlight_groups(&input[1..])?;
|
||||
(Some(Attribute::HighlightedLines(lines)), input)
|
||||
}
|
||||
Some(_) => return Err(CodeBlockParseError::InvalidToken(Self::next_identifier(input).into())),
|
||||
None => (None, input),
|
||||
};
|
||||
Ok((attribute, input))
|
||||
}
|
||||
|
||||
fn parse_highlight_groups(input: &str) -> ParseResult<(Vec<HighlightGroup>, &str)> {
|
||||
use CodeBlockParseError::InvalidHighlightedLines;
|
||||
let Some((head, tail)) = input.split_once('}') else {
|
||||
return Err(InvalidHighlightedLines("no enclosing '}'".into()));
|
||||
};
|
||||
let head = head.trim();
|
||||
if head.is_empty() {
|
||||
return Ok((Vec::new(), tail));
|
||||
}
|
||||
|
||||
let mut highlight_groups = Vec::new();
|
||||
for group in head.split('|') {
|
||||
let group = Self::parse_highlight_group(group)?;
|
||||
highlight_groups.push(group);
|
||||
}
|
||||
Ok((highlight_groups, tail))
|
||||
}
|
||||
|
||||
fn parse_highlight_group(input: &str) -> ParseResult<HighlightGroup> {
|
||||
let mut highlights = Vec::new();
|
||||
for piece in input.split(',') {
|
||||
let piece = piece.trim();
|
||||
if piece == "all" {
|
||||
highlights.push(Highlight::All);
|
||||
continue;
|
||||
}
|
||||
match piece.split_once('-') {
|
||||
Some((left, right)) => {
|
||||
let left = Self::parse_number(left)?;
|
||||
let right = Self::parse_number(right)?;
|
||||
let right = right
|
||||
.checked_add(1)
|
||||
.ok_or_else(|| CodeBlockParseError::InvalidHighlightedLines(format!("{right} is too large")))?;
|
||||
highlights.push(Highlight::Range(left..right));
|
||||
}
|
||||
None => {
|
||||
let number = Self::parse_number(piece)?;
|
||||
highlights.push(Highlight::Single(number));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(HighlightGroup::new(highlights))
|
||||
}
|
||||
|
||||
fn parse_number(input: &str) -> ParseResult<u16> {
|
||||
input
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|_| CodeBlockParseError::InvalidHighlightedLines(format!("not a number: '{input}'")))
|
||||
}
|
||||
|
||||
fn parse_width(input: &str) -> ParseResult<(Percent, &str)> {
|
||||
let end_index = input.find(' ').unwrap_or(input.len());
|
||||
let value = input[0..end_index].parse().map_err(CodeBlockParseError::InvalidWidth)?;
|
||||
Ok((value, &input[end_index..]))
|
||||
}
|
||||
|
||||
fn skip_whitespace(input: &str) -> &str {
|
||||
input.trim_start_matches(' ')
|
||||
}
|
||||
|
||||
fn next_identifier(input: &str) -> &str {
|
||||
match input.split_once(' ') {
|
||||
Some((token, _)) => token,
|
||||
None => input,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub(crate) enum CodeBlockParseError {
|
||||
#[error("invalid code attribute: {0}")]
|
||||
InvalidToken(String),
|
||||
|
||||
#[error("invalid highlighted lines: {0}")]
|
||||
InvalidHighlightedLines(String),
|
||||
|
||||
#[error("invalid width: {0}")]
|
||||
InvalidWidth(PercentParseError),
|
||||
|
||||
#[error("duplicate attribute: {0}")]
|
||||
DuplicateAttribute(&'static str),
|
||||
|
||||
#[error("language {0:?} does not support {1}")]
|
||||
UnsupportedAttribute(SnippetLanguage, &'static str),
|
||||
|
||||
#[error("attribute {0} can only be set in +render blocks")]
|
||||
NotRenderSnippet(&'static str),
|
||||
}
|
||||
|
||||
#[derive(EnumDiscriminants)]
|
||||
enum Attribute {
|
||||
LineNumbers,
|
||||
Exec,
|
||||
AutoRender,
|
||||
HighlightedLines(Vec<HighlightGroup>),
|
||||
Width(Percent),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
use Highlight::*;
|
||||
|
||||
fn parse_language(input: &str) -> SnippetLanguage {
|
||||
let (language, _) = CodeBlockParser::parse_block_info(input).expect("parse failed");
|
||||
language
|
||||
}
|
||||
|
||||
fn try_parse_attributes(input: &str) -> Result<SnippetAttributes, CodeBlockParseError> {
|
||||
let (_, attributes) = CodeBlockParser::parse_block_info(input)?;
|
||||
Ok(attributes)
|
||||
}
|
||||
|
||||
fn parse_attributes(input: &str) -> SnippetAttributes {
|
||||
try_parse_attributes(input).expect("parse failed")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_language() {
|
||||
assert_eq!(parse_language("potato"), SnippetLanguage::Unknown("potato".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_attributes() {
|
||||
assert_eq!(parse_language("rust"), SnippetLanguage::Rust);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_attribute() {
|
||||
let attributes = parse_attributes("bash +exec");
|
||||
assert!(attributes.execute);
|
||||
assert!(!attributes.line_numbers);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_attributes() {
|
||||
let attributes = parse_attributes("bash +exec +line_numbers");
|
||||
assert!(attributes.execute);
|
||||
assert!(attributes.line_numbers);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_attributes() {
|
||||
CodeBlockParser::parse_block_info("bash +potato").unwrap_err();
|
||||
CodeBlockParser::parse_block_info("bash potato").unwrap_err();
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::no_end("{")]
|
||||
#[case::number_no_end("{42")]
|
||||
#[case::comma_nothing("{42,")]
|
||||
#[case::brace_comma("{,}")]
|
||||
#[case::range_no_end("{42-")]
|
||||
#[case::range_end("{42-}")]
|
||||
#[case::too_many_ranges("{42-3-5}")]
|
||||
#[case::range_comma("{42-,")]
|
||||
#[case::too_large("{65536}")]
|
||||
#[case::too_large_end("{1-65536}")]
|
||||
fn invalid_line_highlights(#[case] input: &str) {
|
||||
let input = format!("bash {input}");
|
||||
CodeBlockParser::parse_block_info(&input).expect_err("parsed successfully");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlight_none() {
|
||||
let attributes = parse_attributes("bash {}");
|
||||
assert_eq!(attributes.highlight_groups, &[HighlightGroup::new(vec![Highlight::All])]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlight_specific_lines() {
|
||||
let attributes = parse_attributes("bash { 1, 2 , 3 }");
|
||||
assert_eq!(attributes.highlight_groups, &[HighlightGroup::new(vec![Single(1), Single(2), Single(3)])]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlight_line_range() {
|
||||
let attributes = parse_attributes("bash { 1, 2-4,6 , all , 10 - 12 }");
|
||||
assert_eq!(
|
||||
attributes.highlight_groups,
|
||||
&[HighlightGroup::new(vec![Single(1), Range(2..5), Single(6), All, Range(10..13)])]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_groups() {
|
||||
let attributes = parse_attributes("bash {1-3,5 |6-9}");
|
||||
assert_eq!(attributes.highlight_groups.len(), 2);
|
||||
assert_eq!(attributes.highlight_groups[0], HighlightGroup::new(vec![Range(1..4), Single(5)]));
|
||||
assert_eq!(attributes.highlight_groups[1], HighlightGroup::new(vec![Range(6..10)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_width() {
|
||||
let attributes = parse_attributes("mermaid +width:50% +render");
|
||||
assert!(attributes.auto_render);
|
||||
assert_eq!(attributes.width, Some(Percent(50)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_width() {
|
||||
try_parse_attributes("mermaid +width:50%% +render").expect_err("parse succeeded");
|
||||
try_parse_attributes("mermaid +width: +render").expect_err("parse succeeded");
|
||||
try_parse_attributes("mermaid +width:50%").expect_err("parse succeeded");
|
||||
}
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
use crate::style::TextStyle;
|
||||
use serde_with::DeserializeFromStr;
|
||||
use std::{convert::Infallible, fmt::Write, iter, ops::Range, path::PathBuf, str::FromStr};
|
||||
use strum::EnumIter;
|
||||
use super::text_style::{Color, TextStyle, UndefinedPaletteColorError};
|
||||
use crate::theme::{ColorPalette, raw::RawColor};
|
||||
use comrak::nodes::AlertType;
|
||||
use std::{fmt, iter, path::PathBuf, str::FromStr};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
/// A markdown element.
|
||||
@ -14,13 +14,13 @@ pub(crate) enum MarkdownElement {
|
||||
FrontMatter(String),
|
||||
|
||||
/// A setex heading.
|
||||
SetexHeading { text: TextBlock },
|
||||
SetexHeading { text: Line<RawColor> },
|
||||
|
||||
/// A normal heading.
|
||||
Heading { level: u8, text: TextBlock },
|
||||
Heading { level: u8, text: Line<RawColor> },
|
||||
|
||||
/// A paragraph, composed of text and line breaks.
|
||||
Paragraph(Vec<ParagraphElement>),
|
||||
/// A paragraph composed by a list of lines.
|
||||
Paragraph(Vec<Line<RawColor>>),
|
||||
|
||||
/// An image.
|
||||
Image { path: PathBuf, title: String, source_position: SourcePosition },
|
||||
@ -31,7 +31,16 @@ pub(crate) enum MarkdownElement {
|
||||
List(Vec<ListItem>),
|
||||
|
||||
/// A code snippet.
|
||||
Snippet(Snippet),
|
||||
Snippet {
|
||||
/// The information line that specifies this code's language, attributes, etc.
|
||||
info: String,
|
||||
|
||||
/// The code in this snippet.
|
||||
code: String,
|
||||
|
||||
/// The position in the source file this snippet came from.
|
||||
source_position: SourcePosition,
|
||||
},
|
||||
|
||||
/// A table.
|
||||
Table(Table),
|
||||
@ -42,20 +51,30 @@ pub(crate) enum MarkdownElement {
|
||||
/// An HTML comment.
|
||||
Comment { comment: String, source_position: SourcePosition },
|
||||
|
||||
/// A quote.
|
||||
BlockQuote(Vec<String>),
|
||||
/// A block quote containing a list of lines.
|
||||
BlockQuote(Vec<Line<RawColor>>),
|
||||
|
||||
/// An alert.
|
||||
Alert {
|
||||
/// The alert's type.
|
||||
alert_type: AlertType,
|
||||
|
||||
/// The optional title.
|
||||
title: Option<String>,
|
||||
|
||||
/// The content lines in this alert.
|
||||
lines: Vec<Line<RawColor>>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct SourcePosition {
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct SourcePosition {
|
||||
pub(crate) start: LineColumn,
|
||||
}
|
||||
|
||||
impl SourcePosition {
|
||||
pub(crate) fn offset_lines(&self, offset: usize) -> SourcePosition {
|
||||
let mut output = self.clone();
|
||||
output.start.line += offset;
|
||||
output
|
||||
impl fmt::Display for SourcePosition {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}:{}", self.start.line, self.start.column)
|
||||
}
|
||||
}
|
||||
|
||||
@ -65,7 +84,7 @@ impl From<comrak::nodes::Sourcepos> for SourcePosition {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub(crate) struct LineColumn {
|
||||
pub(crate) line: usize,
|
||||
pub(crate) column: usize,
|
||||
@ -77,32 +96,26 @@ impl From<comrak::nodes::LineColumn> for LineColumn {
|
||||
}
|
||||
}
|
||||
|
||||
/// The components that make up a paragraph.
|
||||
///
|
||||
/// This does not map one-to-one with the commonmark spec and only handles text (including its
|
||||
/// style) and line breaks. Any other inlines that could show up on a paragraph, such as images,
|
||||
/// are a [MarkdownElement] on their own.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum ParagraphElement {
|
||||
/// A block of text.
|
||||
Text(TextBlock),
|
||||
|
||||
/// A line break.
|
||||
LineBreak,
|
||||
}
|
||||
|
||||
/// A block of text.
|
||||
/// A text line.
|
||||
///
|
||||
/// Text is represented as a series of chunks, each with their own formatting.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Default)]
|
||||
pub(crate) struct TextBlock(pub(crate) Vec<Text>);
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct Line<C = Color>(pub(crate) Vec<Text<C>>);
|
||||
|
||||
impl TextBlock {
|
||||
impl<C> Default for Line<C> {
|
||||
fn default() -> Self {
|
||||
Self(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
impl<C> Line<C> {
|
||||
/// Get the total width for this text.
|
||||
pub(crate) fn width(&self) -> usize {
|
||||
self.0.iter().map(|text| text.content.width()).sum()
|
||||
}
|
||||
}
|
||||
|
||||
impl Line<Color> {
|
||||
/// Applies the given style to this text.
|
||||
pub(crate) fn apply_style(&mut self, style: &TextStyle) {
|
||||
for text in &mut self.0 {
|
||||
@ -111,7 +124,19 @@ impl TextBlock {
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Into<Text>> From<T> for TextBlock {
|
||||
impl Line<RawColor> {
|
||||
/// Resolve the colors in this line.
|
||||
pub(crate) fn resolve(self, palette: &ColorPalette) -> Result<Line<Color>, UndefinedPaletteColorError> {
|
||||
let mut output = Vec::with_capacity(self.0.len());
|
||||
for text in self.0 {
|
||||
let style = text.style.resolve(palette)?;
|
||||
output.push(Text::new(text.content, style));
|
||||
}
|
||||
Ok(Line(output))
|
||||
}
|
||||
}
|
||||
|
||||
impl<C, T: Into<Text<C>>> From<T> for Line<C> {
|
||||
fn from(text: T) -> Self {
|
||||
Self(vec![text.into()])
|
||||
}
|
||||
@ -121,25 +146,36 @@ impl<T: Into<Text>> From<T> for TextBlock {
|
||||
///
|
||||
/// This is the most granular text representation: a `String` and a style.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct Text {
|
||||
pub(crate) struct Text<C = Color> {
|
||||
pub(crate) content: String,
|
||||
pub(crate) style: TextStyle,
|
||||
pub(crate) style: TextStyle<C>,
|
||||
}
|
||||
|
||||
impl Text {
|
||||
/// Construct a new styled text.
|
||||
pub(crate) fn new<S: Into<String>>(content: S, style: TextStyle) -> Self {
|
||||
Self { content: content.into(), style }
|
||||
impl<C> Default for Text<C> {
|
||||
fn default() -> Self {
|
||||
Self { content: Default::default(), style: TextStyle::default() }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for Text {
|
||||
impl<C> Text<C> {
|
||||
/// Construct a new styled text.
|
||||
pub(crate) fn new<S: Into<String>>(content: S, style: TextStyle<C>) -> Self {
|
||||
Self { content: content.into(), style }
|
||||
}
|
||||
|
||||
/// Get the width of this text.
|
||||
pub(crate) fn width(&self) -> usize {
|
||||
self.content.width()
|
||||
}
|
||||
}
|
||||
|
||||
impl<C> From<String> for Text<C> {
|
||||
fn from(text: String) -> Self {
|
||||
Self { content: text, style: TextStyle::default() }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for Text {
|
||||
impl<C> From<&str> for Text<C> {
|
||||
fn from(text: &str) -> Self {
|
||||
Self { content: text.into(), style: TextStyle::default() }
|
||||
}
|
||||
@ -154,7 +190,7 @@ pub(crate) struct ListItem {
|
||||
pub(crate) depth: u8,
|
||||
|
||||
/// The contents of this list item.
|
||||
pub(crate) contents: TextBlock,
|
||||
pub(crate) contents: Line<RawColor>,
|
||||
|
||||
/// The type of list item.
|
||||
pub(crate) item_type: ListItemType,
|
||||
@ -167,241 +203,10 @@ pub(crate) enum ListItemType {
|
||||
Unordered,
|
||||
|
||||
/// A list item for an ordered list that uses parenthesis after the list item number.
|
||||
OrderedParens,
|
||||
OrderedParens(usize),
|
||||
|
||||
/// A list item for an ordered list that uses a period after the list item number.
|
||||
OrderedPeriod,
|
||||
}
|
||||
|
||||
/// A code snippet.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct Snippet {
|
||||
/// The snippet itself.
|
||||
pub(crate) contents: String,
|
||||
|
||||
/// The programming language this snippet is written in.
|
||||
pub(crate) language: SnippetLanguage,
|
||||
|
||||
/// The attributes used for snippet.
|
||||
pub(crate) attributes: SnippetAttributes,
|
||||
}
|
||||
|
||||
impl Snippet {
|
||||
pub(crate) fn visible_lines(&self) -> impl Iterator<Item = &str> {
|
||||
let prefix = self.language.hidden_line_prefix();
|
||||
self.contents.lines().filter(move |line| !prefix.is_some_and(|prefix| line.starts_with(prefix)))
|
||||
}
|
||||
|
||||
pub(crate) fn executable_contents(&self) -> String {
|
||||
if let Some(prefix) = self.language.hidden_line_prefix() {
|
||||
self.contents.lines().fold(String::new(), |mut output, line| {
|
||||
let line = line.strip_prefix(prefix).unwrap_or(line);
|
||||
let _ = writeln!(output, "{line}");
|
||||
output
|
||||
})
|
||||
} else {
|
||||
self.contents.to_owned()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The language of a code snippet.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, EnumIter, PartialOrd, Ord, DeserializeFromStr)]
|
||||
pub enum SnippetLanguage {
|
||||
Ada,
|
||||
Asp,
|
||||
Awk,
|
||||
Bash,
|
||||
BatchFile,
|
||||
C,
|
||||
CMake,
|
||||
Crontab,
|
||||
CSharp,
|
||||
Clojure,
|
||||
Cpp,
|
||||
Css,
|
||||
DLang,
|
||||
Diff,
|
||||
Docker,
|
||||
Dotenv,
|
||||
Elixir,
|
||||
Elm,
|
||||
Erlang,
|
||||
Fish,
|
||||
Go,
|
||||
Haskell,
|
||||
Html,
|
||||
Java,
|
||||
JavaScript,
|
||||
Json,
|
||||
Kotlin,
|
||||
Latex,
|
||||
Lua,
|
||||
Makefile,
|
||||
Mermaid,
|
||||
Markdown,
|
||||
Nix,
|
||||
Nushell,
|
||||
OCaml,
|
||||
Perl,
|
||||
Php,
|
||||
Protobuf,
|
||||
Puppet,
|
||||
Python,
|
||||
R,
|
||||
Ruby,
|
||||
Rust,
|
||||
RustScript,
|
||||
Scala,
|
||||
Shell,
|
||||
Sql,
|
||||
Swift,
|
||||
Svelte,
|
||||
Terraform,
|
||||
TypeScript,
|
||||
Typst,
|
||||
Unknown(String),
|
||||
Xml,
|
||||
Yaml,
|
||||
Vue,
|
||||
Zig,
|
||||
Zsh,
|
||||
}
|
||||
|
||||
impl SnippetLanguage {
|
||||
pub(crate) fn supports_auto_render(&self) -> bool {
|
||||
matches!(self, Self::Latex | Self::Typst | Self::Mermaid)
|
||||
}
|
||||
|
||||
pub(crate) fn hidden_line_prefix(&self) -> Option<&'static str> {
|
||||
use SnippetLanguage::*;
|
||||
match self {
|
||||
Rust => Some("# "),
|
||||
Python | Bash | Fish | Shell | Zsh | Kotlin | Java | JavaScript | TypeScript | C | Cpp | Go => Some("/// "),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for SnippetLanguage {
|
||||
type Err = Infallible;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
use SnippetLanguage::*;
|
||||
let language = match s {
|
||||
"ada" => Ada,
|
||||
"asp" => Asp,
|
||||
"awk" => Awk,
|
||||
"bash" => Bash,
|
||||
"c" => C,
|
||||
"cmake" => CMake,
|
||||
"crontab" => Crontab,
|
||||
"csharp" => CSharp,
|
||||
"clojure" => Clojure,
|
||||
"cpp" | "c++" => Cpp,
|
||||
"css" => Css,
|
||||
"d" => DLang,
|
||||
"diff" => Diff,
|
||||
"docker" => Docker,
|
||||
"dotenv" => Dotenv,
|
||||
"elixir" => Elixir,
|
||||
"elm" => Elm,
|
||||
"erlang" => Erlang,
|
||||
"fish" => Fish,
|
||||
"go" => Go,
|
||||
"haskell" => Haskell,
|
||||
"html" => Html,
|
||||
"java" => Java,
|
||||
"javascript" | "js" => JavaScript,
|
||||
"json" => Json,
|
||||
"kotlin" => Kotlin,
|
||||
"latex" => Latex,
|
||||
"lua" => Lua,
|
||||
"make" => Makefile,
|
||||
"markdown" => Markdown,
|
||||
"mermaid" => Mermaid,
|
||||
"nix" => Nix,
|
||||
"nushell" | "nu" => Nushell,
|
||||
"ocaml" => OCaml,
|
||||
"perl" => Perl,
|
||||
"php" => Php,
|
||||
"protobuf" => Protobuf,
|
||||
"puppet" => Puppet,
|
||||
"python" => Python,
|
||||
"r" => R,
|
||||
"ruby" => Ruby,
|
||||
"rust" => Rust,
|
||||
"rust-script" => RustScript,
|
||||
"scala" => Scala,
|
||||
"shell" | "sh" => Shell,
|
||||
"sql" => Sql,
|
||||
"svelte" => Svelte,
|
||||
"swift" => Swift,
|
||||
"terraform" => Terraform,
|
||||
"typescript" | "ts" => TypeScript,
|
||||
"typst" => Typst,
|
||||
"xml" => Xml,
|
||||
"yaml" => Yaml,
|
||||
"vue" => Vue,
|
||||
"zig" => Zig,
|
||||
"zsh" => Zsh,
|
||||
other => Unknown(other.to_string()),
|
||||
};
|
||||
Ok(language)
|
||||
}
|
||||
}
|
||||
|
||||
/// Attributes for code snippets.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct SnippetAttributes {
|
||||
/// Whether the snippet is marked as executable.
|
||||
pub(crate) execute: bool,
|
||||
|
||||
/// Whether a snippet is marked to be auto rendered.
|
||||
///
|
||||
/// An auto rendered snippet is transformed during parsing, leading to some visual
|
||||
/// representation of it being shown rather than the original code.
|
||||
pub(crate) auto_render: bool,
|
||||
|
||||
/// Whether the snippet should show line numbers.
|
||||
pub(crate) line_numbers: bool,
|
||||
|
||||
/// The groups of lines to highlight.
|
||||
pub(crate) highlight_groups: Vec<HighlightGroup>,
|
||||
|
||||
/// The width of the generated image.
|
||||
///
|
||||
/// Only valid for +render snippets.
|
||||
pub(crate) width: Option<Percent>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct HighlightGroup(Vec<Highlight>);
|
||||
|
||||
impl HighlightGroup {
|
||||
pub(crate) fn new(highlights: Vec<Highlight>) -> Self {
|
||||
Self(highlights)
|
||||
}
|
||||
|
||||
pub(crate) fn contains(&self, line_number: u16) -> bool {
|
||||
for higlight in &self.0 {
|
||||
match higlight {
|
||||
Highlight::All => return true,
|
||||
Highlight::Single(number) if number == &line_number => return true,
|
||||
Highlight::Range(range) if range.contains(&line_number) => return true,
|
||||
_ => continue,
|
||||
};
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// A highlighted set of lines
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Highlight {
|
||||
All,
|
||||
Single(u16),
|
||||
Range(Range<u16>),
|
||||
OrderedPeriod(usize),
|
||||
}
|
||||
|
||||
/// A table.
|
||||
@ -423,7 +228,7 @@ impl Table {
|
||||
/// Iterates all the text entries in a column.
|
||||
///
|
||||
/// This includes the header.
|
||||
pub(crate) fn iter_column(&self, column: usize) -> impl Iterator<Item = &TextBlock> {
|
||||
pub(crate) fn iter_column(&self, column: usize) -> impl Iterator<Item = &Line<RawColor>> {
|
||||
let header_element = &self.header.0[column];
|
||||
let row_elements = self.rows.iter().map(move |row| &row.0[column]);
|
||||
iter::once(header_element).chain(row_elements)
|
||||
@ -432,7 +237,7 @@ impl Table {
|
||||
|
||||
/// A table row.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct TableRow(pub(crate) Vec<TextBlock>);
|
||||
pub(crate) struct TableRow(pub(crate) Vec<Line<RawColor>>);
|
||||
|
||||
/// A percentage.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@ -471,82 +276,3 @@ pub enum PercentParseError {
|
||||
#[error("unexpected: '{0}'")]
|
||||
Trailer(String),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn code_visible_lines_bash() {
|
||||
let contents = r"echo 'hello world'
|
||||
/// echo 'this was hidden'
|
||||
|
||||
echo '/// is the prefix'
|
||||
/// echo 'the prefix is /// '
|
||||
echo 'hello again'
|
||||
"
|
||||
.to_string();
|
||||
|
||||
let expected = vec!["echo 'hello world'", "", "echo '/// is the prefix'", "echo 'hello again'"];
|
||||
let code = Snippet { contents, language: SnippetLanguage::Bash, attributes: Default::default() };
|
||||
assert_eq!(expected, code.visible_lines().collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_visible_lines_rust() {
|
||||
let contents = r##"# fn main() {
|
||||
println!("Hello world");
|
||||
# // The prefix is # .
|
||||
# }
|
||||
"##
|
||||
.to_string();
|
||||
|
||||
let expected = vec!["println!(\"Hello world\");"];
|
||||
let code = Snippet { contents, language: SnippetLanguage::Rust, attributes: Default::default() };
|
||||
assert_eq!(expected, code.visible_lines().collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_executable_contents_bash() {
|
||||
let contents = r"echo 'hello world'
|
||||
/// echo 'this was hidden'
|
||||
|
||||
echo '/// is the prefix'
|
||||
/// echo 'the prefix is /// '
|
||||
echo 'hello again'
|
||||
"
|
||||
.to_string();
|
||||
|
||||
let expected = r"echo 'hello world'
|
||||
echo 'this was hidden'
|
||||
|
||||
echo '/// is the prefix'
|
||||
echo 'the prefix is /// '
|
||||
echo 'hello again'
|
||||
"
|
||||
.to_string();
|
||||
|
||||
let code = Snippet { contents, language: SnippetLanguage::Bash, attributes: Default::default() };
|
||||
assert_eq!(expected, code.executable_contents());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_executable_contents_rust() {
|
||||
let contents = r##"# fn main() {
|
||||
println!("Hello world");
|
||||
# // The prefix is # .
|
||||
# }
|
||||
"##
|
||||
.to_string();
|
||||
|
||||
let expected = r##"fn main() {
|
||||
println!("Hello world");
|
||||
// The prefix is # .
|
||||
}
|
||||
"##
|
||||
.to_string();
|
||||
|
||||
let code = Snippet { contents, language: SnippetLanguage::Rust, attributes: Default::default() };
|
||||
assert_eq!(expected, code.executable_contents());
|
||||
}
|
||||
}
|
||||
|
195
src/markdown/html.rs
Normal file
195
src/markdown/html.rs
Normal file
@ -0,0 +1,195 @@
|
||||
use super::text_style::{Color, TextStyle};
|
||||
use crate::theme::raw::{ParseColorError, RawColor};
|
||||
use std::{borrow::Cow, str, str::Utf8Error};
|
||||
use tl::Attributes;
|
||||
|
||||
pub(crate) struct HtmlParseOptions {
|
||||
pub(crate) strict: bool,
|
||||
}
|
||||
|
||||
impl Default for HtmlParseOptions {
|
||||
fn default() -> Self {
|
||||
Self { strict: true }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct HtmlParser {
|
||||
options: HtmlParseOptions,
|
||||
}
|
||||
|
||||
impl HtmlParser {
|
||||
pub(crate) fn parse(self, input: &str) -> Result<HtmlInline, ParseHtmlError> {
|
||||
if input.starts_with("</") {
|
||||
if input.starts_with("</span") {
|
||||
return Ok(HtmlInline::CloseSpan);
|
||||
} else {
|
||||
return Err(ParseHtmlError::UnsupportedClosingTag(input.to_string()));
|
||||
}
|
||||
}
|
||||
let dom = tl::parse(input, Default::default())?;
|
||||
let top = dom.children().iter().next().ok_or(ParseHtmlError::NoTags)?;
|
||||
let node = top.get(dom.parser()).expect("failed to get");
|
||||
let tag = node.as_tag().ok_or(ParseHtmlError::NoTags)?;
|
||||
if tag.name().as_bytes() != b"span" {
|
||||
return Err(ParseHtmlError::UnsupportedHtml);
|
||||
}
|
||||
let style = self.parse_attributes(tag.attributes())?;
|
||||
Ok(HtmlInline::OpenSpan { style })
|
||||
}
|
||||
|
||||
fn parse_attributes(&self, attributes: &Attributes) -> Result<TextStyle<RawColor>, ParseHtmlError> {
|
||||
let mut style = TextStyle::default();
|
||||
for (name, value) in attributes.iter() {
|
||||
let value = value.unwrap_or(Cow::Borrowed(""));
|
||||
match name.as_ref() {
|
||||
"style" => self.parse_css_attribute(&value, &mut style)?,
|
||||
"class" => {
|
||||
style = style.fg_color(RawColor::ForegroundClass(value.to_string()));
|
||||
style = style.bg_color(RawColor::BackgroundClass(value.to_string()));
|
||||
}
|
||||
_ => {
|
||||
if self.options.strict {
|
||||
return Err(ParseHtmlError::UnsupportedTagAttribute(name.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(style)
|
||||
}
|
||||
|
||||
fn parse_css_attribute(&self, attribute: &str, style: &mut TextStyle<RawColor>) -> Result<(), ParseHtmlError> {
|
||||
for attribute in attribute.split(';') {
|
||||
let attribute = attribute.trim();
|
||||
if attribute.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (key, value) = attribute.split_once(':').ok_or(ParseHtmlError::NoColonInAttribute)?;
|
||||
let key = key.trim();
|
||||
let value = value.trim();
|
||||
match key {
|
||||
"color" => style.colors.foreground = Some(Self::parse_color(value)?),
|
||||
"background-color" => style.colors.background = Some(Self::parse_color(value)?),
|
||||
_ => {
|
||||
if self.options.strict {
|
||||
return Err(ParseHtmlError::UnsupportedCssAttribute(key.into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_color(input: &str) -> Result<RawColor, ParseHtmlError> {
|
||||
if input.starts_with('#') {
|
||||
let color = input.strip_prefix('#').unwrap().parse()?;
|
||||
if matches!(color, RawColor::Color(Color::Rgb { .. })) { Ok(color) } else { Ok(input.parse()?) }
|
||||
} else {
|
||||
let color = input.parse::<RawColor>()?;
|
||||
if matches!(color, RawColor::Color(Color::Rgb { .. })) {
|
||||
Err(ParseHtmlError::InvalidColor("missing '#' in rgb color".into()))
|
||||
} else {
|
||||
Ok(color)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum HtmlInline {
|
||||
OpenSpan { style: TextStyle<RawColor> },
|
||||
CloseSpan,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(crate) enum ParseHtmlError {
|
||||
#[error("parsing html failed: {0}")]
|
||||
ParsingHtml(#[from] tl::ParseError),
|
||||
|
||||
#[error("no html tags found")]
|
||||
NoTags,
|
||||
|
||||
#[error("non utf8 content: {0}")]
|
||||
NotUtf8(#[from] Utf8Error),
|
||||
|
||||
#[error("attribute has no ':'")]
|
||||
NoColonInAttribute,
|
||||
|
||||
#[error("invalid color: {0}")]
|
||||
InvalidColor(String),
|
||||
|
||||
#[error("invalid css attribute: {0}")]
|
||||
UnsupportedCssAttribute(String),
|
||||
|
||||
#[error("HTML can only contain span tags")]
|
||||
UnsupportedHtml,
|
||||
|
||||
#[error("unsupported tag attribute: {0}")]
|
||||
UnsupportedTagAttribute(String),
|
||||
|
||||
#[error("unsupported closing tag: {0}")]
|
||||
UnsupportedClosingTag(String),
|
||||
}
|
||||
|
||||
impl From<ParseColorError> for ParseHtmlError {
|
||||
fn from(e: ParseColorError) -> Self {
|
||||
Self::InvalidColor(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
|
||||
#[test]
|
||||
fn parse_style() {
|
||||
let tag =
|
||||
HtmlParser::default().parse(r#"<span style="color: red; background-color: black">"#).expect("parse failed");
|
||||
let HtmlInline::OpenSpan { style } = tag else { panic!("not an open tag") };
|
||||
assert_eq!(style, TextStyle::default().bg_color(Color::Black).fg_color(Color::Red));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_class() {
|
||||
let tag = HtmlParser::default().parse(r#"<span class="foo">"#).expect("parse failed");
|
||||
let HtmlInline::OpenSpan { style } = tag else { panic!("not an open tag") };
|
||||
assert_eq!(
|
||||
style,
|
||||
TextStyle::default()
|
||||
.bg_color(RawColor::BackgroundClass("foo".into()))
|
||||
.fg_color(RawColor::ForegroundClass("foo".into()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_end_tag() {
|
||||
let tag = HtmlParser::default().parse("</span>").expect("parse failed");
|
||||
assert!(matches!(tag, HtmlInline::CloseSpan));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::invalid_start_tag("<div>")]
|
||||
#[case::invalid_end_tag("</div>")]
|
||||
#[case::invalid_attribute("<span foo=\"bar\">")]
|
||||
#[case::invalid_attribute("<span style=\"bleh: 42\"")]
|
||||
#[case::invalid_color("<span style=\"color: 42\"")]
|
||||
fn parse_invalid_html(#[case] input: &str) {
|
||||
HtmlParser::default().parse(input).expect_err("parse succeeded");
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::rgb("#ff0000", Color::Rgb{r: 255, g: 0, b: 0})]
|
||||
#[case::red("red", Color::Red)]
|
||||
fn parse_color(#[case] input: &str, #[case] expected: Color) {
|
||||
let color = HtmlParser::parse_color(input).expect("parse failed");
|
||||
assert_eq!(color, expected.into());
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::rgb("ff0000")]
|
||||
#[case::red("#red")]
|
||||
fn parse_invalid_color(#[case] input: &str) {
|
||||
HtmlParser::parse_color(input).expect_err("parse succeeded");
|
||||
}
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
pub(crate) mod code;
|
||||
pub(crate) mod elements;
|
||||
pub(crate) mod html;
|
||||
pub(crate) mod parse;
|
||||
pub(crate) mod text;
|
||||
pub(crate) mod text_style;
|
||||
|
@ -1,31 +1,29 @@
|
||||
use super::{code::CodeBlockParseError, elements::SourcePosition};
|
||||
use crate::{
|
||||
markdown::{
|
||||
code::CodeBlockParser,
|
||||
elements::{ListItem, ListItemType, MarkdownElement, ParagraphElement, Table, TableRow, Text, TextBlock},
|
||||
},
|
||||
style::TextStyle,
|
||||
use super::{
|
||||
elements::{Line, ListItem, ListItemType, MarkdownElement, SourcePosition, Table, TableRow, Text},
|
||||
html::{HtmlInline, HtmlParser, ParseHtmlError},
|
||||
text_style::TextStyle,
|
||||
};
|
||||
use crate::theme::raw::RawColor;
|
||||
use comrak::{
|
||||
Arena, ComrakOptions,
|
||||
arena_tree::Node,
|
||||
format_commonmark,
|
||||
nodes::{
|
||||
Ast, AstNode, ListDelimType, ListType, NodeCodeBlock, NodeHeading, NodeHtmlBlock, NodeList, NodeValue,
|
||||
Sourcepos,
|
||||
Ast, AstNode, ListDelimType, ListType, NodeAlert, NodeCodeBlock, NodeHeading, NodeHtmlBlock, NodeList,
|
||||
NodeValue, Sourcepos,
|
||||
},
|
||||
parse_document, Arena, ComrakOptions, ListStyleType,
|
||||
parse_document,
|
||||
};
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
fmt::{self, Debug, Display},
|
||||
io::BufWriter,
|
||||
mem,
|
||||
};
|
||||
|
||||
/// The result of parsing a markdown file.
|
||||
pub(crate) type ParseResult<T> = Result<T, ParseError>;
|
||||
|
||||
struct ParserOptions(ComrakOptions<'static>);
|
||||
struct ParserOptions(comrak::Options<'static>);
|
||||
|
||||
impl Default for ParserOptions {
|
||||
fn default() -> Self {
|
||||
@ -34,6 +32,8 @@ impl Default for ParserOptions {
|
||||
options.extension.table = true;
|
||||
options.extension.strikethrough = true;
|
||||
options.extension.multiline_block_quotes = true;
|
||||
options.extension.alerts = true;
|
||||
options.extension.wikilinks_title_before_pipe = true;
|
||||
Self(options)
|
||||
}
|
||||
}
|
||||
@ -43,7 +43,7 @@ impl Default for ParserOptions {
|
||||
/// This takes the contents of a markdown file and parses it into a list of [MarkdownElement].
|
||||
pub struct MarkdownParser<'a> {
|
||||
arena: &'a Arena<AstNode<'a>>,
|
||||
options: ComrakOptions<'static>,
|
||||
options: comrak::Options<'static>,
|
||||
}
|
||||
|
||||
impl<'a> MarkdownParser<'a> {
|
||||
@ -56,37 +56,40 @@ impl<'a> MarkdownParser<'a> {
|
||||
pub(crate) fn parse(&self, contents: &str) -> ParseResult<Vec<MarkdownElement>> {
|
||||
let node = parse_document(self.arena, contents, &self.options);
|
||||
let mut elements = Vec::new();
|
||||
let mut lines_offset = 0;
|
||||
for node in node.children() {
|
||||
let mut parsed_elements =
|
||||
self.parse_node(node).map_err(|e| ParseError::new(e.kind, e.sourcepos.offset_lines(lines_offset)))?;
|
||||
if let Some(MarkdownElement::FrontMatter(contents)) = parsed_elements.first() {
|
||||
lines_offset += contents.lines().count() + 2;
|
||||
}
|
||||
// comrak ignores the lines in the front matter so we need to offset this ourselves.
|
||||
Self::adjust_source_positions(parsed_elements.iter_mut(), lines_offset);
|
||||
let parsed_elements = self.parse_node(node).map_err(|e| ParseError::new(e.kind, e.sourcepos))?;
|
||||
elements.extend(parsed_elements);
|
||||
}
|
||||
Ok(elements)
|
||||
}
|
||||
|
||||
fn adjust_source_positions<'b>(elements: impl Iterator<Item = &'b mut MarkdownElement>, lines_offset: usize) {
|
||||
for element in elements {
|
||||
let position = match element {
|
||||
MarkdownElement::FrontMatter(_)
|
||||
| MarkdownElement::SetexHeading { .. }
|
||||
| MarkdownElement::Heading { .. }
|
||||
| MarkdownElement::Paragraph(_)
|
||||
| MarkdownElement::Image { .. }
|
||||
| MarkdownElement::List(_)
|
||||
| MarkdownElement::Snippet(_)
|
||||
| MarkdownElement::Table(_)
|
||||
| MarkdownElement::ThematicBreak
|
||||
| MarkdownElement::BlockQuote(_) => continue,
|
||||
MarkdownElement::Comment { source_position, .. } => source_position,
|
||||
};
|
||||
*position = position.offset_lines(lines_offset);
|
||||
/// Parse inlines in a markdown input.
|
||||
pub(crate) fn parse_inlines(&self, line: &str) -> Result<Line<RawColor>, ParseInlinesError> {
|
||||
let node = parse_document(self.arena, line, &self.options);
|
||||
if node.children().count() == 0 {
|
||||
return Ok(Default::default());
|
||||
}
|
||||
if node.children().count() > 1 {
|
||||
return Err(ParseInlinesError("inline must be simple text".into()));
|
||||
}
|
||||
let node = node.first_child().expect("must have one child");
|
||||
let data = node.data.borrow();
|
||||
let NodeValue::Paragraph = &data.value else {
|
||||
return Err(ParseInlinesError("inline must be simple text".into()));
|
||||
};
|
||||
let parser = InlinesParser::new(self.arena, SoftBreak::Space, StringifyImages::No);
|
||||
let inlines = parser.parse(node).map_err(|e| ParseInlinesError(e.to_string()))?;
|
||||
let mut output = Line::default();
|
||||
for inline in inlines {
|
||||
match inline {
|
||||
Inline::Text(line) => {
|
||||
output.0.extend(line.0);
|
||||
}
|
||||
Inline::Image { .. } => return Err(ParseInlinesError("images not supported".into())),
|
||||
Inline::LineBreak => return Err(ParseInlinesError("line breaks not supported".into())),
|
||||
};
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn parse_node(&self, node: &'a AstNode<'a>) -> ParseResult<Vec<MarkdownElement>> {
|
||||
@ -103,15 +106,16 @@ impl<'a> MarkdownParser<'a> {
|
||||
NodeValue::Table(_) => self.parse_table(node)?,
|
||||
NodeValue::CodeBlock(block) => Self::parse_code_block(block, data.sourcepos)?,
|
||||
NodeValue::ThematicBreak => MarkdownElement::ThematicBreak,
|
||||
NodeValue::HtmlBlock(block) => Self::parse_html_block(block, data.sourcepos)?,
|
||||
NodeValue::BlockQuote | NodeValue::MultilineBlockQuote(_) => Self::parse_block_quote(node)?,
|
||||
NodeValue::HtmlBlock(block) => self.parse_html_block(block, data.sourcepos)?,
|
||||
NodeValue::BlockQuote | NodeValue::MultilineBlockQuote(_) => self.parse_block_quote(node)?,
|
||||
NodeValue::Alert(alert) => self.parse_alert(alert, node)?,
|
||||
other => return Err(ParseErrorKind::UnsupportedElement(other.identifier()).with_sourcepos(data.sourcepos)),
|
||||
};
|
||||
Ok(vec![element])
|
||||
}
|
||||
|
||||
fn parse_front_matter(contents: &str) -> ParseResult<MarkdownElement> {
|
||||
// Remote leading and trailing delimiters before parsing. This is quite poopy but hey, it
|
||||
// Remove leading and trailing delimiters before parsing. This is quite poopy but hey, it
|
||||
// works.
|
||||
let contents = contents.strip_prefix("---\n").unwrap_or(contents);
|
||||
let contents = contents.strip_prefix("---\r\n").unwrap_or(contents);
|
||||
@ -122,7 +126,7 @@ impl<'a> MarkdownParser<'a> {
|
||||
Ok(MarkdownElement::FrontMatter(contents.into()))
|
||||
}
|
||||
|
||||
fn parse_html_block(block: &NodeHtmlBlock, sourcepos: Sourcepos) -> ParseResult<MarkdownElement> {
|
||||
fn parse_html_block(&self, block: &NodeHtmlBlock, sourcepos: Sourcepos) -> ParseResult<MarkdownElement> {
|
||||
let block = block.literal.trim();
|
||||
let start_tag = "<!--";
|
||||
let end_tag = "-->";
|
||||
@ -134,31 +138,18 @@ impl<'a> MarkdownParser<'a> {
|
||||
Ok(MarkdownElement::Comment { comment: block.into(), source_position: sourcepos.into() })
|
||||
}
|
||||
|
||||
fn parse_block_quote(node: &'a AstNode<'a>) -> ParseResult<MarkdownElement> {
|
||||
// This renders the contents of this block quote AST as commonmark, given we otherwise
|
||||
// would need to either do this outselves or pull the raw block contents off of the
|
||||
// original raw string and that also isn't great.
|
||||
let mut buffer = BufWriter::new(Vec::new());
|
||||
let mut options = ParserOptions::default().0;
|
||||
options.render.list_style = ListStyleType::Star;
|
||||
format_commonmark(node, &options, &mut buffer)
|
||||
.map_err(|e| ParseErrorKind::Internal(e.to_string()).with_sourcepos(node.data.borrow().sourcepos))?;
|
||||
|
||||
let buffer = buffer.into_inner().expect("unwrapping writer failed");
|
||||
fn parse_block_quote(&self, node: &'a AstNode<'a>) -> ParseResult<MarkdownElement> {
|
||||
let mut lines = Vec::new();
|
||||
for line in String::from_utf8_lossy(&buffer).lines() {
|
||||
let line = match line.find('>') {
|
||||
Some(index) => line[index + 1..].trim(),
|
||||
None => line,
|
||||
};
|
||||
let mut line = line.to_string();
|
||||
// `format_commonmark` escapes these symbols so we un-escape them.
|
||||
for escape in &["\\*", "\\!", "\\[", "\\]", "\\#", "\\`", "\\<", "\\>"] {
|
||||
if line.contains(escape) {
|
||||
line = line.replace(escape, &escape[1..]);
|
||||
}
|
||||
let inlines = InlinesParser::new(self.arena, SoftBreak::Newline, StringifyImages::Yes).parse(node)?;
|
||||
for inline in inlines {
|
||||
match inline {
|
||||
Inline::Text(text) => lines.push(text),
|
||||
Inline::LineBreak => lines.push(Line::from("")),
|
||||
Inline::Image { .. } => {}
|
||||
}
|
||||
lines.push(line);
|
||||
}
|
||||
if lines.last() == Some(&Line::<RawColor>::from("")) {
|
||||
lines.pop();
|
||||
}
|
||||
Ok(MarkdownElement::BlockQuote(lines))
|
||||
}
|
||||
@ -167,9 +158,16 @@ impl<'a> MarkdownParser<'a> {
|
||||
if !block.fenced {
|
||||
return Err(ParseErrorKind::UnfencedCodeBlock.with_sourcepos(sourcepos));
|
||||
}
|
||||
let code =
|
||||
CodeBlockParser::parse(block).map_err(|e| ParseErrorKind::InvalidCodeBlock(e).with_sourcepos(sourcepos))?;
|
||||
Ok(MarkdownElement::Snippet(code))
|
||||
Ok(MarkdownElement::Snippet {
|
||||
info: block.info.clone(),
|
||||
code: block.literal.clone(),
|
||||
source_position: sourcepos.into(),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_alert(&self, alert: &NodeAlert, node: &'a AstNode<'a>) -> ParseResult<MarkdownElement> {
|
||||
let MarkdownElement::BlockQuote(lines) = self.parse_block_quote(node)? else { panic!("not a block quote") };
|
||||
Ok(MarkdownElement::Alert { alert_type: alert.alert_type, title: alert.title.clone(), lines })
|
||||
}
|
||||
|
||||
fn parse_heading(&self, heading: &NodeHeading, node: &'a AstNode<'a>) -> ParseResult<MarkdownElement> {
|
||||
@ -183,12 +181,12 @@ impl<'a> MarkdownParser<'a> {
|
||||
|
||||
fn parse_paragraph(&self, node: &'a AstNode<'a>) -> ParseResult<Vec<MarkdownElement>> {
|
||||
let mut elements = Vec::new();
|
||||
let inlines = InlinesParser::new(self.arena).parse(node)?;
|
||||
let inlines = InlinesParser::new(self.arena, SoftBreak::Space, StringifyImages::No).parse(node)?;
|
||||
let mut paragraph_elements = Vec::new();
|
||||
for inline in inlines {
|
||||
match inline {
|
||||
Inline::Text(text) => paragraph_elements.push(ParagraphElement::Text(text)),
|
||||
Inline::LineBreak => paragraph_elements.push(ParagraphElement::LineBreak),
|
||||
Inline::Text(text) => paragraph_elements.push(text),
|
||||
Inline::LineBreak => (),
|
||||
Inline::Image { path, title } => {
|
||||
if !paragraph_elements.is_empty() {
|
||||
elements.push(MarkdownElement::Paragraph(mem::take(&mut paragraph_elements)));
|
||||
@ -207,8 +205,8 @@ impl<'a> MarkdownParser<'a> {
|
||||
Ok(elements)
|
||||
}
|
||||
|
||||
fn parse_text(&self, node: &'a AstNode<'a>) -> ParseResult<TextBlock> {
|
||||
let inlines = InlinesParser::new(self.arena).parse(node)?;
|
||||
fn parse_text(&self, node: &'a AstNode<'a>) -> ParseResult<Line<RawColor>> {
|
||||
let inlines = InlinesParser::new(self.arena, SoftBreak::Space, StringifyImages::No).parse(node)?;
|
||||
let mut chunks = Vec::new();
|
||||
for inline in inlines {
|
||||
match inline {
|
||||
@ -219,7 +217,7 @@ impl<'a> MarkdownParser<'a> {
|
||||
}
|
||||
};
|
||||
}
|
||||
Ok(TextBlock(chunks))
|
||||
Ok(Line(chunks))
|
||||
}
|
||||
|
||||
fn parse_list(&self, root: &'a AstNode<'a>, depth: u8) -> ParseResult<Vec<ListItem>> {
|
||||
@ -245,8 +243,8 @@ impl<'a> MarkdownParser<'a> {
|
||||
fn parse_list_item(&self, item: &NodeList, root: &'a AstNode<'a>, depth: u8) -> ParseResult<Vec<ListItem>> {
|
||||
let item_type = match (item.list_type, item.delimiter) {
|
||||
(ListType::Bullet, _) => ListItemType::Unordered,
|
||||
(ListType::Ordered, ListDelimType::Paren) => ListItemType::OrderedParens,
|
||||
(ListType::Ordered, ListDelimType::Period) => ListItemType::OrderedPeriod,
|
||||
(ListType::Ordered, ListDelimType::Paren) => ListItemType::OrderedParens(item.start),
|
||||
(ListType::Ordered, ListDelimType::Period) => ListItemType::OrderedPeriod(item.start),
|
||||
};
|
||||
let mut elements = Vec::new();
|
||||
for node in root.children() {
|
||||
@ -311,15 +309,27 @@ impl<'a> MarkdownParser<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
enum SoftBreak {
|
||||
Newline,
|
||||
Space,
|
||||
}
|
||||
|
||||
enum StringifyImages {
|
||||
Yes,
|
||||
No,
|
||||
}
|
||||
|
||||
struct InlinesParser<'a> {
|
||||
inlines: Vec<Inline>,
|
||||
pending_text: Vec<Text>,
|
||||
pending_text: Vec<Text<RawColor>>,
|
||||
arena: &'a Arena<AstNode<'a>>,
|
||||
soft_break: SoftBreak,
|
||||
stringify_images: StringifyImages,
|
||||
}
|
||||
|
||||
impl<'a> InlinesParser<'a> {
|
||||
fn new(arena: &'a Arena<AstNode<'a>>) -> Self {
|
||||
Self { inlines: Vec::new(), pending_text: Vec::new(), arena }
|
||||
fn new(arena: &'a Arena<AstNode<'a>>, soft_break: SoftBreak, stringify_images: StringifyImages) -> Self {
|
||||
Self { inlines: Vec::new(), pending_text: Vec::new(), arena, soft_break, stringify_images }
|
||||
}
|
||||
|
||||
fn parse(mut self, node: &'a AstNode<'a>) -> ParseResult<Vec<Inline>> {
|
||||
@ -331,29 +341,63 @@ impl<'a> InlinesParser<'a> {
|
||||
fn store_pending_text(&mut self) {
|
||||
let chunks = mem::take(&mut self.pending_text);
|
||||
if !chunks.is_empty() {
|
||||
self.inlines.push(Inline::Text(TextBlock(chunks)));
|
||||
self.inlines.push(Inline::Text(Line(chunks)));
|
||||
}
|
||||
}
|
||||
|
||||
fn process_node(&mut self, node: &'a AstNode<'a>, style: TextStyle) -> ParseResult<()> {
|
||||
fn process_node(
|
||||
&mut self,
|
||||
node: &'a AstNode<'a>,
|
||||
parent: &'a AstNode<'a>,
|
||||
style: TextStyle<RawColor>,
|
||||
) -> ParseResult<Option<HtmlStyle>> {
|
||||
let data = node.data.borrow();
|
||||
match &data.value {
|
||||
NodeValue::Text(text) => {
|
||||
self.pending_text.push(Text::new(text.clone(), style.clone()));
|
||||
self.pending_text.push(Text::new(text.clone(), style));
|
||||
}
|
||||
NodeValue::Code(code) => {
|
||||
self.pending_text.push(Text::new(code.literal.clone(), TextStyle::default().code()));
|
||||
}
|
||||
NodeValue::Strong => self.process_children(node, style.clone().bold())?,
|
||||
NodeValue::Emph => self.process_children(node, style.clone().italics())?,
|
||||
NodeValue::Strikethrough => self.process_children(node, style.clone().strikethrough())?,
|
||||
NodeValue::SoftBreak => self.pending_text.push(Text::from(" ")),
|
||||
NodeValue::Link(link) => self.pending_text.push(Text::new(link.url.clone(), TextStyle::default().link())),
|
||||
NodeValue::Strong => self.process_children(node, style.bold())?,
|
||||
NodeValue::Emph => self.process_children(node, style.italics())?,
|
||||
NodeValue::Strikethrough => self.process_children(node, style.strikethrough())?,
|
||||
NodeValue::SoftBreak => {
|
||||
match self.soft_break {
|
||||
SoftBreak::Newline => {
|
||||
self.store_pending_text();
|
||||
}
|
||||
SoftBreak::Space => self.pending_text.push(Text::new(" ", style)),
|
||||
};
|
||||
}
|
||||
NodeValue::Link(link) => {
|
||||
let has_label = node.first_child().is_some();
|
||||
if has_label {
|
||||
self.process_children(node, TextStyle::default().link_label())?;
|
||||
self.pending_text.push(Text::from(" ("));
|
||||
}
|
||||
self.pending_text.push(Text::new(link.url.clone(), TextStyle::default().link_url()));
|
||||
if !link.title.is_empty() {
|
||||
self.pending_text.push(Text::from(" \""));
|
||||
self.pending_text.push(Text::new(link.title.clone(), TextStyle::default().link_title()));
|
||||
self.pending_text.push(Text::from("\""));
|
||||
}
|
||||
if has_label {
|
||||
self.pending_text.push(Text::from(")"));
|
||||
}
|
||||
}
|
||||
NodeValue::WikiLink(link) => {
|
||||
self.pending_text.push(Text::new(link.url.clone(), TextStyle::default().link_url()));
|
||||
}
|
||||
NodeValue::LineBreak => {
|
||||
self.store_pending_text();
|
||||
self.inlines.push(Inline::LineBreak);
|
||||
}
|
||||
NodeValue::Image(link) => {
|
||||
if matches!(self.stringify_images, StringifyImages::Yes) {
|
||||
self.pending_text.push(Text::from(format!("", link.title, link.url)));
|
||||
return Ok(None);
|
||||
}
|
||||
self.store_pending_text();
|
||||
|
||||
// The image "title" contains inlines so we create a dummy paragraph node that
|
||||
@ -371,24 +415,75 @@ impl<'a> InlinesParser<'a> {
|
||||
let title = String::from_utf8_lossy(&buffer).trim_end().to_string();
|
||||
self.inlines.push(Inline::Image { path: link.url.clone(), title });
|
||||
}
|
||||
NodeValue::Paragraph => {
|
||||
self.process_children(node, style)?;
|
||||
self.store_pending_text();
|
||||
if matches!(parent.data.borrow().value, NodeValue::BlockQuote | NodeValue::MultilineBlockQuote(_)) {
|
||||
self.inlines.push(Inline::LineBreak);
|
||||
}
|
||||
}
|
||||
NodeValue::List(_) => {
|
||||
self.process_children(node, style)?;
|
||||
self.store_pending_text();
|
||||
self.inlines.push(Inline::LineBreak);
|
||||
}
|
||||
NodeValue::Item(item) => {
|
||||
match (item.list_type, item.delimiter) {
|
||||
(ListType::Bullet, _) => self.pending_text.push(Text::from("* ")),
|
||||
(ListType::Ordered, ListDelimType::Period) => {
|
||||
self.pending_text.push(Text::from(format!("{}. ", item.start)))
|
||||
}
|
||||
(ListType::Ordered, ListDelimType::Paren) => {
|
||||
self.pending_text.push(Text::from(format!("{}) ", item.start)))
|
||||
}
|
||||
};
|
||||
self.process_children(node, style)?;
|
||||
}
|
||||
NodeValue::HtmlInline(html) => {
|
||||
let html_inline = HtmlParser::default()
|
||||
.parse(html)
|
||||
.map_err(|e| ParseErrorKind::InvalidHtml(e).with_sourcepos(data.sourcepos))?;
|
||||
match html_inline {
|
||||
HtmlInline::OpenSpan { style } => return Ok(Some(HtmlStyle::Add(style))),
|
||||
HtmlInline::CloseSpan => return Ok(Some(HtmlStyle::Remove)),
|
||||
};
|
||||
}
|
||||
other => {
|
||||
return Err(ParseErrorKind::UnsupportedStructure { container: "text", element: other.identifier() }
|
||||
.with_sourcepos(data.sourcepos));
|
||||
}
|
||||
};
|
||||
Ok(())
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn process_children(&mut self, node: &'a AstNode<'a>, style: TextStyle) -> ParseResult<()> {
|
||||
for node in node.children() {
|
||||
self.process_node(node, style.clone())?;
|
||||
fn process_children(&mut self, root: &'a AstNode<'a>, base_style: TextStyle<RawColor>) -> ParseResult<()> {
|
||||
let mut html_styles = Vec::new();
|
||||
let mut style = base_style.clone();
|
||||
for node in root.children() {
|
||||
if let Some(html_style) = self.process_node(node, root, style.clone())? {
|
||||
match html_style {
|
||||
HtmlStyle::Add(style) => html_styles.push(style),
|
||||
HtmlStyle::Remove => {
|
||||
html_styles.pop();
|
||||
}
|
||||
};
|
||||
style = base_style.clone();
|
||||
for html_style in html_styles.iter().rev() {
|
||||
style.merge(html_style);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
enum HtmlStyle {
|
||||
Add(TextStyle<RawColor>),
|
||||
Remove,
|
||||
}
|
||||
|
||||
enum Inline {
|
||||
Text(TextBlock),
|
||||
Text(Line<RawColor>),
|
||||
Image { path: String, title: String },
|
||||
LineBreak,
|
||||
}
|
||||
@ -415,7 +510,7 @@ pub struct ParseError {
|
||||
|
||||
impl Display for ParseError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "parse error at {}:{}: {}", self.sourcepos.start.line, self.sourcepos.start.column, self.kind)
|
||||
write!(f, "parse error at {}: {}", self.sourcepos, self.kind)
|
||||
}
|
||||
}
|
||||
|
||||
@ -437,8 +532,8 @@ pub(crate) enum ParseErrorKind {
|
||||
/// We don't support unfenced code blocks.
|
||||
UnfencedCodeBlock,
|
||||
|
||||
/// A code block contains invalid attributes.
|
||||
InvalidCodeBlock(CodeBlockParseError),
|
||||
/// Invalid HTML was found.
|
||||
InvalidHtml(ParseHtmlError),
|
||||
|
||||
/// An internal parsing error.
|
||||
Internal(String),
|
||||
@ -452,7 +547,7 @@ impl Display for ParseErrorKind {
|
||||
write!(f, "unsupported structure in {container}: {element}")
|
||||
}
|
||||
Self::UnfencedCodeBlock => write!(f, "only fenced code blocks are supported"),
|
||||
Self::InvalidCodeBlock(error) => write!(f, "invalid code block: {error}"),
|
||||
Self::InvalidHtml(inner) => write!(f, "invalid HTML: {inner}"),
|
||||
Self::Internal(message) => write!(f, "internal error: {message}"),
|
||||
}
|
||||
}
|
||||
@ -509,14 +604,22 @@ impl Identifier for NodeValue {
|
||||
NodeValue::Underline => "underline",
|
||||
NodeValue::SpoileredText => "spoilered text",
|
||||
NodeValue::EscapedTag(_) => "escaped tag",
|
||||
NodeValue::Subscript => "subscript",
|
||||
NodeValue::Raw(_) => "raw",
|
||||
NodeValue::Alert(_) => "alert",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("invalid markdown line: {0}")]
|
||||
pub(crate) struct ParseInlinesError(String);
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use crate::markdown::text_style::Color;
|
||||
|
||||
use super::*;
|
||||
use crate::markdown::elements::SnippetLanguage;
|
||||
use rstest::rstest;
|
||||
use std::path::Path;
|
||||
|
||||
@ -566,17 +669,96 @@ boop
|
||||
Text::new("strikethrough", TextStyle::default().strikethrough()),
|
||||
];
|
||||
|
||||
let expected_elements = &[ParagraphElement::Text(TextBlock(expected_chunks))];
|
||||
let expected_elements = &[Line(expected_chunks)];
|
||||
assert_eq!(elements, expected_elements);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link() {
|
||||
fn html_inlines() {
|
||||
let parsed = parse_single(
|
||||
"hi<span style=\"color: red\">red<span style=\"background-color: blue\">blue<span style=\"color: yellow\">yellow</span></span></span>",
|
||||
);
|
||||
let MarkdownElement::Paragraph(elements) = parsed else { panic!("not a paragraph: {parsed:?}") };
|
||||
let expected_chunks = vec![
|
||||
Text::from("hi"),
|
||||
Text::new("red", TextStyle::default().fg_color(Color::Red)),
|
||||
Text::new("blue", TextStyle::default().fg_color(Color::Red).bg_color(Color::Blue)),
|
||||
Text::new("yellow", TextStyle::default().fg_color(Color::Yellow).bg_color(Color::Blue)),
|
||||
];
|
||||
|
||||
let expected_elements = &[Line(expected_chunks)];
|
||||
assert_eq!(elements, expected_elements);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_wo_label_wo_title() {
|
||||
let parsed = parse_single("my [](https://example.com)");
|
||||
let MarkdownElement::Paragraph(elements) = parsed else { panic!("not a paragraph: {parsed:?}") };
|
||||
let expected_chunks =
|
||||
vec![Text::from("my "), Text::new("https://example.com", TextStyle::default().link_url())];
|
||||
|
||||
let expected_elements = &[Line(expected_chunks)];
|
||||
assert_eq!(elements, expected_elements);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_w_label_wo_title() {
|
||||
let parsed = parse_single("my [website](https://example.com)");
|
||||
let MarkdownElement::Paragraph(elements) = parsed else { panic!("not a paragraph: {parsed:?}") };
|
||||
let expected_chunks = vec![Text::from("my "), Text::new("https://example.com", TextStyle::default().link())];
|
||||
let expected_chunks = vec![
|
||||
Text::from("my "),
|
||||
Text::new("website", TextStyle::default().link_label()),
|
||||
Text::from(" ("),
|
||||
Text::new("https://example.com", TextStyle::default().link_url()),
|
||||
Text::from(")"),
|
||||
];
|
||||
|
||||
let expected_elements = &[ParagraphElement::Text(TextBlock(expected_chunks))];
|
||||
let expected_elements = &[Line(expected_chunks)];
|
||||
assert_eq!(elements, expected_elements);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_wo_label_w_title() {
|
||||
let parsed = parse_single("my [](https://example.com \"Example\")");
|
||||
let MarkdownElement::Paragraph(elements) = parsed else { panic!("not a paragraph: {parsed:?}") };
|
||||
let expected_chunks = vec![
|
||||
Text::from("my "),
|
||||
Text::new("https://example.com", TextStyle::default().link_url()),
|
||||
Text::from(" \""),
|
||||
Text::new("Example", TextStyle::default().link_title()),
|
||||
Text::from("\""),
|
||||
];
|
||||
|
||||
let expected_elements = &[Line(expected_chunks)];
|
||||
assert_eq!(elements, expected_elements);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_w_label_w_title() {
|
||||
let parsed = parse_single("my [website](https://example.com \"Example\")");
|
||||
let MarkdownElement::Paragraph(elements) = parsed else { panic!("not a paragraph: {parsed:?}") };
|
||||
let expected_chunks = vec![
|
||||
Text::from("my "),
|
||||
Text::new("website", TextStyle::default().link_label()),
|
||||
Text::from(" ("),
|
||||
Text::new("https://example.com", TextStyle::default().link_url()),
|
||||
Text::from(" \""),
|
||||
Text::new("Example", TextStyle::default().link_title()),
|
||||
Text::from("\""),
|
||||
Text::from(")"),
|
||||
];
|
||||
|
||||
let expected_elements = &[Line(expected_chunks)];
|
||||
assert_eq!(elements, expected_elements);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wikilink_wo_title() {
|
||||
let parsed = parse_single("[[https://example.com]]");
|
||||
let MarkdownElement::Paragraph(elements) = parsed else { panic!("not a paragraph: {parsed:?}") };
|
||||
let expected_chunks = vec![Text::new("https://example.com", TextStyle::default().link_url())];
|
||||
|
||||
let expected_elements = &[Line(expected_chunks)];
|
||||
assert_eq!(elements, expected_elements);
|
||||
}
|
||||
|
||||
@ -640,6 +822,26 @@ Title
|
||||
assert_eq!(next().depth, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordered_list_starting_non_one() {
|
||||
let parsed = parse_single(
|
||||
r"
|
||||
4. One
|
||||
1. Sub1
|
||||
2. Sub2
|
||||
5. Two
|
||||
6. Three",
|
||||
);
|
||||
let MarkdownElement::List(items) = parsed else { panic!("not a list: {parsed:?}") };
|
||||
let mut items = items.into_iter();
|
||||
let mut next = || items.next().expect("list ended prematurely");
|
||||
assert_eq!(next().item_type, ListItemType::OrderedPeriod(4));
|
||||
assert_eq!(next().item_type, ListItemType::OrderedPeriod(1));
|
||||
assert_eq!(next().item_type, ListItemType::OrderedPeriod(2));
|
||||
assert_eq!(next().item_type, ListItemType::OrderedPeriod(5));
|
||||
assert_eq!(next().item_type, ListItemType::OrderedPeriod(6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_breaks() {
|
||||
let parsed = parse_all(
|
||||
@ -654,42 +856,25 @@ another",
|
||||
assert_eq!(parsed.len(), 2);
|
||||
|
||||
let MarkdownElement::Paragraph(elements) = &parsed[0] else { panic!("not a line break: {parsed:?}") };
|
||||
assert_eq!(elements.len(), 3);
|
||||
assert_eq!(elements.len(), 2);
|
||||
|
||||
let expected_chunks = &[Text::from("some text"), Text::from(" "), Text::from("with line breaks")];
|
||||
let ParagraphElement::Text(text) = &elements[0] else { panic!("non-text in paragraph") };
|
||||
let text = &elements[0];
|
||||
assert_eq!(text.0, expected_chunks);
|
||||
assert!(matches!(&elements[1], ParagraphElement::LineBreak));
|
||||
assert!(matches!(&elements[2], ParagraphElement::Text(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_block() {
|
||||
let parsed = parse_single(
|
||||
r"
|
||||
```rust
|
||||
```rust +exec
|
||||
let q = 42;
|
||||
````
|
||||
",
|
||||
);
|
||||
let MarkdownElement::Snippet(code) = parsed else { panic!("not a code block: {parsed:?}") };
|
||||
assert_eq!(code.language, SnippetLanguage::Rust);
|
||||
assert_eq!(code.contents, "let q = 42;\n");
|
||||
assert!(!code.attributes.execute);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn executable_code_block() {
|
||||
let parsed = parse_single(
|
||||
r"
|
||||
```bash +exec
|
||||
echo hi mom
|
||||
````
|
||||
",
|
||||
);
|
||||
let MarkdownElement::Snippet(code) = parsed else { panic!("not a code block: {parsed:?}") };
|
||||
assert_eq!(code.language, SnippetLanguage::Bash);
|
||||
assert!(code.attributes.execute);
|
||||
let MarkdownElement::Snippet { info, code, .. } = parsed else { panic!("not a code block: {parsed:?}") };
|
||||
assert_eq!(info, "rust +exec");
|
||||
assert_eq!(code, "let q = 42;\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -699,7 +884,7 @@ echo hi mom
|
||||
let expected_chunks = &[Text::from("some "), Text::new("inline code", TextStyle::default().code())];
|
||||
assert_eq!(elements.len(), 1);
|
||||
|
||||
let ParagraphElement::Text(text) = &elements[0] else { panic!("non-text in paragraph") };
|
||||
let text = &elements[0];
|
||||
assert_eq!(text.0, expected_chunks);
|
||||
}
|
||||
|
||||
@ -749,20 +934,38 @@ echo hi mom
|
||||
fn block_quote() {
|
||||
let parsed = parse_single(
|
||||
r#"
|
||||
> bar!@#$%^&*()[]'"{}-=`~,.<>/?
|
||||
> foo
|
||||
> foo **is not** bar
|
||||
>  test 
|
||||
>
|
||||
> * a
|
||||
> * b
|
||||
>
|
||||
> 1. a
|
||||
> 2. b
|
||||
>
|
||||
> 1) a
|
||||
> 2) b
|
||||
"#,
|
||||
);
|
||||
let MarkdownElement::BlockQuote(lines) = parsed else { panic!("not a block quote: {parsed:?}") };
|
||||
assert_eq!(lines.len(), 5);
|
||||
assert_eq!(lines[0], "bar!@#$%^&*()[]'\"{}-=`~,.<>/?");
|
||||
assert_eq!(lines[1], "foo");
|
||||
assert_eq!(lines[2], "");
|
||||
assert_eq!(lines[3], "* a");
|
||||
assert_eq!(lines[4], "* b");
|
||||
assert_eq!(lines.len(), 11);
|
||||
assert_eq!(
|
||||
lines[0],
|
||||
Line(vec![Text::from("foo "), Text::new("is not", TextStyle::default().bold()), Text::from(" bar")])
|
||||
);
|
||||
assert_eq!(
|
||||
lines[1],
|
||||
Line(vec"), Text::from(" test "), Text::from("")])
|
||||
);
|
||||
assert_eq!(lines[2], Line::from(""));
|
||||
assert_eq!(lines[3], Line(vec![Text::from("* "), Text::from("a")]));
|
||||
assert_eq!(lines[4], Line(vec![Text::from("* "), Text::from("b")]));
|
||||
assert_eq!(lines[5], Line::from(""));
|
||||
assert_eq!(lines[6], Line(vec![Text::from("1. "), Text::from("a")]));
|
||||
assert_eq!(lines[7], Line(vec![Text::from("2. "), Text::from("b")]));
|
||||
assert_eq!(lines[8], Line::from(""));
|
||||
assert_eq!(lines[9], Line(vec![Text::from("1) "), Text::from("a")]));
|
||||
assert_eq!(lines[10], Line(vec![Text::from("2) "), Text::from("b")]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -779,11 +982,11 @@ foo
|
||||
);
|
||||
let MarkdownElement::BlockQuote(lines) = parsed else { panic!("not a block quote: {parsed:?}") };
|
||||
assert_eq!(lines.len(), 5);
|
||||
assert_eq!(lines[0], "bar");
|
||||
assert_eq!(lines[1], "foo");
|
||||
assert_eq!(lines[2], "");
|
||||
assert_eq!(lines[3], "* a");
|
||||
assert_eq!(lines[4], "* b");
|
||||
assert_eq!(lines[0], Line::from("bar"));
|
||||
assert_eq!(lines[1], Line::from("foo"));
|
||||
assert_eq!(lines[2], Line::from(""));
|
||||
assert_eq!(lines[3], Line(vec![Text::from("* "), Text::from("a")]));
|
||||
assert_eq!(lines[4], Line(vec![Text::from("* "), Text::from("b")]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -815,7 +1018,7 @@ mom
|
||||
let Err(e) = result else {
|
||||
panic!("parsing didn't fail");
|
||||
};
|
||||
assert_eq!(e.sourcepos.start.line, 5);
|
||||
assert_eq!(e.sourcepos.start.line, 6);
|
||||
assert_eq!(e.sourcepos.start.column, 3);
|
||||
}
|
||||
|
||||
@ -831,7 +1034,7 @@ mom
|
||||
",
|
||||
);
|
||||
let MarkdownElement::Comment { source_position, .. } = &parsed[1] else { panic!("not a comment") };
|
||||
assert_eq!(source_position.start.line, 5);
|
||||
assert_eq!(source_position.start.line, 6);
|
||||
assert_eq!(source_position.start.column, 1);
|
||||
}
|
||||
|
||||
@ -846,4 +1049,32 @@ mom
|
||||
let expected = format!("hi{nl}mom{nl}");
|
||||
assert_eq!(contents, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_alert() {
|
||||
let input = r"
|
||||
> [!note]
|
||||
> hi mom
|
||||
> bye **mom**
|
||||
";
|
||||
let MarkdownElement::Alert { lines, .. } = parse_single(&input) else {
|
||||
panic!("not an alert");
|
||||
};
|
||||
assert_eq!(lines.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_inlines() {
|
||||
let arena = Arena::new();
|
||||
let input = "hello **mom** how _are you_?";
|
||||
let parsed = MarkdownParser::new(&arena).parse_inlines(input).expect("parse failed");
|
||||
let expected = &[
|
||||
"hello ".into(),
|
||||
Text::new("mom", TextStyle::default().bold()),
|
||||
" how ".into(),
|
||||
Text::new("are you", TextStyle::default().italics()),
|
||||
"?".into(),
|
||||
];
|
||||
assert_eq!(parsed.0, expected);
|
||||
}
|
||||
}
|
||||
|
@ -1,42 +1,55 @@
|
||||
use super::elements::{Text, TextBlock};
|
||||
use crate::style::TextStyle;
|
||||
use super::{
|
||||
elements::{Line, Text},
|
||||
text_style::TextStyle,
|
||||
};
|
||||
use std::mem;
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
||||
|
||||
/// A weighted block of text.
|
||||
/// A weighted line of text.
|
||||
///
|
||||
/// The weight of a character is its given by its width in unicode.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct WeightedTextBlock(Vec<WeightedText>);
|
||||
pub(crate) struct WeightedLine {
|
||||
text: Vec<WeightedText>,
|
||||
width: usize,
|
||||
font_size: u8,
|
||||
}
|
||||
|
||||
impl WeightedTextBlock {
|
||||
impl WeightedLine {
|
||||
/// Split this line into chunks of at most `max_length` width.
|
||||
pub(crate) fn split(&self, max_length: usize) -> SplitTextIter {
|
||||
SplitTextIter::new(&self.0, max_length)
|
||||
SplitTextIter::new(&self.text, max_length)
|
||||
}
|
||||
|
||||
/// The total width of this line.
|
||||
pub(crate) fn width(&self) -> usize {
|
||||
self.0.iter().map(|text| text.width()).sum()
|
||||
self.width
|
||||
}
|
||||
|
||||
/// The height of this line.
|
||||
pub(crate) fn font_size(&self) -> u8 {
|
||||
self.font_size
|
||||
}
|
||||
|
||||
/// Get an iterator to the underlying text chunks.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn iter_texts(&self) -> impl Iterator<Item = &WeightedText> {
|
||||
self.0.iter()
|
||||
self.text.iter()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TextBlock> for WeightedTextBlock {
|
||||
fn from(block: TextBlock) -> Self {
|
||||
impl From<Line> for WeightedLine {
|
||||
fn from(block: Line) -> Self {
|
||||
block.0.into()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Vec<Text>> for WeightedTextBlock {
|
||||
impl From<Vec<Text>> for WeightedLine {
|
||||
fn from(mut texts: Vec<Text>) -> Self {
|
||||
let mut output = Vec::new();
|
||||
let mut index = 0;
|
||||
let mut width = 0;
|
||||
let mut font_size = 1;
|
||||
// Compact chunks so any consecutive chunk with the same style is merged into the same block.
|
||||
while index < texts.len() {
|
||||
let mut target = mem::replace(&mut texts[index], Text::from(""));
|
||||
@ -46,17 +59,27 @@ impl From<Vec<Text>> for WeightedTextBlock {
|
||||
target.content.push_str(¤t_content);
|
||||
current += 1;
|
||||
}
|
||||
let size = target.style.size.max(1);
|
||||
width += target.content.width() * size as usize;
|
||||
output.push(target.into());
|
||||
index = current;
|
||||
font_size = font_size.max(size);
|
||||
}
|
||||
Self(output)
|
||||
Self { text: output, width, font_size }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for WeightedTextBlock {
|
||||
impl From<String> for WeightedLine {
|
||||
fn from(text: String) -> Self {
|
||||
let texts = vec![WeightedText::from(text)];
|
||||
Self(texts)
|
||||
let width = text.width();
|
||||
let text = vec![WeightedText::from(text)];
|
||||
Self { text, width, font_size: 1 }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for WeightedLine {
|
||||
fn from(text: &str) -> Self {
|
||||
Self::from(text.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@ -75,14 +98,13 @@ pub(crate) struct WeightedText {
|
||||
|
||||
impl WeightedText {
|
||||
fn to_ref(&self) -> WeightedTextRef {
|
||||
WeightedTextRef { text: &self.text.content, accumulators: &self.accumulators, style: self.text.style.clone() }
|
||||
WeightedTextRef { text: &self.text.content, accumulators: &self.accumulators, style: self.text.style }
|
||||
}
|
||||
|
||||
pub(crate) fn width(&self) -> usize {
|
||||
self.accumulators.last().map(|a| a.width).unwrap_or(0)
|
||||
self.to_ref().width()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn text(&self) -> &Text {
|
||||
&self.text
|
||||
}
|
||||
@ -179,6 +201,7 @@ impl<'a> WeightedTextRef<'a> {
|
||||
return (self.make_ref(0, self.text.len()), self.make_ref(0, 0));
|
||||
}
|
||||
|
||||
let max_length = (max_length / self.style.size as usize).max(1);
|
||||
let target_chunk = self.substr(max_length + 1);
|
||||
let output_chunk = match target_chunk.rsplit_once(' ') {
|
||||
Some((before, _)) => before,
|
||||
@ -197,7 +220,7 @@ impl<'a> WeightedTextRef<'a> {
|
||||
let leading_char_count = self.text[0..from].chars().count();
|
||||
let output_char_count = text.chars().count();
|
||||
let character_lengths = &self.accumulators[leading_char_count..leading_char_count + output_char_count + 1];
|
||||
WeightedTextRef { text, accumulators: character_lengths, style: self.style.clone() }
|
||||
WeightedTextRef { text, accumulators: character_lengths, style: self.style }
|
||||
}
|
||||
|
||||
fn trim_start(self) -> Self {
|
||||
@ -207,10 +230,10 @@ impl<'a> WeightedTextRef<'a> {
|
||||
Self { text, accumulators, style: self.style }
|
||||
}
|
||||
|
||||
fn width(&self) -> usize {
|
||||
pub(crate) fn width(&self) -> usize {
|
||||
let last_width = self.accumulators.last().map(|a| a.width).unwrap_or(0);
|
||||
let first_width = self.accumulators.first().map(|a| a.width).unwrap_or(0);
|
||||
last_width - first_width
|
||||
(last_width - first_width) * self.style.size as usize
|
||||
}
|
||||
|
||||
fn bytes_until(&self, index: usize) -> usize {
|
||||
@ -281,6 +304,15 @@ mod test {
|
||||
assert_eq!(rest.width(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_size_split() {
|
||||
let text = WeightedText::from(Text::new("█████", TextStyle::default().size(2)));
|
||||
let text_ref = text.to_ref();
|
||||
let (head, rest) = text_ref.word_split_at_length(3);
|
||||
assert_eq!(head.width(), 2);
|
||||
assert_eq!(rest.width(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_ref() {
|
||||
let text = WeightedText::from("hello world");
|
||||
@ -304,7 +336,7 @@ mod test {
|
||||
|
||||
#[test]
|
||||
fn split_at_full_length() {
|
||||
let text = WeightedTextBlock(vec![WeightedText::from("hello world")]);
|
||||
let text = WeightedLine::from("hello world");
|
||||
let lines = join_lines(text.split(11));
|
||||
let expected = vec!["hello world"];
|
||||
assert_eq!(lines, expected);
|
||||
@ -312,7 +344,11 @@ mod test {
|
||||
|
||||
#[test]
|
||||
fn no_split_necessary() {
|
||||
let text = WeightedTextBlock(vec![WeightedText::from("short"), WeightedText::from("text")]);
|
||||
let text = WeightedLine {
|
||||
text: vec![WeightedText::from("short"), WeightedText::from("text")],
|
||||
width: 0,
|
||||
font_size: 1,
|
||||
};
|
||||
let lines = join_lines(text.split(50));
|
||||
let expected = vec!["short text"];
|
||||
assert_eq!(lines, expected);
|
||||
@ -320,7 +356,8 @@ mod test {
|
||||
|
||||
#[test]
|
||||
fn split_lines_single() {
|
||||
let text = WeightedTextBlock(vec![WeightedText::from("this is a slightly long line")]);
|
||||
let text =
|
||||
WeightedLine { text: vec![WeightedText::from("this is a slightly long line")], width: 0, font_size: 1 };
|
||||
let lines = join_lines(text.split(6));
|
||||
let expected = vec!["this", "is a", "slight", "ly", "long", "line"];
|
||||
assert_eq!(lines, expected);
|
||||
@ -328,11 +365,15 @@ mod test {
|
||||
|
||||
#[test]
|
||||
fn split_lines_multi() {
|
||||
let text = WeightedTextBlock(vec![
|
||||
WeightedText::from("this is a slightly long line"),
|
||||
WeightedText::from("another chunk"),
|
||||
WeightedText::from("yet some other piece"),
|
||||
]);
|
||||
let text = WeightedLine {
|
||||
text: vec![
|
||||
WeightedText::from("this is a slightly long line"),
|
||||
WeightedText::from("another chunk"),
|
||||
WeightedText::from("yet some other piece"),
|
||||
],
|
||||
width: 0,
|
||||
font_size: 1,
|
||||
};
|
||||
let lines = join_lines(text.split(10));
|
||||
let expected = vec!["this is a", "slightly", "long line", "another", "chunk yet", "some other", "piece"];
|
||||
assert_eq!(lines, expected);
|
||||
@ -340,11 +381,15 @@ mod test {
|
||||
|
||||
#[test]
|
||||
fn long_splits() {
|
||||
let text = WeightedTextBlock(vec![
|
||||
WeightedText::from("this is a slightly long line"),
|
||||
WeightedText::from("another chunk"),
|
||||
WeightedText::from("yet some other piece"),
|
||||
]);
|
||||
let text = WeightedLine {
|
||||
text: vec![
|
||||
WeightedText::from("this is a slightly long line"),
|
||||
WeightedText::from("another chunk"),
|
||||
WeightedText::from("yet some other piece"),
|
||||
],
|
||||
width: 0,
|
||||
font_size: 1,
|
||||
};
|
||||
let lines = join_lines(text.split(50));
|
||||
let expected = vec!["this is a slightly long line another chunk yet some", "other piece"];
|
||||
assert_eq!(lines, expected);
|
||||
@ -352,7 +397,7 @@ mod test {
|
||||
|
||||
#[test]
|
||||
fn prefixed_by_whitespace() {
|
||||
let text = WeightedTextBlock(vec![WeightedText::from(" * bullet")]);
|
||||
let text = WeightedLine::from(" * bullet");
|
||||
let lines = join_lines(text.split(50));
|
||||
let expected = vec![" * bullet"];
|
||||
assert_eq!(lines, expected);
|
||||
@ -360,7 +405,7 @@ mod test {
|
||||
|
||||
#[test]
|
||||
fn utf8_character() {
|
||||
let text = WeightedTextBlock(vec![WeightedText::from("• A")]);
|
||||
let text = WeightedLine::from("• A");
|
||||
let lines = join_lines(text.split(50));
|
||||
let expected = vec!["• A"];
|
||||
assert_eq!(lines, expected);
|
||||
@ -369,7 +414,7 @@ mod test {
|
||||
#[test]
|
||||
fn many_utf8_characters() {
|
||||
let content = "█████ ██";
|
||||
let text = WeightedTextBlock(vec![WeightedText::from(content)]);
|
||||
let text = WeightedLine::from(content);
|
||||
let lines = join_lines(text.split(3));
|
||||
let expected = vec!["███", "██", "██"];
|
||||
assert_eq!(lines, expected);
|
||||
@ -378,7 +423,7 @@ mod test {
|
||||
#[test]
|
||||
fn no_whitespaces_ascii() {
|
||||
let content = "X".repeat(10);
|
||||
let text = WeightedTextBlock(vec![WeightedText::from(content)]);
|
||||
let text = WeightedLine::from(content);
|
||||
let lines = join_lines(text.split(3));
|
||||
let expected = vec!["XXX", "XXX", "XXX", "X"];
|
||||
assert_eq!(lines, expected);
|
||||
@ -387,7 +432,7 @@ mod test {
|
||||
#[test]
|
||||
fn no_whitespaces_utf8() {
|
||||
let content = "─".repeat(10);
|
||||
let text = WeightedTextBlock(vec![WeightedText::from(content)]);
|
||||
let text = WeightedLine::from(content);
|
||||
let lines = join_lines(text.split(3));
|
||||
let expected = vec!["───", "───", "───", "─"];
|
||||
assert_eq!(lines, expected);
|
||||
@ -396,7 +441,7 @@ mod test {
|
||||
#[test]
|
||||
fn wide_characters() {
|
||||
let content = "Hello world";
|
||||
let text = WeightedTextBlock(vec![WeightedText::from(content)]);
|
||||
let text = WeightedLine::from(content);
|
||||
let lines = join_lines(text.split(10));
|
||||
// Each word is 10 characters long
|
||||
let expected = vec!["Hello", "world"];
|
||||
@ -410,7 +455,7 @@ mod test {
|
||||
#[case::split(&["hello".into(), Text::new(" ", TextStyle::default().bold()), "world".into()], 3)]
|
||||
#[case::split_merged(&["hello".into(), Text::new(" ", TextStyle::default().bold()), Text::new("w", TextStyle::default().bold()), "orld".into()], 3)]
|
||||
fn compaction(#[case] texts: &[Text], #[case] expected: usize) {
|
||||
let block = WeightedTextBlock::from(texts.to_vec());
|
||||
assert_eq!(block.0.len(), expected);
|
||||
let block = WeightedLine::from(texts.to_vec());
|
||||
assert_eq!(block.text.len(), expected);
|
||||
}
|
||||
}
|
||||
|
390
src/markdown/text_style.rs
Normal file
390
src/markdown/text_style.rs
Normal file
@ -0,0 +1,390 @@
|
||||
use crate::theme::{ColorPalette, raw::RawColor};
|
||||
use crossterm::style::{StyledContent, Stylize};
|
||||
use hex::FromHexError;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt::{self, Display};
|
||||
|
||||
/// The style of a piece of text.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct TextStyle<C = Color> {
|
||||
flags: u8,
|
||||
pub(crate) colors: Colors<C>,
|
||||
pub(crate) size: u8,
|
||||
}
|
||||
|
||||
impl<C> Default for TextStyle<C> {
|
||||
fn default() -> Self {
|
||||
Self { flags: Default::default(), colors: Default::default(), size: 1 }
|
||||
}
|
||||
}
|
||||
|
||||
impl<C> TextStyle<C>
|
||||
where
|
||||
C: Clone,
|
||||
{
|
||||
pub(crate) fn colored(colors: Colors<C>) -> Self {
|
||||
Self { colors, ..Default::default() }
|
||||
}
|
||||
|
||||
pub(crate) fn size(mut self, size: u8) -> Self {
|
||||
self.size = size.min(16);
|
||||
self
|
||||
}
|
||||
|
||||
/// Add bold to this style.
|
||||
pub(crate) fn bold(self) -> Self {
|
||||
self.add_flag(TextFormatFlags::Bold)
|
||||
}
|
||||
|
||||
/// Add italics to this style.
|
||||
pub(crate) fn italics(self) -> Self {
|
||||
self.add_flag(TextFormatFlags::Italics)
|
||||
}
|
||||
|
||||
/// Indicate this text is a piece of inline code.
|
||||
pub(crate) fn code(self) -> Self {
|
||||
self.add_flag(TextFormatFlags::Code)
|
||||
}
|
||||
|
||||
/// Add strikethrough to this style.
|
||||
pub(crate) fn strikethrough(self) -> Self {
|
||||
self.add_flag(TextFormatFlags::Strikethrough)
|
||||
}
|
||||
|
||||
/// Add underline to this style.
|
||||
pub(crate) fn underlined(self) -> Self {
|
||||
self.add_flag(TextFormatFlags::Underlined)
|
||||
}
|
||||
|
||||
/// Indicate this is a link label.
|
||||
pub(crate) fn link_label(self) -> Self {
|
||||
self.bold()
|
||||
}
|
||||
|
||||
/// Indicate this is a link title.
|
||||
pub(crate) fn link_title(self) -> Self {
|
||||
self.italics()
|
||||
}
|
||||
|
||||
/// Indicate this is a link url.
|
||||
pub(crate) fn link_url(self) -> Self {
|
||||
self.italics().underlined()
|
||||
}
|
||||
|
||||
/// Set the background color for this text style.
|
||||
pub(crate) fn bg_color<U: Into<C>>(mut self, color: U) -> Self {
|
||||
self.colors.background = Some(color.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the foreground color for this text style.
|
||||
pub(crate) fn fg_color<U: Into<C>>(mut self, color: U) -> Self {
|
||||
self.colors.foreground = Some(color.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the colors on this style.
|
||||
pub(crate) fn colors(mut self, colors: Colors<C>) -> Self {
|
||||
self.colors = colors;
|
||||
self
|
||||
}
|
||||
|
||||
/// Check whether this text is code.
|
||||
pub(crate) fn is_code(&self) -> bool {
|
||||
self.has_flag(TextFormatFlags::Code)
|
||||
}
|
||||
|
||||
/// Merge this style with another one.
|
||||
pub(crate) fn merge(&mut self, other: &TextStyle<C>) {
|
||||
self.flags |= other.flags;
|
||||
self.size = self.size.max(other.size);
|
||||
self.colors.background = self.colors.background.clone().or(other.colors.background.clone());
|
||||
self.colors.foreground = self.colors.foreground.clone().or(other.colors.foreground.clone());
|
||||
}
|
||||
|
||||
/// Return a new style merged with the one passed in.
|
||||
pub(crate) fn merged(mut self, other: &TextStyle<C>) -> Self {
|
||||
self.merge(other);
|
||||
self
|
||||
}
|
||||
|
||||
fn add_flag(mut self, flag: TextFormatFlags) -> Self {
|
||||
self.flags |= flag as u8;
|
||||
self
|
||||
}
|
||||
|
||||
fn has_flag(&self, flag: TextFormatFlags) -> bool {
|
||||
self.flags & flag as u8 != 0
|
||||
}
|
||||
}
|
||||
|
||||
impl TextStyle<Color> {
|
||||
/// Apply this style to a piece of text.
|
||||
pub(crate) fn apply<'a>(&self, text: &'a str) -> StyledContent<impl Display + Clone + 'a> {
|
||||
let text = FontSizedStr { contents: text, font_size: self.size };
|
||||
let mut styled = StyledContent::new(Default::default(), text);
|
||||
for attr in self.iter_attributes() {
|
||||
styled = match attr {
|
||||
TextAttribute::Bold => styled.bold(),
|
||||
TextAttribute::Italics => styled.italic(),
|
||||
TextAttribute::Strikethrough => styled.crossed_out(),
|
||||
TextAttribute::Underlined => styled.underlined(),
|
||||
TextAttribute::ForegroundColor(color) => styled.with(color.into()),
|
||||
TextAttribute::BackgroundColor(color) => styled.on(color.into()),
|
||||
}
|
||||
}
|
||||
styled
|
||||
}
|
||||
|
||||
pub(crate) fn into_raw(self) -> TextStyle<RawColor> {
|
||||
let colors = Colors {
|
||||
background: self.colors.background.map(Into::into),
|
||||
foreground: self.colors.foreground.map(Into::into),
|
||||
};
|
||||
TextStyle { flags: self.flags, colors, size: self.size }
|
||||
}
|
||||
|
||||
/// Iterate all attributes in this style.
|
||||
pub(crate) fn iter_attributes(&self) -> AttributeIterator {
|
||||
AttributeIterator {
|
||||
flags: self.flags,
|
||||
next_mask: Some(TextFormatFlags::Bold),
|
||||
background_color: self.colors.background,
|
||||
foreground_color: self.colors.foreground,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TextStyle<RawColor> {
|
||||
pub(crate) fn resolve(&self, palette: &ColorPalette) -> Result<TextStyle, UndefinedPaletteColorError> {
|
||||
let colors = self.colors.resolve(palette)?;
|
||||
Ok(TextStyle { flags: self.flags, colors, size: self.size })
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct AttributeIterator {
|
||||
flags: u8,
|
||||
next_mask: Option<TextFormatFlags>,
|
||||
background_color: Option<Color>,
|
||||
foreground_color: Option<Color>,
|
||||
}
|
||||
|
||||
impl Iterator for AttributeIterator {
|
||||
type Item = TextAttribute;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
if let Some(c) = self.background_color.take() {
|
||||
return Some(TextAttribute::BackgroundColor(c));
|
||||
}
|
||||
if let Some(c) = self.foreground_color.take() {
|
||||
return Some(TextAttribute::ForegroundColor(c));
|
||||
}
|
||||
use TextFormatFlags::*;
|
||||
loop {
|
||||
let next_mask = self.next_mask?;
|
||||
self.next_mask = match next_mask {
|
||||
Bold => Some(Italics),
|
||||
Italics => Some(Strikethrough),
|
||||
Code => Some(Strikethrough),
|
||||
Strikethrough => Some(Underlined),
|
||||
Underlined => None,
|
||||
};
|
||||
if self.flags & next_mask as u8 != 0 {
|
||||
let attr = match next_mask {
|
||||
Bold => TextAttribute::Bold,
|
||||
Italics => TextAttribute::Italics,
|
||||
Code => panic!("code shouldn't reach here"),
|
||||
Strikethrough => TextAttribute::Strikethrough,
|
||||
Underlined => TextAttribute::Underlined,
|
||||
};
|
||||
return Some(attr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) enum TextAttribute {
|
||||
Bold,
|
||||
Italics,
|
||||
Strikethrough,
|
||||
Underlined,
|
||||
ForegroundColor(Color),
|
||||
BackgroundColor(Color),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FontSizedStr<'a> {
|
||||
contents: &'a str,
|
||||
font_size: u8,
|
||||
}
|
||||
|
||||
impl fmt::Display for FontSizedStr<'_> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let contents = &self.contents;
|
||||
match self.font_size {
|
||||
0 | 1 => write!(f, "{contents}"),
|
||||
size => write!(f, "\x1b]66;s={size};{contents}\x1b\\"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum TextFormatFlags {
|
||||
Bold = 1,
|
||||
Italics = 2,
|
||||
Code = 4,
|
||||
Strikethrough = 8,
|
||||
Underlined = 16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum Color {
|
||||
Black,
|
||||
DarkGrey,
|
||||
Red,
|
||||
DarkRed,
|
||||
Green,
|
||||
DarkGreen,
|
||||
Yellow,
|
||||
DarkYellow,
|
||||
Blue,
|
||||
DarkBlue,
|
||||
Magenta,
|
||||
DarkMagenta,
|
||||
Cyan,
|
||||
DarkCyan,
|
||||
White,
|
||||
Grey,
|
||||
Rgb { r: u8, g: u8, b: u8 },
|
||||
}
|
||||
|
||||
impl Color {
|
||||
pub(crate) fn new(r: u8, g: u8, b: u8) -> Self {
|
||||
Self::Rgb { r, g, b }
|
||||
}
|
||||
|
||||
pub(crate) fn as_rgb(&self) -> Option<(u8, u8, u8)> {
|
||||
match self {
|
||||
Self::Rgb { r, g, b } => Some((*r, *g, *b)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn from_ansi(color: u8) -> Option<Self> {
|
||||
let color = match color {
|
||||
30 | 40 => Color::Black,
|
||||
31 | 41 => Color::Red,
|
||||
32 | 42 => Color::Green,
|
||||
33 | 43 => Color::Yellow,
|
||||
34 | 44 => Color::Blue,
|
||||
35 | 45 => Color::Magenta,
|
||||
36 | 46 => Color::Cyan,
|
||||
37 | 47 => Color::White,
|
||||
_ => return None,
|
||||
};
|
||||
Some(color)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Color> for crossterm::style::Color {
|
||||
fn from(value: Color) -> Self {
|
||||
use crossterm::style::Color as C;
|
||||
match value {
|
||||
Color::Black => C::Black,
|
||||
Color::DarkGrey => C::DarkGrey,
|
||||
Color::Red => C::Red,
|
||||
Color::DarkRed => C::DarkRed,
|
||||
Color::Green => C::Green,
|
||||
Color::DarkGreen => C::DarkGreen,
|
||||
Color::Yellow => C::Yellow,
|
||||
Color::DarkYellow => C::DarkYellow,
|
||||
Color::Blue => C::Blue,
|
||||
Color::DarkBlue => C::DarkBlue,
|
||||
Color::Magenta => C::Magenta,
|
||||
Color::DarkMagenta => C::DarkMagenta,
|
||||
Color::Cyan => C::Cyan,
|
||||
Color::DarkCyan => C::DarkCyan,
|
||||
Color::White => C::White,
|
||||
Color::Grey => C::Grey,
|
||||
Color::Rgb { r, g, b } => C::Rgb { r, g, b },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("unresolved palette color: {0}")]
|
||||
pub(crate) struct PaletteColorError(String);
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("undefined palette color: {0}")]
|
||||
pub(crate) struct UndefinedPaletteColorError(pub(crate) String);
|
||||
|
||||
/// Text colors.
|
||||
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)]
|
||||
pub(crate) struct Colors<C = Color> {
|
||||
/// The background color.
|
||||
pub(crate) background: Option<C>,
|
||||
|
||||
/// The foreground color.
|
||||
pub(crate) foreground: Option<C>,
|
||||
}
|
||||
|
||||
impl<C> Default for Colors<C> {
|
||||
fn default() -> Self {
|
||||
Self { background: None, foreground: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl Colors<RawColor> {
|
||||
pub(crate) fn resolve(&self, palette: &ColorPalette) -> Result<Colors<Color>, UndefinedPaletteColorError> {
|
||||
let background = self.background.clone().map(|c| c.resolve(palette)).transpose()?.flatten();
|
||||
let foreground = self.foreground.clone().map(|c| c.resolve(palette)).transpose()?.flatten();
|
||||
Ok(Colors { foreground, background })
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Colors> for crossterm::style::Colors {
|
||||
fn from(value: Colors) -> Self {
|
||||
let foreground = value.foreground.map(Color::into);
|
||||
let background = value.background.map(Color::into);
|
||||
Self { foreground, background }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub(crate) enum ParseColorError {
|
||||
#[error("invalid hex color: {0}")]
|
||||
Hex(#[from] FromHexError),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
#[case::default(TextStyle::default(), &[])]
|
||||
#[case::code(TextStyle::default().code(), &[])]
|
||||
#[case::bold(TextStyle::default().bold(), &[TextAttribute::Bold])]
|
||||
#[case::italics(TextStyle::default().italics(), &[TextAttribute::Italics])]
|
||||
#[case::strikethrough(TextStyle::default().strikethrough(), &[TextAttribute::Strikethrough])]
|
||||
#[case::underlined(TextStyle::default().underlined(), &[TextAttribute::Underlined])]
|
||||
#[case::bg_color(TextStyle::default().bg_color(Color::Red), &[TextAttribute::BackgroundColor(Color::Red)])]
|
||||
#[case::bg_color(TextStyle::default().fg_color(Color::Red), &[TextAttribute::ForegroundColor(Color::Red)])]
|
||||
#[case::all(
|
||||
TextStyle::default().bold().code().italics().strikethrough().underlined().bg_color(Color::Black).fg_color(Color::Red),
|
||||
&[
|
||||
TextAttribute::BackgroundColor(Color::Black),
|
||||
TextAttribute::ForegroundColor(Color::Red),
|
||||
TextAttribute::Bold,
|
||||
TextAttribute::Italics,
|
||||
TextAttribute::Strikethrough,
|
||||
TextAttribute::Underlined,
|
||||
]
|
||||
)]
|
||||
fn iterate_attributes(#[case] style: TextStyle, #[case] expected: &[TextAttribute]) {
|
||||
let attrs: Vec<_> = style.iter_attributes().collect();
|
||||
assert_eq!(attrs, expected);
|
||||
}
|
||||
}
|
@ -1,136 +0,0 @@
|
||||
use super::printer::{PrintImage, PrintImageError, PrintOptions, RegisterImageError, ResourceProperties};
|
||||
use crossterm::{
|
||||
cursor::{MoveRight, MoveToColumn},
|
||||
style::{Color, Stylize},
|
||||
QueueableCommand,
|
||||
};
|
||||
use image::{imageops::FilterType, DynamicImage, GenericImageView, Pixel, Rgba};
|
||||
use itertools::Itertools;
|
||||
use std::{fs, ops::Deref};
|
||||
|
||||
const TOP_CHAR: char = '▀';
|
||||
const BOTTOM_CHAR: char = '▄';
|
||||
|
||||
pub(crate) struct AsciiResource(DynamicImage);
|
||||
|
||||
impl ResourceProperties for AsciiResource {
|
||||
fn dimensions(&self) -> (u32, u32) {
|
||||
self.0.dimensions()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DynamicImage> for AsciiResource {
|
||||
fn from(image: DynamicImage) -> Self {
|
||||
let image = image.into_rgba8();
|
||||
Self(image.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for AsciiResource {
|
||||
type Target = DynamicImage;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct AsciiPrinter;
|
||||
|
||||
impl AsciiPrinter {
|
||||
fn pixel_color(pixel: &Rgba<u8>, background: Option<Color>) -> Option<Color> {
|
||||
let [r, g, b, alpha] = pixel.0;
|
||||
if alpha == 0 {
|
||||
None
|
||||
} else if alpha < 255 {
|
||||
// For alpha > 0 && < 255, we blend it with the background color (if any). This helps
|
||||
// smooth the image's borders.
|
||||
let mut pixel = *pixel;
|
||||
match background {
|
||||
Some(Color::Rgb { r, g, b }) => {
|
||||
pixel.blend(&Rgba([r, g, b, 255 - alpha]));
|
||||
Some(Color::Rgb { r: pixel[0], g: pixel[1], b: pixel[2] })
|
||||
}
|
||||
// For transparent backgrounds, we can't really know whether we should blend it
|
||||
// towards light or dark.
|
||||
None | Some(_) => Some(Color::Rgb { r, g, b }),
|
||||
}
|
||||
} else {
|
||||
Some(Color::Rgb { r, g, b })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PrintImage for AsciiPrinter {
|
||||
type Resource = AsciiResource;
|
||||
|
||||
fn register_image(&self, image: image::DynamicImage) -> Result<Self::Resource, RegisterImageError> {
|
||||
Ok(AsciiResource(image))
|
||||
}
|
||||
|
||||
fn register_resource<P: AsRef<std::path::Path>>(&self, path: P) -> Result<Self::Resource, RegisterImageError> {
|
||||
let contents = fs::read(path)?;
|
||||
let image = image::load_from_memory(&contents)?;
|
||||
Ok(AsciiResource(image))
|
||||
}
|
||||
|
||||
fn print<W>(&self, image: &Self::Resource, options: &PrintOptions, writer: &mut W) -> Result<(), PrintImageError>
|
||||
where
|
||||
W: std::io::Write,
|
||||
{
|
||||
// The strategy here is taken from viuer: use half vertical ascii blocks in combination
|
||||
// with foreground/background colors to fit 2 vertical pixels per cell. That is, cell (x, y)
|
||||
// will contain the pixels at (x, y) and (x, y + 1) combined.
|
||||
let image = image.0.resize_exact(options.columns as u32, 2 * options.rows as u32, FilterType::Triangle);
|
||||
let image = image.into_rgba8();
|
||||
let default_background = options.background_color.map(Color::from);
|
||||
|
||||
// Iterate pixel rows in pairs to be able to merge both pixels in a single iteration.
|
||||
// Note that may not have a second row if there's an odd number of them.
|
||||
for mut rows in &image.rows().chunks(2) {
|
||||
writer.queue(MoveToColumn(options.cursor_position.column))?;
|
||||
|
||||
let top_row = rows.next().unwrap();
|
||||
let mut bottom_row = rows.next();
|
||||
for top_pixel in top_row {
|
||||
let bottom_pixel = bottom_row.as_mut().and_then(|pixels| pixels.next());
|
||||
|
||||
// Get pixel colors for both of these. At this point the special case for the odd
|
||||
// number of rows disappears as we treat a transparent pixel and a non-existent
|
||||
// one the same: they're simply transparent.
|
||||
let background = options.background_color.map(Color::from);
|
||||
let top = Self::pixel_color(top_pixel, background);
|
||||
let bottom = bottom_pixel.and_then(|c| Self::pixel_color(c, background));
|
||||
match (top, bottom) {
|
||||
(Some(top), Some(bottom)) => {
|
||||
write!(writer, "{}", TOP_CHAR.with(top).on(bottom))?;
|
||||
}
|
||||
(Some(top), None) => {
|
||||
write!(writer, "{}", TOP_CHAR.with(top).maybe_on(default_background))?;
|
||||
}
|
||||
(None, Some(bottom)) => {
|
||||
write!(writer, "{}", BOTTOM_CHAR.with(bottom).maybe_on(default_background))?;
|
||||
}
|
||||
(None, None) => {
|
||||
writer.queue(MoveRight(1))?;
|
||||
}
|
||||
};
|
||||
}
|
||||
writeln!(writer)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
trait StylizeExt: Stylize {
|
||||
fn maybe_on(self, color: Option<Color>) -> Self::Styled;
|
||||
}
|
||||
|
||||
impl<T: Stylize> StylizeExt for T {
|
||||
fn maybe_on(self, color: Option<Color>) -> Self::Styled {
|
||||
match color {
|
||||
Some(background) => self.on(background),
|
||||
None => self.stylize(),
|
||||
}
|
||||
}
|
||||
}
|
@ -1,13 +0,0 @@
|
||||
use super::kitty::KittyMode;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum GraphicsMode {
|
||||
Iterm2,
|
||||
Kitty {
|
||||
mode: KittyMode,
|
||||
inside_tmux: bool,
|
||||
},
|
||||
AsciiBlocks,
|
||||
#[cfg(feature = "sixel")]
|
||||
Sixel,
|
||||
}
|
@ -1,45 +0,0 @@
|
||||
use crate::media::printer::{ImageResource, ResourceProperties};
|
||||
use std::{fmt::Debug, ops::Deref, path::PathBuf, sync::Arc};
|
||||
|
||||
/// An image.
|
||||
///
|
||||
/// This stores the image in an [std::sync::Arc] so it's cheap to clone.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Image {
|
||||
pub(crate) resource: Arc<ImageResource>,
|
||||
pub(crate) source: ImageSource,
|
||||
}
|
||||
|
||||
impl PartialEq for Image {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.source == other.source
|
||||
}
|
||||
}
|
||||
|
||||
impl Debug for Image {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let (width, height) = self.resource.dimensions();
|
||||
write!(f, "Image<{width}x{height}>")
|
||||
}
|
||||
}
|
||||
|
||||
impl Image {
|
||||
/// Constructs a new image.
|
||||
pub(crate) fn new(resource: ImageResource, source: ImageSource) -> Self {
|
||||
Self { resource: Arc::new(resource), source }
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for Image {
|
||||
type Target = ImageResource;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.resource
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) enum ImageSource {
|
||||
Filesystem(PathBuf),
|
||||
Generated,
|
||||
}
|
@ -1,80 +0,0 @@
|
||||
use super::printer::{PrintImage, PrintImageError, PrintOptions, RegisterImageError, ResourceProperties};
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use image::{codecs::png::PngEncoder, GenericImageView, ImageEncoder};
|
||||
use std::{env, fs, path::Path};
|
||||
|
||||
pub(crate) struct ItermResource {
|
||||
dimensions: (u32, u32),
|
||||
raw_length: usize,
|
||||
base64_contents: String,
|
||||
}
|
||||
|
||||
impl ItermResource {
|
||||
fn new(contents: Vec<u8>, dimensions: (u32, u32)) -> Self {
|
||||
let raw_length = contents.len();
|
||||
let base64_contents = STANDARD.encode(&contents);
|
||||
Self { dimensions, raw_length, base64_contents }
|
||||
}
|
||||
}
|
||||
|
||||
impl ResourceProperties for ItermResource {
|
||||
fn dimensions(&self) -> (u32, u32) {
|
||||
self.dimensions
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ItermPrinter {
|
||||
// Whether this is iterm2. Otherwise it can be a terminal that _supports_ the iterm2 protocol.
|
||||
is_iterm: bool,
|
||||
}
|
||||
|
||||
impl Default for ItermPrinter {
|
||||
fn default() -> Self {
|
||||
for key in ["TERM_PROGRAM", "LC_TERMINAL"] {
|
||||
if let Ok(value) = env::var(key) {
|
||||
if value.contains("iTerm") {
|
||||
return Self { is_iterm: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
Self { is_iterm: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl PrintImage for ItermPrinter {
|
||||
type Resource = ItermResource;
|
||||
|
||||
fn register_image(&self, image: image::DynamicImage) -> Result<Self::Resource, RegisterImageError> {
|
||||
let dimensions = image.dimensions();
|
||||
let mut contents = Vec::new();
|
||||
let encoder = PngEncoder::new(&mut contents);
|
||||
encoder.write_image(image.as_bytes(), dimensions.0, dimensions.1, image.color().into())?;
|
||||
Ok(ItermResource::new(contents, dimensions))
|
||||
}
|
||||
|
||||
fn register_resource<P: AsRef<Path>>(&self, path: P) -> Result<Self::Resource, RegisterImageError> {
|
||||
let contents = fs::read(path)?;
|
||||
let image = image::load_from_memory(&contents)?;
|
||||
Ok(ItermResource::new(contents, image.dimensions()))
|
||||
}
|
||||
|
||||
fn print<W>(&self, image: &Self::Resource, options: &PrintOptions, writer: &mut W) -> Result<(), PrintImageError>
|
||||
where
|
||||
W: std::io::Write,
|
||||
{
|
||||
let size = image.raw_length;
|
||||
let columns = options.columns;
|
||||
let rows = options.rows;
|
||||
let contents = &image.base64_contents;
|
||||
write!(
|
||||
writer,
|
||||
"\x1b]1337;File=size={size};width={columns};height={rows};inline=1;preserveAspectRatio=0:{contents}\x07"
|
||||
)?;
|
||||
// iterm2 really respects what we say and leaves no space, whereas wezterm does leave an
|
||||
// extra line here.
|
||||
if self.is_iterm {
|
||||
writeln!(writer)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
@ -1,11 +0,0 @@
|
||||
mod ascii;
|
||||
pub(crate) mod emulator;
|
||||
pub(crate) mod graphics;
|
||||
pub(crate) mod image;
|
||||
mod iterm;
|
||||
pub(crate) mod kitty;
|
||||
pub(crate) mod printer;
|
||||
pub(crate) mod register;
|
||||
pub(crate) mod scale;
|
||||
#[cfg(feature = "sixel")]
|
||||
pub(crate) mod sixel;
|
@ -1,194 +0,0 @@
|
||||
use super::{
|
||||
ascii::{AsciiPrinter, AsciiResource},
|
||||
graphics::GraphicsMode,
|
||||
iterm::{ItermPrinter, ItermResource},
|
||||
kitty::{KittyMode, KittyPrinter, KittyResource},
|
||||
};
|
||||
use crate::{render::properties::CursorPosition, style::Color};
|
||||
use image::{DynamicImage, ImageError};
|
||||
use std::{borrow::Cow, io, path::Path};
|
||||
|
||||
pub(crate) trait PrintImage {
|
||||
type Resource: ResourceProperties;
|
||||
|
||||
/// Register an image.
|
||||
fn register_image(&self, image: DynamicImage) -> Result<Self::Resource, RegisterImageError>;
|
||||
|
||||
/// Load and register a resource from the given path.
|
||||
fn register_resource<P: AsRef<Path>>(&self, path: P) -> Result<Self::Resource, RegisterImageError>;
|
||||
|
||||
fn print<W>(&self, image: &Self::Resource, options: &PrintOptions, writer: &mut W) -> Result<(), PrintImageError>
|
||||
where
|
||||
W: io::Write;
|
||||
}
|
||||
|
||||
pub(crate) trait ResourceProperties {
|
||||
fn dimensions(&self) -> (u32, u32);
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct PrintOptions {
|
||||
pub(crate) columns: u16,
|
||||
pub(crate) rows: u16,
|
||||
pub(crate) cursor_position: CursorPosition,
|
||||
pub(crate) z_index: i32,
|
||||
pub(crate) background_color: Option<Color>,
|
||||
// Width/height in pixels.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) column_width: u16,
|
||||
#[allow(dead_code)]
|
||||
pub(crate) row_height: u16,
|
||||
}
|
||||
|
||||
pub(crate) enum ImageResource {
|
||||
Kitty(KittyResource),
|
||||
Iterm(ItermResource),
|
||||
Ascii(AsciiResource),
|
||||
#[cfg(feature = "sixel")]
|
||||
Sixel(super::sixel::SixelResource),
|
||||
}
|
||||
|
||||
impl ResourceProperties for ImageResource {
|
||||
fn dimensions(&self) -> (u32, u32) {
|
||||
match self {
|
||||
Self::Kitty(resource) => resource.dimensions(),
|
||||
Self::Iterm(resource) => resource.dimensions(),
|
||||
Self::Ascii(resource) => resource.dimensions(),
|
||||
#[cfg(feature = "sixel")]
|
||||
Self::Sixel(resource) => resource.dimensions(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum ImagePrinter {
|
||||
Kitty(KittyPrinter),
|
||||
Iterm(ItermPrinter),
|
||||
Ascii(AsciiPrinter),
|
||||
Null,
|
||||
#[cfg(feature = "sixel")]
|
||||
Sixel(super::sixel::SixelPrinter),
|
||||
}
|
||||
|
||||
impl Default for ImagePrinter {
|
||||
fn default() -> Self {
|
||||
Self::new_ascii()
|
||||
}
|
||||
}
|
||||
|
||||
impl ImagePrinter {
|
||||
pub fn new(mode: GraphicsMode) -> Result<Self, CreatePrinterError> {
|
||||
let printer = match mode {
|
||||
GraphicsMode::Kitty { mode, inside_tmux } => Self::new_kitty(mode, inside_tmux)?,
|
||||
GraphicsMode::Iterm2 => Self::new_iterm(),
|
||||
GraphicsMode::AsciiBlocks => Self::new_ascii(),
|
||||
#[cfg(feature = "sixel")]
|
||||
GraphicsMode::Sixel => Self::new_sixel()?,
|
||||
};
|
||||
Ok(printer)
|
||||
}
|
||||
|
||||
fn new_kitty(mode: KittyMode, inside_tmux: bool) -> io::Result<Self> {
|
||||
Ok(Self::Kitty(KittyPrinter::new(mode, inside_tmux)?))
|
||||
}
|
||||
|
||||
fn new_iterm() -> Self {
|
||||
Self::Iterm(ItermPrinter::default())
|
||||
}
|
||||
|
||||
fn new_ascii() -> Self {
|
||||
Self::Ascii(AsciiPrinter)
|
||||
}
|
||||
|
||||
#[cfg(feature = "sixel")]
|
||||
fn new_sixel() -> Result<Self, CreatePrinterError> {
|
||||
Ok(Self::Sixel(super::sixel::SixelPrinter::new()?))
|
||||
}
|
||||
}
|
||||
|
||||
impl PrintImage for ImagePrinter {
|
||||
type Resource = ImageResource;
|
||||
|
||||
fn register_image(&self, image: DynamicImage) -> Result<Self::Resource, RegisterImageError> {
|
||||
let resource = match self {
|
||||
Self::Kitty(printer) => ImageResource::Kitty(printer.register_image(image)?),
|
||||
Self::Iterm(printer) => ImageResource::Iterm(printer.register_image(image)?),
|
||||
Self::Ascii(printer) => ImageResource::Ascii(printer.register_image(image)?),
|
||||
Self::Null => return Err(RegisterImageError::Unsupported),
|
||||
#[cfg(feature = "sixel")]
|
||||
Self::Sixel(printer) => ImageResource::Sixel(printer.register_image(image)?),
|
||||
};
|
||||
Ok(resource)
|
||||
}
|
||||
|
||||
fn register_resource<P: AsRef<Path>>(&self, path: P) -> Result<Self::Resource, RegisterImageError> {
|
||||
let resource = match self {
|
||||
Self::Kitty(printer) => ImageResource::Kitty(printer.register_resource(path)?),
|
||||
Self::Iterm(printer) => ImageResource::Iterm(printer.register_resource(path)?),
|
||||
Self::Ascii(printer) => ImageResource::Ascii(printer.register_resource(path)?),
|
||||
Self::Null => return Err(RegisterImageError::Unsupported),
|
||||
#[cfg(feature = "sixel")]
|
||||
Self::Sixel(printer) => ImageResource::Sixel(printer.register_resource(path)?),
|
||||
};
|
||||
Ok(resource)
|
||||
}
|
||||
|
||||
fn print<W>(&self, image: &Self::Resource, options: &PrintOptions, writer: &mut W) -> Result<(), PrintImageError>
|
||||
where
|
||||
W: io::Write,
|
||||
{
|
||||
match (self, image) {
|
||||
(Self::Kitty(printer), ImageResource::Kitty(image)) => printer.print(image, options, writer),
|
||||
(Self::Iterm(printer), ImageResource::Iterm(image)) => printer.print(image, options, writer),
|
||||
(Self::Ascii(printer), ImageResource::Ascii(image)) => printer.print(image, options, writer),
|
||||
(Self::Null, _) => Ok(()),
|
||||
#[cfg(feature = "sixel")]
|
||||
(Self::Sixel(printer), ImageResource::Sixel(image)) => printer.print(image, options, writer),
|
||||
_ => Err(PrintImageError::Unsupported),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum CreatePrinterError {
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] io::Error),
|
||||
|
||||
#[error("unexpected: {0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PrintImageError {
|
||||
#[error(transparent)]
|
||||
Io(#[from] io::Error),
|
||||
|
||||
#[error("unsupported image type")]
|
||||
Unsupported,
|
||||
|
||||
#[error("image decoding: {0}")]
|
||||
Image(#[from] ImageError),
|
||||
|
||||
#[error("other: {0}")]
|
||||
Other(Cow<'static, str>),
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum RegisterImageError {
|
||||
#[error(transparent)]
|
||||
Io(#[from] io::Error),
|
||||
|
||||
#[error("image decoding: {0}")]
|
||||
Image(#[from] ImageError),
|
||||
|
||||
#[error("printer can't register resources")]
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
impl PrintImageError {
|
||||
pub(crate) fn other<S>(message: S) -> Self
|
||||
where
|
||||
S: Into<Cow<'static, str>>,
|
||||
{
|
||||
Self::Other(message.into())
|
||||
}
|
||||
}
|
@ -1,24 +0,0 @@
|
||||
use super::{
|
||||
image::{Image, ImageSource},
|
||||
printer::{PrintImage, RegisterImageError},
|
||||
};
|
||||
use crate::ImagePrinter;
|
||||
use image::DynamicImage;
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ImageRegistry(pub Arc<ImagePrinter>);
|
||||
|
||||
impl ImageRegistry {
|
||||
pub(crate) fn register_image(&self, image: DynamicImage) -> Result<Image, RegisterImageError> {
|
||||
let resource = self.0.register_image(image)?;
|
||||
let image = Image::new(resource, ImageSource::Generated);
|
||||
Ok(image)
|
||||
}
|
||||
|
||||
pub(crate) fn register_resource(&self, path: PathBuf) -> Result<Image, RegisterImageError> {
|
||||
let resource = self.0.register_resource(&path)?;
|
||||
let image = Image::new(resource, ImageSource::Filesystem(path));
|
||||
Ok(image)
|
||||
}
|
||||
}
|
@ -1,64 +0,0 @@
|
||||
use crate::render::properties::{CursorPosition, WindowSize};
|
||||
|
||||
/// Scale an image to a specific size.
|
||||
pub(crate) fn scale_image(
|
||||
scale_size: &WindowSize,
|
||||
window_dimensions: &WindowSize,
|
||||
image_width: u32,
|
||||
image_height: u32,
|
||||
position: &CursorPosition,
|
||||
) -> TerminalRect {
|
||||
let aspect_ratio = image_height as f64 / image_width as f64;
|
||||
let column_in_pixels = scale_size.pixels_per_column();
|
||||
let width_in_columns = scale_size.columns;
|
||||
let image_width = width_in_columns as f64 * column_in_pixels;
|
||||
let image_height = image_width * aspect_ratio;
|
||||
|
||||
fit_image_to_window(window_dimensions, image_width as u32, image_height as u32, position)
|
||||
}
|
||||
|
||||
/// Shrink an image so it fits the dimensions of the layout it's being displayed in.
|
||||
pub(crate) fn fit_image_to_window(
|
||||
dimensions: &WindowSize,
|
||||
image_width: u32,
|
||||
image_height: u32,
|
||||
position: &CursorPosition,
|
||||
) -> TerminalRect {
|
||||
let aspect_ratio = image_height as f64 / image_width as f64;
|
||||
|
||||
// Compute the image's width in columns by translating pixels -> columns.
|
||||
let column_in_pixels = dimensions.pixels_per_column();
|
||||
let column_margin = (dimensions.columns as f64 * 0.95) as u32;
|
||||
let mut width_in_columns = (image_width as f64 / column_in_pixels) as u32;
|
||||
|
||||
// Do the same for its height.
|
||||
let row_in_pixels = dimensions.pixels_per_row();
|
||||
let height_in_rows = (image_height as f64 / row_in_pixels) as u32;
|
||||
|
||||
// If the image doesn't fit vertically, shrink it.
|
||||
let available_height = dimensions.rows.saturating_sub(position.row) as u32;
|
||||
if height_in_rows > available_height {
|
||||
// Because we only use the width to draw, here we scale the width based on how much we
|
||||
// need to shrink the height.
|
||||
let shrink_ratio = available_height as f64 / height_in_rows as f64;
|
||||
width_in_columns = (width_in_columns as f64 * shrink_ratio).ceil() as u32;
|
||||
}
|
||||
// Don't go too far wide.
|
||||
let width_in_columns = width_in_columns.min(column_margin);
|
||||
let height_in_rows = (width_in_columns as f64 * aspect_ratio / 2.0) as u16;
|
||||
|
||||
let width_in_columns = width_in_columns.max(1);
|
||||
let height_in_rows = height_in_rows.max(1);
|
||||
|
||||
// Draw it in the middle
|
||||
let start_column = dimensions.columns / 2 - (width_in_columns / 2) as u16;
|
||||
let start_column = start_column + position.column;
|
||||
TerminalRect { start_column, columns: width_in_columns as u16, rows: height_in_rows }
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct TerminalRect {
|
||||
pub(crate) start_column: u16,
|
||||
pub(crate) columns: u16,
|
||||
pub(crate) rows: u16,
|
||||
}
|
File diff suppressed because it is too large
Load Diff
390
src/presentation/builder/snippet.rs
Normal file
390
src/presentation/builder/snippet.rs
Normal file
@ -0,0 +1,390 @@
|
||||
use super::{BuildError, BuildResult, ExecutionMode, PresentationBuilderOptions};
|
||||
use crate::{
|
||||
ImageRegistry,
|
||||
code::{
|
||||
execute::SnippetExecutor,
|
||||
highlighting::SnippetHighlighter,
|
||||
snippet::{
|
||||
ExternalFile, Highlight, HighlightContext, HighlightGroup, HighlightMutator, HighlightedLine, Snippet,
|
||||
SnippetExec, SnippetLanguage, SnippetLine, SnippetParser, SnippetRepr, SnippetSplitter,
|
||||
},
|
||||
},
|
||||
markdown::elements::SourcePosition,
|
||||
presentation::ChunkMutator,
|
||||
render::{
|
||||
operation::{AsRenderOperations, RenderAsyncStartPolicy, RenderOperation},
|
||||
properties::WindowSize,
|
||||
},
|
||||
resource::Resources,
|
||||
theme::{Alignment, CodeBlockStyle, PresentationTheme},
|
||||
third_party::{ThirdPartyRender, ThirdPartyRenderRequest},
|
||||
ui::execution::{
|
||||
RunAcquireTerminalSnippet, RunImageSnippet, RunSnippetOperation, SnippetExecutionDisabledOperation,
|
||||
disabled::ExecutionType, snippet::DisplaySeparator,
|
||||
},
|
||||
};
|
||||
use itertools::Itertools;
|
||||
use std::{cell::RefCell, rc::Rc, sync::Arc};
|
||||
|
||||
pub(crate) struct SnippetProcessorState<'a> {
|
||||
pub(crate) resources: &'a Resources,
|
||||
pub(crate) image_registry: &'a ImageRegistry,
|
||||
pub(crate) snippet_executor: Arc<SnippetExecutor>,
|
||||
pub(crate) theme: &'a PresentationTheme,
|
||||
pub(crate) third_party: &'a ThirdPartyRender,
|
||||
pub(crate) highlighter: &'a SnippetHighlighter,
|
||||
pub(crate) options: &'a PresentationBuilderOptions,
|
||||
pub(crate) font_size: u8,
|
||||
}
|
||||
|
||||
pub(crate) struct SnippetProcessor<'a> {
|
||||
operations: Vec<RenderOperation>,
|
||||
mutators: Vec<Box<dyn ChunkMutator>>,
|
||||
resources: &'a Resources,
|
||||
image_registry: &'a ImageRegistry,
|
||||
snippet_executor: Arc<SnippetExecutor>,
|
||||
theme: &'a PresentationTheme,
|
||||
third_party: &'a ThirdPartyRender,
|
||||
highlighter: &'a SnippetHighlighter,
|
||||
options: &'a PresentationBuilderOptions,
|
||||
font_size: u8,
|
||||
}
|
||||
|
||||
impl<'a> SnippetProcessor<'a> {
|
||||
pub(crate) fn new(state: SnippetProcessorState<'a>) -> Self {
|
||||
let SnippetProcessorState {
|
||||
resources,
|
||||
image_registry,
|
||||
snippet_executor,
|
||||
theme,
|
||||
third_party,
|
||||
highlighter,
|
||||
options,
|
||||
font_size,
|
||||
} = state;
|
||||
Self {
|
||||
operations: Vec::new(),
|
||||
mutators: Vec::new(),
|
||||
resources,
|
||||
image_registry,
|
||||
snippet_executor,
|
||||
theme,
|
||||
third_party,
|
||||
highlighter,
|
||||
options,
|
||||
font_size,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn process_code(
|
||||
mut self,
|
||||
info: String,
|
||||
code: String,
|
||||
source_position: SourcePosition,
|
||||
) -> Result<SnippetOperations, BuildError> {
|
||||
self.do_process_code(info, code, source_position)?;
|
||||
|
||||
let Self { operations, mutators, .. } = self;
|
||||
Ok(SnippetOperations { operations, mutators })
|
||||
}
|
||||
|
||||
fn do_process_code(&mut self, info: String, code: String, source_position: SourcePosition) -> BuildResult {
|
||||
let mut snippet = SnippetParser::parse(info, code)
|
||||
.map_err(|e| BuildError::InvalidSnippet { source_position, error: e.to_string() })?;
|
||||
if matches!(snippet.language, SnippetLanguage::File) {
|
||||
snippet = self.load_external_snippet(snippet, source_position)?;
|
||||
}
|
||||
if self.options.auto_render_languages.contains(&snippet.language) {
|
||||
snippet.attributes.representation = SnippetRepr::Render;
|
||||
}
|
||||
self.push_differ(snippet.contents.clone());
|
||||
// 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_code_execution(snippet, 0, ExecutionMode::ReplaceSnippet);
|
||||
}
|
||||
}
|
||||
SnippetRepr::Snippet => (),
|
||||
};
|
||||
|
||||
let block_length = self.push_code_lines(&snippet);
|
||||
match snippet.attributes.execution {
|
||||
SnippetExec::None => Ok(()),
|
||||
SnippetExec::Exec | SnippetExec::AcquireTerminal if !execution_allowed => {
|
||||
let exec_type = match snippet.attributes.representation {
|
||||
SnippetRepr::Image => ExecutionType::Image,
|
||||
SnippetRepr::ExecReplace => ExecutionType::ExecReplace,
|
||||
SnippetRepr::Render | SnippetRepr::Snippet => ExecutionType::Execute,
|
||||
};
|
||||
self.push_execution_disabled_operation(exec_type);
|
||||
Ok(())
|
||||
}
|
||||
SnippetExec::Exec => self.push_code_execution(snippet, block_length, ExecutionMode::AlongSnippet),
|
||||
SnippetExec::AcquireTerminal => self.push_acquire_terminal_execution(snippet, block_length),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_execution_allowed(&self, snippet: &Snippet) -> bool {
|
||||
match snippet.attributes.representation {
|
||||
SnippetRepr::Snippet => self.options.enable_snippet_execution,
|
||||
SnippetRepr::Image | SnippetRepr::ExecReplace => self.options.enable_snippet_execution_replace,
|
||||
SnippetRepr::Render => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn push_code_lines(&mut self, snippet: &Snippet) -> u16 {
|
||||
let lines = SnippetSplitter::new(&self.theme.code, self.snippet_executor.hidden_line_prefix(&snippet.language))
|
||||
.split(snippet);
|
||||
let block_length = lines.iter().map(|line| line.width()).max().unwrap_or(0) * self.font_size as usize;
|
||||
let block_length = block_length as u16;
|
||||
let (lines, context) = self.highlight_lines(snippet, lines, block_length);
|
||||
for line in lines {
|
||||
self.operations.push(RenderOperation::RenderDynamic(Rc::new(line)));
|
||||
}
|
||||
self.operations.push(RenderOperation::SetColors(self.theme.default_style.style.colors));
|
||||
if self.options.allow_mutations && context.borrow().groups.len() > 1 {
|
||||
self.mutators.push(Box::new(HighlightMutator::new(context)));
|
||||
}
|
||||
block_length
|
||||
}
|
||||
|
||||
fn load_external_snippet(
|
||||
&mut self,
|
||||
mut code: Snippet,
|
||||
source_position: SourcePosition,
|
||||
) -> Result<Snippet, BuildError> {
|
||||
let file: ExternalFile = serde_yaml::from_str(&code.contents)
|
||||
.map_err(|e| BuildError::InvalidSnippet { source_position, error: e.to_string() })?;
|
||||
let path = file.path;
|
||||
let path_display = path.display();
|
||||
let contents = self.resources.external_snippet(&path).map_err(|e| BuildError::InvalidSnippet {
|
||||
source_position,
|
||||
error: format!("failed to load {path_display}: {e}"),
|
||||
})?;
|
||||
code.language = file.language;
|
||||
code.contents = Self::filter_lines(contents, file.start_line, file.end_line);
|
||||
Ok(code)
|
||||
}
|
||||
|
||||
fn filter_lines(code: String, start: Option<usize>, end: Option<usize>) -> String {
|
||||
let start = start.map(|s| s.saturating_sub(1));
|
||||
match (start, end) {
|
||||
(None, None) => code,
|
||||
(None, Some(end)) => code.lines().take(end).join("\n"),
|
||||
(Some(start), None) => code.lines().skip(start).join("\n"),
|
||||
(Some(start), Some(end)) => code.lines().skip(start).take(end.saturating_sub(start)).join("\n"),
|
||||
}
|
||||
}
|
||||
|
||||
fn push_rendered_code(&mut self, code: Snippet, source_position: SourcePosition) -> BuildResult {
|
||||
let Snippet { contents, language, attributes } = code;
|
||||
let request = match language {
|
||||
SnippetLanguage::Typst => ThirdPartyRenderRequest::Typst(contents, self.theme.typst.clone()),
|
||||
SnippetLanguage::Latex => ThirdPartyRenderRequest::Latex(contents, self.theme.typst.clone()),
|
||||
SnippetLanguage::Mermaid => ThirdPartyRenderRequest::Mermaid(contents, self.theme.mermaid.clone()),
|
||||
_ => {
|
||||
return Err(BuildError::InvalidSnippet {
|
||||
source_position,
|
||||
error: format!("language {language:?} doesn't support rendering"),
|
||||
})?;
|
||||
}
|
||||
};
|
||||
let operation = self.third_party.render(request, self.theme, attributes.width)?;
|
||||
self.operations.push(operation);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn highlight_lines(
|
||||
&self,
|
||||
code: &Snippet,
|
||||
lines: Vec<SnippetLine>,
|
||||
block_length: u16,
|
||||
) -> (Vec<HighlightedLine>, Rc<RefCell<HighlightContext>>) {
|
||||
let mut code_highlighter = self.highlighter.language_highlighter(&code.language);
|
||||
let style = self.code_style(code);
|
||||
let block_length = self.theme.code.alignment.adjust_size(block_length);
|
||||
let font_size = self.font_size;
|
||||
let dim_style = {
|
||||
let mut highlighter = self.highlighter.language_highlighter(&SnippetLanguage::Rust);
|
||||
highlighter.style_line("//", &style).0.first().expect("no styles").style.size(font_size)
|
||||
};
|
||||
let groups = match self.options.allow_mutations {
|
||||
true => code.attributes.highlight_groups.clone(),
|
||||
false => vec![HighlightGroup::new(vec![Highlight::All])],
|
||||
};
|
||||
let context =
|
||||
Rc::new(RefCell::new(HighlightContext { groups, current: 0, block_length, alignment: style.alignment }));
|
||||
|
||||
let mut output = Vec::new();
|
||||
for line in lines.into_iter() {
|
||||
let prefix = line.dim_prefix(&dim_style);
|
||||
let highlighted = line.highlight(&mut code_highlighter, &style, font_size);
|
||||
let not_highlighted = line.dim(&dim_style);
|
||||
let line_number = line.line_number;
|
||||
let context = context.clone();
|
||||
output.push(HighlightedLine {
|
||||
prefix,
|
||||
right_padding_length: line.right_padding_length * self.font_size as u16,
|
||||
highlighted,
|
||||
not_highlighted,
|
||||
line_number,
|
||||
context,
|
||||
block_color: dim_style.colors.background,
|
||||
});
|
||||
}
|
||||
(output, context)
|
||||
}
|
||||
|
||||
fn code_style(&self, snippet: &Snippet) -> CodeBlockStyle {
|
||||
let mut style = self.theme.code.clone();
|
||||
if snippet.attributes.no_background {
|
||||
style.background = false;
|
||||
}
|
||||
style
|
||||
}
|
||||
|
||||
fn push_execution_disabled_operation(&mut self, exec_type: ExecutionType) {
|
||||
let policy = match exec_type {
|
||||
ExecutionType::ExecReplace | ExecutionType::Image => RenderAsyncStartPolicy::Automatic,
|
||||
ExecutionType::Execute => RenderAsyncStartPolicy::OnDemand,
|
||||
};
|
||||
let operation = SnippetExecutionDisabledOperation::new(
|
||||
self.theme.execution_output.status.failure_style,
|
||||
self.theme.code.alignment,
|
||||
policy,
|
||||
exec_type,
|
||||
);
|
||||
self.operations.push(RenderOperation::RenderAsync(Rc::new(operation)));
|
||||
}
|
||||
|
||||
fn push_code_as_image(&mut self, snippet: Snippet) -> BuildResult {
|
||||
if !self.snippet_executor.is_execution_supported(&snippet.language) {
|
||||
return Err(BuildError::UnsupportedExecution(snippet.language));
|
||||
}
|
||||
let operation = RunImageSnippet::new(
|
||||
snippet,
|
||||
self.snippet_executor.clone(),
|
||||
self.image_registry.clone(),
|
||||
self.theme.execution_output.status.clone(),
|
||||
);
|
||||
let operation = RenderOperation::RenderAsync(Rc::new(operation));
|
||||
self.operations.push(operation);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn push_acquire_terminal_execution(&mut self, snippet: Snippet, block_length: u16) -> BuildResult {
|
||||
if !self.snippet_executor.is_execution_supported(&snippet.language) {
|
||||
return Err(BuildError::UnsupportedExecution(snippet.language));
|
||||
}
|
||||
let block_length = self.theme.code.alignment.adjust_size(block_length);
|
||||
let operation = RunAcquireTerminalSnippet::new(
|
||||
snippet,
|
||||
self.snippet_executor.clone(),
|
||||
self.theme.execution_output.status.clone(),
|
||||
block_length,
|
||||
self.font_size,
|
||||
);
|
||||
let operation = RenderOperation::RenderAsync(Rc::new(operation));
|
||||
self.operations.push(operation);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn push_code_execution(&mut self, snippet: Snippet, block_length: u16, mode: ExecutionMode) -> BuildResult {
|
||||
if !self.snippet_executor.is_execution_supported(&snippet.language) {
|
||||
return Err(BuildError::UnsupportedExecution(snippet.language));
|
||||
}
|
||||
let separator = match mode {
|
||||
ExecutionMode::AlongSnippet => DisplaySeparator::On,
|
||||
ExecutionMode::ReplaceSnippet => DisplaySeparator::Off,
|
||||
};
|
||||
let default_alignment = self.code_style(&snippet).alignment;
|
||||
// If we're replacing the snippet output and we have center alignment, use center alignment but
|
||||
// without any margins and minimum sizes so we truly center the output.
|
||||
let alignment = match (&mode, default_alignment) {
|
||||
(ExecutionMode::ReplaceSnippet, Alignment::Center { .. }) => {
|
||||
Alignment::Center { minimum_margin: Default::default(), minimum_size: 0 }
|
||||
}
|
||||
(_, alignment) => alignment,
|
||||
};
|
||||
let default_colors = self.theme.default_style.style.colors;
|
||||
let mut execution_output_style = self.theme.execution_output.clone();
|
||||
if snippet.attributes.no_background {
|
||||
execution_output_style.style.colors.background = None;
|
||||
}
|
||||
let policy = match mode {
|
||||
ExecutionMode::AlongSnippet => RenderAsyncStartPolicy::OnDemand,
|
||||
ExecutionMode::ReplaceSnippet => RenderAsyncStartPolicy::Automatic,
|
||||
};
|
||||
let operation = RunSnippetOperation::new(
|
||||
snippet,
|
||||
self.snippet_executor.clone(),
|
||||
default_colors,
|
||||
execution_output_style,
|
||||
block_length,
|
||||
separator,
|
||||
alignment,
|
||||
self.font_size,
|
||||
policy,
|
||||
);
|
||||
let operation = RenderOperation::RenderAsync(Rc::new(operation));
|
||||
self.operations.push(operation);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn push_differ(&mut self, text: String) {
|
||||
self.operations.push(RenderOperation::RenderDynamic(Rc::new(Differ(text))));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct SnippetOperations {
|
||||
pub(crate) operations: Vec<RenderOperation>,
|
||||
pub(crate) mutators: Vec<Box<dyn ChunkMutator>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Differ(String);
|
||||
|
||||
impl AsRenderOperations for Differ {
|
||||
fn as_render_operations(&self, _: &WindowSize) -> Vec<RenderOperation> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn diffable_content(&self) -> Option<&str> {
|
||||
Some(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
#[case::no_filters(None, None, &["a", "b", "c", "d", "e"])]
|
||||
#[case::start_from_first(Some(1), None, &["a", "b", "c", "d", "e"])]
|
||||
#[case::start_from_second(Some(2), None, &["b", "c", "d", "e"])]
|
||||
#[case::start_from_end(Some(5), None, &["e"])]
|
||||
#[case::start_from_past_end(Some(6), None, &[])]
|
||||
#[case::end_last(None, Some(5), &["a", "b", "c", "d", "e"])]
|
||||
#[case::end_one_before_last(None, Some(4), &["a", "b", "c", "d"])]
|
||||
#[case::end_at_first(None, Some(1), &["a"])]
|
||||
#[case::end_at_zero(None, Some(0), &[])]
|
||||
#[case::start_and_end(Some(2), Some(3), &["b", "c"])]
|
||||
#[case::crossed(Some(2), Some(1), &[])]
|
||||
fn filter_lines(#[case] start: Option<usize>, #[case] end: Option<usize>, #[case] expected: &[&str]) {
|
||||
let code = ["a", "b", "c", "d", "e"].join("\n");
|
||||
let output = SnippetProcessor::filter_lines(code, start, end);
|
||||
let expected = expected.join("\n");
|
||||
assert_eq!(output, expected);
|
||||
}
|
||||
}
|
@ -114,11 +114,15 @@ where
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::{
|
||||
presentation::{
|
||||
AsRenderOperations, BlockLine, BlockLineText, RenderAsync, RenderAsyncState, Slide, SlideBuilder,
|
||||
markdown::{
|
||||
text::WeightedLine,
|
||||
text_style::{Color, Colors},
|
||||
},
|
||||
presentation::{Slide, SlideBuilder},
|
||||
render::{
|
||||
operation::{AsRenderOperations, BlockLine, Pollable, RenderAsync, ToggleState},
|
||||
properties::WindowSize,
|
||||
},
|
||||
render::properties::WindowSize,
|
||||
style::{Color, Colors},
|
||||
theme::{Alignment, Margin},
|
||||
};
|
||||
use rstest::rstest;
|
||||
@ -131,19 +135,12 @@ mod test {
|
||||
fn as_render_operations(&self, _dimensions: &WindowSize) -> Vec<RenderOperation> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn diffable_content(&self) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderAsync for Dynamic {
|
||||
fn start_render(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn poll_state(&self) -> RenderAsyncState {
|
||||
RenderAsyncState::Rendered
|
||||
fn pollable(&self) -> Box<dyn Pollable> {
|
||||
// Use some random one, we don't care
|
||||
Box::new(ToggleState::new(Default::default()))
|
||||
}
|
||||
}
|
||||
|
||||
@ -156,10 +153,13 @@ mod test {
|
||||
#[case(RenderOperation::RenderText{line: String::from("asd").into(), alignment: Default::default()})]
|
||||
#[case(RenderOperation::RenderBlockLine(
|
||||
BlockLine{
|
||||
text: BlockLineText::Preformatted("".into()),
|
||||
prefix: "".into(),
|
||||
right_padding_length: 0,
|
||||
repeat_prefix_on_wrap: false,
|
||||
text: WeightedLine::from("".to_string()),
|
||||
alignment: Default::default(),
|
||||
block_length: 42,
|
||||
unformatted_length: 1337
|
||||
block_color: None,
|
||||
}
|
||||
))]
|
||||
#[case(RenderOperation::RenderDynamic(Rc::new(Dynamic)))]
|
@ -1,21 +1,17 @@
|
||||
use crate::{
|
||||
custom::OptionsConfig,
|
||||
markdown::text::WeightedTextBlock,
|
||||
media::image::Image,
|
||||
render::properties::WindowSize,
|
||||
style::{Color, Colors},
|
||||
theme::{Alignment, Margin, PresentationTheme},
|
||||
};
|
||||
use crate::{config::OptionsConfig, render::operation::RenderOperation};
|
||||
use serde::Deserialize;
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::HashSet,
|
||||
fmt::Debug,
|
||||
ops::Deref,
|
||||
rc::Rc,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
pub(crate) mod builder;
|
||||
pub(crate) mod diff;
|
||||
pub(crate) mod poller;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Modals {
|
||||
pub(crate) slide_index: Vec<RenderOperation>,
|
||||
@ -41,6 +37,11 @@ impl Presentation {
|
||||
self.slides.iter()
|
||||
}
|
||||
|
||||
/// Iterate the slides in this presentation.
|
||||
pub(crate) fn iter_slides_mut(&mut self) -> impl Iterator<Item = &mut Slide> {
|
||||
self.slides.iter_mut()
|
||||
}
|
||||
|
||||
/// Iterate the operations that render the slide index.
|
||||
pub(crate) fn iter_slide_index_operations(&self) -> impl Iterator<Item = &RenderOperation> {
|
||||
self.modals.slide_index.iter()
|
||||
@ -52,7 +53,6 @@ impl Presentation {
|
||||
}
|
||||
|
||||
/// Consume this presentation and return its slides.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn into_slides(self) -> Vec<Slide> {
|
||||
self.slides
|
||||
}
|
||||
@ -140,92 +140,7 @@ impl Presentation {
|
||||
self.current_slide().current_chunk_index()
|
||||
}
|
||||
|
||||
/// Trigger async render operations in all slides.
|
||||
pub(crate) fn trigger_all_async_renders(&mut self) -> HashSet<usize> {
|
||||
let mut triggered_slides = HashSet::new();
|
||||
for (index, slide) in self.slides.iter_mut().enumerate() {
|
||||
for operation in slide.iter_operations_mut() {
|
||||
if let RenderOperation::RenderAsync(operation) = operation {
|
||||
if operation.start_render() {
|
||||
triggered_slides.insert(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
triggered_slides
|
||||
}
|
||||
|
||||
/// Trigger async render operations in this slide.
|
||||
pub(crate) fn trigger_slide_async_renders(&mut self) -> bool {
|
||||
let slide = self.current_slide_mut();
|
||||
let mut any_rendered = false;
|
||||
for operation in slide.iter_visible_operations_mut() {
|
||||
if let RenderOperation::RenderAsync(operation) = operation {
|
||||
let is_rendered = operation.start_render();
|
||||
any_rendered = any_rendered || is_rendered;
|
||||
}
|
||||
}
|
||||
any_rendered
|
||||
}
|
||||
|
||||
// Get all slides that contain async render operations.
|
||||
pub(crate) fn slides_with_async_renders(&self) -> HashSet<usize> {
|
||||
let mut indexes = HashSet::new();
|
||||
for (index, slide) in self.slides.iter().enumerate() {
|
||||
for operation in slide.iter_operations() {
|
||||
if let RenderOperation::RenderAsync(operation) = operation {
|
||||
if matches!(operation.poll_state(), RenderAsyncState::Rendering { .. }) {
|
||||
indexes.insert(index);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
indexes
|
||||
}
|
||||
|
||||
/// Poll every async render operation in the current slide and check whether they're completed.
|
||||
pub(crate) fn poll_slide_async_renders(&mut self) -> RenderAsyncState {
|
||||
let slide = self.current_slide_mut();
|
||||
let mut slide_state = RenderAsyncState::Rendered;
|
||||
for operation in slide.iter_operations_mut() {
|
||||
if let RenderOperation::RenderAsync(operation) = operation {
|
||||
let state = operation.poll_state();
|
||||
slide_state = match (&slide_state, &state) {
|
||||
// If one finished rendering and another one still is rendering, claim that we
|
||||
// are still rendering and there's modifications.
|
||||
(RenderAsyncState::JustFinishedRendering, RenderAsyncState::Rendering { .. })
|
||||
| (RenderAsyncState::Rendering { .. }, RenderAsyncState::JustFinishedRendering) => {
|
||||
RenderAsyncState::Rendering { modified: true }
|
||||
}
|
||||
// Render + modified overrides anything, rendering overrides only "rendered".
|
||||
(_, RenderAsyncState::Rendering { modified: true })
|
||||
| (RenderAsyncState::Rendered, RenderAsyncState::Rendering { .. })
|
||||
| (_, RenderAsyncState::JustFinishedRendering) => state,
|
||||
_ => slide_state,
|
||||
};
|
||||
}
|
||||
}
|
||||
slide_state
|
||||
}
|
||||
|
||||
/// Run a callback through every operation and let it mutate it in place.
|
||||
///
|
||||
/// This should be used with care!
|
||||
pub(crate) fn mutate_operations<F>(&mut self, mut callback: F)
|
||||
where
|
||||
F: FnMut(&mut RenderOperation),
|
||||
{
|
||||
for slide in &mut self.slides {
|
||||
for chunk in &mut slide.chunks {
|
||||
for operation in &mut chunk.operations {
|
||||
callback(operation);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn current_slide_mut(&mut self) -> &mut Slide {
|
||||
pub(crate) fn current_slide_mut(&mut self) -> &mut Slide {
|
||||
let index = self.current_slide_index();
|
||||
&mut self.slides[index]
|
||||
}
|
||||
@ -383,7 +298,7 @@ impl Slide {
|
||||
self.current_chunk().reset_mutations();
|
||||
}
|
||||
|
||||
fn show_all_chunks(&mut self) {
|
||||
pub(crate) fn show_all_chunks(&mut self) {
|
||||
self.visible_chunks = self.chunks.len();
|
||||
for chunk in &self.chunks {
|
||||
chunk.apply_all_mutations();
|
||||
@ -493,6 +408,18 @@ pub(crate) struct PresentationMetadata {
|
||||
#[serde(default)]
|
||||
pub(crate) sub_title: Option<String>,
|
||||
|
||||
/// The presentation event.
|
||||
#[serde(default)]
|
||||
pub(crate) event: Option<String>,
|
||||
|
||||
/// The presentation location.
|
||||
#[serde(default)]
|
||||
pub(crate) location: Option<String>,
|
||||
|
||||
/// The presentation date.
|
||||
#[serde(default)]
|
||||
pub(crate) date: Option<String>,
|
||||
|
||||
/// The presentation author.
|
||||
#[serde(default)]
|
||||
pub(crate) author: Option<String>,
|
||||
@ -510,6 +437,19 @@ pub(crate) struct PresentationMetadata {
|
||||
pub(crate) options: Option<OptionsConfig>,
|
||||
}
|
||||
|
||||
impl PresentationMetadata {
|
||||
/// Check if this presentation has frontmatter.
|
||||
pub(crate) fn has_frontmatter(&self) -> bool {
|
||||
self.title.is_some()
|
||||
|| self.sub_title.is_some()
|
||||
|| self.event.is_some()
|
||||
|| self.location.is_some()
|
||||
|| self.date.is_some()
|
||||
|| self.author.is_some()
|
||||
|| !self.authors.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// A presentation's theme metadata.
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
pub(crate) struct PresentationThemeMetadata {
|
||||
@ -523,153 +463,7 @@ pub(crate) struct PresentationThemeMetadata {
|
||||
|
||||
/// Any specific overrides for the presentation's theme.
|
||||
#[serde(default, rename = "override")]
|
||||
pub(crate) overrides: Option<PresentationTheme>,
|
||||
}
|
||||
|
||||
/// A line of preformatted text to be rendered.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct BlockLine {
|
||||
pub(crate) text: BlockLineText,
|
||||
pub(crate) unformatted_length: u16,
|
||||
pub(crate) block_length: u16,
|
||||
pub(crate) alignment: Alignment,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) enum BlockLineText {
|
||||
Preformatted(String),
|
||||
Weighted(WeightedTextBlock),
|
||||
}
|
||||
|
||||
/// A render operation.
|
||||
///
|
||||
/// Render operations are primitives that allow the input markdown file to be decoupled with what
|
||||
/// we draw on the screen.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum RenderOperation {
|
||||
/// Clear the entire screen.
|
||||
ClearScreen,
|
||||
|
||||
/// Set the colors to be used for any subsequent operations.
|
||||
SetColors(Colors),
|
||||
|
||||
/// Jump the draw cursor into the vertical center, that is, at `screen_height / 2`.
|
||||
JumpToVerticalCenter,
|
||||
|
||||
/// Jumps to the N-th row in the current layout.
|
||||
///
|
||||
/// The index is zero based where 0 represents the top row.
|
||||
JumpToRow { index: u16 },
|
||||
|
||||
/// Jumps to the N-th to last row in the current layout.
|
||||
///
|
||||
/// The index is zero based where 0 represents the bottom row.
|
||||
JumpToBottomRow { index: u16 },
|
||||
|
||||
/// Render text.
|
||||
RenderText { line: WeightedTextBlock, alignment: Alignment },
|
||||
|
||||
/// Render a line break.
|
||||
RenderLineBreak,
|
||||
|
||||
/// Render an image.
|
||||
RenderImage(Image, ImageProperties),
|
||||
|
||||
/// Render a line.
|
||||
RenderBlockLine(BlockLine),
|
||||
|
||||
/// Render a dynamically generated sequence of render operations.
|
||||
///
|
||||
/// This allows drawing something on the screen that requires knowing dynamic properties of the
|
||||
/// screen, like window size, without coupling the transformation of markdown into
|
||||
/// [RenderOperation] with the screen itself.
|
||||
RenderDynamic(Rc<dyn AsRenderOperations>),
|
||||
|
||||
/// An operation that is rendered asynchronously.
|
||||
RenderAsync(Rc<dyn RenderAsync>),
|
||||
|
||||
/// Initialize a column layout.
|
||||
///
|
||||
/// The value for each column is the width of the column in column-unit units, where the entire
|
||||
/// screen contains `columns.sum()` column-units.
|
||||
InitColumnLayout { columns: Vec<u8> },
|
||||
|
||||
/// Enter a column in a column layout.
|
||||
///
|
||||
/// The index is 0-index based and will be tied to a previous `InitColumnLayout` operation.
|
||||
EnterColumn { column: usize },
|
||||
|
||||
/// Exit the current layout and go back to the default one.
|
||||
ExitLayout,
|
||||
|
||||
/// Apply a margin to every following operation.
|
||||
ApplyMargin(MarginProperties),
|
||||
|
||||
/// Pop an `ApplyMargin` operation.
|
||||
PopMargin,
|
||||
}
|
||||
|
||||
/// The properties of an image being rendered.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct ImageProperties {
|
||||
pub(crate) z_index: i32,
|
||||
pub(crate) size: ImageSize,
|
||||
pub(crate) restore_cursor: bool,
|
||||
pub(crate) background_color: Option<Color>,
|
||||
}
|
||||
|
||||
/// The size used when printing an image.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub(crate) enum ImageSize {
|
||||
#[default]
|
||||
ShrinkIfNeeded,
|
||||
Specific(u16, u16),
|
||||
WidthScaled {
|
||||
ratio: f64,
|
||||
},
|
||||
}
|
||||
|
||||
/// Slide properties, set on initialization.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct MarginProperties {
|
||||
/// The horizontal margin.
|
||||
pub(crate) horizontal_margin: Margin,
|
||||
|
||||
/// The margin at the bottom of the slide.
|
||||
pub(crate) bottom_slide_margin: u16,
|
||||
}
|
||||
|
||||
/// A type that can generate render operations.
|
||||
pub(crate) trait AsRenderOperations: Debug + 'static {
|
||||
/// Generate render operations.
|
||||
fn as_render_operations(&self, dimensions: &WindowSize) -> Vec<RenderOperation>;
|
||||
|
||||
/// Get the content in this type to diff it against another `AsRenderOperations`.
|
||||
fn diffable_content(&self) -> Option<&str>;
|
||||
}
|
||||
|
||||
/// An operation that can be rendered asynchronously.
|
||||
pub(crate) trait RenderAsync: AsRenderOperations {
|
||||
/// Start the render for this operation.
|
||||
///
|
||||
/// Should return true if the invocation triggered the rendering (aka if rendering wasn't
|
||||
/// already started before).
|
||||
fn start_render(&self) -> bool;
|
||||
|
||||
/// Update the internal state and return the updated state.
|
||||
fn poll_state(&self) -> RenderAsyncState;
|
||||
}
|
||||
|
||||
/// The state of a [RenderAsync].
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) enum RenderAsyncState {
|
||||
#[default]
|
||||
NotStarted,
|
||||
Rendering {
|
||||
modified: bool,
|
||||
},
|
||||
Rendered,
|
||||
JustFinishedRendering,
|
||||
pub(crate) overrides: Option<crate::theme::raw::PresentationTheme>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
118
src/presentation/poller.rs
Normal file
118
src/presentation/poller.rs
Normal file
@ -0,0 +1,118 @@
|
||||
use crate::render::operation::{Pollable, PollableState};
|
||||
use std::{
|
||||
sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel},
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
const POLL_INTERVAL: Duration = Duration::from_millis(25);
|
||||
|
||||
pub(crate) struct Poller {
|
||||
sender: Sender<PollerCommand>,
|
||||
receiver: Receiver<PollableEffect>,
|
||||
}
|
||||
|
||||
impl Poller {
|
||||
pub(crate) fn launch() -> Self {
|
||||
let (command_sender, command_receiver) = channel();
|
||||
let (effect_sender, effect_receiver) = channel();
|
||||
let worker = PollerWorker::new(command_receiver, effect_sender);
|
||||
thread::spawn(move || {
|
||||
worker.run();
|
||||
});
|
||||
Self { sender: command_sender, receiver: effect_receiver }
|
||||
}
|
||||
|
||||
pub(crate) fn send(&self, command: PollerCommand) {
|
||||
let _ = self.sender.send(command);
|
||||
}
|
||||
|
||||
pub(crate) fn next_effect(&mut self) -> Option<PollableEffect> {
|
||||
self.receiver.try_recv().ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// An effect caused by a pollable.
|
||||
#[derive(Clone)]
|
||||
pub(crate) enum PollableEffect {
|
||||
/// Refresh the given slide.
|
||||
RefreshSlide(usize),
|
||||
|
||||
/// Display an error for the given slide.
|
||||
DisplayError { slide: usize, error: String },
|
||||
}
|
||||
|
||||
/// A poller command.
|
||||
pub(crate) enum PollerCommand {
|
||||
/// Start polling a pollable that's positioned in the given slide.
|
||||
Poll { pollable: Box<dyn Pollable>, slide: usize },
|
||||
|
||||
/// Reset all pollables.
|
||||
Reset,
|
||||
}
|
||||
|
||||
struct PollerWorker {
|
||||
receiver: Receiver<PollerCommand>,
|
||||
sender: Sender<PollableEffect>,
|
||||
pollables: Vec<(Box<dyn Pollable>, usize)>,
|
||||
}
|
||||
|
||||
impl PollerWorker {
|
||||
fn new(receiver: Receiver<PollerCommand>, sender: Sender<PollableEffect>) -> Self {
|
||||
Self { receiver, sender, pollables: Default::default() }
|
||||
}
|
||||
|
||||
fn run(mut self) {
|
||||
loop {
|
||||
match self.receiver.recv_timeout(POLL_INTERVAL) {
|
||||
Ok(command) => self.process_command(command),
|
||||
// TODO don't loop forever.
|
||||
Err(RecvTimeoutError::Timeout) => self.poll(),
|
||||
Err(RecvTimeoutError::Disconnected) => break,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
fn process_command(&mut self, command: PollerCommand) {
|
||||
match command {
|
||||
PollerCommand::Poll { mut pollable, slide } => {
|
||||
// Poll and only insert if it's still running.
|
||||
match pollable.poll() {
|
||||
PollableState::Unmodified | PollableState::Modified => {
|
||||
self.pollables.push((pollable, slide));
|
||||
}
|
||||
PollableState::Done => {
|
||||
let _ = self.sender.send(PollableEffect::RefreshSlide(slide));
|
||||
}
|
||||
PollableState::Failed { error } => {
|
||||
let _ = self.sender.send(PollableEffect::DisplayError { slide, error });
|
||||
}
|
||||
};
|
||||
}
|
||||
PollerCommand::Reset => self.pollables.clear(),
|
||||
}
|
||||
}
|
||||
|
||||
fn poll(&mut self) {
|
||||
let mut removables = Vec::new();
|
||||
for (index, (pollable, slide)) in self.pollables.iter_mut().enumerate() {
|
||||
let slide = *slide;
|
||||
let (effect, remove) = match pollable.poll() {
|
||||
PollableState::Unmodified => (None, false),
|
||||
PollableState::Modified => (Some(PollableEffect::RefreshSlide(slide)), false),
|
||||
PollableState::Done => (Some(PollableEffect::RefreshSlide(slide)), true),
|
||||
PollableState::Failed { error } => (Some(PollableEffect::DisplayError { slide, error }), true),
|
||||
};
|
||||
if let Some(effect) = effect {
|
||||
let _ = self.sender.send(effect);
|
||||
}
|
||||
if remove {
|
||||
removables.push(index);
|
||||
}
|
||||
}
|
||||
// Walk back and swap remove to avoid invalidating indexes.
|
||||
for index in removables.iter().rev() {
|
||||
self.pollables.swap_remove(*index);
|
||||
}
|
||||
}
|
||||
}
|
435
src/presenter.rs
435
src/presenter.rs
@ -1,32 +1,48 @@
|
||||
use crate::{
|
||||
custom::KeyBindingsConfig,
|
||||
diff::PresentationDiffer,
|
||||
execute::SnippetExecutor,
|
||||
export::ImageReplacer,
|
||||
input::source::{Command, CommandSource},
|
||||
code::execute::SnippetExecutor,
|
||||
commands::{
|
||||
listener::{Command, CommandListener},
|
||||
speaker_notes::{SpeakerNotesEvent, SpeakerNotesEventPublisher},
|
||||
},
|
||||
config::{KeyBindingsConfig, SlideTransitionConfig, SlideTransitionStyleConfig},
|
||||
markdown::parse::{MarkdownParser, ParseError},
|
||||
media::{printer::ImagePrinter, register::ImageRegistry},
|
||||
presentation::{Presentation, RenderAsyncState},
|
||||
processing::builder::{BuildError, PresentationBuilder, PresentationBuilderOptions, Themes},
|
||||
presentation::{
|
||||
Presentation, Slide,
|
||||
builder::{BuildError, PresentationBuilder, PresentationBuilderOptions, Themes},
|
||||
diff::PresentationDiffer,
|
||||
poller::{PollableEffect, Poller, PollerCommand},
|
||||
},
|
||||
render::{
|
||||
draw::{ErrorSource, RenderError, RenderResult, TerminalDrawer},
|
||||
ErrorSource, RenderError, RenderResult, TerminalDrawer, TerminalDrawerOptions,
|
||||
ascii_scaler::AsciiScaler,
|
||||
engine::{MaxSize, RenderEngine, RenderEngineOptions},
|
||||
operation::{Pollable, RenderAsyncStartPolicy, RenderOperation},
|
||||
properties::WindowSize,
|
||||
validate::OverflowValidator,
|
||||
},
|
||||
resource::Resources,
|
||||
theme::PresentationTheme,
|
||||
terminal::{
|
||||
image::printer::{ImagePrinter, ImageRegistry},
|
||||
printer::{TerminalCommand, TerminalIo},
|
||||
virt::{ImageBehavior, TerminalGrid, VirtualTerminal},
|
||||
},
|
||||
theme::{ProcessingThemeError, raw::PresentationTheme},
|
||||
third_party::ThirdPartyRender,
|
||||
transitions::{
|
||||
AnimateTransition, AnimationFrame, LinesFrame, TransitionDirection,
|
||||
collapse_horizontal::CollapseHorizontalAnimation, fade::FadeAnimation,
|
||||
slide_horizontal::SlideHorizontalAnimation,
|
||||
},
|
||||
};
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
fmt::Display,
|
||||
fs,
|
||||
io::{self, Stdout},
|
||||
io::{self},
|
||||
mem,
|
||||
ops::Deref,
|
||||
path::Path,
|
||||
rc::Rc,
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
pub struct PresenterOptions {
|
||||
@ -35,6 +51,8 @@ pub struct PresenterOptions {
|
||||
pub font_size_fallback: u8,
|
||||
pub bindings: KeyBindingsConfig,
|
||||
pub validate_overflows: bool,
|
||||
pub max_size: MaxSize,
|
||||
pub transition: Option<SlideTransitionConfig>,
|
||||
}
|
||||
|
||||
/// A slideshow presenter.
|
||||
@ -42,16 +60,17 @@ pub struct PresenterOptions {
|
||||
/// This type puts everything else together.
|
||||
pub struct Presenter<'a> {
|
||||
default_theme: &'a PresentationTheme,
|
||||
commands: CommandSource,
|
||||
listener: CommandListener,
|
||||
parser: MarkdownParser<'a>,
|
||||
resources: Resources,
|
||||
third_party: ThirdPartyRender,
|
||||
code_executor: Rc<SnippetExecutor>,
|
||||
code_executor: Arc<SnippetExecutor>,
|
||||
state: PresenterState,
|
||||
slides_with_pending_async_renders: HashSet<usize>,
|
||||
image_printer: Arc<ImagePrinter>,
|
||||
themes: Themes,
|
||||
options: PresenterOptions,
|
||||
speaker_notes_event_publisher: Option<SpeakerNotesEventPublisher>,
|
||||
poller: Poller,
|
||||
}
|
||||
|
||||
impl<'a> Presenter<'a> {
|
||||
@ -59,72 +78,137 @@ impl<'a> Presenter<'a> {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
default_theme: &'a PresentationTheme,
|
||||
commands: CommandSource,
|
||||
listener: CommandListener,
|
||||
parser: MarkdownParser<'a>,
|
||||
resources: Resources,
|
||||
third_party: ThirdPartyRender,
|
||||
code_executor: Rc<SnippetExecutor>,
|
||||
code_executor: Arc<SnippetExecutor>,
|
||||
themes: Themes,
|
||||
image_printer: Arc<ImagePrinter>,
|
||||
options: PresenterOptions,
|
||||
speaker_notes_event_publisher: Option<SpeakerNotesEventPublisher>,
|
||||
) -> Self {
|
||||
Self {
|
||||
default_theme,
|
||||
commands,
|
||||
listener,
|
||||
parser,
|
||||
resources,
|
||||
third_party,
|
||||
code_executor,
|
||||
state: PresenterState::Empty,
|
||||
slides_with_pending_async_renders: HashSet::new(),
|
||||
image_printer,
|
||||
themes,
|
||||
options,
|
||||
speaker_notes_event_publisher,
|
||||
poller: Poller::launch(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a presentation.
|
||||
pub fn present(mut self, path: &Path) -> Result<(), PresentationError> {
|
||||
if matches!(self.options.mode, PresentMode::Development) {
|
||||
self.resources.watch_presentation_file(path.to_path_buf());
|
||||
}
|
||||
self.state = PresenterState::Presenting(Presentation::from(vec![]));
|
||||
self.try_reload(path, true);
|
||||
self.try_reload(path, true)?;
|
||||
|
||||
let mut drawer =
|
||||
TerminalDrawer::new(io::stdout(), self.image_printer.clone(), self.options.font_size_fallback)?;
|
||||
let drawer_options = TerminalDrawerOptions {
|
||||
font_size_fallback: self.options.font_size_fallback,
|
||||
max_size: self.options.max_size.clone(),
|
||||
};
|
||||
let mut drawer = TerminalDrawer::new(self.image_printer.clone(), drawer_options)?;
|
||||
loop {
|
||||
if matches!(self.options.mode, PresentMode::Export) {
|
||||
if let PresenterState::Failure { error, .. } = &self.state {
|
||||
return Err(PresentationError::Fatal(format!("failed to run presentation: {error}")));
|
||||
}
|
||||
}
|
||||
// Poll async renders once before we draw just in case.
|
||||
self.poll_async_renders()?;
|
||||
self.render(&mut drawer)?;
|
||||
|
||||
loop {
|
||||
if self.poll_async_renders()? {
|
||||
if self.process_poller_effects()? {
|
||||
self.render(&mut drawer)?;
|
||||
}
|
||||
let Some(command) = self.commands.try_next_command()? else {
|
||||
if self.check_async_error() {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
|
||||
let command = match self.listener.try_next_command()? {
|
||||
Some(command) => command,
|
||||
_ => match self.resources.resources_modified() {
|
||||
true => Command::Reload,
|
||||
false => {
|
||||
if self.check_async_error() {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
},
|
||||
};
|
||||
match self.apply_command(command) {
|
||||
CommandSideEffect::Exit => return Ok(()),
|
||||
CommandSideEffect::Exit => {
|
||||
self.publish_event(SpeakerNotesEvent::Exit)?;
|
||||
return Ok(());
|
||||
}
|
||||
CommandSideEffect::Suspend => {
|
||||
self.suspend(&mut drawer);
|
||||
break;
|
||||
}
|
||||
CommandSideEffect::Reload => {
|
||||
self.try_reload(path, false);
|
||||
self.try_reload(path, false)?;
|
||||
break;
|
||||
}
|
||||
CommandSideEffect::Redraw => {
|
||||
self.try_scale_transition_images()?;
|
||||
break;
|
||||
}
|
||||
CommandSideEffect::AnimateNextSlide => {
|
||||
self.animate_next_slide(&mut drawer)?;
|
||||
break;
|
||||
}
|
||||
CommandSideEffect::AnimatePreviousSlide => {
|
||||
self.animate_previous_slide(&mut drawer)?;
|
||||
break;
|
||||
}
|
||||
CommandSideEffect::None => (),
|
||||
};
|
||||
}
|
||||
self.publish_event(SpeakerNotesEvent::GoToSlide {
|
||||
slide: self.state.presentation().current_slide_index() as u32 + 1,
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
fn process_poller_effects(&mut self) -> Result<bool, PresentationError> {
|
||||
let current_slide = match &self.state {
|
||||
PresenterState::Presenting(presentation)
|
||||
| PresenterState::SlideIndex(presentation)
|
||||
| PresenterState::KeyBindings(presentation)
|
||||
| PresenterState::Failure { presentation, .. } => presentation.current_slide_index(),
|
||||
PresenterState::Empty => usize::MAX,
|
||||
};
|
||||
let mut refreshed = false;
|
||||
let mut needs_render = false;
|
||||
while let Some(effect) = self.poller.next_effect() {
|
||||
match effect {
|
||||
PollableEffect::RefreshSlide(index) => {
|
||||
needs_render = needs_render || index == current_slide;
|
||||
refreshed = true;
|
||||
}
|
||||
PollableEffect::DisplayError { slide, error } => {
|
||||
let presentation = mem::take(&mut self.state).into_presentation();
|
||||
self.state =
|
||||
PresenterState::failure(error, presentation, ErrorSource::Slide(slide + 1), FailureMode::Other);
|
||||
needs_render = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if refreshed {
|
||||
self.try_scale_transition_images()?;
|
||||
}
|
||||
Ok(needs_render)
|
||||
}
|
||||
|
||||
fn publish_event(&self, event: SpeakerNotesEvent) -> io::Result<()> {
|
||||
if let Some(publisher) = &self.speaker_notes_event_publisher {
|
||||
publisher.send(event)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_async_error(&mut self) -> bool {
|
||||
let error_holder = self.state.presentation().state.async_error_holder();
|
||||
let error_holder = error_holder.lock().unwrap();
|
||||
@ -143,37 +227,18 @@ impl<'a> Presenter<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_async_renders(&mut self) -> Result<bool, RenderError> {
|
||||
if matches!(self.state, PresenterState::Failure { .. }) {
|
||||
return Ok(false);
|
||||
}
|
||||
let current_index = self.state.presentation().current_slide_index();
|
||||
if self.slides_with_pending_async_renders.contains(¤t_index) {
|
||||
let state = self.state.presentation_mut().poll_slide_async_renders();
|
||||
match state {
|
||||
RenderAsyncState::NotStarted | RenderAsyncState::Rendering { modified: false } => (),
|
||||
RenderAsyncState::Rendering { modified: true } => {
|
||||
return Ok(true);
|
||||
}
|
||||
RenderAsyncState::Rendered | RenderAsyncState::JustFinishedRendering => {
|
||||
self.slides_with_pending_async_renders.remove(¤t_index);
|
||||
return Ok(true);
|
||||
}
|
||||
};
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn render(&mut self, drawer: &mut TerminalDrawer<Stdout>) -> RenderResult {
|
||||
fn render(&mut self, drawer: &mut TerminalDrawer) -> RenderResult {
|
||||
let result = match &self.state {
|
||||
PresenterState::Presenting(presentation) => drawer.render_slide(presentation),
|
||||
PresenterState::Presenting(presentation) => {
|
||||
drawer.render_operations(presentation.current_slide().iter_visible_operations())
|
||||
}
|
||||
PresenterState::SlideIndex(presentation) => {
|
||||
drawer.render_slide(presentation)?;
|
||||
drawer.render_slide_index(presentation)
|
||||
drawer.render_operations(presentation.current_slide().iter_visible_operations())?;
|
||||
drawer.render_operations(presentation.iter_slide_index_operations())
|
||||
}
|
||||
PresenterState::KeyBindings(presentation) => {
|
||||
drawer.render_slide(presentation)?;
|
||||
drawer.render_key_bindings(presentation)
|
||||
drawer.render_operations(presentation.current_slide().iter_visible_operations())?;
|
||||
drawer.render_operations(presentation.iter_bindings_operations())
|
||||
}
|
||||
PresenterState::Failure { error, source, .. } => drawer.render_error(error, source),
|
||||
PresenterState::Empty => panic!("cannot render without state"),
|
||||
@ -196,6 +261,7 @@ impl<'a> Presenter<'a> {
|
||||
return CommandSideEffect::Reload;
|
||||
}
|
||||
Command::Exit => return CommandSideEffect::Exit,
|
||||
Command::Suspend => return CommandSideEffect::Suspend,
|
||||
_ => (),
|
||||
};
|
||||
if matches!(command, Command::Redraw) {
|
||||
@ -216,16 +282,37 @@ impl<'a> Presenter<'a> {
|
||||
}
|
||||
};
|
||||
let needs_redraw = match command {
|
||||
Command::Next => presentation.jump_next(),
|
||||
Command::Next => {
|
||||
let current_slide = presentation.current_slide_index();
|
||||
if !presentation.jump_next() {
|
||||
false
|
||||
} else if presentation.current_slide_index() != current_slide {
|
||||
return CommandSideEffect::AnimateNextSlide;
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
Command::NextFast => presentation.jump_next_fast(),
|
||||
Command::Previous => presentation.jump_previous(),
|
||||
Command::Previous => {
|
||||
let current_slide = presentation.current_slide_index();
|
||||
if !presentation.jump_previous() {
|
||||
false
|
||||
} else if presentation.current_slide_index() != current_slide {
|
||||
return CommandSideEffect::AnimatePreviousSlide;
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
Command::PreviousFast => presentation.jump_previous_fast(),
|
||||
Command::FirstSlide => presentation.jump_first_slide(),
|
||||
Command::LastSlide => presentation.jump_last_slide(),
|
||||
Command::GoToSlide(number) => presentation.go_to_slide(number.saturating_sub(1) as usize),
|
||||
Command::RenderAsyncOperations => {
|
||||
if presentation.trigger_slide_async_renders() {
|
||||
self.slides_with_pending_async_renders.insert(self.state.presentation().current_slide_index());
|
||||
let pollables = Self::trigger_slide_async_renders(presentation);
|
||||
if !pollables.is_empty() {
|
||||
for pollable in pollables {
|
||||
self.poller.send(PollerCommand::Poll { pollable, slide: presentation.current_slide_index() });
|
||||
}
|
||||
return CommandSideEffect::Redraw;
|
||||
} else {
|
||||
return CommandSideEffect::None;
|
||||
@ -245,18 +332,19 @@ impl<'a> Presenter<'a> {
|
||||
true
|
||||
}
|
||||
// These are handled above as they don't require the presentation
|
||||
Command::Reload | Command::HardReload | Command::Exit | Command::Redraw => {
|
||||
Command::Reload | Command::HardReload | Command::Exit | Command::Suspend | Command::Redraw => {
|
||||
panic!("unreachable commands")
|
||||
}
|
||||
};
|
||||
if needs_redraw { CommandSideEffect::Redraw } else { CommandSideEffect::None }
|
||||
}
|
||||
|
||||
fn try_reload(&mut self, path: &Path, force: bool) {
|
||||
fn try_reload(&mut self, path: &Path, force: bool) -> RenderResult {
|
||||
if matches!(self.options.mode, PresentMode::Presentation) && !force {
|
||||
return;
|
||||
return Ok(());
|
||||
}
|
||||
self.slides_with_pending_async_renders.clear();
|
||||
self.poller.send(PollerCommand::Reset);
|
||||
self.resources.clear_watches();
|
||||
match self.load_presentation(path) {
|
||||
Ok(mut presentation) => {
|
||||
let current = self.state.presentation();
|
||||
@ -267,21 +355,40 @@ impl<'a> Presenter<'a> {
|
||||
presentation.go_to_slide(current.current_slide_index());
|
||||
presentation.jump_chunk(current.current_chunk());
|
||||
}
|
||||
self.slides_with_pending_async_renders = match self.options.mode {
|
||||
PresentMode::Development | PresentMode::Presentation => {
|
||||
presentation.slides_with_async_renders().into_iter().collect()
|
||||
}
|
||||
// Trigger all async renders so we get snippet execution output in the PDF
|
||||
// file.
|
||||
PresentMode::Export => presentation.trigger_all_async_renders(),
|
||||
};
|
||||
self.start_automatic_async_renders(&mut presentation);
|
||||
self.state = self.validate_overflows(presentation);
|
||||
self.try_scale_transition_images()?;
|
||||
}
|
||||
Err(e) => {
|
||||
let presentation = mem::take(&mut self.state).into_presentation();
|
||||
self.state = PresenterState::failure(e, presentation, ErrorSource::Presentation, FailureMode::Other);
|
||||
}
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn try_scale_transition_images(&self) -> RenderResult {
|
||||
if self.options.transition.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let options = RenderEngineOptions { max_size: self.options.max_size.clone(), ..Default::default() };
|
||||
let scaler = AsciiScaler::new(options);
|
||||
let dimensions = WindowSize::current(self.options.font_size_fallback)?;
|
||||
scaler.process(self.state.presentation(), &dimensions)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn trigger_slide_async_renders(presentation: &mut Presentation) -> Vec<Box<dyn Pollable>> {
|
||||
let slide = presentation.current_slide_mut();
|
||||
let mut pollables = Vec::new();
|
||||
for operation in slide.iter_visible_operations_mut() {
|
||||
if let RenderOperation::RenderAsync(operation) = operation {
|
||||
if let RenderAsyncStartPolicy::OnDemand = operation.start_policy() {
|
||||
pollables.push(operation.pollable());
|
||||
}
|
||||
}
|
||||
}
|
||||
pollables
|
||||
}
|
||||
|
||||
fn is_displaying_other_error(&self) -> bool {
|
||||
@ -308,22 +415,17 @@ impl<'a> Presenter<'a> {
|
||||
fn load_presentation(&mut self, path: &Path) -> Result<Presentation, LoadPresentationError> {
|
||||
let content = fs::read_to_string(path).map_err(LoadPresentationError::Reading)?;
|
||||
let elements = self.parser.parse(&content)?;
|
||||
let export_mode = matches!(self.options.mode, PresentMode::Export);
|
||||
let mut presentation = PresentationBuilder::new(
|
||||
let presentation = PresentationBuilder::new(
|
||||
self.default_theme,
|
||||
&mut self.resources,
|
||||
self.resources.clone(),
|
||||
&mut self.third_party,
|
||||
self.code_executor.clone(),
|
||||
&self.themes,
|
||||
ImageRegistry(self.image_printer.clone()),
|
||||
ImageRegistry::new(self.image_printer.clone()),
|
||||
self.options.bindings.clone(),
|
||||
self.options.builder_options.clone(),
|
||||
)
|
||||
)?
|
||||
.build(elements)?;
|
||||
if export_mode {
|
||||
ImageReplacer::default().replace_presentation_images(&mut presentation);
|
||||
}
|
||||
|
||||
Ok(presentation)
|
||||
}
|
||||
|
||||
@ -348,12 +450,158 @@ impl<'a> Presenter<'a> {
|
||||
other => self.state = other,
|
||||
}
|
||||
}
|
||||
|
||||
fn suspend(&self, drawer: &mut TerminalDrawer) {
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
drawer.terminal.suspend();
|
||||
libc::raise(libc::SIGTSTP);
|
||||
drawer.terminal.resume();
|
||||
}
|
||||
}
|
||||
|
||||
fn animate_next_slide(&mut self, drawer: &mut TerminalDrawer) -> RenderResult {
|
||||
let Some(config) = self.options.transition.clone() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let options = drawer.render_engine_options();
|
||||
let presentation = self.state.presentation_mut();
|
||||
let dimensions = WindowSize::current(self.options.font_size_fallback)?;
|
||||
presentation.jump_previous();
|
||||
let left = Self::virtual_render(presentation.current_slide(), dimensions.clone(), &options)?;
|
||||
presentation.jump_next();
|
||||
let right = Self::virtual_render(presentation.current_slide(), dimensions.clone(), &options)?;
|
||||
let direction = TransitionDirection::Next;
|
||||
self.animate_transition(drawer, left, right, direction, dimensions, config)
|
||||
}
|
||||
|
||||
fn animate_previous_slide(&mut self, drawer: &mut TerminalDrawer) -> RenderResult {
|
||||
let Some(config) = self.options.transition.clone() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let options = drawer.render_engine_options();
|
||||
let presentation = self.state.presentation_mut();
|
||||
let dimensions = WindowSize::current(self.options.font_size_fallback)?;
|
||||
presentation.jump_next();
|
||||
|
||||
// Re-borrow to avoid calling fns above while mutably borrowing
|
||||
let presentation = self.state.presentation_mut();
|
||||
|
||||
let right = Self::virtual_render(presentation.current_slide(), dimensions.clone(), &options)?;
|
||||
presentation.jump_previous();
|
||||
let left = Self::virtual_render(presentation.current_slide(), dimensions.clone(), &options)?;
|
||||
let direction = TransitionDirection::Previous;
|
||||
self.animate_transition(drawer, left, right, direction, dimensions, config)
|
||||
}
|
||||
|
||||
fn animate_transition(
|
||||
&mut self,
|
||||
drawer: &mut TerminalDrawer,
|
||||
left: TerminalGrid,
|
||||
right: TerminalGrid,
|
||||
direction: TransitionDirection,
|
||||
dimensions: WindowSize,
|
||||
config: SlideTransitionConfig,
|
||||
) -> RenderResult {
|
||||
let first = match &direction {
|
||||
TransitionDirection::Next => left.clone(),
|
||||
TransitionDirection::Previous => right.clone(),
|
||||
};
|
||||
match &config.animation {
|
||||
SlideTransitionStyleConfig::SlideHorizontal => self.run_animation(
|
||||
drawer,
|
||||
first,
|
||||
SlideHorizontalAnimation::new(left, right, dimensions, direction),
|
||||
config,
|
||||
),
|
||||
SlideTransitionStyleConfig::Fade => {
|
||||
self.run_animation(drawer, first, FadeAnimation::new(left, right, direction), config)
|
||||
}
|
||||
SlideTransitionStyleConfig::CollapseHorizontal => {
|
||||
self.run_animation(drawer, first, CollapseHorizontalAnimation::new(left, right, direction), config)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_animation<T>(
|
||||
&mut self,
|
||||
drawer: &mut TerminalDrawer,
|
||||
first: TerminalGrid,
|
||||
animation: T,
|
||||
config: SlideTransitionConfig,
|
||||
) -> RenderResult
|
||||
where
|
||||
T: AnimateTransition,
|
||||
{
|
||||
let total_time = Duration::from_millis(config.duration_millis as u64);
|
||||
let frames: usize = config.frames;
|
||||
let total_frames = animation.total_frames();
|
||||
let step = total_time / (frames as u32 * 2);
|
||||
let mut last_frame_index = 0;
|
||||
let mut frame_index = 1;
|
||||
// Render the first frame as text to have images as ascii
|
||||
Self::render_frame(&LinesFrame::from(&first).build_commands(), drawer)?;
|
||||
while frame_index < total_frames {
|
||||
let start = Instant::now();
|
||||
let frame = animation.build_frame(frame_index, last_frame_index);
|
||||
let commands = frame.build_commands();
|
||||
Self::render_frame(&commands, drawer)?;
|
||||
|
||||
let elapsed = start.elapsed();
|
||||
let sleep_needed = step.saturating_sub(elapsed);
|
||||
if sleep_needed.as_millis() > 0 {
|
||||
std::thread::sleep(step);
|
||||
}
|
||||
last_frame_index = frame_index;
|
||||
frame_index += total_frames.div_ceil(frames);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_frame(commands: &[TerminalCommand<'_>], drawer: &mut TerminalDrawer) -> RenderResult {
|
||||
drawer.terminal.execute(&TerminalCommand::BeginUpdate)?;
|
||||
for command in commands {
|
||||
drawer.terminal.execute(command)?;
|
||||
}
|
||||
drawer.terminal.execute(&TerminalCommand::EndUpdate)?;
|
||||
drawer.terminal.execute(&TerminalCommand::Flush)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn virtual_render(
|
||||
slide: &Slide,
|
||||
dimensions: WindowSize,
|
||||
options: &RenderEngineOptions,
|
||||
) -> Result<TerminalGrid, RenderError> {
|
||||
let mut term = VirtualTerminal::new(dimensions.clone(), ImageBehavior::PrintAscii);
|
||||
let engine = RenderEngine::new(&mut term, dimensions.clone(), options.clone());
|
||||
engine.render(slide.iter_visible_operations())?;
|
||||
Ok(term.into_contents())
|
||||
}
|
||||
|
||||
fn start_automatic_async_renders(&self, presentation: &mut Presentation) {
|
||||
for (index, slide) in presentation.iter_slides_mut().enumerate() {
|
||||
for operation in slide.iter_operations_mut() {
|
||||
if let RenderOperation::RenderAsync(operation) = operation {
|
||||
if let RenderAsyncStartPolicy::Automatic = operation.start_policy() {
|
||||
let pollable = operation.pollable();
|
||||
self.poller.send(PollerCommand::Poll { pollable, slide: index });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum CommandSideEffect {
|
||||
Exit,
|
||||
Suspend,
|
||||
Redraw,
|
||||
Reload,
|
||||
AnimateNextSlide,
|
||||
AnimatePreviousSlide,
|
||||
None,
|
||||
}
|
||||
|
||||
@ -425,9 +673,6 @@ pub enum PresentMode {
|
||||
|
||||
/// This is a live presentation so we don't want hot reloading.
|
||||
Presentation,
|
||||
|
||||
/// We are running a presentation that's being consumed by `presenterm-export`.
|
||||
Export,
|
||||
}
|
||||
|
||||
/// An error when loading a presentation.
|
||||
@ -441,6 +686,9 @@ pub enum LoadPresentationError {
|
||||
|
||||
#[error(transparent)]
|
||||
Processing(#[from] BuildError),
|
||||
|
||||
#[error("processing theme: {0}")]
|
||||
ProcessingTheme(#[from] ProcessingThemeError),
|
||||
}
|
||||
|
||||
/// An error during the presentation.
|
||||
@ -451,7 +699,4 @@ pub enum PresentationError {
|
||||
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] io::Error),
|
||||
|
||||
#[error("fatal error: {0}")]
|
||||
Fatal(String),
|
||||
}
|
||||
|
@ -1,218 +0,0 @@
|
||||
use super::padding::NumberPadder;
|
||||
use crate::{
|
||||
markdown::elements::{HighlightGroup, Snippet},
|
||||
presentation::{AsRenderOperations, BlockLine, BlockLineText, ChunkMutator, RenderOperation},
|
||||
render::{
|
||||
highlighting::{LanguageHighlighter, StyledTokens},
|
||||
properties::WindowSize,
|
||||
},
|
||||
theme::{Alignment, CodeBlockStyle},
|
||||
PresentationTheme,
|
||||
};
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
use syntect::highlighting::Style;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
pub(crate) struct CodePreparer<'a> {
|
||||
theme: &'a PresentationTheme,
|
||||
}
|
||||
|
||||
impl<'a> CodePreparer<'a> {
|
||||
pub(crate) fn new(theme: &'a PresentationTheme) -> Self {
|
||||
Self { theme }
|
||||
}
|
||||
|
||||
pub(crate) fn prepare(&self, code: &Snippet) -> Vec<CodeLine> {
|
||||
let mut lines = Vec::new();
|
||||
let horizontal_padding = self.theme.code.padding.horizontal.unwrap_or(0);
|
||||
let vertical_padding = self.theme.code.padding.vertical.unwrap_or(0);
|
||||
if vertical_padding > 0 {
|
||||
lines.push(CodeLine::empty());
|
||||
}
|
||||
self.push_lines(code, horizontal_padding, &mut lines);
|
||||
if vertical_padding > 0 {
|
||||
lines.push(CodeLine::empty());
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
fn push_lines(&self, code: &Snippet, horizontal_padding: u8, lines: &mut Vec<CodeLine>) {
|
||||
if code.contents.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let padding = " ".repeat(horizontal_padding as usize);
|
||||
let padder = NumberPadder::new(code.visible_lines().count());
|
||||
for (index, line) in code.visible_lines().enumerate() {
|
||||
let mut line = line.to_string();
|
||||
let mut prefix = padding.clone();
|
||||
if code.attributes.line_numbers {
|
||||
let line_number = index + 1;
|
||||
prefix.push_str(&padder.pad_right(line_number));
|
||||
prefix.push(' ');
|
||||
}
|
||||
line.push('\n');
|
||||
let line_number = Some(index as u16 + 1);
|
||||
lines.push(CodeLine { prefix, code: line, suffix: padding.clone(), line_number });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct CodeLine {
|
||||
pub(crate) prefix: String,
|
||||
pub(crate) code: String,
|
||||
pub(crate) suffix: String,
|
||||
pub(crate) line_number: Option<u16>,
|
||||
}
|
||||
|
||||
impl CodeLine {
|
||||
pub(crate) fn empty() -> Self {
|
||||
Self { prefix: String::new(), code: "\n".into(), suffix: String::new(), line_number: None }
|
||||
}
|
||||
|
||||
pub(crate) fn width(&self) -> usize {
|
||||
self.prefix.width() + self.code.width() + self.suffix.width()
|
||||
}
|
||||
|
||||
pub(crate) fn highlight(
|
||||
&self,
|
||||
dim_style: &Style,
|
||||
code_highlighter: &mut LanguageHighlighter,
|
||||
block_style: &CodeBlockStyle,
|
||||
) -> String {
|
||||
let mut output = StyledTokens { style: *dim_style, tokens: &self.prefix }.apply_style(block_style);
|
||||
output.push_str(&code_highlighter.highlight_line(&self.code, block_style));
|
||||
output.push_str(&StyledTokens { style: *dim_style, tokens: &self.suffix }.apply_style(block_style));
|
||||
output
|
||||
}
|
||||
|
||||
pub(crate) fn dim(&self, padding_style: &Style, block_style: &CodeBlockStyle) -> String {
|
||||
let mut output = String::new();
|
||||
for chunk in [&self.prefix, &self.code, &self.suffix] {
|
||||
output.push_str(&StyledTokens { style: *padding_style, tokens: chunk }.apply_style(block_style));
|
||||
}
|
||||
output
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct HighlightContext {
|
||||
pub(crate) groups: Vec<HighlightGroup>,
|
||||
pub(crate) current: usize,
|
||||
pub(crate) block_length: usize,
|
||||
pub(crate) alignment: Alignment,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct HighlightedLine {
|
||||
pub(crate) highlighted: String,
|
||||
pub(crate) not_highlighted: String,
|
||||
pub(crate) line_number: Option<u16>,
|
||||
pub(crate) width: usize,
|
||||
pub(crate) context: Rc<RefCell<HighlightContext>>,
|
||||
}
|
||||
|
||||
impl AsRenderOperations for HighlightedLine {
|
||||
fn as_render_operations(&self, _: &WindowSize) -> Vec<RenderOperation> {
|
||||
let context = self.context.borrow();
|
||||
let group = &context.groups[context.current];
|
||||
let needs_highlight = self.line_number.map(|number| group.contains(number)).unwrap_or_default();
|
||||
// TODO: Cow<str>?
|
||||
let text = match needs_highlight {
|
||||
true => self.highlighted.clone(),
|
||||
false => self.not_highlighted.clone(),
|
||||
};
|
||||
vec![
|
||||
RenderOperation::RenderBlockLine(BlockLine {
|
||||
text: BlockLineText::Preformatted(text),
|
||||
unformatted_length: self.width as u16,
|
||||
block_length: context.block_length as u16,
|
||||
alignment: context.alignment.clone(),
|
||||
}),
|
||||
RenderOperation::RenderLineBreak,
|
||||
]
|
||||
}
|
||||
|
||||
fn diffable_content(&self) -> Option<&str> {
|
||||
Some(&self.highlighted)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct HighlightMutator {
|
||||
context: Rc<RefCell<HighlightContext>>,
|
||||
}
|
||||
|
||||
impl HighlightMutator {
|
||||
pub(crate) fn new(context: Rc<RefCell<HighlightContext>>) -> Self {
|
||||
Self { context }
|
||||
}
|
||||
}
|
||||
|
||||
impl ChunkMutator for HighlightMutator {
|
||||
fn mutate_next(&self) -> bool {
|
||||
let mut context = self.context.borrow_mut();
|
||||
if context.current == context.groups.len() - 1 {
|
||||
false
|
||||
} else {
|
||||
context.current += 1;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn mutate_previous(&self) -> bool {
|
||||
let mut context = self.context.borrow_mut();
|
||||
if context.current == 0 {
|
||||
false
|
||||
} else {
|
||||
context.current -= 1;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn reset_mutations(&self) {
|
||||
self.context.borrow_mut().current = 0;
|
||||
}
|
||||
|
||||
fn apply_all_mutations(&self) {
|
||||
let mut context = self.context.borrow_mut();
|
||||
context.current = context.groups.len() - 1;
|
||||
}
|
||||
|
||||
fn mutations(&self) -> (usize, usize) {
|
||||
let context = self.context.borrow();
|
||||
(context.current, context.groups.len())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use crate::markdown::elements::{SnippetAttributes, SnippetLanguage};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn code_with_line_numbers() {
|
||||
let total_lines = 11;
|
||||
let input_lines = "hi\n".repeat(total_lines);
|
||||
let code = Snippet {
|
||||
contents: input_lines,
|
||||
language: SnippetLanguage::Unknown("".to_string()),
|
||||
attributes: SnippetAttributes { line_numbers: true, ..Default::default() },
|
||||
};
|
||||
let lines = CodePreparer { theme: &Default::default() }.prepare(&code);
|
||||
assert_eq!(lines.len(), total_lines);
|
||||
|
||||
let mut lines = lines.into_iter().enumerate();
|
||||
// 0..=9
|
||||
for (index, line) in lines.by_ref().take(9) {
|
||||
let line_number = index + 1;
|
||||
assert_eq!(&line.prefix, &format!(" {line_number} "));
|
||||
}
|
||||
// 10..
|
||||
for (index, line) in lines {
|
||||
let line_number = index + 1;
|
||||
assert_eq!(&line.prefix, &format!("{line_number} "));
|
||||
}
|
||||
}
|
||||
}
|
@ -1,225 +0,0 @@
|
||||
use super::separator::{RenderSeparator, SeparatorWidth};
|
||||
use crate::{
|
||||
execute::{ExecutionHandle, ExecutionState, ProcessStatus, SnippetExecutor},
|
||||
markdown::elements::{Snippet, Text, TextBlock},
|
||||
presentation::{AsRenderOperations, BlockLine, BlockLineText, RenderAsync, RenderAsyncState, RenderOperation},
|
||||
render::properties::WindowSize,
|
||||
style::{Colors, TextStyle},
|
||||
theme::{Alignment, ExecutionStatusBlockStyle},
|
||||
PresentationTheme,
|
||||
};
|
||||
use itertools::Itertools;
|
||||
use std::{cell::RefCell, mem, rc::Rc};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct RunSnippetOperationInner {
|
||||
handle: Option<ExecutionHandle>,
|
||||
output_lines: Vec<String>,
|
||||
state: RenderAsyncState,
|
||||
max_line_length: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct RunSnippetOperation {
|
||||
code: Snippet,
|
||||
executor: Rc<SnippetExecutor>,
|
||||
default_colors: Colors,
|
||||
block_colors: Colors,
|
||||
status_colors: ExecutionStatusBlockStyle,
|
||||
block_length: u16,
|
||||
alignment: Alignment,
|
||||
inner: Rc<RefCell<RunSnippetOperationInner>>,
|
||||
state_description: RefCell<Text>,
|
||||
}
|
||||
|
||||
impl RunSnippetOperation {
|
||||
pub(crate) fn new(
|
||||
code: Snippet,
|
||||
executor: Rc<SnippetExecutor>,
|
||||
theme: &PresentationTheme,
|
||||
block_length: u16,
|
||||
) -> Self {
|
||||
let default_colors = theme.default_style.colors.clone();
|
||||
let block_colors = theme.execution_output.colors.clone();
|
||||
let status_colors = theme.execution_output.status.clone();
|
||||
let running_colors = status_colors.running.clone();
|
||||
let alignment = theme.code.alignment.clone().unwrap_or_default();
|
||||
let block_length = match &alignment {
|
||||
Alignment::Left { .. } | Alignment::Right { .. } => block_length,
|
||||
Alignment::Center { minimum_size, .. } => block_length.max(*minimum_size),
|
||||
};
|
||||
let inner = RunSnippetOperationInner {
|
||||
handle: None,
|
||||
output_lines: Vec::new(),
|
||||
state: RenderAsyncState::default(),
|
||||
max_line_length: 0,
|
||||
};
|
||||
Self {
|
||||
code,
|
||||
executor,
|
||||
default_colors,
|
||||
block_colors,
|
||||
status_colors,
|
||||
block_length,
|
||||
alignment,
|
||||
inner: Rc::new(RefCell::new(inner)),
|
||||
state_description: Text::new("running", TextStyle::default().colors(running_colors)).into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_line(&self, line: String, block_length: u16) -> RenderOperation {
|
||||
let line_len = line.width() as u16;
|
||||
RenderOperation::RenderBlockLine(BlockLine {
|
||||
text: BlockLineText::Preformatted(line),
|
||||
unformatted_length: line_len,
|
||||
block_length,
|
||||
alignment: self.alignment.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRenderOperations for RunSnippetOperation {
|
||||
fn as_render_operations(&self, dimensions: &WindowSize) -> Vec<RenderOperation> {
|
||||
let inner = self.inner.borrow();
|
||||
if matches!(inner.state, RenderAsyncState::NotStarted) {
|
||||
return Vec::new();
|
||||
}
|
||||
let description = self.state_description.borrow();
|
||||
let heading = TextBlock(vec![" [".into(), description.clone(), "] ".into()]);
|
||||
let separator_width = match &self.alignment {
|
||||
Alignment::Left { .. } | Alignment::Right { .. } => SeparatorWidth::FitToWindow,
|
||||
Alignment::Center { .. } => SeparatorWidth::Fixed(self.block_length),
|
||||
};
|
||||
let separator = RenderSeparator::new(heading, separator_width);
|
||||
let mut operations = vec![
|
||||
RenderOperation::RenderLineBreak,
|
||||
RenderOperation::RenderDynamic(Rc::new(separator)),
|
||||
RenderOperation::RenderLineBreak,
|
||||
RenderOperation::RenderLineBreak,
|
||||
RenderOperation::SetColors(self.block_colors.clone()),
|
||||
];
|
||||
|
||||
let block_length = self.block_length.max(inner.max_line_length.saturating_add(1));
|
||||
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(), block_length));
|
||||
operations.push(RenderOperation::RenderLineBreak);
|
||||
}
|
||||
}
|
||||
operations.push(RenderOperation::SetColors(self.default_colors.clone()));
|
||||
operations
|
||||
}
|
||||
|
||||
fn diffable_content(&self) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderAsync for RunSnippetOperation {
|
||||
fn poll_state(&self) -> RenderAsyncState {
|
||||
let mut inner = self.inner.borrow_mut();
|
||||
if let Some(handle) = inner.handle.as_mut() {
|
||||
let mut state = handle.state.lock().unwrap();
|
||||
let ExecutionState { output, status } = &mut *state;
|
||||
*self.state_description.borrow_mut() = match status {
|
||||
ProcessStatus::Running => {
|
||||
Text::new("running", TextStyle::default().colors(self.status_colors.running.clone()))
|
||||
}
|
||||
ProcessStatus::Success => {
|
||||
Text::new("finished", TextStyle::default().colors(self.status_colors.success.clone()))
|
||||
}
|
||||
ProcessStatus::Failure => {
|
||||
Text::new("finished with error", TextStyle::default().colors(self.status_colors.failure.clone()))
|
||||
}
|
||||
};
|
||||
let new_lines = mem::take(output);
|
||||
let modified = !new_lines.is_empty();
|
||||
let is_finished = status.is_finished();
|
||||
drop(state);
|
||||
|
||||
let mut max_line_length = 0;
|
||||
for line in &new_lines {
|
||||
let width = u16::try_from(line.width()).unwrap_or(u16::MAX);
|
||||
max_line_length = max_line_length.max(width);
|
||||
}
|
||||
if is_finished {
|
||||
inner.handle.take();
|
||||
inner.state = RenderAsyncState::JustFinishedRendering;
|
||||
} else {
|
||||
inner.state = RenderAsyncState::Rendering { modified };
|
||||
}
|
||||
inner.output_lines.extend(new_lines);
|
||||
inner.max_line_length = inner.max_line_length.max(max_line_length);
|
||||
}
|
||||
inner.state.clone()
|
||||
}
|
||||
|
||||
fn start_render(&self) -> bool {
|
||||
let mut inner = self.inner.borrow_mut();
|
||||
if !matches!(inner.state, RenderAsyncState::NotStarted) {
|
||||
return false;
|
||||
}
|
||||
match self.executor.execute(&self.code) {
|
||||
Ok(handle) => {
|
||||
inner.handle = Some(handle);
|
||||
inner.state = RenderAsyncState::Rendering { modified: false };
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
inner.output_lines = vec![e.to_string()];
|
||||
inner.state = RenderAsyncState::Rendered;
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct SnippetExecutionDisabledOperation {
|
||||
colors: Colors,
|
||||
alignment: Alignment,
|
||||
started: RefCell<bool>,
|
||||
}
|
||||
|
||||
impl SnippetExecutionDisabledOperation {
|
||||
pub(crate) fn new(colors: Colors, alignment: Alignment) -> Self {
|
||||
Self { colors, alignment, started: Default::default() }
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRenderOperations for SnippetExecutionDisabledOperation {
|
||||
fn as_render_operations(&self, _: &WindowSize) -> Vec<RenderOperation> {
|
||||
if !*self.started.borrow() {
|
||||
return Vec::new();
|
||||
}
|
||||
vec![
|
||||
RenderOperation::RenderLineBreak,
|
||||
RenderOperation::RenderText {
|
||||
line: vec![Text::new(
|
||||
"snippet execution is disabled",
|
||||
TextStyle::default().colors(self.colors.clone()),
|
||||
)]
|
||||
.into(),
|
||||
alignment: self.alignment.clone(),
|
||||
},
|
||||
RenderOperation::RenderLineBreak,
|
||||
]
|
||||
}
|
||||
|
||||
fn diffable_content(&self) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderAsync for SnippetExecutionDisabledOperation {
|
||||
fn start_render(&self) -> bool {
|
||||
let was_started = mem::replace(&mut *self.started.borrow_mut(), true);
|
||||
!was_started
|
||||
}
|
||||
|
||||
fn poll_state(&self) -> RenderAsyncState {
|
||||
RenderAsyncState::Rendered
|
||||
}
|
||||
}
|
@ -1,90 +0,0 @@
|
||||
use crate::{
|
||||
markdown::elements::Text,
|
||||
presentation::{AsRenderOperations, RenderOperation},
|
||||
render::properties::WindowSize,
|
||||
style::{Colors, TextStyle},
|
||||
theme::{Alignment, FooterStyle, Margin},
|
||||
};
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct FooterContext {
|
||||
pub(crate) total_slides: usize,
|
||||
pub(crate) author: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct FooterGenerator {
|
||||
pub(crate) current_slide: usize,
|
||||
pub(crate) context: Rc<RefCell<FooterContext>>,
|
||||
pub(crate) style: FooterStyle,
|
||||
}
|
||||
|
||||
impl FooterGenerator {
|
||||
fn render_template(
|
||||
template: &str,
|
||||
current_slide: &str,
|
||||
context: &FooterContext,
|
||||
colors: Colors,
|
||||
alignment: Alignment,
|
||||
) -> RenderOperation {
|
||||
let contents = template
|
||||
.replace("{current_slide}", current_slide)
|
||||
.replace("{total_slides}", &context.total_slides.to_string())
|
||||
.replace("{author}", &context.author);
|
||||
let text = Text::new(contents, TextStyle::default().colors(colors));
|
||||
RenderOperation::RenderText { line: vec![text].into(), alignment }
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRenderOperations for FooterGenerator {
|
||||
fn as_render_operations(&self, dimensions: &WindowSize) -> Vec<RenderOperation> {
|
||||
let context = self.context.borrow();
|
||||
match &self.style {
|
||||
FooterStyle::Template { left, center, right, colors } => {
|
||||
let current_slide = (self.current_slide + 1).to_string();
|
||||
// We print this one row below the bottom so there's one row of padding.
|
||||
let mut operations = vec![RenderOperation::JumpToBottomRow { index: 1 }];
|
||||
let margin = Margin::Fixed(1);
|
||||
let alignments = [
|
||||
Alignment::Left { margin: margin.clone() },
|
||||
Alignment::Center { minimum_size: 0, minimum_margin: margin.clone() },
|
||||
Alignment::Right { margin: margin.clone() },
|
||||
];
|
||||
for (text, alignment) in [left, center, right].iter().zip(alignments) {
|
||||
if let Some(text) = text {
|
||||
operations.push(Self::render_template(
|
||||
text,
|
||||
¤t_slide,
|
||||
&context,
|
||||
colors.clone(),
|
||||
alignment,
|
||||
));
|
||||
}
|
||||
}
|
||||
operations
|
||||
}
|
||||
FooterStyle::ProgressBar { character, colors } => {
|
||||
let character = character.unwrap_or('█').to_string();
|
||||
let total_columns = dimensions.columns as usize / character.width();
|
||||
let progress_ratio = (self.current_slide + 1) as f64 / context.total_slides as f64;
|
||||
let columns_ratio = (total_columns as f64 * progress_ratio).ceil();
|
||||
let bar = character.repeat(columns_ratio as usize);
|
||||
let bar = Text::new(bar, TextStyle::default().colors(colors.clone()));
|
||||
vec![
|
||||
RenderOperation::JumpToBottomRow { index: 0 },
|
||||
RenderOperation::RenderText {
|
||||
line: vec![bar].into(),
|
||||
alignment: Alignment::Left { margin: Margin::Fixed(0) },
|
||||
},
|
||||
]
|
||||
}
|
||||
FooterStyle::Empty => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn diffable_content(&self) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
}
|
102
src/render/ascii_scaler.rs
Normal file
102
src/render/ascii_scaler.rs
Normal file
@ -0,0 +1,102 @@
|
||||
use super::{
|
||||
RenderError,
|
||||
engine::{RenderEngine, RenderEngineOptions},
|
||||
};
|
||||
use crate::{
|
||||
WindowSize,
|
||||
presentation::Presentation,
|
||||
terminal::{
|
||||
image::Image,
|
||||
printer::{TerminalCommand, TerminalError, TerminalIo},
|
||||
},
|
||||
};
|
||||
use std::thread;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
pub(crate) struct AsciiScaler {
|
||||
options: RenderEngineOptions,
|
||||
}
|
||||
|
||||
impl AsciiScaler {
|
||||
pub(crate) fn new(options: RenderEngineOptions) -> Self {
|
||||
Self { options }
|
||||
}
|
||||
|
||||
pub(crate) fn process(self, presentation: &Presentation, dimensions: &WindowSize) -> Result<(), RenderError> {
|
||||
let mut collector = ImageCollector::default();
|
||||
for slide in presentation.iter_slides() {
|
||||
let engine = RenderEngine::new(&mut collector, dimensions.clone(), self.options.clone());
|
||||
engine.render(slide.iter_operations())?;
|
||||
}
|
||||
thread::spawn(move || Self::scale(collector.images));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn scale(images: Vec<ScalableImage>) {
|
||||
for image in images {
|
||||
let ascii_image = image.image.to_ascii();
|
||||
ascii_image.cache_scaling(image.columns, image.rows);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ScalableImage {
|
||||
image: Image,
|
||||
rows: u16,
|
||||
columns: u16,
|
||||
}
|
||||
|
||||
struct ImageCollector {
|
||||
current_column: u16,
|
||||
current_row: u16,
|
||||
current_row_height: u16,
|
||||
images: Vec<ScalableImage>,
|
||||
}
|
||||
|
||||
impl Default for ImageCollector {
|
||||
fn default() -> Self {
|
||||
Self { current_row: 0, current_column: 0, current_row_height: 1, images: Default::default() }
|
||||
}
|
||||
}
|
||||
|
||||
impl TerminalIo for ImageCollector {
|
||||
fn execute(&mut self, command: &TerminalCommand<'_>) -> Result<(), TerminalError> {
|
||||
use TerminalCommand::*;
|
||||
match command {
|
||||
MoveTo { column, row } => {
|
||||
self.current_column = *column;
|
||||
self.current_row = *row;
|
||||
}
|
||||
MoveToRow(row) => self.current_row = *row,
|
||||
MoveToColumn(column) => self.current_column = *column,
|
||||
MoveDown(amount) => self.current_row = self.current_row.saturating_add(*amount),
|
||||
MoveRight(amount) => self.current_column = self.current_column.saturating_add(*amount),
|
||||
MoveLeft(amount) => self.current_column = self.current_column.saturating_sub(*amount),
|
||||
MoveToNextLine => {
|
||||
self.current_row = self.current_row.saturating_add(1);
|
||||
self.current_column = 0;
|
||||
self.current_row_height = 1;
|
||||
}
|
||||
PrintText { content, style } => {
|
||||
self.current_column = self.current_column.saturating_add(content.width() as u16);
|
||||
self.current_row_height = self.current_row_height.max(style.size as u16);
|
||||
}
|
||||
PrintImage { image, options } => {
|
||||
// we can only really cache filesystem images for now
|
||||
let image = ScalableImage { image: image.clone(), rows: options.rows * 2, columns: options.columns };
|
||||
self.images.push(image);
|
||||
}
|
||||
ClearScreen => {
|
||||
self.current_column = 0;
|
||||
self.current_row = 0;
|
||||
self.current_row_height = 1;
|
||||
}
|
||||
BeginUpdate | EndUpdate | Flush | SetColors(_) | SetBackgroundColor(_) => (),
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cursor_row(&self) -> u16 {
|
||||
self.current_row
|
||||
}
|
||||
}
|
@ -1,132 +0,0 @@
|
||||
use super::{
|
||||
engine::RenderEngine,
|
||||
terminal::{Terminal, TerminalWrite},
|
||||
};
|
||||
use crate::{
|
||||
markdown::{elements::Text, text::WeightedTextBlock},
|
||||
media::printer::{ImagePrinter, PrintImageError},
|
||||
presentation::{Presentation, RenderOperation},
|
||||
render::properties::WindowSize,
|
||||
style::{Color, Colors, TextStyle},
|
||||
theme::{Alignment, Margin},
|
||||
};
|
||||
use std::{io, sync::Arc};
|
||||
|
||||
/// The result of a render operation.
|
||||
pub(crate) type RenderResult = Result<(), RenderError>;
|
||||
|
||||
/// Allows drawing elements in the terminal.
|
||||
pub(crate) struct TerminalDrawer<W: TerminalWrite> {
|
||||
terminal: Terminal<W>,
|
||||
font_size_fallback: u8,
|
||||
}
|
||||
|
||||
impl<W> TerminalDrawer<W>
|
||||
where
|
||||
W: TerminalWrite,
|
||||
{
|
||||
/// Construct a drawer over a [std::io::Write].
|
||||
pub(crate) fn new(handle: W, image_printer: Arc<ImagePrinter>, font_size_fallback: u8) -> io::Result<Self> {
|
||||
let terminal = Terminal::new(handle, image_printer)?;
|
||||
Ok(Self { terminal, font_size_fallback })
|
||||
}
|
||||
|
||||
/// Render a slide.
|
||||
pub(crate) fn render_slide(&mut self, presentation: &Presentation) -> RenderResult {
|
||||
let dimensions = WindowSize::current(self.font_size_fallback)?;
|
||||
let slide = presentation.current_slide();
|
||||
let engine = self.create_engine(dimensions);
|
||||
engine.render(slide.iter_visible_operations())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Render an error.
|
||||
pub(crate) fn render_error(&mut self, message: &str, source: &ErrorSource) -> RenderResult {
|
||||
let dimensions = WindowSize::current(self.font_size_fallback)?;
|
||||
let heading_text = match source {
|
||||
ErrorSource::Presentation => "Error loading presentation".to_string(),
|
||||
ErrorSource::Slide(slide) => {
|
||||
format!("Error in slide {slide}")
|
||||
}
|
||||
};
|
||||
let heading = vec![Text::new(heading_text, TextStyle::default().bold()), Text::from(": ")];
|
||||
let total_lines = message.lines().count();
|
||||
let starting_row = (dimensions.rows / 2).saturating_sub(total_lines as u16 / 2 + 3);
|
||||
let alignment = Alignment::Left { margin: Margin::Percent(25) };
|
||||
|
||||
let mut operations = vec![
|
||||
RenderOperation::SetColors(Colors {
|
||||
foreground: Some(Color::new(255, 0, 0)),
|
||||
background: Some(Color::new(0, 0, 0)),
|
||||
}),
|
||||
RenderOperation::ClearScreen,
|
||||
RenderOperation::JumpToRow { index: starting_row },
|
||||
RenderOperation::RenderText { line: WeightedTextBlock::from(heading), alignment: alignment.clone() },
|
||||
RenderOperation::RenderLineBreak,
|
||||
RenderOperation::RenderLineBreak,
|
||||
];
|
||||
for line in message.lines() {
|
||||
let error = vec![Text::from(line)];
|
||||
let op = RenderOperation::RenderText { line: WeightedTextBlock::from(error), alignment: alignment.clone() };
|
||||
operations.extend([op, RenderOperation::RenderLineBreak]);
|
||||
}
|
||||
let engine = self.create_engine(dimensions);
|
||||
engine.render(operations.iter())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn render_slide_index(&mut self, presentation: &Presentation) -> RenderResult {
|
||||
let dimensions = WindowSize::current(self.font_size_fallback)?;
|
||||
let engine = self.create_engine(dimensions);
|
||||
engine.render(presentation.iter_slide_index_operations())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn render_key_bindings(&mut self, presentation: &Presentation) -> RenderResult {
|
||||
let dimensions = WindowSize::current(self.font_size_fallback)?;
|
||||
let engine = self.create_engine(dimensions);
|
||||
engine.render(presentation.iter_bindings_operations())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_engine(&mut self, dimensions: WindowSize) -> RenderEngine<W> {
|
||||
let options = Default::default();
|
||||
RenderEngine::new(&mut self.terminal, dimensions, options)
|
||||
}
|
||||
}
|
||||
|
||||
/// A rendering error.
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum RenderError {
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] io::Error),
|
||||
|
||||
#[error("unsupported structure: {0}")]
|
||||
UnsupportedStructure(&'static str),
|
||||
|
||||
#[error("screen is too small")]
|
||||
TerminalTooSmall,
|
||||
|
||||
#[error("tried to move to non existent layout location")]
|
||||
InvalidLayoutEnter,
|
||||
|
||||
#[error("tried to pop default screen")]
|
||||
PopDefaultScreen,
|
||||
|
||||
#[error("printing image: {0}")]
|
||||
PrintImage(#[from] PrintImageError),
|
||||
|
||||
#[error("horizontal overflow")]
|
||||
HorizontalOverflow,
|
||||
|
||||
#[error("vertical overflow")]
|
||||
VerticalOverflow,
|
||||
|
||||
#[error(transparent)]
|
||||
Other(Box<dyn std::error::Error>),
|
||||
}
|
||||
|
||||
pub(crate) enum ErrorSource {
|
||||
Presentation,
|
||||
Slide(usize),
|
||||
}
|
@ -1,55 +1,83 @@
|
||||
use super::{
|
||||
draw::{RenderError, RenderResult},
|
||||
layout::Layout,
|
||||
properties::CursorPosition,
|
||||
terminal::{Terminal, TerminalWrite},
|
||||
text::TextDrawer,
|
||||
RenderError, RenderResult, layout::Layout, operation::ImagePosition, properties::CursorPosition, text::TextDrawer,
|
||||
};
|
||||
use crate::{
|
||||
markdown::text::WeightedTextBlock,
|
||||
media::{
|
||||
image::Image,
|
||||
printer::{PrintOptions, ResourceProperties},
|
||||
scale::{fit_image_to_window, scale_image},
|
||||
config::{MaxColumnsAlignment, MaxRowsAlignment},
|
||||
markdown::{text::WeightedLine, text_style::Colors},
|
||||
render::{
|
||||
layout::Positioning,
|
||||
operation::{
|
||||
AsRenderOperations, BlockLine, ImageRenderProperties, ImageSize, MarginProperties, RenderAsync,
|
||||
RenderOperation,
|
||||
},
|
||||
properties::WindowSize,
|
||||
},
|
||||
presentation::{
|
||||
AsRenderOperations, BlockLine, BlockLineText, ImageProperties, ImageSize, MarginProperties, RenderAsync,
|
||||
RenderOperation,
|
||||
terminal::{
|
||||
image::{
|
||||
Image,
|
||||
printer::{ImageProperties, PrintOptions},
|
||||
scale::{ImageScaler, ScaleImage},
|
||||
},
|
||||
printer::{TerminalCommand, TerminalIo},
|
||||
},
|
||||
render::{layout::Positioning, properties::WindowSize},
|
||||
style::Colors,
|
||||
theme::Alignment,
|
||||
};
|
||||
use std::mem;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct RenderEngineOptions {
|
||||
pub(crate) validate_overflows: bool,
|
||||
const MINIMUM_LINE_LENGTH: u16 = 10;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct MaxSize {
|
||||
pub(crate) max_columns: u16,
|
||||
pub(crate) max_columns_alignment: MaxColumnsAlignment,
|
||||
pub(crate) max_rows: u16,
|
||||
pub(crate) max_rows_alignment: MaxRowsAlignment,
|
||||
}
|
||||
|
||||
pub(crate) struct RenderEngine<'a, W>
|
||||
impl Default for MaxSize {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_columns: u16::MAX,
|
||||
max_columns_alignment: Default::default(),
|
||||
max_rows: u16::MAX,
|
||||
max_rows_alignment: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct RenderEngineOptions {
|
||||
pub(crate) validate_overflows: bool,
|
||||
pub(crate) max_size: MaxSize,
|
||||
pub(crate) column_layout_margin: u16,
|
||||
}
|
||||
|
||||
impl Default for RenderEngineOptions {
|
||||
fn default() -> Self {
|
||||
Self { validate_overflows: false, max_size: Default::default(), column_layout_margin: 4 }
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct RenderEngine<'a, T>
|
||||
where
|
||||
W: TerminalWrite,
|
||||
T: TerminalIo,
|
||||
{
|
||||
terminal: &'a mut Terminal<W>,
|
||||
terminal: &'a mut T,
|
||||
window_rects: Vec<WindowRect>,
|
||||
colors: Colors,
|
||||
max_modified_row: u16,
|
||||
layout: LayoutState,
|
||||
options: RenderEngineOptions,
|
||||
image_scaler: Box<dyn ScaleImage>,
|
||||
}
|
||||
|
||||
impl<'a, W> RenderEngine<'a, W>
|
||||
impl<'a, T> RenderEngine<'a, T>
|
||||
where
|
||||
W: TerminalWrite,
|
||||
T: TerminalIo,
|
||||
{
|
||||
pub(crate) fn new(
|
||||
terminal: &'a mut Terminal<W>,
|
||||
window_dimensions: WindowSize,
|
||||
options: RenderEngineOptions,
|
||||
) -> Self {
|
||||
let max_modified_row = terminal.cursor_row;
|
||||
let current_rect = WindowRect { dimensions: window_dimensions, start_column: 0 };
|
||||
pub(crate) fn new(terminal: &'a mut T, window_dimensions: WindowSize, options: RenderEngineOptions) -> Self {
|
||||
let max_modified_row = terminal.cursor_row();
|
||||
let current_rect = Self::starting_rect(window_dimensions, &options);
|
||||
let window_rects = vec![current_rect.clone()];
|
||||
Self {
|
||||
terminal,
|
||||
@ -58,16 +86,46 @@ where
|
||||
max_modified_row,
|
||||
layout: Default::default(),
|
||||
options,
|
||||
image_scaler: Box::<ImageScaler>::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn starting_rect(mut dimensions: WindowSize, options: &RenderEngineOptions) -> WindowRect {
|
||||
let mut start_row = 0;
|
||||
let mut start_column = 0;
|
||||
if dimensions.columns > options.max_size.max_columns {
|
||||
let extra_width = dimensions.columns - options.max_size.max_columns;
|
||||
dimensions = dimensions.shrink_columns(extra_width);
|
||||
start_column = match options.max_size.max_columns_alignment {
|
||||
MaxColumnsAlignment::Left => 0,
|
||||
MaxColumnsAlignment::Center => extra_width / 2,
|
||||
MaxColumnsAlignment::Right => extra_width,
|
||||
};
|
||||
}
|
||||
if dimensions.rows > options.max_size.max_rows {
|
||||
let extra_height = dimensions.rows - options.max_size.max_rows;
|
||||
dimensions = dimensions.shrink_rows(extra_height);
|
||||
start_row = match options.max_size.max_rows_alignment {
|
||||
MaxRowsAlignment::Top => 0,
|
||||
MaxRowsAlignment::Center => extra_height / 2,
|
||||
MaxRowsAlignment::Bottom => extra_height,
|
||||
};
|
||||
}
|
||||
WindowRect { dimensions, start_column, start_row }
|
||||
}
|
||||
|
||||
pub(crate) fn render<'b>(mut self, operations: impl Iterator<Item = &'b RenderOperation>) -> RenderResult {
|
||||
self.terminal.begin_update()?;
|
||||
let current_rect = self.current_rect().clone();
|
||||
self.terminal.execute(&TerminalCommand::BeginUpdate)?;
|
||||
if current_rect.start_row != 0 || current_rect.start_column != 0 {
|
||||
self.terminal
|
||||
.execute(&TerminalCommand::MoveTo { column: current_rect.start_column, row: current_rect.start_row })?;
|
||||
}
|
||||
for operation in operations {
|
||||
self.render_one(operation)?;
|
||||
}
|
||||
self.terminal.end_update()?;
|
||||
self.terminal.flush()?;
|
||||
self.terminal.execute(&TerminalCommand::EndUpdate)?;
|
||||
self.terminal.execute(&TerminalCommand::Flush)?;
|
||||
if self.options.validate_overflows && self.max_modified_row > self.window_rects[0].dimensions.rows {
|
||||
return Err(RenderError::VerticalOverflow);
|
||||
}
|
||||
@ -83,17 +141,21 @@ where
|
||||
RenderOperation::JumpToVerticalCenter => self.jump_to_vertical_center(),
|
||||
RenderOperation::JumpToRow { index } => self.jump_to_row(*index),
|
||||
RenderOperation::JumpToBottomRow { index } => self.jump_to_bottom(*index),
|
||||
RenderOperation::RenderText { line, alignment } => self.render_text(line, alignment),
|
||||
RenderOperation::JumpToColumn { index } => self.jump_to_column(*index),
|
||||
RenderOperation::RenderText { line, alignment } => self.render_text(line, *alignment),
|
||||
RenderOperation::RenderLineBreak => self.render_line_break(),
|
||||
RenderOperation::RenderImage(image, properties) => self.render_image(image, properties),
|
||||
RenderOperation::RenderBlockLine(operation) => self.render_preformatted_line(operation),
|
||||
RenderOperation::RenderBlockLine(operation) => self.render_block_line(operation),
|
||||
RenderOperation::RenderDynamic(generator) => self.render_dynamic(generator.as_ref()),
|
||||
RenderOperation::RenderAsync(generator) => self.render_async(generator.as_ref()),
|
||||
RenderOperation::InitColumnLayout { columns } => self.init_column_layout(columns),
|
||||
RenderOperation::EnterColumn { column } => self.enter_column(*column),
|
||||
RenderOperation::ExitLayout => self.exit_layout(),
|
||||
}?;
|
||||
self.max_modified_row = self.max_modified_row.max(self.terminal.cursor_row);
|
||||
if let LayoutState::EnteredColumn { column, columns } = &mut self.layout {
|
||||
columns[*column].current_row = self.terminal.cursor_row();
|
||||
};
|
||||
self.max_modified_row = self.max_modified_row.max(self.terminal.cursor_row());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -107,17 +169,21 @@ where
|
||||
}
|
||||
|
||||
fn clear_screen(&mut self) -> RenderResult {
|
||||
self.terminal.clear_screen()?;
|
||||
self.terminal.move_to(0, 0)?;
|
||||
let current = self.current_rect().clone();
|
||||
self.terminal.execute(&TerminalCommand::ClearScreen)?;
|
||||
self.terminal.execute(&TerminalCommand::MoveTo { column: current.start_column, row: current.start_row })?;
|
||||
self.max_modified_row = 0;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_margin(&mut self, properties: &MarginProperties) -> RenderResult {
|
||||
let MarginProperties { horizontal_margin, bottom_slide_margin } = properties;
|
||||
let MarginProperties { horizontal: horizontal_margin, top, bottom } = properties;
|
||||
let current = self.current_rect();
|
||||
let margin = horizontal_margin.as_characters(current.dimensions.columns);
|
||||
let new_rect = current.apply_margin(margin).shrink_rows(*bottom_slide_margin);
|
||||
let new_rect = current.shrink_horizontal(margin).shrink_bottom(*bottom).shrink_top(*top);
|
||||
if new_rect.start_row != self.terminal.cursor_row() {
|
||||
self.terminal.execute(&TerminalCommand::MoveToRow(new_rect.start_row))?;
|
||||
}
|
||||
self.window_rects.push(new_rect);
|
||||
Ok(())
|
||||
}
|
||||
@ -131,107 +197,146 @@ where
|
||||
}
|
||||
|
||||
fn set_colors(&mut self, colors: &Colors) -> RenderResult {
|
||||
self.colors = colors.clone();
|
||||
self.colors = *colors;
|
||||
self.apply_colors()
|
||||
}
|
||||
|
||||
fn apply_colors(&mut self) -> RenderResult {
|
||||
self.terminal.set_colors(self.colors.clone())?;
|
||||
self.terminal.execute(&TerminalCommand::SetColors(self.colors))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn jump_to_vertical_center(&mut self) -> RenderResult {
|
||||
let center_row = self.current_dimensions().rows / 2;
|
||||
self.terminal.move_to_row(center_row)?;
|
||||
let current = self.current_rect();
|
||||
let center_row = current.dimensions.rows / 2;
|
||||
let center_row = center_row.saturating_add(current.start_row);
|
||||
self.terminal.execute(&TerminalCommand::MoveToRow(center_row))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn jump_to_row(&mut self, index: u16) -> RenderResult {
|
||||
self.terminal.move_to_row(index)?;
|
||||
fn jump_to_row(&mut self, row: u16) -> RenderResult {
|
||||
// Make this relative to the beginning of the current rect.
|
||||
let row = self.current_rect().start_row.saturating_add(row);
|
||||
self.terminal.execute(&TerminalCommand::MoveToRow(row))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn jump_to_bottom(&mut self, index: u16) -> RenderResult {
|
||||
let target_row = self.current_dimensions().rows.saturating_sub(index).saturating_sub(1);
|
||||
self.terminal.move_to_row(target_row)?;
|
||||
let current = self.current_rect();
|
||||
let target_row = current.dimensions.rows.saturating_sub(index).saturating_sub(1);
|
||||
let target_row = target_row.saturating_add(current.start_row);
|
||||
self.terminal.execute(&TerminalCommand::MoveToRow(target_row))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_text(&mut self, text: &WeightedTextBlock, alignment: &Alignment) -> RenderResult {
|
||||
let layout = self.build_layout(alignment.clone());
|
||||
let text_drawer = TextDrawer::new(&layout, text, self.current_dimensions(), &self.colors)?;
|
||||
text_drawer.draw(self.terminal)
|
||||
fn jump_to_column(&mut self, column: u16) -> RenderResult {
|
||||
// Make this relative to the beginning of the current rect.
|
||||
let column = self.current_rect().start_column.saturating_add(column);
|
||||
self.terminal.execute(&TerminalCommand::MoveToColumn(column))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_text(&mut self, text: &WeightedLine, alignment: Alignment) -> RenderResult {
|
||||
let layout = self.build_layout(alignment);
|
||||
let dimensions = self.current_dimensions();
|
||||
let positioning = layout.compute(dimensions, text.width() as u16);
|
||||
let prefix = "".into();
|
||||
let text_drawer = TextDrawer::new(&prefix, 0, text, positioning, &self.colors, MINIMUM_LINE_LENGTH)?;
|
||||
let center_newlines = matches!(alignment, Alignment::Center { .. });
|
||||
let text_drawer = text_drawer.center_newlines(center_newlines);
|
||||
text_drawer.draw(self.terminal)?;
|
||||
// Restore colors
|
||||
self.apply_colors()
|
||||
}
|
||||
|
||||
fn render_line_break(&mut self) -> RenderResult {
|
||||
self.terminal.move_to_next_line(1)?;
|
||||
self.terminal.execute(&TerminalCommand::MoveToNextLine)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_image(&mut self, image: &Image, properties: &ImageProperties) -> RenderResult {
|
||||
let rect = self.current_rect();
|
||||
let starting_position = CursorPosition { row: self.terminal.cursor_row, column: rect.start_column };
|
||||
fn render_image(&mut self, image: &Image, properties: &ImageRenderProperties) -> RenderResult {
|
||||
let rect = self.current_rect().clone();
|
||||
let starting_row = self.terminal.cursor_row();
|
||||
let starting_cursor =
|
||||
CursorPosition { row: starting_row.saturating_sub(rect.start_row), column: rect.start_column };
|
||||
|
||||
let (width, height) = image.dimensions();
|
||||
let (cursor_position, columns, rows) = match properties.size {
|
||||
let (width, height) = image.image().dimensions();
|
||||
let (columns, rows) = match properties.size {
|
||||
ImageSize::ShrinkIfNeeded => {
|
||||
let scale = fit_image_to_window(&rect.dimensions, width, height, &starting_position);
|
||||
(CursorPosition { row: starting_position.row, column: scale.start_column }, scale.columns, scale.rows)
|
||||
let image_scale =
|
||||
self.image_scaler.fit_image_to_rect(&rect.dimensions, width, height, &starting_cursor);
|
||||
(image_scale.columns, image_scale.rows)
|
||||
}
|
||||
ImageSize::Specific(columns, rows) => (starting_position.clone(), columns, rows),
|
||||
ImageSize::Specific(columns, rows) => (columns, rows),
|
||||
ImageSize::WidthScaled { ratio } => {
|
||||
let extra_columns = (rect.dimensions.columns as f64 * (1.0 - ratio)).ceil() as u16;
|
||||
let dimensions = rect.dimensions.shrink_columns(extra_columns);
|
||||
let scale = scale_image(&dimensions, &rect.dimensions, width, height, &starting_position);
|
||||
(CursorPosition { row: starting_position.row, column: scale.start_column }, scale.columns, scale.rows)
|
||||
let image_scale =
|
||||
self.image_scaler.scale_image(&dimensions, &rect.dimensions, width, height, &starting_cursor);
|
||||
(image_scale.columns, image_scale.rows)
|
||||
}
|
||||
};
|
||||
let cursor = match &properties.position {
|
||||
ImagePosition::Cursor => starting_cursor.clone(),
|
||||
ImagePosition::Center => Self::center_cursor(columns, &rect.dimensions, &starting_cursor),
|
||||
ImagePosition::Right => Self::align_cursor_right(columns, &rect.dimensions, &starting_cursor),
|
||||
};
|
||||
self.terminal.execute(&TerminalCommand::MoveToColumn(cursor.column))?;
|
||||
|
||||
let options = PrintOptions {
|
||||
columns,
|
||||
rows,
|
||||
cursor_position,
|
||||
z_index: properties.z_index,
|
||||
column_width: rect.dimensions.pixels_per_column() as u16,
|
||||
row_height: rect.dimensions.pixels_per_row() as u16,
|
||||
background_color: properties.background_color,
|
||||
};
|
||||
self.terminal.print_image(image, &options)?;
|
||||
self.terminal.execute(&TerminalCommand::PrintImage { image: image.clone(), options })?;
|
||||
if properties.restore_cursor {
|
||||
self.terminal.move_to(starting_position.column, starting_position.row)?;
|
||||
self.terminal.execute(&TerminalCommand::MoveTo { column: starting_cursor.column, row: starting_row })?;
|
||||
} else {
|
||||
self.terminal.move_to_row(starting_position.row + rows)?;
|
||||
self.terminal.execute(&TerminalCommand::MoveToRow(starting_row + rows))?;
|
||||
}
|
||||
Ok(())
|
||||
self.apply_colors()
|
||||
}
|
||||
|
||||
fn render_preformatted_line(&mut self, operation: &BlockLine) -> RenderResult {
|
||||
let BlockLine { text, unformatted_length, block_length, alignment } = operation;
|
||||
let layout = self.build_layout(alignment.clone());
|
||||
fn center_cursor(columns: u16, window: &WindowSize, cursor: &CursorPosition) -> CursorPosition {
|
||||
let start_column = window.columns / 2 - (columns / 2);
|
||||
let start_column = start_column + cursor.column;
|
||||
CursorPosition { row: cursor.row, column: start_column }
|
||||
}
|
||||
|
||||
fn align_cursor_right(columns: u16, window: &WindowSize, cursor: &CursorPosition) -> CursorPosition {
|
||||
let start_column = window.columns.saturating_sub(columns).saturating_add(cursor.column);
|
||||
CursorPosition { row: cursor.row, column: start_column }
|
||||
}
|
||||
|
||||
fn render_block_line(&mut self, operation: &BlockLine) -> RenderResult {
|
||||
let BlockLine {
|
||||
text,
|
||||
block_length,
|
||||
alignment,
|
||||
block_color,
|
||||
prefix,
|
||||
right_padding_length,
|
||||
repeat_prefix_on_wrap,
|
||||
} = operation;
|
||||
let layout = self.build_layout(*alignment).with_font_size(text.font_size());
|
||||
|
||||
let dimensions = self.current_dimensions();
|
||||
let Positioning { max_line_length, start_column } = layout.compute(dimensions, *block_length);
|
||||
if self.options.validate_overflows && unformatted_length > &max_line_length {
|
||||
if self.options.validate_overflows && text.width() as u16 > max_line_length {
|
||||
return Err(RenderError::HorizontalOverflow);
|
||||
}
|
||||
|
||||
self.terminal.move_to_column(start_column)?;
|
||||
self.terminal.execute(&TerminalCommand::MoveToColumn(start_column))?;
|
||||
|
||||
// Pad this code block with spaces so we get a nice little rectangle.
|
||||
let until_right_edge = max_line_length.saturating_sub(*unformatted_length);
|
||||
match text {
|
||||
BlockLineText::Preformatted(text) => self.terminal.print_line(text)?,
|
||||
BlockLineText::Weighted(text) => self.render_text(text, alignment)?,
|
||||
};
|
||||
self.terminal.print_line(&" ".repeat(until_right_edge as usize))?;
|
||||
|
||||
// If this line is longer than the screen, our cursor wrapped around so we need to update
|
||||
// the terminal.
|
||||
if *unformatted_length > max_line_length {
|
||||
let lines_wrapped = *unformatted_length / max_line_length;
|
||||
let new_row = self.terminal.cursor_row + lines_wrapped;
|
||||
self.terminal.sync_cursor_row(new_row)?;
|
||||
}
|
||||
let positioning = Positioning { max_line_length, start_column };
|
||||
let text_drawer =
|
||||
TextDrawer::new(prefix, *right_padding_length, text, positioning, &self.colors, MINIMUM_LINE_LENGTH)?
|
||||
.with_surrounding_block(*block_color)
|
||||
.repeat_prefix_on_wrap(*repeat_prefix_on_wrap);
|
||||
text_drawer.draw(self.terminal)?;
|
||||
|
||||
// Restore colors
|
||||
self.apply_colors()?;
|
||||
@ -258,47 +363,53 @@ where
|
||||
if !matches!(self.layout, LayoutState::Default) {
|
||||
self.exit_layout()?;
|
||||
}
|
||||
let columns = columns.iter().copied().map(u16::from).collect();
|
||||
let current_position = self.terminal.cursor_row;
|
||||
self.layout = LayoutState::InitializedColumn { columns, start_row: current_position };
|
||||
let columns = columns
|
||||
.iter()
|
||||
.map(|width| Column { width: *width as u16, current_row: self.terminal.cursor_row() })
|
||||
.collect();
|
||||
self.layout = LayoutState::InitializedColumn { columns };
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn enter_column(&mut self, column_index: usize) -> RenderResult {
|
||||
let (columns, start_row) = match mem::take(&mut self.layout) {
|
||||
let columns = match mem::take(&mut self.layout) {
|
||||
LayoutState::Default => return Err(RenderError::InvalidLayoutEnter),
|
||||
LayoutState::InitializedColumn { columns, .. } | LayoutState::EnteredColumn { columns, .. }
|
||||
if column_index >= columns.len() =>
|
||||
{
|
||||
return Err(RenderError::InvalidLayoutEnter);
|
||||
}
|
||||
LayoutState::InitializedColumn { columns, start_row } => (columns, start_row),
|
||||
LayoutState::EnteredColumn { columns, start_row, .. } => {
|
||||
LayoutState::InitializedColumn { columns } => columns,
|
||||
LayoutState::EnteredColumn { columns, .. } => {
|
||||
// Pop this one and start clean
|
||||
self.pop_margin()?;
|
||||
(columns, start_row)
|
||||
columns
|
||||
}
|
||||
};
|
||||
let total_column_units: u16 = columns.iter().sum();
|
||||
let column_units_before: u16 = columns.iter().take(column_index).sum();
|
||||
let total_column_units: u16 = columns.iter().map(|c| c.width).sum();
|
||||
let column_units_before: u16 = columns.iter().take(column_index).map(|c| c.width).sum();
|
||||
let current_rect = self.current_rect();
|
||||
let unit_width = current_rect.dimensions.columns as f64 / total_column_units as f64;
|
||||
let start_column = current_rect.start_column + (unit_width * column_units_before as f64) as u16;
|
||||
let new_column_count = (total_column_units - columns[column_index]) * unit_width as u16;
|
||||
let new_size = current_rect.dimensions.shrink_columns(new_column_count);
|
||||
let mut dimensions = WindowRect { dimensions: new_size, start_column };
|
||||
let start_row = columns[column_index].current_row;
|
||||
let new_column_count = (total_column_units - columns[column_index].width) * unit_width as u16;
|
||||
let new_size = current_rect
|
||||
.dimensions
|
||||
.shrink_columns(new_column_count)
|
||||
.shrink_rows(start_row.saturating_sub(current_rect.start_row));
|
||||
let mut dimensions = WindowRect { dimensions: new_size, start_column, start_row };
|
||||
// Shrink every column's right edge except for last
|
||||
if column_index < columns.len() - 1 {
|
||||
dimensions = dimensions.shrink_right(4);
|
||||
dimensions = dimensions.shrink_right(self.options.column_layout_margin);
|
||||
}
|
||||
// Shrink every column's left edge except for first
|
||||
if column_index > 0 {
|
||||
dimensions = dimensions.shrink_left(4);
|
||||
dimensions = dimensions.shrink_left(self.options.column_layout_margin);
|
||||
}
|
||||
|
||||
self.window_rects.push(dimensions);
|
||||
self.layout = LayoutState::EnteredColumn { columns, start_row };
|
||||
self.terminal.move_to_row(start_row)?;
|
||||
self.terminal.execute(&TerminalCommand::MoveToRow(start_row))?;
|
||||
self.layout = LayoutState::EnteredColumn { column: column_index, columns };
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -306,7 +417,7 @@ where
|
||||
match &self.layout {
|
||||
LayoutState::Default | LayoutState::InitializedColumn { .. } => Ok(()),
|
||||
LayoutState::EnteredColumn { .. } => {
|
||||
self.terminal.move_to(0, self.max_modified_row)?;
|
||||
self.terminal.execute(&TerminalCommand::MoveTo { column: 0, row: self.max_modified_row })?;
|
||||
self.layout = LayoutState::Default;
|
||||
self.pop_margin()?;
|
||||
Ok(())
|
||||
@ -324,41 +435,507 @@ enum LayoutState {
|
||||
#[default]
|
||||
Default,
|
||||
InitializedColumn {
|
||||
columns: Vec<u16>,
|
||||
start_row: u16,
|
||||
columns: Vec<Column>,
|
||||
},
|
||||
EnteredColumn {
|
||||
columns: Vec<u16>,
|
||||
start_row: u16,
|
||||
column: usize,
|
||||
columns: Vec<Column>,
|
||||
},
|
||||
}
|
||||
|
||||
struct Column {
|
||||
width: u16,
|
||||
current_row: u16,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct WindowRect {
|
||||
dimensions: WindowSize,
|
||||
start_column: u16,
|
||||
start_row: u16,
|
||||
}
|
||||
|
||||
impl WindowRect {
|
||||
fn apply_margin(&self, margin: u16) -> Self {
|
||||
fn shrink_horizontal(&self, margin: u16) -> Self {
|
||||
let dimensions = self.dimensions.shrink_columns(margin.saturating_mul(2));
|
||||
let start_column = self.start_column + margin;
|
||||
Self { dimensions, start_column }
|
||||
Self { dimensions, start_column, start_row: self.start_row }
|
||||
}
|
||||
|
||||
fn shrink_left(&self, size: u16) -> Self {
|
||||
let dimensions = self.dimensions.shrink_columns(size);
|
||||
let start_column = self.start_column.saturating_add(size);
|
||||
Self { dimensions, start_column }
|
||||
Self { dimensions, start_column, start_row: self.start_row }
|
||||
}
|
||||
|
||||
fn shrink_right(&self, size: u16) -> Self {
|
||||
let dimensions = self.dimensions.shrink_columns(size);
|
||||
Self { dimensions, start_column: self.start_column }
|
||||
Self { dimensions, start_column: self.start_column, start_row: self.start_row }
|
||||
}
|
||||
|
||||
fn shrink_rows(&self, rows: u16) -> Self {
|
||||
fn shrink_top(&self, rows: u16) -> Self {
|
||||
let dimensions = self.dimensions.shrink_rows(rows);
|
||||
Self { dimensions, start_column: self.start_column }
|
||||
let start_row = self.start_row.saturating_add(rows);
|
||||
Self { dimensions, start_column: self.start_column, start_row }
|
||||
}
|
||||
|
||||
fn shrink_bottom(&self, rows: u16) -> Self {
|
||||
let dimensions = self.dimensions.shrink_rows(rows);
|
||||
Self { dimensions, start_column: self.start_column, start_row: self.start_row }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
markdown::text_style::{Color, TextStyle},
|
||||
terminal::{
|
||||
image::{
|
||||
ImageSource,
|
||||
printer::{PrintImageError, TerminalImage},
|
||||
scale::TerminalRect,
|
||||
},
|
||||
printer::TerminalError,
|
||||
},
|
||||
theme::Margin,
|
||||
};
|
||||
use ::image::{ColorType, DynamicImage};
|
||||
use rstest::rstest;
|
||||
use std::io;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum Instruction {
|
||||
MoveTo(u16, u16),
|
||||
MoveToRow(u16),
|
||||
MoveToColumn(u16),
|
||||
MoveDown(u16),
|
||||
MoveRight(u16),
|
||||
MoveLeft(u16),
|
||||
MoveToNextLine,
|
||||
PrintText(String),
|
||||
ClearScreen,
|
||||
SetBackgroundColor(Color),
|
||||
PrintImage(PrintOptions),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct TerminalBuf {
|
||||
instructions: Vec<Instruction>,
|
||||
cursor_row: u16,
|
||||
}
|
||||
|
||||
impl TerminalBuf {
|
||||
fn push(&mut self, instruction: Instruction) -> io::Result<()> {
|
||||
self.instructions.push(instruction);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn move_to(&mut self, column: u16, row: u16) -> io::Result<()> {
|
||||
self.cursor_row = row;
|
||||
self.push(Instruction::MoveTo(column, row))
|
||||
}
|
||||
|
||||
fn move_to_row(&mut self, row: u16) -> io::Result<()> {
|
||||
self.cursor_row = row;
|
||||
self.push(Instruction::MoveToRow(row))
|
||||
}
|
||||
|
||||
fn move_to_column(&mut self, column: u16) -> io::Result<()> {
|
||||
self.push(Instruction::MoveToColumn(column))
|
||||
}
|
||||
|
||||
fn move_down(&mut self, amount: u16) -> io::Result<()> {
|
||||
self.push(Instruction::MoveDown(amount))
|
||||
}
|
||||
|
||||
fn move_right(&mut self, amount: u16) -> io::Result<()> {
|
||||
self.push(Instruction::MoveRight(amount))
|
||||
}
|
||||
|
||||
fn move_left(&mut self, amount: u16) -> io::Result<()> {
|
||||
self.push(Instruction::MoveLeft(amount))
|
||||
}
|
||||
|
||||
fn move_to_next_line(&mut self) -> io::Result<()> {
|
||||
self.push(Instruction::MoveToNextLine)
|
||||
}
|
||||
|
||||
fn print_text(&mut self, content: &str, _style: &TextStyle) -> io::Result<()> {
|
||||
let content = content.to_string();
|
||||
if content.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
self.cursor_row = content.width() as u16;
|
||||
self.push(Instruction::PrintText(content))
|
||||
}
|
||||
|
||||
fn clear_screen(&mut self) -> io::Result<()> {
|
||||
self.cursor_row = 0;
|
||||
self.push(Instruction::ClearScreen)
|
||||
}
|
||||
|
||||
fn set_colors(&mut self, _colors: Colors) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_background_color(&mut self, color: Color) -> io::Result<()> {
|
||||
self.push(Instruction::SetBackgroundColor(color))
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_image(&mut self, _image: &Image, options: &PrintOptions) -> Result<(), PrintImageError> {
|
||||
let _ = self.push(Instruction::PrintImage(options.clone()));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl TerminalIo for TerminalBuf {
|
||||
fn execute(&mut self, command: &TerminalCommand<'_>) -> Result<(), TerminalError> {
|
||||
use TerminalCommand::*;
|
||||
match command {
|
||||
BeginUpdate => (),
|
||||
EndUpdate => (),
|
||||
MoveTo { column, row } => self.move_to(*column, *row)?,
|
||||
MoveToRow(row) => self.move_to_row(*row)?,
|
||||
MoveToColumn(column) => self.move_to_column(*column)?,
|
||||
MoveDown(amount) => self.move_down(*amount)?,
|
||||
MoveRight(amount) => self.move_right(*amount)?,
|
||||
MoveLeft(amount) => self.move_left(*amount)?,
|
||||
MoveToNextLine => self.move_to_next_line()?,
|
||||
PrintText { content, style } => self.print_text(content, style)?,
|
||||
ClearScreen => self.clear_screen()?,
|
||||
SetColors(colors) => self.set_colors(*colors)?,
|
||||
SetBackgroundColor(color) => self.set_background_color(*color)?,
|
||||
Flush => self.flush()?,
|
||||
PrintImage { image, options } => self.print_image(image, options)?,
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cursor_row(&self) -> u16 {
|
||||
self.cursor_row
|
||||
}
|
||||
}
|
||||
|
||||
struct DummyImageScaler;
|
||||
|
||||
impl ScaleImage for DummyImageScaler {
|
||||
fn scale_image(
|
||||
&self,
|
||||
_scale_size: &WindowSize,
|
||||
_window_dimensions: &WindowSize,
|
||||
image_width: u32,
|
||||
image_height: u32,
|
||||
_position: &CursorPosition,
|
||||
) -> TerminalRect {
|
||||
TerminalRect { rows: image_width as u16, columns: image_height as u16 }
|
||||
}
|
||||
|
||||
fn fit_image_to_rect(
|
||||
&self,
|
||||
_dimensions: &WindowSize,
|
||||
image_width: u32,
|
||||
image_height: u32,
|
||||
_position: &CursorPosition,
|
||||
) -> TerminalRect {
|
||||
TerminalRect { rows: image_width as u16, columns: image_height as u16 }
|
||||
}
|
||||
}
|
||||
|
||||
fn do_render(max_size: MaxSize, operations: &[RenderOperation]) -> Vec<Instruction> {
|
||||
let mut buf = TerminalBuf::default();
|
||||
let dimensions = WindowSize { rows: 100, columns: 100, height: 200, width: 200 };
|
||||
let options = RenderEngineOptions { validate_overflows: false, max_size, column_layout_margin: 0 };
|
||||
let mut engine = RenderEngine::new(&mut buf, dimensions, options);
|
||||
engine.image_scaler = Box::new(DummyImageScaler);
|
||||
engine.render(operations.iter()).expect("render failed");
|
||||
buf.instructions
|
||||
}
|
||||
|
||||
fn render(operations: &[RenderOperation]) -> Vec<Instruction> {
|
||||
do_render(Default::default(), operations)
|
||||
}
|
||||
|
||||
fn render_with_max_size(operations: &[RenderOperation]) -> Vec<Instruction> {
|
||||
let max_size = MaxSize {
|
||||
max_rows: 10,
|
||||
max_rows_alignment: MaxRowsAlignment::Center,
|
||||
max_columns: 20,
|
||||
max_columns_alignment: MaxColumnsAlignment::Center,
|
||||
};
|
||||
do_render(max_size, operations)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn columns() {
|
||||
let ops = render(&[
|
||||
RenderOperation::InitColumnLayout { columns: vec![1, 1] },
|
||||
// print on column 0
|
||||
RenderOperation::EnterColumn { column: 0 },
|
||||
RenderOperation::RenderText { line: "A".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },
|
||||
// print on column 1
|
||||
RenderOperation::EnterColumn { column: 1 },
|
||||
RenderOperation::RenderText { line: "B".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },
|
||||
// go back to column 0 and print
|
||||
RenderOperation::EnterColumn { column: 0 },
|
||||
RenderOperation::RenderText { line: "1".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },
|
||||
]);
|
||||
let expected = [
|
||||
Instruction::MoveToRow(0),
|
||||
Instruction::MoveToColumn(0),
|
||||
Instruction::PrintText("A".into()),
|
||||
Instruction::MoveToRow(0),
|
||||
Instruction::MoveToColumn(50),
|
||||
Instruction::PrintText("B".into()),
|
||||
// when we go back we should proceed from where we left off (row == 1)
|
||||
Instruction::MoveToRow(1),
|
||||
Instruction::MoveToColumn(0),
|
||||
Instruction::PrintText("1".into()),
|
||||
];
|
||||
assert_eq!(ops, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bottom_margin() {
|
||||
let ops = render(&[
|
||||
RenderOperation::ApplyMargin(MarginProperties { horizontal: Margin::Fixed(1), top: 0, bottom: 10 }),
|
||||
RenderOperation::RenderText { line: "A".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },
|
||||
RenderOperation::JumpToBottomRow { index: 0 },
|
||||
RenderOperation::RenderText { line: "B".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },
|
||||
]);
|
||||
let expected = [
|
||||
Instruction::MoveToColumn(1),
|
||||
Instruction::PrintText("A".into()),
|
||||
// 100 - 10 (bottom margin)
|
||||
Instruction::MoveToRow(89),
|
||||
Instruction::MoveToColumn(1),
|
||||
Instruction::PrintText("B".into()),
|
||||
];
|
||||
assert_eq!(ops, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_margin() {
|
||||
let ops = render(&[
|
||||
RenderOperation::ApplyMargin(MarginProperties { horizontal: Margin::Fixed(1), top: 3, bottom: 0 }),
|
||||
RenderOperation::RenderText { line: "A".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },
|
||||
]);
|
||||
let expected = [Instruction::MoveToRow(3), Instruction::MoveToColumn(1), Instruction::PrintText("A".into())];
|
||||
assert_eq!(ops, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn margins() {
|
||||
let ops = render(&[
|
||||
RenderOperation::ApplyMargin(MarginProperties { horizontal: Margin::Fixed(1), top: 3, bottom: 10 }),
|
||||
RenderOperation::JumpToRow { index: 0 },
|
||||
RenderOperation::RenderText { line: "A".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },
|
||||
RenderOperation::JumpToBottomRow { index: 0 },
|
||||
RenderOperation::RenderText { line: "B".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },
|
||||
]);
|
||||
let expected = [
|
||||
Instruction::MoveToRow(3),
|
||||
Instruction::MoveToRow(3),
|
||||
Instruction::MoveToColumn(1),
|
||||
Instruction::PrintText("A".into()),
|
||||
// 100 - 10 (bottom margin)
|
||||
Instruction::MoveToRow(89),
|
||||
Instruction::MoveToColumn(1),
|
||||
Instruction::PrintText("B".into()),
|
||||
];
|
||||
assert_eq!(ops, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_margins() {
|
||||
let ops = render(&[
|
||||
RenderOperation::ApplyMargin(MarginProperties { horizontal: Margin::Fixed(1), top: 0, bottom: 10 }),
|
||||
RenderOperation::ApplyMargin(MarginProperties { horizontal: Margin::Fixed(1), top: 0, bottom: 10 }),
|
||||
RenderOperation::RenderText { line: "A".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },
|
||||
RenderOperation::JumpToBottomRow { index: 0 },
|
||||
RenderOperation::RenderText { line: "B".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },
|
||||
// pop and go to bottom, this should go back up to the end of the first margin
|
||||
RenderOperation::PopMargin,
|
||||
RenderOperation::JumpToBottomRow { index: 0 },
|
||||
RenderOperation::RenderText { line: "C".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },
|
||||
]);
|
||||
let expected = [
|
||||
Instruction::MoveToColumn(2),
|
||||
Instruction::PrintText("A".into()),
|
||||
// 100 - 10 (margin) - 10 (second margin)
|
||||
Instruction::MoveToRow(79),
|
||||
Instruction::MoveToColumn(2),
|
||||
Instruction::PrintText("B".into()),
|
||||
// 100 - 10 (margin)
|
||||
Instruction::MoveToRow(89),
|
||||
Instruction::MoveToColumn(1),
|
||||
Instruction::PrintText("C".into()),
|
||||
];
|
||||
assert_eq!(ops, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn margin_with_max_size() {
|
||||
let ops = render_with_max_size(&[
|
||||
RenderOperation::RenderText { line: "A".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },
|
||||
RenderOperation::ApplyMargin(MarginProperties { horizontal: Margin::Fixed(1), top: 2, bottom: 1 }),
|
||||
RenderOperation::RenderText { line: "B".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },
|
||||
RenderOperation::JumpToBottomRow { index: 0 },
|
||||
RenderOperation::RenderText { line: "C".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },
|
||||
]);
|
||||
let expected = [
|
||||
// centered 20x10
|
||||
Instruction::MoveTo(40, 45),
|
||||
Instruction::MoveToColumn(40),
|
||||
Instruction::PrintText("A".into()),
|
||||
// jump 2 down because of top margin
|
||||
Instruction::MoveToRow(47),
|
||||
// jump 1 right because of horizontal margin
|
||||
Instruction::MoveToColumn(41),
|
||||
Instruction::PrintText("B".into()),
|
||||
// rows go from 47 to 53 (7 total)
|
||||
Instruction::MoveToRow(53),
|
||||
Instruction::MoveToColumn(41),
|
||||
Instruction::PrintText("C".into()),
|
||||
];
|
||||
assert_eq!(ops, expected);
|
||||
}
|
||||
|
||||
// print the same 2x2 image with all size configs, they should all yield the same
|
||||
#[rstest]
|
||||
#[case::shrink(ImageSize::ShrinkIfNeeded)]
|
||||
#[case::specific(ImageSize::Specific(2, 2))]
|
||||
#[case::width_scaled(ImageSize::WidthScaled { ratio: 1.0 })]
|
||||
fn image(#[case] size: ImageSize) {
|
||||
let image = DynamicImage::new(2, 2, ColorType::Rgba8);
|
||||
let image = Image::new(TerminalImage::Ascii(image.into()), ImageSource::Generated);
|
||||
let properties = ImageRenderProperties {
|
||||
z_index: 0,
|
||||
size,
|
||||
restore_cursor: false,
|
||||
background_color: None,
|
||||
position: ImagePosition::Cursor,
|
||||
};
|
||||
let ops = render_with_max_size(&[RenderOperation::RenderImage(image, properties)]);
|
||||
let expected = [
|
||||
// centered 20x10, the image is 2x2 so we stand one away from center
|
||||
Instruction::MoveTo(40, 45),
|
||||
Instruction::MoveToColumn(40),
|
||||
Instruction::PrintImage(PrintOptions {
|
||||
columns: 2,
|
||||
rows: 2,
|
||||
z_index: 0,
|
||||
background_color: None,
|
||||
column_width: 2,
|
||||
row_height: 2,
|
||||
}),
|
||||
// place cursor after the image
|
||||
Instruction::MoveToRow(47),
|
||||
];
|
||||
assert_eq!(ops, expected);
|
||||
}
|
||||
|
||||
// same as the above but center it
|
||||
#[rstest]
|
||||
#[case::shrink(ImageSize::ShrinkIfNeeded)]
|
||||
#[case::specific(ImageSize::Specific(2, 2))]
|
||||
#[case::width_scaled(ImageSize::WidthScaled { ratio: 1.0 })]
|
||||
fn centered_image(#[case] size: ImageSize) {
|
||||
let image = DynamicImage::new(2, 2, ColorType::Rgba8);
|
||||
let image = Image::new(TerminalImage::Ascii(image.into()), ImageSource::Generated);
|
||||
let properties = ImageRenderProperties {
|
||||
z_index: 0,
|
||||
size,
|
||||
restore_cursor: false,
|
||||
background_color: None,
|
||||
position: ImagePosition::Center,
|
||||
};
|
||||
let ops = render_with_max_size(&[RenderOperation::RenderImage(image, properties)]);
|
||||
let expected = [
|
||||
// centered 20x10, the image is 2x2 so we stand one away from center
|
||||
Instruction::MoveTo(40, 45),
|
||||
Instruction::MoveToColumn(49),
|
||||
Instruction::PrintImage(PrintOptions {
|
||||
columns: 2,
|
||||
rows: 2,
|
||||
z_index: 0,
|
||||
background_color: None,
|
||||
column_width: 2,
|
||||
row_height: 2,
|
||||
}),
|
||||
// place cursor after the image
|
||||
Instruction::MoveToRow(47),
|
||||
];
|
||||
assert_eq!(ops, expected);
|
||||
}
|
||||
|
||||
// same as the above but use right alignment
|
||||
#[rstest]
|
||||
#[case::shrink(ImageSize::ShrinkIfNeeded)]
|
||||
#[case::specific(ImageSize::Specific(2, 2))]
|
||||
#[case::width_scaled(ImageSize::WidthScaled { ratio: 1.0 })]
|
||||
fn right_aligned_image(#[case] size: ImageSize) {
|
||||
let image = DynamicImage::new(2, 2, ColorType::Rgba8);
|
||||
let image = Image::new(TerminalImage::Ascii(image.into()), ImageSource::Generated);
|
||||
let properties = ImageRenderProperties {
|
||||
z_index: 0,
|
||||
size,
|
||||
restore_cursor: false,
|
||||
background_color: None,
|
||||
position: ImagePosition::Right,
|
||||
};
|
||||
let ops = render_with_max_size(&[RenderOperation::RenderImage(image, properties)]);
|
||||
let expected = [
|
||||
// right aligned 20x10, the image is 2x2 so we stand one away from the right
|
||||
Instruction::MoveTo(40, 45),
|
||||
Instruction::MoveToColumn(58),
|
||||
Instruction::PrintImage(PrintOptions {
|
||||
columns: 2,
|
||||
rows: 2,
|
||||
z_index: 0,
|
||||
background_color: None,
|
||||
column_width: 2,
|
||||
row_height: 2,
|
||||
}),
|
||||
// place cursor after the image
|
||||
Instruction::MoveToRow(47),
|
||||
];
|
||||
assert_eq!(ops, expected);
|
||||
}
|
||||
|
||||
// same as the above but center it
|
||||
#[rstest]
|
||||
fn restore_cursor_after_image() {
|
||||
let image = DynamicImage::new(2, 2, ColorType::Rgba8);
|
||||
let image = Image::new(TerminalImage::Ascii(image.into()), ImageSource::Generated);
|
||||
let properties = ImageRenderProperties {
|
||||
z_index: 0,
|
||||
size: ImageSize::ShrinkIfNeeded,
|
||||
restore_cursor: true,
|
||||
background_color: None,
|
||||
position: ImagePosition::Center,
|
||||
};
|
||||
let ops = render_with_max_size(&[RenderOperation::RenderImage(image, properties)]);
|
||||
let expected = [
|
||||
// centered 20x10, the image is 2x2 so we stand one away from center
|
||||
Instruction::MoveTo(40, 45),
|
||||
Instruction::MoveToColumn(49),
|
||||
Instruction::PrintImage(PrintOptions {
|
||||
columns: 2,
|
||||
rows: 2,
|
||||
z_index: 0,
|
||||
background_color: None,
|
||||
column_width: 2,
|
||||
row_height: 2,
|
||||
}),
|
||||
// place cursor after the image
|
||||
Instruction::MoveTo(40, 45),
|
||||
];
|
||||
assert_eq!(ops, expected);
|
||||
}
|
||||
}
|
||||
|
@ -4,11 +4,12 @@ use crate::{render::properties::WindowSize, theme::Alignment};
|
||||
pub(crate) struct Layout {
|
||||
alignment: Alignment,
|
||||
start_column_offset: u16,
|
||||
font_size: u16,
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
pub(crate) fn new(alignment: Alignment) -> Self {
|
||||
Self { alignment, start_column_offset: 0 }
|
||||
Self { alignment, start_column_offset: 0, font_size: 1 }
|
||||
}
|
||||
|
||||
pub(crate) fn with_start_column(mut self, column: u16) -> Self {
|
||||
@ -16,7 +17,13 @@ impl Layout {
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_font_size(mut self, font_size: u8) -> Self {
|
||||
self.font_size = font_size as u16;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn compute(&self, dimensions: &WindowSize, text_length: u16) -> Positioning {
|
||||
let text_length = text_length * self.font_size;
|
||||
let max_line_length;
|
||||
let mut start_column;
|
||||
match &self.alignment {
|
||||
|
@ -1,8 +1,161 @@
|
||||
pub(crate) mod draw;
|
||||
pub(crate) mod ascii_scaler;
|
||||
pub(crate) mod engine;
|
||||
pub(crate) mod highlighting;
|
||||
pub(crate) mod layout;
|
||||
pub(crate) mod operation;
|
||||
pub(crate) mod properties;
|
||||
pub(crate) mod terminal;
|
||||
pub(crate) mod text;
|
||||
pub(crate) mod validate;
|
||||
|
||||
use crate::{
|
||||
markdown::{
|
||||
elements::Text,
|
||||
text::WeightedLine,
|
||||
text_style::{Color, Colors, PaletteColorError, TextStyle},
|
||||
},
|
||||
render::{operation::RenderOperation, properties::WindowSize},
|
||||
terminal::{
|
||||
Terminal,
|
||||
image::printer::{ImagePrinter, PrintImageError},
|
||||
printer::TerminalError,
|
||||
},
|
||||
theme::{Alignment, Margin},
|
||||
};
|
||||
use engine::{MaxSize, RenderEngine, RenderEngineOptions};
|
||||
use operation::AsRenderOperations;
|
||||
use std::{
|
||||
io::{self, Stdout},
|
||||
iter,
|
||||
rc::Rc,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
/// The result of a render operation.
|
||||
pub(crate) type RenderResult = Result<(), RenderError>;
|
||||
|
||||
pub(crate) struct TerminalDrawerOptions {
|
||||
pub(crate) font_size_fallback: u8,
|
||||
pub(crate) max_size: MaxSize,
|
||||
}
|
||||
|
||||
impl Default for TerminalDrawerOptions {
|
||||
fn default() -> Self {
|
||||
Self { font_size_fallback: 1, max_size: Default::default() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Allows drawing on the terminal.
|
||||
pub(crate) struct TerminalDrawer {
|
||||
pub(crate) terminal: Terminal<Stdout>,
|
||||
options: TerminalDrawerOptions,
|
||||
}
|
||||
|
||||
impl TerminalDrawer {
|
||||
pub(crate) fn new(image_printer: Arc<ImagePrinter>, options: TerminalDrawerOptions) -> io::Result<Self> {
|
||||
let terminal = Terminal::new(io::stdout(), image_printer)?;
|
||||
Ok(Self { terminal, options })
|
||||
}
|
||||
|
||||
pub(crate) fn render_operations<'a>(
|
||||
&mut self,
|
||||
operations: impl Iterator<Item = &'a RenderOperation>,
|
||||
) -> RenderResult {
|
||||
let dimensions = WindowSize::current(self.options.font_size_fallback)?;
|
||||
let engine = self.create_engine(dimensions);
|
||||
engine.render(operations)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn render_error(&mut self, message: &str, source: &ErrorSource) -> RenderResult {
|
||||
let operation = RenderErrorOperation { message: message.into(), source: source.clone() };
|
||||
let operation = RenderOperation::RenderDynamic(Rc::new(operation));
|
||||
let dimensions = WindowSize::current(self.options.font_size_fallback)?;
|
||||
let engine = self.create_engine(dimensions);
|
||||
engine.render(iter::once(&operation))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn render_engine_options(&self) -> RenderEngineOptions {
|
||||
RenderEngineOptions { max_size: self.options.max_size.clone(), ..Default::default() }
|
||||
}
|
||||
|
||||
fn create_engine(&mut self, dimensions: WindowSize) -> RenderEngine<Terminal<Stdout>> {
|
||||
let options = self.render_engine_options();
|
||||
RenderEngine::new(&mut self.terminal, dimensions, options)
|
||||
}
|
||||
}
|
||||
|
||||
/// A rendering error.
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub(crate) enum RenderError {
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] io::Error),
|
||||
|
||||
#[error("terminal: {0}")]
|
||||
Terminal(#[from] TerminalError),
|
||||
|
||||
#[error("screen is too small")]
|
||||
TerminalTooSmall,
|
||||
|
||||
#[error("tried to move to non existent layout location")]
|
||||
InvalidLayoutEnter,
|
||||
|
||||
#[error("tried to pop default screen")]
|
||||
PopDefaultScreen,
|
||||
|
||||
#[error("printing image: {0}")]
|
||||
PrintImage(#[from] PrintImageError),
|
||||
|
||||
#[error("horizontal overflow")]
|
||||
HorizontalOverflow,
|
||||
|
||||
#[error("vertical overflow")]
|
||||
VerticalOverflow,
|
||||
|
||||
#[error(transparent)]
|
||||
PaletteColor(#[from] PaletteColorError),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum ErrorSource {
|
||||
Presentation,
|
||||
Slide(usize),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct RenderErrorOperation {
|
||||
message: String,
|
||||
source: ErrorSource,
|
||||
}
|
||||
|
||||
impl AsRenderOperations for RenderErrorOperation {
|
||||
fn as_render_operations(&self, dimensions: &WindowSize) -> Vec<RenderOperation> {
|
||||
let heading_text = match self.source {
|
||||
ErrorSource::Presentation => "Error loading presentation".to_string(),
|
||||
ErrorSource::Slide(slide) => {
|
||||
format!("Error in slide {slide}")
|
||||
}
|
||||
};
|
||||
let heading = vec![Text::new(heading_text, TextStyle::default().bold()), Text::from(": ")];
|
||||
let total_lines = self.message.lines().count();
|
||||
let starting_row = (dimensions.rows / 2).saturating_sub(total_lines as u16 / 2 + 3);
|
||||
let alignment = Alignment::Left { margin: Margin::Percent(25) };
|
||||
|
||||
let mut operations = vec![
|
||||
RenderOperation::SetColors(Colors {
|
||||
foreground: Some(Color::new(255, 0, 0)),
|
||||
background: Some(Color::new(0, 0, 0)),
|
||||
}),
|
||||
RenderOperation::ClearScreen,
|
||||
RenderOperation::JumpToRow { index: starting_row },
|
||||
RenderOperation::RenderText { line: WeightedLine::from(heading), alignment },
|
||||
RenderOperation::RenderLineBreak,
|
||||
RenderOperation::RenderLineBreak,
|
||||
];
|
||||
for line in self.message.lines() {
|
||||
let error = vec![Text::from(line)];
|
||||
let op = RenderOperation::RenderText { line: WeightedLine::from(error), alignment };
|
||||
operations.extend([op, RenderOperation::RenderLineBreak]);
|
||||
}
|
||||
operations
|
||||
}
|
||||
}
|
||||
|
220
src/render/operation.rs
Normal file
220
src/render/operation.rs
Normal file
@ -0,0 +1,220 @@
|
||||
use super::properties::WindowSize;
|
||||
use crate::{
|
||||
markdown::{
|
||||
text::{WeightedLine, WeightedText},
|
||||
text_style::{Color, Colors},
|
||||
},
|
||||
terminal::image::Image,
|
||||
theme::{Alignment, Margin},
|
||||
};
|
||||
use std::{
|
||||
fmt::Debug,
|
||||
rc::Rc,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
const DEFAULT_IMAGE_Z_INDEX: i32 = -2;
|
||||
|
||||
/// A line of preformatted text to be rendered.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct BlockLine {
|
||||
pub(crate) prefix: WeightedText,
|
||||
pub(crate) right_padding_length: u16,
|
||||
pub(crate) repeat_prefix_on_wrap: bool,
|
||||
pub(crate) text: WeightedLine,
|
||||
pub(crate) block_length: u16,
|
||||
pub(crate) block_color: Option<Color>,
|
||||
pub(crate) alignment: Alignment,
|
||||
}
|
||||
|
||||
/// A render operation.
|
||||
///
|
||||
/// Render operations are primitives that allow the input markdown file to be decoupled with what
|
||||
/// we draw on the screen.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum RenderOperation {
|
||||
/// Clear the entire screen.
|
||||
ClearScreen,
|
||||
|
||||
/// Set the colors to be used for any subsequent operations.
|
||||
SetColors(Colors),
|
||||
|
||||
/// Jump the draw cursor into the vertical center, that is, at `screen_height / 2`.
|
||||
JumpToVerticalCenter,
|
||||
|
||||
/// Jumps to the N-th row in the current layout.
|
||||
///
|
||||
/// The index is zero based where 0 represents the top row.
|
||||
JumpToRow { index: u16 },
|
||||
|
||||
/// Jumps to the N-th to last row in the current layout.
|
||||
///
|
||||
/// The index is zero based where 0 represents the bottom row.
|
||||
JumpToBottomRow { index: u16 },
|
||||
|
||||
/// Jump to the N-th column in the current layout.
|
||||
JumpToColumn { index: u16 },
|
||||
|
||||
/// Render text.
|
||||
RenderText { line: WeightedLine, alignment: Alignment },
|
||||
|
||||
/// Render a line break.
|
||||
RenderLineBreak,
|
||||
|
||||
/// Render an image.
|
||||
RenderImage(Image, ImageRenderProperties),
|
||||
|
||||
/// Render a line.
|
||||
RenderBlockLine(BlockLine),
|
||||
|
||||
/// Render a dynamically generated sequence of render operations.
|
||||
///
|
||||
/// This allows drawing something on the screen that requires knowing dynamic properties of the
|
||||
/// screen, like window size, without coupling the transformation of markdown into
|
||||
/// [RenderOperation] with the screen itself.
|
||||
RenderDynamic(Rc<dyn AsRenderOperations>),
|
||||
|
||||
/// An operation that is rendered asynchronously.
|
||||
RenderAsync(Rc<dyn RenderAsync>),
|
||||
|
||||
/// Initialize a column layout.
|
||||
///
|
||||
/// The value for each column is the width of the column in column-unit units, where the entire
|
||||
/// screen contains `columns.sum()` column-units.
|
||||
InitColumnLayout { columns: Vec<u8> },
|
||||
|
||||
/// Enter a column in a column layout.
|
||||
///
|
||||
/// The index is 0-index based and will be tied to a previous `InitColumnLayout` operation.
|
||||
EnterColumn { column: usize },
|
||||
|
||||
/// Exit the current layout and go back to the default one.
|
||||
ExitLayout,
|
||||
|
||||
/// Apply a margin to every following operation.
|
||||
ApplyMargin(MarginProperties),
|
||||
|
||||
/// Pop an `ApplyMargin` operation.
|
||||
PopMargin,
|
||||
}
|
||||
|
||||
/// The properties of an image being rendered.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct ImageRenderProperties {
|
||||
pub(crate) z_index: i32,
|
||||
pub(crate) size: ImageSize,
|
||||
pub(crate) restore_cursor: bool,
|
||||
pub(crate) background_color: Option<Color>,
|
||||
pub(crate) position: ImagePosition,
|
||||
}
|
||||
|
||||
impl Default for ImageRenderProperties {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
z_index: DEFAULT_IMAGE_Z_INDEX,
|
||||
size: Default::default(),
|
||||
restore_cursor: false,
|
||||
background_color: None,
|
||||
position: ImagePosition::Center,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) enum ImagePosition {
|
||||
Cursor,
|
||||
Center,
|
||||
Right,
|
||||
}
|
||||
|
||||
/// The size used when printing an image.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub(crate) enum ImageSize {
|
||||
#[default]
|
||||
ShrinkIfNeeded,
|
||||
Specific(u16, u16),
|
||||
WidthScaled {
|
||||
ratio: f64,
|
||||
},
|
||||
}
|
||||
|
||||
/// Slide properties, set on initialization.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct MarginProperties {
|
||||
/// The horizontal margin.
|
||||
pub(crate) horizontal: Margin,
|
||||
|
||||
/// The margin at the top.
|
||||
pub(crate) top: u16,
|
||||
|
||||
/// The margin at the bottom.
|
||||
pub(crate) bottom: u16,
|
||||
}
|
||||
|
||||
/// A type that can generate render operations.
|
||||
pub(crate) trait AsRenderOperations: Debug + 'static {
|
||||
/// Generate render operations.
|
||||
fn as_render_operations(&self, dimensions: &WindowSize) -> Vec<RenderOperation>;
|
||||
|
||||
/// Get the content in this type to diff it against another `AsRenderOperations`.
|
||||
fn diffable_content(&self) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// An operation that can be rendered asynchronously.
|
||||
pub(crate) trait RenderAsync: AsRenderOperations {
|
||||
/// Create a pollable for this render async.
|
||||
///
|
||||
/// The pollable will be used to poll this by a separate thread, so all state that will
|
||||
/// be loaded asynchronously should be shared between this operation and any pollables
|
||||
/// generated from it.
|
||||
fn pollable(&self) -> Box<dyn Pollable>;
|
||||
|
||||
/// Get the start policy for this render.
|
||||
fn start_policy(&self) -> RenderAsyncStartPolicy {
|
||||
RenderAsyncStartPolicy::OnDemand
|
||||
}
|
||||
}
|
||||
|
||||
/// The start policy for an async render.
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub(crate) enum RenderAsyncStartPolicy {
|
||||
/// Start automatically.
|
||||
Automatic,
|
||||
|
||||
/// Start on demand.
|
||||
OnDemand,
|
||||
}
|
||||
|
||||
/// A pollable that can be used to pull and update the state of an operation asynchronously.
|
||||
pub(crate) trait Pollable: Send + 'static {
|
||||
/// Update the internal state and return the updated state.
|
||||
fn poll(&mut self) -> PollableState;
|
||||
}
|
||||
|
||||
/// The state of a [Pollable].
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum PollableState {
|
||||
Unmodified,
|
||||
Modified,
|
||||
Done,
|
||||
Failed { error: String },
|
||||
}
|
||||
|
||||
pub(crate) struct ToggleState {
|
||||
toggled: Arc<Mutex<bool>>,
|
||||
}
|
||||
|
||||
impl ToggleState {
|
||||
pub(crate) fn new(toggled: Arc<Mutex<bool>>) -> Self {
|
||||
Self { toggled }
|
||||
}
|
||||
}
|
||||
|
||||
impl Pollable for ToggleState {
|
||||
fn poll(&mut self) -> PollableState {
|
||||
*self.toggled.lock().unwrap() = true;
|
||||
PollableState::Done
|
||||
}
|
||||
}
|
@ -27,10 +27,10 @@ impl WindowSize {
|
||||
};
|
||||
let font_size_fallback = font_size_fallback as u16;
|
||||
if size.width == 0 {
|
||||
size.width = size.columns * font_size_fallback;
|
||||
size.width = size.columns * font_size_fallback.max(1);
|
||||
}
|
||||
if size.height == 0 {
|
||||
size.height = size.rows * font_size_fallback * 2;
|
||||
size.height = size.rows * font_size_fallback.max(1) * 2;
|
||||
}
|
||||
Ok(size)
|
||||
}
|
||||
@ -72,6 +72,11 @@ impl WindowSize {
|
||||
pub(crate) fn pixels_per_row(&self) -> f64 {
|
||||
self.height as f64 / self.rows as f64
|
||||
}
|
||||
|
||||
/// The aspect ratio for this size.
|
||||
pub(crate) fn aspect_ratio(&self) -> f64 {
|
||||
(self.rows as f64 / self.height as f64) / (self.columns as f64 / self.width as f64)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crossterm::terminal::WindowSize> for WindowSize {
|
||||
@ -87,7 +92,7 @@ impl From<(u16, u16)> for WindowSize {
|
||||
}
|
||||
|
||||
/// The cursor's position.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub(crate) struct CursorPosition {
|
||||
pub(crate) column: u16,
|
||||
pub(crate) row: u16,
|
||||
|
@ -1,161 +0,0 @@
|
||||
use crate::{
|
||||
media::{
|
||||
image::Image,
|
||||
printer::{ImagePrinter, PrintImage, PrintImageError, PrintOptions},
|
||||
},
|
||||
style::Colors,
|
||||
};
|
||||
use crossterm::{
|
||||
cursor,
|
||||
style::{self, StyledContent},
|
||||
terminal::{self},
|
||||
QueueableCommand,
|
||||
};
|
||||
use std::{
|
||||
io::{self, Write},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
/// A wrapper over the terminal write handle.
|
||||
pub(crate) struct Terminal<W>
|
||||
where
|
||||
W: TerminalWrite,
|
||||
{
|
||||
writer: W,
|
||||
image_printer: Arc<ImagePrinter>,
|
||||
pub(crate) cursor_row: u16,
|
||||
}
|
||||
|
||||
impl<W: TerminalWrite> Terminal<W> {
|
||||
pub(crate) fn new(mut writer: W, image_printer: Arc<ImagePrinter>) -> io::Result<Self> {
|
||||
writer.init()?;
|
||||
Ok(Self { writer, image_printer, cursor_row: 0 })
|
||||
}
|
||||
|
||||
pub(crate) fn begin_update(&mut self) -> io::Result<()> {
|
||||
self.writer.queue(terminal::BeginSynchronizedUpdate)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn end_update(&mut self) -> io::Result<()> {
|
||||
self.writer.queue(terminal::EndSynchronizedUpdate)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn move_to(&mut self, column: u16, row: u16) -> io::Result<()> {
|
||||
self.writer.queue(cursor::MoveTo(column, row))?;
|
||||
self.cursor_row = row;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn move_to_row(&mut self, row: u16) -> io::Result<()> {
|
||||
self.writer.queue(cursor::MoveToRow(row))?;
|
||||
self.cursor_row = row;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn move_to_column(&mut self, column: u16) -> io::Result<()> {
|
||||
self.writer.queue(cursor::MoveToColumn(column))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn move_down(&mut self, amount: u16) -> io::Result<()> {
|
||||
self.writer.queue(cursor::MoveDown(amount))?;
|
||||
self.cursor_row += amount;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn move_to_next_line(&mut self, amount: u16) -> io::Result<()> {
|
||||
self.writer.queue(cursor::MoveToNextLine(amount))?;
|
||||
self.cursor_row += amount;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn print_line(&mut self, text: &str) -> io::Result<()> {
|
||||
self.writer.queue(style::Print(text))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn print_styled_line(&mut self, content: StyledContent<String>) -> io::Result<()> {
|
||||
self.writer.queue(style::PrintStyledContent(content))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn clear_screen(&mut self) -> io::Result<()> {
|
||||
self.writer.queue(terminal::Clear(terminal::ClearType::All))?;
|
||||
self.cursor_row = 0;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn set_colors(&mut self, colors: Colors) -> io::Result<()> {
|
||||
self.writer.queue(style::ResetColor)?;
|
||||
self.writer.queue(style::SetColors(colors.into()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn flush(&mut self) -> io::Result<()> {
|
||||
self.writer.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn sync_cursor_row(&mut self, position: u16) -> io::Result<()> {
|
||||
self.cursor_row = position;
|
||||
self.writer.queue(cursor::MoveToRow(position))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn print_image(&mut self, image: &Image, options: &PrintOptions) -> Result<(), PrintImageError> {
|
||||
self.move_to_column(options.cursor_position.column)?;
|
||||
self.image_printer.print(&image.resource, options, &mut self.writer)?;
|
||||
self.cursor_row += options.rows;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl<W> Drop for Terminal<W>
|
||||
where
|
||||
W: TerminalWrite,
|
||||
{
|
||||
fn drop(&mut self) {
|
||||
self.writer.deinit();
|
||||
}
|
||||
}
|
||||
|
||||
fn should_hide_cursor() -> bool {
|
||||
// WezTerm on Windows fails to display images if we've hidden the cursor so we **always** hide it
|
||||
// unless we're on WezTerm on Windows.
|
||||
let term = std::env::var("TERM_PROGRAM");
|
||||
let is_wezterm = term.as_ref().map(|s| s.as_str()) == Ok("WezTerm");
|
||||
!(is_windows_based_os() && is_wezterm)
|
||||
}
|
||||
|
||||
fn is_windows_based_os() -> bool {
|
||||
let is_windows = std::env::consts::OS == "windows";
|
||||
let is_wsl = std::env::var("WSL_DISTRO_NAME").is_ok();
|
||||
is_windows || is_wsl
|
||||
}
|
||||
|
||||
pub trait TerminalWrite: io::Write {
|
||||
fn init(&mut self) -> io::Result<()>;
|
||||
fn deinit(&mut self);
|
||||
}
|
||||
|
||||
impl TerminalWrite for io::Stdout {
|
||||
fn init(&mut self) -> io::Result<()> {
|
||||
terminal::enable_raw_mode()?;
|
||||
if should_hide_cursor() {
|
||||
self.queue(cursor::Hide)?;
|
||||
}
|
||||
self.queue(terminal::EnterAlternateScreen)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn deinit(&mut self) {
|
||||
let _ = self.queue(terminal::LeaveAlternateScreen);
|
||||
if should_hide_cursor() {
|
||||
let _ = self.queue(cursor::Show);
|
||||
}
|
||||
let _ = self.flush();
|
||||
let _ = terminal::disable_raw_mode();
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user