mirror of
https://github.com/mfontanini/presenterm.git
synced 2025-05-05 15:32:58 +00:00
Compare commits
604 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 | ||
|
db2778ddbd | ||
|
53fb4383ab | ||
|
04ab64c864 | ||
|
01cb49f606 | ||
|
a8bb9b69e0 | ||
|
b09b3f634f | ||
|
982115cc1b | ||
|
68c9de1185 | ||
|
2df7ccfd75 | ||
|
aa68469b4e | ||
|
2b645f47a8 | ||
|
0c9ae114c7 | ||
|
7079b3fda8 | ||
|
53c1e1baad | ||
|
50b7220b30 | ||
|
d8ae2762fc | ||
|
487714a534 | ||
|
ce35ea0832 | ||
|
e2000e6a9c | ||
|
f5e85fa289 | ||
|
37963e4d50 | ||
|
4e7e13aa35 | ||
|
b46a9011df | ||
|
0a3e6514dd | ||
|
962bf47079 | ||
|
f99f47e511 | ||
|
3c3da331b7 | ||
|
29e6cf1b03 | ||
|
9de8c6e14f | ||
|
9f3425c0d0 | ||
|
9d8570b4ce | ||
|
874b0f011e | ||
|
1f9c98e580 | ||
|
18fea741f6 | ||
|
e1f45f81c8 | ||
|
2f23e92d39 | ||
|
8d6a745823 | ||
|
9d5a6beced | ||
|
d7523a8459 | ||
|
690168922b | ||
|
c4654a1c6b | ||
|
c1b172ba48 | ||
|
0d1dc6ea2b | ||
|
be99fc84d7 | ||
|
dd5531310b | ||
|
d2e30b268e | ||
|
3c015af108 | ||
|
0d364e9296 | ||
|
fe2c6f0e89 | ||
|
97d0b9947e | ||
|
be28e93447 | ||
|
7e26e8ba00 | ||
|
e9a15cd8d5 | ||
|
eafd70d438 | ||
|
b783b49a09 | ||
|
03f8e3bece | ||
|
bdea52b4b1 | ||
|
7b008d9e86 | ||
|
4ec76797a5 | ||
|
a2df29ddbe | ||
|
480f9475cc | ||
|
99bafe8aea | ||
|
6c6f9d5123 | ||
|
139885b531 | ||
|
9d91a83c68 | ||
|
7168afec66 | ||
|
fd487b9d4c | ||
|
041a83f2e9 | ||
|
b1ae243255 | ||
|
7240878bcd | ||
|
759a6ca865 | ||
|
86b9d54786 | ||
|
e9c542d349 | ||
|
dba3621333 | ||
|
0bd7463e93 | ||
|
bafa7106b7 | ||
|
61f3ce5658 | ||
|
45e531436d | ||
|
33c9033947 | ||
|
37d8f61ced | ||
|
98f4402fb1 | ||
|
5dc93f28a6 | ||
|
252a892560 | ||
|
731dbdae97 | ||
|
523c723a59 | ||
|
b525c5bab9 | ||
|
cc8ed53988 | ||
|
4e97bfd7c5 | ||
|
f2ece18475 | ||
|
ef267d32ab | ||
|
8aa2da6e8c | ||
|
c4f97c8c82 | ||
|
0ed9d0da06 | ||
|
1726e69a02 | ||
|
d62eb4359f | ||
|
48b40af6e1 | ||
|
71ac6e5cc7 | ||
|
316771e952 | ||
|
9dba9b5a33 | ||
|
aea239c0dc | ||
|
6ef75f8a47 | ||
|
978292e998 | ||
|
57540dcbc6 | ||
|
e34564e4d2 | ||
|
800d258e5d | ||
|
e1e88c780a | ||
|
95ee33f1f2 | ||
|
a515212abe | ||
|
ca261ca10d | ||
|
3b2d41e96b | ||
|
932f0dfdfb | ||
|
e950a12c84 | ||
|
3ce1133c35 | ||
|
ecf5e65849 | ||
|
46cc0e01cf | ||
|
f5feca8262 | ||
|
e4b28326ee | ||
|
5163a84142 | ||
|
e88a9f9851 | ||
|
4516434f3a | ||
|
a16d87436b | ||
|
38dd1d4e96 | ||
|
50db4bb375 | ||
|
c21ab99cd7 | ||
|
39ed30878e | ||
|
093673f6d8 | ||
|
c0a72658db | ||
|
16f0d18203 | ||
|
3833ef2f73 | ||
|
d7a4cb5f6f | ||
|
501b133fd7 | ||
|
6f0933ec15 | ||
|
48e9fb7b79 | ||
|
b2387548da | ||
|
aa9f35f1c6 | ||
|
da8cdbfec7 | ||
|
cea460bd64 | ||
|
5d87605fa8 | ||
|
5739d6eb97 | ||
|
94820681d5 | ||
|
629869178f | ||
|
97b75865a7 | ||
|
df685c5e92 | ||
|
c782bcc5d0 | ||
|
15ae4d7008 | ||
|
d880322e6f | ||
|
f894c854bf | ||
|
c0d81ceb4d |
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: |
|
||||
|
75
.github/workflows/merge.yaml
vendored
75
.github/workflows/merge.yaml
vendored
@ -8,58 +8,62 @@ 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
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install nix
|
||||
uses: DeterminateSystems/nix-installer-action@v4
|
||||
- name: Magic nix cache
|
||||
uses: DeterminateSystems/magic-nix-cache-action@v2
|
||||
uses: DeterminateSystems/nix-installer-action@v13
|
||||
- name: Build
|
||||
run: nix build .#dev.presenterm
|
||||
|
||||
@ -69,5 +73,14 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v2
|
||||
- name: Verify assets
|
||||
- name: Validate assets
|
||||
run: ./bat/verify.sh
|
||||
|
||||
json-schemas:
|
||||
name: Validate JSON schemas
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v2
|
||||
- name: Validate config file schema
|
||||
run: ./scripts/validate-config-file-schema.sh
|
||||
|
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 }}`
|
444
CHANGELOG.md
444
CHANGELOG.md
@ -1,34 +1,312 @@
|
||||
# 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
|
||||
|
||||
* Force users to explicitly enable snippet execution ([#276](https://github.com/mfontanini/presenterm/issues/276)) ([#281](https://github.com/mfontanini/presenterm/issues/281)).
|
||||
|
||||
## New features
|
||||
|
||||
* Code snippet execution for various programming languages ([#253](https://github.com/mfontanini/presenterm/issues/253)) ([#255](https://github.com/mfontanini/presenterm/issues/255)) ([#256](https://github.com/mfontanini/presenterm/issues/256)) ([#258](https://github.com/mfontanini/presenterm/issues/258)) ([#282](https://github.com/mfontanini/presenterm/issues/282)).
|
||||
* Allow executing compiled snippets in windows ([#303](https://github.com/mfontanini/presenterm/issues/303)).
|
||||
* Add support for hidden lines in code snippets ([#283](https://github.com/mfontanini/presenterm/issues/283)) ([#254](https://github.com/mfontanini/presenterm/issues/254)) - thanks @dmackdev.
|
||||
* Support [mermaid](https://mermaid.js.org/) snippet rendering to image via `+render` attribute ([#268](https://github.com/mfontanini/presenterm/issues/268)).
|
||||
* Allow scaling images dynamically based on terminal size ([#288](https://github.com/mfontanini/presenterm/issues/288)) ([#291](https://github.com/mfontanini/presenterm/issues/291)).
|
||||
* Allow scaling images generated via `+render` code blocks (mermaid, typst, latex) ([#290](https://github.com/mfontanini/presenterm/issues/290)).
|
||||
* Show `stderr` output from code execution ([#252](https://github.com/mfontanini/presenterm/issues/252)) - thanks @dmackdev.
|
||||
* Wait for code execution process to exit completely ([#250](https://github.com/mfontanini/presenterm/issues/250)) - thanks @dmackdev.
|
||||
* Generate images in `+render` code snippets asynchronously ([#273](https://github.com/mfontanini/presenterm/issues/273)) ([#293](https://github.com/mfontanini/presenterm/issues/293)) ([#284](https://github.com/mfontanini/presenterm/issues/284)) ([#279](https://github.com/mfontanini/presenterm/issues/279)).
|
||||
* Dim non highlighted code snippet lines ([#287](https://github.com/mfontanini/presenterm/issues/287)).
|
||||
* Shrink snippet execution to match code block width ([#286](https://github.com/mfontanini/presenterm/issues/286)).
|
||||
* Include code snippet execution output in generated PDF ([#295](https://github.com/mfontanini/presenterm/issues/295)).
|
||||
* Cache `+render` block images ([#270](https://github.com/mfontanini/presenterm/issues/270)).
|
||||
* Add kotlin script executor ([#257](https://github.com/mfontanini/presenterm/issues/257)) - thanks @dmackdev.
|
||||
* Add nushell code execution ([#274](https://github.com/mfontanini/presenterm/issues/274)) ([#275](https://github.com/mfontanini/presenterm/issues/275)) - thanks @PitiBouchon.
|
||||
* Add rust-script as a new code executor ([#269](https://github.com/mfontanini/presenterm/issues/269)) - @ZhangHanDong.
|
||||
* Allow custom themes to extend others ([#265](https://github.com/mfontanini/presenterm/issues/265)).
|
||||
* Allow jumping fast between slides ([#244](https://github.com/mfontanini/presenterm/issues/244)).
|
||||
* Allow explicitly disabling footer in certain slides ([#239](https://github.com/mfontanini/presenterm/issues/239)).
|
||||
* Allow using image paths in typst ([#235](https://github.com/mfontanini/presenterm/issues/235)).
|
||||
* Add JSON schema for validation,completion,documentation ([#228](https://github.com/mfontanini/presenterm/issues/228)) ([#236](https://github.com/mfontanini/presenterm/issues/236)) - thanks @mikavilpas.
|
||||
* Allow having multiple authors ([#227](https://github.com/mfontanini/presenterm/issues/227)).
|
||||
|
||||
## Fixes
|
||||
|
||||
* Avoid re-rendering code output and auto rendered blocks ([#280](https://github.com/mfontanini/presenterm/issues/280)).
|
||||
* Use unicode width to calculate execution output's line len ([#261](https://github.com/mfontanini/presenterm/issues/261)).
|
||||
* Display background color behind '\t' in code exec output ([#245](https://github.com/mfontanini/presenterm/issues/245)).
|
||||
* Close child process stdin by default ([#297](https://github.com/mfontanini/presenterm/issues/297)).
|
||||
|
||||
## Improvements
|
||||
|
||||
* Update install instructions for Arch Linux ([#248](https://github.com/mfontanini/presenterm/issues/248)) - thanks @orhun.
|
||||
* Fix all clippy warnings ([#231](https://github.com/mfontanini/presenterm/issues/231)) - thanks @mikavilpas.
|
||||
* Include strict `_front_matter_parsing` in default config ([#229](https://github.com/mfontanini/presenterm/issues/229)) - thanks @mikavilpas.
|
||||
* `CHANGELOG.md` contains clickable links to issues ([#230](https://github.com/mfontanini/presenterm/issues/230)) - thanks @mikavilpas.
|
||||
* Add Support for Ruby Code Highlighting ([#226](https://github.com/mfontanini/presenterm/issues/226)) - thanks @pranavrao145.
|
||||
* Use ".presenterm" as prefix for tmp files ([#306](https://github.com/mfontanini/presenterm/issues/306)).
|
||||
* Add more descriptive error message when loading image fails ([#298](https://github.com/mfontanini/presenterm/issues/298)).
|
||||
* Align all error messages to left ([#301](https://github.com/mfontanini/presenterm/issues/301)).
|
||||
|
||||
# v0.7.0 - 2024-03-02
|
||||
|
||||
## New features
|
||||
|
||||
* Add color to prefix in block quote (#218).
|
||||
* Allow having code blocks without background (#215 #216).
|
||||
* Allow validating whether presentation overflows terminal (#209 #211).
|
||||
* Add parameter to list themes (#207).
|
||||
* Add catppuccin themes (#197 #205 #206) - thanks @Mawdac.
|
||||
* Detect konsole terminal emulator (#204).
|
||||
* Allow customizing slide title style (#201).
|
||||
* Add color to prefix in block quote ([#218](https://github.com/mfontanini/presenterm/issues/218)).
|
||||
* Allow having code blocks without background ([#215](https://github.com/mfontanini/presenterm/issues/215) [#216](https://github.com/mfontanini/presenterm/issues/216)).
|
||||
* Allow validating whether presentation overflows terminal ([#209](https://github.com/mfontanini/presenterm/issues/209) [#211](https://github.com/mfontanini/presenterm/issues/211)).
|
||||
* Add parameter to list themes ([#207](https://github.com/mfontanini/presenterm/issues/207)).
|
||||
* Add catppuccin themes ([#197](https://github.com/mfontanini/presenterm/issues/197) [#205](https://github.com/mfontanini/presenterm/issues/205) [#206](https://github.com/mfontanini/presenterm/issues/206)) - thanks @Mawdac.
|
||||
* Detect konsole terminal emulator ([#204](https://github.com/mfontanini/presenterm/issues/204)).
|
||||
* Allow customizing slide title style ([#201](https://github.com/mfontanini/presenterm/issues/201)).
|
||||
|
||||
## Fixes
|
||||
|
||||
* Don't crash in present mode (#210).
|
||||
* Set colors properly before displaying an error (#212).
|
||||
* Don't crash in present mode ([#210](https://github.com/mfontanini/presenterm/issues/210)).
|
||||
* Set colors properly before displaying an error ([#212](https://github.com/mfontanini/presenterm/issues/212)).
|
||||
|
||||
## Improvements
|
||||
|
||||
* Suggest a tool is missing when spawning returns ENOTFOUND (#221).
|
||||
* Sort input file list (#202) - thanks @bmwiedemann.
|
||||
* Add more example presentations (#217).
|
||||
* Add Scoop to package managers (#200) - thanks @nagromc.
|
||||
* Remove support for uncommon image formats (#208).
|
||||
* Suggest a tool is missing when spawning returns ENOTFOUND ([#221](https://github.com/mfontanini/presenterm/issues/221)).
|
||||
* Sort input file list ([#202](https://github.com/mfontanini/presenterm/issues/202)) - thanks @bmwiedemann.
|
||||
* Add more example presentations ([#217](https://github.com/mfontanini/presenterm/issues/217)).
|
||||
* Add Scoop to package managers ([#200](https://github.com/mfontanini/presenterm/issues/200)) - thanks @nagromc.
|
||||
* Remove support for uncommon image formats ([#208](https://github.com/mfontanini/presenterm/issues/208)).
|
||||
|
||||
# v0.6.1 - 2024-02-11
|
||||
|
||||
## Fixes
|
||||
|
||||
* Don't escape symbols in block quotes (#195).
|
||||
* Respect `XDG_CONFIG_HOME` when loading configuration files and custom themes (#193).
|
||||
* Don't escape symbols in block quotes ([#195](https://github.com/mfontanini/presenterm/issues/195)).
|
||||
* Respect `XDG_CONFIG_HOME` when loading configuration files and custom themes ([#193](https://github.com/mfontanini/presenterm/issues/193)).
|
||||
|
||||
# v0.6.0 - 2024-02-09
|
||||
|
||||
@ -40,116 +318,116 @@
|
||||
|
||||
## New features
|
||||
|
||||
* Add `f` keys, tab, and backspace as possible bindings (#188).
|
||||
* Add support for multiline block quotes (#184).
|
||||
* Use theme color as background on ascii-blocks mode images (#182).
|
||||
* Blend ascii-blocks image semi-transparent borders (#185).
|
||||
* Respect Windows/macOS config paths for configuration (#181).
|
||||
* Allow making front matter strict parsing optional (#190).
|
||||
* Add `f` keys, tab, and backspace as possible bindings ([#188](https://github.com/mfontanini/presenterm/issues/188)).
|
||||
* Add support for multiline block quotes ([#184](https://github.com/mfontanini/presenterm/issues/184)).
|
||||
* Use theme color as background on ascii-blocks mode images ([#182](https://github.com/mfontanini/presenterm/issues/182)).
|
||||
* Blend ascii-blocks image semi-transparent borders ([#185](https://github.com/mfontanini/presenterm/issues/185)).
|
||||
* Respect Windows/macOS config paths for configuration ([#181](https://github.com/mfontanini/presenterm/issues/181)).
|
||||
* Allow making front matter strict parsing optional ([#190](https://github.com/mfontanini/presenterm/issues/190)).
|
||||
|
||||
## Fixes
|
||||
|
||||
* Don't add an extra line after an end slide shorthand (#187).
|
||||
* Don't clear input state on key release event (#183).
|
||||
* Don't add an extra line after an end slide shorthand ([#187](https://github.com/mfontanini/presenterm/issues/187)).
|
||||
* Don't clear input state on key release event ([#183](https://github.com/mfontanini/presenterm/issues/183)).
|
||||
|
||||
# v0.5.0 - 2024-01-26
|
||||
|
||||
## New features
|
||||
|
||||
* Support images on Windows (#120).
|
||||
* Support animated gifs on kitty terminal (#157 #161).
|
||||
* Support images on tmux running in kitty terminal (#166).
|
||||
* Improve sixel support (#169 #172).
|
||||
* Use synchronized updates to remove flickering when switching slides (#156).
|
||||
* Add newlines command (#167).
|
||||
* Detect image protocol instead of relying on viuer (#160).
|
||||
* Turn documentation into mdbook (#141 #147) - thanks @pwnwriter.
|
||||
* Allow using thematic breaks to end slides (#138).
|
||||
* Allow specifying the preferred image protocol via `--image-protocol` / config file (#136 #170).
|
||||
* Add slide index modal (#128 #139 #133 #158).
|
||||
* Allow defining custom keybindings in config file (#132 #155).
|
||||
* Add key bindings modal (#152).
|
||||
* Prioritize CLI args `--theme` over anything else (#116).
|
||||
* Allow enabling automatic list pauses (#106 #109 #110).
|
||||
* Allow passing in config file path via CLI arg (#174).
|
||||
* Support images on Windows ([#120](https://github.com/mfontanini/presenterm/issues/120)).
|
||||
* Support animated gifs on kitty terminal ([#157](https://github.com/mfontanini/presenterm/issues/157) [#161](https://github.com/mfontanini/presenterm/issues/161)).
|
||||
* Support images on tmux running in kitty terminal ([#166](https://github.com/mfontanini/presenterm/issues/166)).
|
||||
* Improve sixel support ([#169](https://github.com/mfontanini/presenterm/issues/169) [#172](https://github.com/mfontanini/presenterm/issues/172)).
|
||||
* Use synchronized updates to remove flickering when switching slides ([#156](https://github.com/mfontanini/presenterm/issues/156)).
|
||||
* Add newlines command ([#167](https://github.com/mfontanini/presenterm/issues/167)).
|
||||
* Detect image protocol instead of relying on viuer ([#160](https://github.com/mfontanini/presenterm/issues/160)).
|
||||
* Turn documentation into mdbook ([#141](https://github.com/mfontanini/presenterm/issues/141) [#147](https://github.com/mfontanini/presenterm/issues/147)) - thanks @pwnwriter.
|
||||
* Allow using thematic breaks to end slides ([#138](https://github.com/mfontanini/presenterm/issues/138)).
|
||||
* Allow specifying the preferred image protocol via `--image-protocol` / config file ([#136](https://github.com/mfontanini/presenterm/issues/136) [#170](https://github.com/mfontanini/presenterm/issues/170)).
|
||||
* Add slide index modal ([#128](https://github.com/mfontanini/presenterm/issues/128) [#139](https://github.com/mfontanini/presenterm/issues/139) [#133](https://github.com/mfontanini/presenterm/issues/133) [#158](https://github.com/mfontanini/presenterm/issues/158)).
|
||||
* Allow defining custom keybindings in config file ([#132](https://github.com/mfontanini/presenterm/issues/132) [#155](https://github.com/mfontanini/presenterm/issues/155)).
|
||||
* Add key bindings modal ([#152](https://github.com/mfontanini/presenterm/issues/152)).
|
||||
* Prioritize CLI args `--theme` over anything else ([#116](https://github.com/mfontanini/presenterm/issues/116)).
|
||||
* Allow enabling automatic list pauses ([#106](https://github.com/mfontanini/presenterm/issues/106) [#109](https://github.com/mfontanini/presenterm/issues/109) [#110](https://github.com/mfontanini/presenterm/issues/110)).
|
||||
* Allow passing in config file path via CLI arg ([#174](https://github.com/mfontanini/presenterm/issues/174)).
|
||||
|
||||
## Fixes
|
||||
|
||||
* Shrink columns layout dimensions correctly when shrinking left (#113).
|
||||
* Explicitly set execution output foreground color in built-in themes (#122).
|
||||
* Detect sixel early and fallback to ascii blocks properly (#135).
|
||||
* Exit with a clap error on missing path (#150).
|
||||
* Don't blow up if presentation file temporarily disappears (#154).
|
||||
* Parse front matter properly in presence of \r\n (#162).
|
||||
* Don't preload graphics mode when generating pdf metadata (#168).
|
||||
* Ignore key release events (#119).
|
||||
* Shrink columns layout dimensions correctly when shrinking left ([#113](https://github.com/mfontanini/presenterm/issues/113)).
|
||||
* Explicitly set execution output foreground color in built-in themes ([#122](https://github.com/mfontanini/presenterm/issues/122)).
|
||||
* Detect sixel early and fallback to ascii blocks properly ([#135](https://github.com/mfontanini/presenterm/issues/135)).
|
||||
* Exit with a clap error on missing path ([#150](https://github.com/mfontanini/presenterm/issues/150)).
|
||||
* Don't blow up if presentation file temporarily disappears ([#154](https://github.com/mfontanini/presenterm/issues/154)).
|
||||
* Parse front matter properly in presence of \r\n ([#162](https://github.com/mfontanini/presenterm/issues/162)).
|
||||
* Don't preload graphics mode when generating pdf metadata ([#168](https://github.com/mfontanini/presenterm/issues/168)).
|
||||
* Ignore key release events ([#119](https://github.com/mfontanini/presenterm/issues/119)).
|
||||
|
||||
## Improvements
|
||||
|
||||
* Validate that config file contains the right attributes (#107).
|
||||
* Display first presentation load error as any other (#118).
|
||||
* Add hashes for windows artifacts (#126).
|
||||
* Remove arch packaging files (#111).
|
||||
* Lower CPU and memory usage when displaying images (#157).
|
||||
* Validate that config file contains the right attributes ([#107](https://github.com/mfontanini/presenterm/issues/107)).
|
||||
* Display first presentation load error as any other ([#118](https://github.com/mfontanini/presenterm/issues/118)).
|
||||
* Add hashes for windows artifacts ([#126](https://github.com/mfontanini/presenterm/issues/126)).
|
||||
* Remove arch packaging files ([#111](https://github.com/mfontanini/presenterm/issues/111)).
|
||||
* Lower CPU and memory usage when displaying images ([#157](https://github.com/mfontanini/presenterm/issues/157)).
|
||||
|
||||
# v0.4.1 - 2023-12-22
|
||||
|
||||
## New features
|
||||
|
||||
* Cause an error if an unknown field name is found on a theme, config file, or front matter (#102).
|
||||
* Cause an error if an unknown field name is found on a theme, config file, or front matter ([#102](https://github.com/mfontanini/presenterm/issues/102)).
|
||||
|
||||
## Fixes
|
||||
|
||||
* Explicitly disable kitty/iterm protocols when printing images in export PDF mode as this was causing PDF generation in
|
||||
macOS to fail (#101).
|
||||
macOS to fail ([#101](https://github.com/mfontanini/presenterm/issues/101)).
|
||||
|
||||
# v0.4.0 - 2023-12-16
|
||||
|
||||
## New features
|
||||
|
||||
* Add support for all of [bat](https://github.com/sharkdp/bat)'s code highlighting themes (#67).
|
||||
* Add `terminal-dark` and `terminal-light` themes that preserve the terminal's colors and background (#68 #69).
|
||||
* Add support for all of [bat](https://github.com/sharkdp/bat)'s code highlighting themes ([#67](https://github.com/mfontanini/presenterm/issues/67)).
|
||||
* Add `terminal-dark` and `terminal-light` themes that preserve the terminal's colors and background ([#68](https://github.com/mfontanini/presenterm/issues/68) [#69](https://github.com/mfontanini/presenterm/issues/69)).
|
||||
* Allow placing themes in `$HOME/.config/presenterm/themes` to make them available automatically as if they were
|
||||
built-in themes (#73).
|
||||
* Allow configuring the default theme in `$HOME/.config/presenterm/config.yaml` (#74).
|
||||
* Add support for rendering _LaTeX_ and _typst_ code blocks automatically as images (#75 #76 #79 #81).
|
||||
* Add syntax highlighting support for _nix_ and _diff_ (#78 #82).
|
||||
* Add comment command to jump into the middle of a slide (#86).
|
||||
* Add configuration option to have implicit slide ends (#87 #89).
|
||||
* Add configuration option to have custom comment-command prefix (#91).
|
||||
built-in themes ([#73](https://github.com/mfontanini/presenterm/issues/73)).
|
||||
* Allow configuring the default theme in `$HOME/.config/presenterm/config.yaml` ([#74](https://github.com/mfontanini/presenterm/issues/74)).
|
||||
* Add support for rendering _LaTeX_ and _typst_ code blocks automatically as images ([#75](https://github.com/mfontanini/presenterm/issues/75) [#76](https://github.com/mfontanini/presenterm/issues/76) [#79](https://github.com/mfontanini/presenterm/issues/79) [#81](https://github.com/mfontanini/presenterm/issues/81)).
|
||||
* Add syntax highlighting support for _nix_ and _diff_ ([#78](https://github.com/mfontanini/presenterm/issues/78) [#82](https://github.com/mfontanini/presenterm/issues/82)).
|
||||
* Add comment command to jump into the middle of a slide ([#86](https://github.com/mfontanini/presenterm/issues/86)).
|
||||
* Add configuration option to have implicit slide ends ([#87](https://github.com/mfontanini/presenterm/issues/87) [#89](https://github.com/mfontanini/presenterm/issues/89)).
|
||||
* Add configuration option to have custom comment-command prefix ([#91](https://github.com/mfontanini/presenterm/issues/91)).
|
||||
|
||||
# v0.3.0 - 2023-11-24
|
||||
|
||||
## New features
|
||||
|
||||
* Support more languages in code blocks thanks to [bat](https://github.com/sharkdp/bat)'s syntax sets (#21 #53).
|
||||
* Add shell script executable code blocks (#17).
|
||||
* Allow exporting presentation to PDF (#43 #60).
|
||||
* Pauses no longer create new slides (#18 #25 #34 #42).
|
||||
* Allow display code block line numbers (#46).
|
||||
* Allow code block selective line highlighting (#48).
|
||||
* Allow code block dynamic line highlighting (#49).
|
||||
* Support animated gifs when using the iterm2 image protocol (#56).
|
||||
* Nix flake packaging (#11 #27).
|
||||
* Arch repo packaging (#10).
|
||||
* Support more languages in code blocks thanks to [bat](https://github.com/sharkdp/bat)'s syntax sets ([#21](https://github.com/mfontanini/presenterm/issues/21) [#53](https://github.com/mfontanini/presenterm/issues/53)).
|
||||
* Add shell script executable code blocks ([#17](https://github.com/mfontanini/presenterm/issues/17)).
|
||||
* Allow exporting presentation to PDF ([#43](https://github.com/mfontanini/presenterm/issues/43) [#60](https://github.com/mfontanini/presenterm/issues/60)).
|
||||
* Pauses no longer create new slides ([#18](https://github.com/mfontanini/presenterm/issues/18) [#25](https://github.com/mfontanini/presenterm/issues/25) [#34](https://github.com/mfontanini/presenterm/issues/34) [#42](https://github.com/mfontanini/presenterm/issues/42)).
|
||||
* Allow display code block line numbers ([#46](https://github.com/mfontanini/presenterm/issues/46)).
|
||||
* Allow code block selective line highlighting ([#48](https://github.com/mfontanini/presenterm/issues/48)).
|
||||
* Allow code block dynamic line highlighting ([#49](https://github.com/mfontanini/presenterm/issues/49)).
|
||||
* Support animated gifs when using the iterm2 image protocol ([#56](https://github.com/mfontanini/presenterm/issues/56)).
|
||||
* Nix flake packaging ([#11](https://github.com/mfontanini/presenterm/issues/11) [#27](https://github.com/mfontanini/presenterm/issues/27)).
|
||||
* Arch repo packaging ([#10](https://github.com/mfontanini/presenterm/issues/10)).
|
||||
* Ignore vim-like code folding tags in comments.
|
||||
* Add keybinding to refresh assets in presentation (#38).
|
||||
* Template style footer is now one row above bottom (#39).
|
||||
* Add keybinding to refresh assets in presentation ([#38](https://github.com/mfontanini/presenterm/issues/38)).
|
||||
* Template style footer is now one row above bottom ([#39](https://github.com/mfontanini/presenterm/issues/39)).
|
||||
* Add `light` theme.
|
||||
|
||||
## Fixes
|
||||
|
||||
* Don't crash on Windows when terminal window size can't be found (#14).
|
||||
* Don't reset numbers on ordered lists when using pauses in between (#19).
|
||||
* Show proper line number when parsing a comment command fails (#29 #40).
|
||||
* Don't reset the default footer when overriding theme in presentation without setting footer (#52).
|
||||
* Don't let code blocks/block quotes that don't fit on the screen cause images to overlap with text (#57).
|
||||
* Don't crash on Windows when terminal window size can't be found ([#14](https://github.com/mfontanini/presenterm/issues/14)).
|
||||
* Don't reset numbers on ordered lists when using pauses in between ([#19](https://github.com/mfontanini/presenterm/issues/19)).
|
||||
* Show proper line number when parsing a comment command fails ([#29](https://github.com/mfontanini/presenterm/issues/29) [#40](https://github.com/mfontanini/presenterm/issues/40)).
|
||||
* Don't reset the default footer when overriding theme in presentation without setting footer ([#52](https://github.com/mfontanini/presenterm/issues/52)).
|
||||
* Don't let code blocks/block quotes that don't fit on the screen cause images to overlap with text ([#57](https://github.com/mfontanini/presenterm/issues/57)).
|
||||
|
||||
# v0.2.1 - 2023-10-18
|
||||
|
||||
## New features
|
||||
|
||||
* Binary artifacts are now automatically generated when a new release is done (#5) - thanks @pwnwriter.
|
||||
* Binary artifacts are now automatically generated when a new release is done ([#5](https://github.com/mfontanini/presenterm/issues/5)) - thanks @pwnwriter.
|
||||
|
||||
# v0.2.0 - 2023-10-17
|
||||
|
||||
@ -163,7 +441,7 @@
|
||||
|
||||
## Fixes
|
||||
|
||||
* Allow using `sh` as language for code block (#3).
|
||||
* Allow using `sh` as language for code block ([#3](https://github.com/mfontanini/presenterm/issues/3)).
|
||||
* Minimum size for code blocks is now prioritized over minimum margin.
|
||||
* Overflowing lines in lists will now correctly be padded to align all text under the same starting column.
|
||||
* Running `cargo run` will now rebuild the tool if any of the built-in themes changed.
|
||||
|
1160
Cargo.lock
generated
1160
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
45
Cargo.toml
45
Cargo.toml
@ -4,46 +4,47 @@ authors = ["Matias Fontanini"]
|
||||
description = "A terminal slideshow presentation tool"
|
||||
repository = "https://github.com/mfontanini/presenterm"
|
||||
license = "BSD-2-Clause"
|
||||
version = "0.7.0"
|
||||
version = "0.13.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.21"
|
||||
anyhow = "1"
|
||||
base64 = "0.22"
|
||||
bincode = "1.3"
|
||||
clap = { version = "4.4", features = ["derive", "string"] }
|
||||
comrak = { version = "0.21", 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.24", features = ["gif", "jpeg", "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.12"
|
||||
itertools = "0.14"
|
||||
once_cell = "1.19"
|
||||
rand = "0.8.5"
|
||||
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"
|
||||
|
||||
[dependencies.syntect]
|
||||
version = "5.2"
|
||||
default-features = false
|
||||
features = ["parsing", "default-themes", "regex-onig", "plist-load"]
|
||||
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"
|
||||
libc = "0.2"
|
||||
vte = "0.15"
|
||||
|
||||
[dev-dependencies]
|
||||
rstest = { version = "0.18", default-features = false }
|
||||
rstest = { version = "0.25", default-features = false }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
sixel = ["sixel-rs"]
|
||||
json-schema = ["dep:schemars"]
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 0
|
||||
|
98
README.md
98
README.md
@ -10,14 +10,15 @@ presenterm
|
||||
[nix-package]: https://search.nixos.org/packages?size=1&show=presenterm
|
||||
[crates-badge]: https://img.shields.io/crates/v/presenterm
|
||||
[crates-package]: https://crates.io/crates/presenterm
|
||||
[arch-badge]: https://img.shields.io/aur/version/presenterm-bin
|
||||
[arch-package]: https://aur.archlinux.org/packages/presenterm-bin
|
||||
[arch-badge]: https://img.shields.io/archlinux/v/extra/x86_64/presenterm
|
||||
[arch-package]: https://archlinux.org/packages/extra/x86_64/presenterm/
|
||||
[scoop-badge]: https://img.shields.io/scoop/v/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,49 +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].
|
||||
* [_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].
|
||||
* [Shell code execution][guide-code-execute].
|
||||
* [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-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
|
||||
|
10
build.rs
10
build.rs
@ -6,13 +6,13 @@ use std::{
|
||||
|
||||
// Take all files under `themes` and turn them into a file that contains a hashmap with their
|
||||
// contents by name. This is pulled in theme.rs to construct themes.
|
||||
fn main() -> io::Result<()> {
|
||||
let out_dir = env::var("OUT_DIR").unwrap();
|
||||
fn build_themes(out_dir: &str) -> io::Result<()> {
|
||||
let output_path = format!("{out_dir}/themes.rs");
|
||||
let mut output_file = BufWriter::new(File::create(output_path)?);
|
||||
output_file.write_all(b"use std::collections::BTreeMap as Map;\n")?;
|
||||
output_file.write_all(b"use once_cell::sync::Lazy;\n")?;
|
||||
output_file.write_all(b"static THEMES: Lazy<Map<&'static str, &'static [u8]>> = Lazy::new(|| Map::from([\n")?;
|
||||
|
||||
let mut paths = fs::read_dir("themes")?.collect::<io::Result<Vec<_>>>()?;
|
||||
paths.sort_by_key(|e| e.path());
|
||||
for theme_file in paths {
|
||||
@ -33,3 +33,9 @@ fn main() -> io::Result<()> {
|
||||
println!("cargo:rerun-if-changed=themes");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() -> io::Result<()> {
|
||||
let out_dir = env::var("OUT_DIR").unwrap();
|
||||
build_themes(&out_dir)?;
|
||||
Ok(())
|
||||
}
|
||||
|
814
config-file-schema.json
Normal file
814
config-file-schema.json
Normal file
@ -0,0 +1,814 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Config",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"bindings": {
|
||||
"$ref": "#/definitions/KeyBindingsConfig"
|
||||
},
|
||||
"defaults": {
|
||||
"description": "The default configuration for the presentation.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/DefaultsConfig"
|
||||
}
|
||||
]
|
||||
},
|
||||
"export": {
|
||||
"$ref": "#/definitions/ExportConfig"
|
||||
},
|
||||
"mermaid": {
|
||||
"$ref": "#/definitions/MermaidConfig"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/OptionsConfig"
|
||||
},
|
||||
"snippet": {
|
||||
"$ref": "#/definitions/SnippetConfig"
|
||||
},
|
||||
"speaker_notes": {
|
||||
"$ref": "#/definitions/SpeakerNotesConfig"
|
||||
},
|
||||
"transition": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/SlideTransitionConfig"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"typst": {
|
||||
"$ref": "#/definitions/TypstConfig"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"definitions": {
|
||||
"DefaultsConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"image_protocol": {
|
||||
"description": "The image protocol to use.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ImageProtocol"
|
||||
}
|
||||
]
|
||||
},
|
||||
"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,
|
||||
"type": "integer",
|
||||
"format": "uint8",
|
||||
"minimum": 1.0
|
||||
},
|
||||
"theme": {
|
||||
"description": "The theme to use by default in every presentation unless overridden.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"validate_overflows": {
|
||||
"description": "Validate that the presentation does not overflow the terminal screen.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ValidateOverflows"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"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": [
|
||||
{
|
||||
"description": "Automatically detect the best image protocol to use.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"auto"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Use the iTerm2 image protocol.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"iterm2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "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.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"kitty-local"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "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.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"kitty-remote"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Use the sixel protocol. Note that this requires compiling presenterm using the --features sixel flag.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"sixel"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "The default image protocol to use when no other is specified.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ascii-blocks"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"KeyBindingsConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"close_modal": {
|
||||
"description": "The key binding to close the currently open modal.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/KeyBinding"
|
||||
}
|
||||
},
|
||||
"execute_code": {
|
||||
"description": "The key binding to execute a piece of shell code.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/KeyBinding"
|
||||
}
|
||||
},
|
||||
"exit": {
|
||||
"description": "The key binding to close the application.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/KeyBinding"
|
||||
}
|
||||
},
|
||||
"first_slide": {
|
||||
"description": "The key binding to jump to the first slide.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/KeyBinding"
|
||||
}
|
||||
},
|
||||
"go_to_slide": {
|
||||
"description": "The key binding to jump to a specific slide.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/KeyBinding"
|
||||
}
|
||||
},
|
||||
"last_slide": {
|
||||
"description": "The key binding to jump to the last slide.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/KeyBinding"
|
||||
}
|
||||
},
|
||||
"next": {
|
||||
"description": "The keys that cause the presentation to move forwards.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/KeyBinding"
|
||||
}
|
||||
},
|
||||
"next_fast": {
|
||||
"description": "The keys that cause the presentation to jump to the next slide \"fast\".\n\n\"fast\" means for slides that contain pauses, we will only jump between the first and last pause rather than going through each individual one.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/KeyBinding"
|
||||
}
|
||||
},
|
||||
"previous": {
|
||||
"description": "The keys that cause the presentation to move backwards.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/KeyBinding"
|
||||
}
|
||||
},
|
||||
"previous_fast": {
|
||||
"description": "The keys that cause the presentation to move backwards \"fast\".\n\n\"fast\" means for slides that contain pauses, we will only jump between the first and last pause rather than going through each individual one.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/KeyBinding"
|
||||
}
|
||||
},
|
||||
"reload": {
|
||||
"description": "The key binding to reload the presentation.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$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",
|
||||
"items": {
|
||||
"$ref": "#/definitions/KeyBinding"
|
||||
}
|
||||
},
|
||||
"toggle_slide_index": {
|
||||
"description": "The key binding to toggle the slide index modal.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/KeyBinding"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"LanguageSnippetExecutionConfig": {
|
||||
"description": "The snippet execution configuration for a specific programming language.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"commands",
|
||||
"filename"
|
||||
],
|
||||
"properties": {
|
||||
"commands": {
|
||||
"description": "The commands to be run when executing snippets for this programming language.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"environment": {
|
||||
"description": "The environment variables to set before invoking every command.",
|
||||
"default": {},
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"scale": {
|
||||
"description": "The scaling parameter to be used in the mermaid CLI.",
|
||||
"default": 2,
|
||||
"type": "integer",
|
||||
"format": "uint32",
|
||||
"minimum": 0.0
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"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": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"end_slide_shorthand": {
|
||||
"description": "Whether to treat a thematic break as a slide end.",
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"image_attributes_prefix": {
|
||||
"description": "The prefix to use for image attributes.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"implicit_slide_ends": {
|
||||
"description": "Whether slides are automatically terminated when a slide title is found.",
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"incremental_lists": {
|
||||
"description": "Show all lists incrementally, by implicitly adding pauses in between elements.",
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"strict_front_matter_parsing": {
|
||||
"description": "Whether to be strict about parsing the presentation's front matter.",
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"exec": {
|
||||
"description": "The properties for snippet execution.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/SnippetExecConfig"
|
||||
}
|
||||
]
|
||||
},
|
||||
"exec_replace": {
|
||||
"description": "The properties for snippet execution.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/SnippetExecReplaceConfig"
|
||||
}
|
||||
]
|
||||
},
|
||||
"render": {
|
||||
"description": "The properties for snippet auto rendering.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/SnippetRenderConfig"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SnippetExecConfig": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"enable"
|
||||
],
|
||||
"properties": {
|
||||
"custom": {
|
||||
"description": "Custom snippet executors.",
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/LanguageSnippetExecutionConfig"
|
||||
}
|
||||
},
|
||||
"enable": {
|
||||
"description": "Whether to enable snippet execution.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"threads": {
|
||||
"description": "The number of threads to use when rendering.",
|
||||
"default": 2,
|
||||
"type": "integer",
|
||||
"format": "uint",
|
||||
"minimum": 0.0
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"ppi": {
|
||||
"description": "The pixels per inch when rendering latex/typst formulas.",
|
||||
"default": 300,
|
||||
"type": "integer",
|
||||
"format": "uint32",
|
||||
"minimum": 0.0
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ValidateOverflows": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"never",
|
||||
"always",
|
||||
"when_presenting",
|
||||
"when_developing"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
@ -1,9 +1,11 @@
|
||||
---
|
||||
# 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.
|
||||
terminal_font_size: 16
|
||||
|
||||
# the theme to use by default in every presentation unless overridden.
|
||||
theme: dark
|
||||
theme: dark
|
||||
|
||||
# the image protocol to use.
|
||||
image_protocol: kitty-local
|
||||
@ -12,6 +14,10 @@ typst:
|
||||
# the pixels per inch when rendering latex/typst formulas.
|
||||
ppi: 300
|
||||
|
||||
mermaid:
|
||||
# the scale parameter passed to the mermaid CLI (mmdc).
|
||||
scale: 2
|
||||
|
||||
options:
|
||||
# whether slides are automatically terminated when a slide title is found.
|
||||
implicit_slide_ends: false
|
||||
@ -22,16 +28,50 @@ options:
|
||||
# show all lists incrementally, by implicitly adding pauses in between elements.
|
||||
incremental_lists: false
|
||||
|
||||
# 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
|
||||
strict_front_matter_parsing: true
|
||||
|
||||
# whether to treat a thematic break as a slide end.
|
||||
end_slide_shorthand: false
|
||||
|
||||
snippet:
|
||||
exec:
|
||||
# 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>", " "]
|
||||
|
||||
# the keys that cause the presentation to move forwards fast.
|
||||
next_fast: ["n"]
|
||||
|
||||
# the keys that cause the presentation to move backwards.
|
||||
previous: ["h", "k", "<left>", "<page_up>", "<up>"]
|
||||
|
||||
# the keys that cause the presentation to move backwards fast
|
||||
previous_fast: ["p"]
|
||||
|
||||
# the key binding to jump to the first slide.
|
||||
first_slide: ["gg"]
|
||||
|
||||
@ -48,13 +88,16 @@ bindings:
|
||||
reload: ["<c-r>"]
|
||||
|
||||
# the key binding to toggle the slide index modal.
|
||||
toggle_slide_index: ["<c-p>"]
|
||||
toggle_slide_index: ["<c-p>"]
|
||||
|
||||
# the key binding to toggle the key bindings modal.
|
||||
toggle_bindings: ["?"]
|
||||
toggle_bindings: ["?"]
|
||||
|
||||
# the key binding to close the currently open modal.
|
||||
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,16 +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)
|
||||
- [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.
|
88
docs/src/features/code/latex.md
Normal file
88
docs/src/features/code/latex.md
Normal file
@ -0,0 +1,88 @@
|
||||
# LaTeX and typst
|
||||
|
||||
`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.
|
||||
|
||||
For example, the following presentation:
|
||||
|
||||
~~~
|
||||
# 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
|
||||
|
||||
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:
|
||||
|
||||
* 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
|
||||
|
||||
_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
|
||||
generated on the fly will have a fixed size. Configuring the PPI used during the conversion can let you adjust this: 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/introduction.md):
|
||||
|
||||
```yaml
|
||||
typst:
|
||||
ppi: 400
|
||||
```
|
||||
|
||||
The default is 300 so adjust it and see what works for you.
|
||||
|
||||
## 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.
|
||||
|
||||
~~~markdown
|
||||
```typst +render +width:50%
|
||||
$f(x) = x + 1$
|
||||
```
|
||||
~~~
|
||||
|
||||
## Customizations
|
||||
|
||||
The colors and margin of the generated images can be defined in your theme:
|
||||
|
||||
```yaml
|
||||
typst:
|
||||
colors:
|
||||
background: ff0000
|
||||
foreground: 00ff00
|
||||
|
||||
# In points
|
||||
horizontal_margin: 2
|
||||
vertical_margin: 2
|
||||
```
|
50
docs/src/features/code/mermaid.md
Normal file
50
docs/src/features/code/mermaid.md
Normal file
@ -0,0 +1,50 @@
|
||||
## 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:
|
||||
|
||||
~~~markdown
|
||||
```mermaid +render
|
||||
sequenceDiagram
|
||||
Mark --> Bob: Hello!
|
||||
Bob --> Mark: Oh, hi mark!
|
||||
```
|
||||
~~~
|
||||
|
||||
**This requires having [mermaid-cli](https://github.com/mermaid-js/mermaid-cli) installed**.
|
||||
|
||||
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/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/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:
|
||||
|
||||
~~~markdown
|
||||
```mermaid +render +width:50%
|
||||
sequenceDiagram
|
||||
Mark --> Bob: Hello!
|
||||
Bob --> Mark: Oh, hi mark!
|
||||
```
|
||||
~~~
|
||||
|
||||
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,226 +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 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!
|
||||
|
||||
#### 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.
|
||||
|
||||
### 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,138 +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
|
||||
* 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:
|
||||
|
||||
~~~
|
||||
```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.
|
||||
|
||||
~~~
|
||||
```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.
|
||||
|
||||
~~~
|
||||
```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/dpXDXJoJRRX4mQ7V6LdR3rO2z)
|
||||
|
||||
### Executing code
|
||||
|
||||
Annotating a shell code block with a `+exec` switch 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.
|
||||
|
||||
~~~
|
||||
```bash +exec
|
||||
echo hello world
|
||||
```
|
||||
~~~
|
||||
|
||||
Note that using `bash`, `zsh`, `fish`, etc, will end up using that specific shell to execute your script.
|
||||
|
||||
[](https://asciinema.org/a/gnzjXpVSOwOiyUqQvhi0AaHG7)
|
||||
|
||||
> **Note**: because this is spawning a process and executing code, you should use this at your own risk.
|
||||
|
||||
### 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 docs](latex.html) for more information.
|
@ -1,245 +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
|
||||
```
|
||||
|
||||
## 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.
|
@ -1,69 +0,0 @@
|
||||
## 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.
|
||||
|
||||
### 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
|
||||
|
||||
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
|
||||
|
||||
_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
|
||||
generated on the fly will have a fixed size. Configuring the PPI used during the conversion can let you adjust this: 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):
|
||||
|
||||
```yaml
|
||||
typst:
|
||||
ppi: 400
|
||||
```
|
||||
|
||||
The default is 300 so adjust it and see what works for you.
|
||||
|
||||
### Customizations
|
||||
|
||||
The colors and margin of the generated images can be defined in your theme:
|
||||
|
||||
```yaml
|
||||
typst:
|
||||
colors:
|
||||
background: ff0000
|
||||
foreground: 00ff00
|
||||
|
||||
# In points
|
||||
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,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,25 +56,31 @@ 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 repository (Aur)
|
||||
## Arch Linux
|
||||
|
||||
_presenterm_ is also available in the AUR. You can use any AUR helper to install:
|
||||
_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:
|
||||
|
||||
#### Binary release (recommended)
|
||||
```bash
|
||||
pacman -S presenterm
|
||||
```
|
||||
|
||||
```shell
|
||||
#### Binary release
|
||||
|
||||
Alternatively, you can use any AUR helper to install the upstream binaries:
|
||||
|
||||
```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)
|
||||
|
@ -13,6 +13,7 @@ This presentation shows how to:
|
||||
|
||||
* Left-align code blocks.
|
||||
* Have code blocks without background.
|
||||
* Execute code snippets.
|
||||
|
||||
```rust
|
||||
pub struct Greeter {
|
||||
@ -74,3 +75,34 @@ fn main() {
|
||||
println!("{greeting}");
|
||||
}
|
||||
```
|
||||
|
||||
<!-- end_slide -->
|
||||
|
||||
Snippet execution
|
||||
===
|
||||
|
||||
Run code snippets from the presentation and display their output dynamically.
|
||||
|
||||
```python +exec
|
||||
/// import time
|
||||
for i in range(0, 5):
|
||||
print(f"count is {i}")
|
||||
time.sleep(0.5)
|
||||
```
|
||||
|
||||
<!-- end_slide -->
|
||||
|
||||
Snippet execution - `stderr`
|
||||
===
|
||||
|
||||
Output from `stderr` will also be shown as output.
|
||||
|
||||
```bash +exec
|
||||
echo "This is a successful command"
|
||||
sleep 0.5
|
||||
echo "This message redirects to stderr" >&2
|
||||
sleep 0.5
|
||||
echo "This is a successful command again"
|
||||
sleep 0.5
|
||||
man # Missing argument
|
||||
```
|
||||
|
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.
|
||||
|
210
examples/demo.md
210
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)]
|
||||
@ -138,17 +106,25 @@ impl Person {
|
||||
|
||||
<!-- end_slide -->
|
||||
|
||||
Shell code execution
|
||||
Snippet execution
|
||||
---
|
||||
|
||||
Run commands from the presentation and display their output dynamically.
|
||||
Code snippets can be executed on demand:
|
||||
|
||||
```bash +exec
|
||||
for i in $(seq 1 5)
|
||||
do
|
||||
echo "hi $i"
|
||||
sleep 0.5
|
||||
done
|
||||
* 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;
|
||||
# use std::time::Duration;
|
||||
fn main() {
|
||||
let names = ["Alice", "Bob", "Eve", "Mallory", "Trent"];
|
||||
for name in names {
|
||||
println!("Hi {name}!");
|
||||
sleep(Duration::from_millis(500));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<!-- end_slide -->
|
||||
@ -156,12 +132,17 @@ done
|
||||
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 -->
|
||||
|
||||

|
||||
|
||||
@ -172,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 {
|
||||
@ -186,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!")
|
||||
@ -211,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
|
||||
|
||||
@ -240,9 +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? -->
|
110
executors.yaml
Normal file
110
executors.yaml
Normal file
@ -0,0 +1,110 @@
|
||||
---
|
||||
bash:
|
||||
filename: script.sh
|
||||
commands:
|
||||
- ["bash", "$pwd/script.sh"]
|
||||
hidden_line_prefix: "/// "
|
||||
c++:
|
||||
filename: snippet.cpp
|
||||
commands:
|
||||
- ["g++", "-std=c++20", "-fdiagnostics-color=always", "$pwd/snippet.cpp", "-o", "$pwd/snippet"]
|
||||
- ["$pwd/snippet"]
|
||||
hidden_line_prefix: "/// "
|
||||
c:
|
||||
filename: snippet.c
|
||||
commands:
|
||||
- ["gcc", "$pwd/snippet.c", "-fdiagnostics-color=always", "-o", "$pwd/snippet"]
|
||||
- ["$pwd/snippet"]
|
||||
hidden_line_prefix: "/// "
|
||||
fish:
|
||||
filename: script.fish
|
||||
commands:
|
||||
- ["fish", "$pwd/script.fish"]
|
||||
hidden_line_prefix: "/// "
|
||||
go:
|
||||
filename: snippet.go
|
||||
environment:
|
||||
GO11MODULE: off
|
||||
commands:
|
||||
- ["go", "run", "$pwd/snippet.go"]
|
||||
hidden_line_prefix: "/// "
|
||||
haskell:
|
||||
filename: snippet.hs
|
||||
commands:
|
||||
- ["runhaskell", "-w", "$pwd/snippet.hs"]
|
||||
java:
|
||||
filename: Snippet.java
|
||||
commands:
|
||||
- ["java", "$pwd/Snippet.java"]
|
||||
hidden_line_prefix: "/// "
|
||||
js:
|
||||
filename: snippet.js
|
||||
commands:
|
||||
- ["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", "$pwd/snippet.kts"]
|
||||
hidden_line_prefix: "/// "
|
||||
lua:
|
||||
filename: snippet.lua
|
||||
commands:
|
||||
- ["lua", "$pwd/snippet.lua"]
|
||||
nushell:
|
||||
filename: snippet.nu
|
||||
commands:
|
||||
- ["nu", "$pwd/snippet.nu"]
|
||||
perl:
|
||||
filename: snippet.pl
|
||||
commands:
|
||||
- ["perl", "$pwd/snippet.pl"]
|
||||
php:
|
||||
filename: snippet.php
|
||||
commands:
|
||||
- ["php", "-f", "$pwd/snippet.php"]
|
||||
hidden_line_prefix: "/// "
|
||||
python:
|
||||
filename: snippet.py
|
||||
commands:
|
||||
- ["python", "-u", "$pwd/snippet.py"]
|
||||
hidden_line_prefix: "/// "
|
||||
r:
|
||||
filename: snippet.R
|
||||
commands:
|
||||
- ["Rscript", "$pwd/snippet.R"]
|
||||
ruby:
|
||||
filename: snippet.rb
|
||||
commands:
|
||||
- ["ruby", "$pwd/snippet.rb"]
|
||||
rust-script:
|
||||
filename: snippet.rs
|
||||
commands:
|
||||
- ["rust-script", "--debug", "$pwd/snippet.rs"]
|
||||
hidden_line_prefix: "# "
|
||||
rust:
|
||||
filename: snippet.rs
|
||||
commands:
|
||||
- ["rustc", "--crate-name", "presenterm_snippet", "$pwd/snippet.rs", "-o", "$pwd/snippet", "--color", "always"]
|
||||
- ["$pwd/snippet"]
|
||||
hidden_line_prefix: "# "
|
||||
sh:
|
||||
filename: script.sh
|
||||
commands:
|
||||
- ["sh", "$pwd/script.sh"]
|
||||
hidden_line_prefix: "/// "
|
||||
zsh:
|
||||
filename: script.sh
|
||||
commands:
|
||||
- ["zsh", "$pwd/script.sh"]
|
||||
hidden_line_prefix: "/// "
|
||||
csharp:
|
||||
filename: snippet.cs
|
||||
commands:
|
||||
- ["dotnet-script", "$pwd/snippet.cs"]
|
||||
hidden_line_prefix: "/// "
|
174
flake.lock
generated
174
flake.lock
generated
@ -3,67 +3,64 @@
|
||||
"android-nixpkgs": {
|
||||
"inputs": {
|
||||
"devshell": "devshell",
|
||||
"flake-utils": "flake-utils_2",
|
||||
"flake-utils": "flake-utils_3",
|
||||
"nixpkgs": [
|
||||
"flakebox",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1695500413,
|
||||
"narHash": "sha256-yinrAWIc4XZbWQoXOYkUO0lCNQ5z/vMyl+QCYuIwdPc=",
|
||||
"owner": "dpc",
|
||||
"lastModified": 1719001124,
|
||||
"narHash": "sha256-JXrMwYlQarZPyjN5UkD4fS9mrHSE1PUa7P//1Z5Sqr0=",
|
||||
"owner": "tadfisher",
|
||||
"repo": "android-nixpkgs",
|
||||
"rev": "2e42268a196375ce9b010a10ec5250d2f91a09b4",
|
||||
"rev": "7fa1348249564e43185d3053f579f9fa923d46cc",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "dpc",
|
||||
"owner": "tadfisher",
|
||||
"repo": "android-nixpkgs",
|
||||
"rev": "2e42268a196375ce9b010a10ec5250d2f91a09b4",
|
||||
"rev": "7fa1348249564e43185d3053f579f9fa923d46cc",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"crane": {
|
||||
"inputs": {
|
||||
"flake-compat": "flake-compat",
|
||||
"flake-utils": "flake-utils_3",
|
||||
"nixpkgs": [
|
||||
"flakebox",
|
||||
"nixpkgs"
|
||||
],
|
||||
"rust-overlay": "rust-overlay"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1697596235,
|
||||
"narHash": "sha256-4VTrrTdoA1u1wyf15krZCFl3c29YLesSNioYEgfb2FY=",
|
||||
"owner": "dpc",
|
||||
"lastModified": 1717383740,
|
||||
"narHash": "sha256-559HbY4uhNeoYvK3H6AMZAtVfmR3y8plXZ1x6ON/cWU=",
|
||||
"owner": "ipetkov",
|
||||
"repo": "crane",
|
||||
"rev": "c97a0c0d83bfdf01c29113c5592a3defc27cb315",
|
||||
"rev": "b65673fce97d277934488a451724be94cc62499a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "dpc",
|
||||
"owner": "ipetkov",
|
||||
"repo": "crane",
|
||||
"rev": "c97a0c0d83bfdf01c29113c5592a3defc27cb315",
|
||||
"rev": "b65673fce97d277934488a451724be94cc62499a",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"devshell": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils_2",
|
||||
"nixpkgs": [
|
||||
"flakebox",
|
||||
"android-nixpkgs",
|
||||
"nixpkgs"
|
||||
],
|
||||
"systems": "systems_2"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1695195896,
|
||||
"narHash": "sha256-pq9q7YsGXnQzJFkR5284TmxrLNFc0wo4NQ/a5E93CQU=",
|
||||
"lastModified": 1717408969,
|
||||
"narHash": "sha256-Q0OEFqe35fZbbRPPRdrjTUUChKVhhWXz3T9ZSKmaoVY=",
|
||||
"owner": "numtide",
|
||||
"repo": "devshell",
|
||||
"rev": "05d40d17bf3459606316e3e9ec683b784ff28f16",
|
||||
"rev": "1ebbe68d57457c8cae98145410b164b5477761f4",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@ -81,11 +78,11 @@
|
||||
"rust-analyzer-src": "rust-analyzer-src"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1696918968,
|
||||
"narHash": "sha256-18rAHsM9YsGp7aKKemO4gKIeWfrSyDsdJZ/mk4dQ3JI=",
|
||||
"lastModified": 1717827974,
|
||||
"narHash": "sha256-ixopuTeTouxqTxfMuzs6IaRttbT8JqRW5C9Q/57WxQw=",
|
||||
"owner": "nix-community",
|
||||
"repo": "fenix",
|
||||
"rev": "638fc95a2a3d01b372c76f71cbb6d73c63909d6e",
|
||||
"rev": "ab655c627777ab5f9964652fe23bbb1dfbd687a8",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@ -94,32 +91,16 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-compat": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1673956053,
|
||||
"narHash": "sha256-4gtG9iQuiKITOjNQQeQIpoIB6b16fm+504Ch3sNKLd8=",
|
||||
"owner": "edolstra",
|
||||
"repo": "flake-compat",
|
||||
"rev": "35bb57c0c8d8b62bbfd284272c928ceb64ddbde9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "edolstra",
|
||||
"repo": "flake-compat",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1694529238,
|
||||
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
|
||||
"lastModified": 1710146030,
|
||||
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
|
||||
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@ -130,14 +111,14 @@
|
||||
},
|
||||
"flake-utils_2": {
|
||||
"inputs": {
|
||||
"systems": "systems_3"
|
||||
"systems": "systems_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1694529238,
|
||||
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
|
||||
"lastModified": 1701680307,
|
||||
"narHash": "sha256-kAuep2h5ajznlPMD9rnQyffWG8EM/C73lejGofXvdM8=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
|
||||
"rev": "4022d587cbbfd70fe950c1e2083a02621806a725",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@ -148,14 +129,14 @@
|
||||
},
|
||||
"flake-utils_3": {
|
||||
"inputs": {
|
||||
"systems": "systems_4"
|
||||
"systems": "systems_3"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1694529238,
|
||||
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
|
||||
"lastModified": 1710146030,
|
||||
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
|
||||
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@ -172,11 +153,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1694529238,
|
||||
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
|
||||
"lastModified": 1710146030,
|
||||
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
|
||||
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@ -192,52 +173,35 @@
|
||||
"fenix": "fenix",
|
||||
"flake-utils": "flake-utils_4",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"nixpkgs-unstable": "nixpkgs-unstable",
|
||||
"systems": "systems_5"
|
||||
"systems": "systems_4"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1697686719,
|
||||
"narHash": "sha256-6zdm+kBKng88fl4LyuubTJrdUypFUlrtazE9OMgJjk0=",
|
||||
"lastModified": 1720463890,
|
||||
"narHash": "sha256-C5bQ8jHIHm2fwh3U/uEANmHYL7vRAHq6S7SNqyakYMI=",
|
||||
"owner": "rustshop",
|
||||
"repo": "flakebox",
|
||||
"rev": "41e88a8c6910829ec598ee356325e515de043541",
|
||||
"rev": "ead24017440df8c5fd75cdb04c16d13c7d6fa50d",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "rustshop",
|
||||
"repo": "flakebox",
|
||||
"rev": "41e88a8c6910829ec598ee356325e515de043541",
|
||||
"rev": "ead24017440df8c5fd75cdb04c16d13c7d6fa50d",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1696697597,
|
||||
"narHash": "sha256-q26Qv4DQ+h6IeozF2o1secyQG0jt2VUT3V0K58jr3pg=",
|
||||
"lastModified": 1718835956,
|
||||
"narHash": "sha256-wM9v2yIxClRYsGHut5vHICZTK7xdrUGfrLkXvSuv6s4=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "5a237aecb57296f67276ac9ab296a41c23981f56",
|
||||
"rev": "dd457de7e08c6d06789b1f5b88fc9327f4d96309",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "nixos-23.05",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-unstable": {
|
||||
"locked": {
|
||||
"lastModified": 1697456312,
|
||||
"narHash": "sha256-roiSnrqb5r+ehnKCauPLugoU8S36KgmWraHgRqVYndo=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "ca012a02bf8327be9e488546faecae5e05d7d749",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "nixos-unstable",
|
||||
"ref": "nixos-24.05",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
@ -251,11 +215,11 @@
|
||||
"rust-analyzer-src": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1696840854,
|
||||
"narHash": "sha256-wphOvjDSDsUN5DMe3MOhdargANIab7YE3hkh3Qv7qso=",
|
||||
"lastModified": 1717583671,
|
||||
"narHash": "sha256-+lRAmz92CNUxorqWusgJbL9VE1eKCnQQojglRemzwkw=",
|
||||
"owner": "rust-lang",
|
||||
"repo": "rust-analyzer",
|
||||
"rev": "aaa1e8e1b82d742b876d164a30dda02f318ce809",
|
||||
"rev": "48bbdd6a74f3176987d5c809894ac33957000d19",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@ -265,33 +229,6 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"rust-overlay": {
|
||||
"inputs": {
|
||||
"flake-utils": [
|
||||
"flakebox",
|
||||
"crane",
|
||||
"flake-utils"
|
||||
],
|
||||
"nixpkgs": [
|
||||
"flakebox",
|
||||
"crane",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1695003086,
|
||||
"narHash": "sha256-d1/ZKuBRpxifmUf7FaedCqhy0lyVbqj44Oc2s+P5bdA=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "b87a14abea512d956f0b89d0d8a1e9b41f3e20ff",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
@ -351,21 +288,6 @@
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems_5": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
|
@ -2,10 +2,11 @@
|
||||
description = "A terminal slideshow tool";
|
||||
|
||||
inputs = {
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
flakebox = {
|
||||
url = "github:rustshop/flakebox?rev=41e88a8c6910829ec598ee356325e515de043541";
|
||||
url = "github:rustshop/flakebox?rev=ead24017440df8c5fd75cdb04c16d13c7d6fa50d";
|
||||
};
|
||||
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs = { self, flake-utils, flakebox }:
|
||||
@ -27,6 +28,7 @@
|
||||
"src"
|
||||
"themes"
|
||||
"bat"
|
||||
"executors.yaml"
|
||||
];
|
||||
|
||||
buildSrc = flakeboxLib.filterSubPaths {
|
||||
|
18
scripts/validate-config-file-schema.sh
Executable file
18
scripts/validate-config-file-schema.sh
Executable file
@ -0,0 +1,18 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
script_dir=$(dirname "$0")
|
||||
root_dir="${script_dir}/../"
|
||||
|
||||
current_schema=$(mktemp)
|
||||
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
|
||||
echo "Config file JSON schema differs:"
|
||||
echo "$diff"
|
||||
exit 1
|
||||
else
|
||||
echo "Config file JSON schema is up to date"
|
||||
fi
|
411
src/code/execute.rs
Normal file
411
src/code/execute.rs
Normal file
@ -0,0 +1,411 @@
|
||||
//! Code execution.
|
||||
|
||||
use super::snippet::{SnippetExec, SnippetRepr};
|
||||
use crate::{
|
||||
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, Read, Write},
|
||||
path::{Path, PathBuf},
|
||||
process::{self, Child, Stdio},
|
||||
sync::{Arc, Mutex},
|
||||
thread,
|
||||
};
|
||||
use tempfile::TempDir;
|
||||
|
||||
static EXECUTORS: Lazy<BTreeMap<SnippetLanguage, LanguageSnippetExecutionConfig>> =
|
||||
Lazy::new(|| serde_yaml::from_slice(include_bytes!("../../executors.yaml")).expect("executors.yaml is broken"));
|
||||
|
||||
/// Allows executing code.
|
||||
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);
|
||||
for (language, config) in &executors {
|
||||
if config.filename.is_empty() {
|
||||
return Err(InvalidSnippetConfig(language.clone(), "filename is empty"));
|
||||
}
|
||||
if config.commands.is_empty() {
|
||||
return Err(InvalidSnippetConfig(language.clone(), "no commands given"));
|
||||
}
|
||||
for command in &config.commands {
|
||||
if command.is_empty() {
|
||||
return Err(InvalidSnippetConfig(language.clone(), "empty command given"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Self { executors, cwd })
|
||||
}
|
||||
|
||||
pub(crate) fn is_execution_supported(&self, language: &SnippetLanguage) -> bool {
|
||||
self.executors.contains_key(language)
|
||||
}
|
||||
|
||||
/// 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,
|
||||
};
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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.as_bytes()).map_err(CodeExecuteError::TempDir)?;
|
||||
Ok(script_dir)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SnippetExecutor {
|
||||
fn default() -> Self {
|
||||
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 {{ .. }}")
|
||||
}
|
||||
}
|
||||
|
||||
/// An invalid executor was found.
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
#[error("invalid snippet execution for '{0:?}': {1}")]
|
||||
pub struct InvalidSnippetConfig(SnippetLanguage, &'static str);
|
||||
|
||||
/// An error during the execution of some code.
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub(crate) enum CodeExecuteError {
|
||||
#[error("code language doesn't support execution")]
|
||||
UnsupportedExecution,
|
||||
|
||||
#[error("code is not marked for execution")]
|
||||
NotExecutableCode,
|
||||
|
||||
#[error("error creating temporary directory: {0}")]
|
||||
TempDir(io::Error),
|
||||
|
||||
#[error("error spawning process '{0}': {1}")]
|
||||
SpawnProcess(String, io::Error),
|
||||
|
||||
#[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.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ExecutionHandle {
|
||||
pub(crate) state: Arc<Mutex<ExecutionState>>,
|
||||
#[allow(dead_code)]
|
||||
reader_handle: thread::JoinHandle<()>,
|
||||
}
|
||||
|
||||
/// Consumes the output of a process and stores it in a shared state.
|
||||
struct CommandsRunner {
|
||||
state: Arc<Mutex<ExecutionState>>,
|
||||
script_directory: TempDir,
|
||||
}
|
||||
|
||||
impl CommandsRunner {
|
||||
fn spawn(
|
||||
state: Arc<Mutex<ExecutionState>>,
|
||||
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(move || reader.run(commands, env, cwd, output_type))
|
||||
}
|
||||
|
||||
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, &cwd, output_type);
|
||||
if !last_result {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let status = match last_result {
|
||||
true => ProcessStatus::Success,
|
||||
false => ProcessStatus::Failure,
|
||||
};
|
||||
self.state.lock().unwrap().status = status;
|
||||
}
|
||||
|
||||
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.extend(e.to_string().into_bytes());
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let _ = Self::process_output(self.state.clone(), reader, output_type);
|
||||
|
||||
match child.wait() {
|
||||
Ok(code) => code.success(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn launch_process(
|
||||
&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", &script_dir);
|
||||
}
|
||||
let (command, args) = commands.split_first().expect("no commands");
|
||||
let child = process::Command::new(command)
|
||||
.args(args)
|
||||
.envs(env)
|
||||
.current_dir(cwd)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(writer)
|
||||
.stderr(writer_clone)
|
||||
.spawn()
|
||||
.map_err(|e| CodeExecuteError::SpawnProcess(command.clone(), e))?;
|
||||
Ok((child, reader))
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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<u8>,
|
||||
pub(crate) status: ProcessStatus,
|
||||
}
|
||||
|
||||
/// The status of a process.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) enum ProcessStatus {
|
||||
#[default]
|
||||
Running,
|
||||
Success,
|
||||
Failure,
|
||||
}
|
||||
|
||||
impl ProcessStatus {
|
||||
/// Check whether the underlying process is finished.
|
||||
pub(crate) fn is_finished(&self) -> bool {
|
||||
matches!(self, ProcessStatus::Success | ProcessStatus::Failure)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::code::snippet::SnippetAttributes;
|
||||
|
||||
#[test]
|
||||
fn shell_code_execution() {
|
||||
let contents = r"
|
||||
echo 'hello world'
|
||||
echo 'bye'"
|
||||
.into();
|
||||
let code = Snippet {
|
||||
contents,
|
||||
language: SnippetLanguage::Shell,
|
||||
attributes: SnippetAttributes { execution: SnippetExec::Exec, ..Default::default() },
|
||||
};
|
||||
let handle = SnippetExecutor::default().execute_async(&code).expect("execution failed");
|
||||
let state = loop {
|
||||
let state = handle.state.lock().unwrap();
|
||||
if state.status.is_finished() {
|
||||
break state;
|
||||
}
|
||||
};
|
||||
|
||||
let expected = b"hello world\nbye\n";
|
||||
assert_eq!(state.output, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_executable_code_cant_be_executed() {
|
||||
let contents = String::new();
|
||||
let code = Snippet {
|
||||
contents,
|
||||
language: SnippetLanguage::Shell,
|
||||
attributes: SnippetAttributes { execution: SnippetExec::None, ..Default::default() },
|
||||
};
|
||||
let result = SnippetExecutor::default().execute_async(&code);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_code_execution_captures_stderr() {
|
||||
let contents = r"
|
||||
echo 'This message redirects to stderr' >&2
|
||||
echo 'hello world'
|
||||
"
|
||||
.into();
|
||||
let code = Snippet {
|
||||
contents,
|
||||
language: SnippetLanguage::Shell,
|
||||
attributes: SnippetAttributes { execution: SnippetExec::Exec, ..Default::default() },
|
||||
};
|
||||
let handle = SnippetExecutor::default().execute_async(&code).expect("execution failed");
|
||||
let state = loop {
|
||||
let state = handle.state.lock().unwrap();
|
||||
if state.status.is_finished() {
|
||||
break state;
|
||||
}
|
||||
};
|
||||
|
||||
let expected = b"This message redirects to stderr\nhello world\n";
|
||||
assert_eq!(state.output, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_code_execution_executes_hidden_lines() {
|
||||
let contents = r"
|
||||
/// echo 'this line was hidden'
|
||||
/// echo 'this line was hidden and contains another prefix /// '
|
||||
echo 'hello world'
|
||||
"
|
||||
.into();
|
||||
let code = Snippet {
|
||||
contents,
|
||||
language: SnippetLanguage::Shell,
|
||||
attributes: SnippetAttributes { execution: SnippetExec::Exec, ..Default::default() },
|
||||
};
|
||||
let handle = SnippetExecutor::default().execute_async(&code).expect("execution failed");
|
||||
let state = loop {
|
||||
let state = handle.state.lock().unwrap();
|
||||
if state.status.is_finished() {
|
||||
break state;
|
||||
}
|
||||
};
|
||||
|
||||
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(), PathBuf::from("./")).expect("invalid default executors");
|
||||
}
|
||||
}
|
@ -1,24 +1,20 @@
|
||||
use crate::{markdown::elements::CodeLanguage, 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,28 +85,28 @@ 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: &CodeLanguage) -> LanguageHighlighter {
|
||||
pub(crate) fn language_highlighter(&self, language: &SnippetLanguage) -> LanguageHighlighter {
|
||||
let extension = Self::language_extension(language);
|
||||
let syntax = SYNTAX_SET.find_syntax_by_extension(extension).unwrap();
|
||||
let highlighter = HighlightLines::new(syntax, &self.theme);
|
||||
LanguageHighlighter { highlighter }
|
||||
}
|
||||
|
||||
fn language_extension(language: &CodeLanguage) -> &'static str {
|
||||
use CodeLanguage::*;
|
||||
fn language_extension(language: &SnippetLanguage) -> &'static str {
|
||||
use SnippetLanguage::*;
|
||||
match language {
|
||||
Ada => "adb",
|
||||
Asp => "asa",
|
||||
Awk => "awk",
|
||||
Bash => "bash",
|
||||
Bash => "sh",
|
||||
BatchFile => "bat",
|
||||
C => "c",
|
||||
CMake => "cmake",
|
||||
@ -126,18 +122,24 @@ 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",
|
||||
Makefile => "make",
|
||||
Markdown => "md",
|
||||
Mermaid => "txt",
|
||||
Nix => "nix",
|
||||
Nushell => "txt",
|
||||
OCaml => "ml",
|
||||
Perl => "pl",
|
||||
Php => "php",
|
||||
@ -145,26 +147,33 @@ impl CodeHighlighter {
|
||||
Puppet => "pp",
|
||||
Python => "py",
|
||||
R => "r",
|
||||
Racket => "rkt",
|
||||
Ruby => "rb",
|
||||
Rust => "rs",
|
||||
RustScript => "rs",
|
||||
Scala => "scala",
|
||||
Shell(_) => "sh",
|
||||
Shell => "sh",
|
||||
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",
|
||||
Unknown(_) => "txt",
|
||||
Verilog => "v",
|
||||
Vue => "vue",
|
||||
Xml => "xml",
|
||||
Yaml => "yaml",
|
||||
Zsh => "sh",
|
||||
Zig => "zig",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
@ -175,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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -229,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,
|
||||
@ -241,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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -258,8 +257,8 @@ mod test {
|
||||
|
||||
#[test]
|
||||
fn language_extensions_exist() {
|
||||
for language in CodeLanguage::iter() {
|
||||
let extension = CodeHighlighter::language_extension(&language);
|
||||
for language in SnippetLanguage::iter() {
|
||||
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");
|
||||
}
|
||||
@ -267,7 +266,7 @@ mod test {
|
||||
|
||||
#[test]
|
||||
fn default_highlighter() {
|
||||
CodeHighlighter::default();
|
||||
SnippetHighlighter::default();
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -289,7 +288,7 @@ mod test {
|
||||
</dict>
|
||||
</array>
|
||||
</dict>"#;
|
||||
fs::write(directory.path().join("potato.tmTheme"), &theme).expect("writing theme");
|
||||
fs::write(directory.path().join("potato.tmTheme"), theme).expect("writing theme");
|
||||
|
||||
let mut themes = HighlightThemeSet::default();
|
||||
themes.register_from_directory(directory.path()).expect("loading themes");
|
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,16 +1,15 @@
|
||||
use super::source::{Command, CommandDiscriminants};
|
||||
use crate::custom::KeyBindingsConfig;
|
||||
use crossterm::event::{poll, read, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||
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() }
|
||||
}
|
||||
@ -74,7 +73,9 @@ impl CommandKeyBindings {
|
||||
let command = match discriminant {
|
||||
Redraw => Command::Redraw,
|
||||
Next => Command::Next,
|
||||
NextFast => Command::NextFast,
|
||||
Previous => Command::Previous,
|
||||
PreviousFast => Command::PreviousFast,
|
||||
FirstSlide => Command::FirstSlide,
|
||||
LastSlide => Command::LastSlide,
|
||||
GoToSlide => {
|
||||
@ -85,8 +86,9 @@ impl CommandKeyBindings {
|
||||
MatchContext::Number(number) => Command::GoToSlide(number),
|
||||
}
|
||||
}
|
||||
RenderWidgets => Command::RenderWidgets,
|
||||
RenderAsyncOperations => Command::RenderAsyncOperations,
|
||||
Exit => Command::Exit,
|
||||
Suspend => Command::Suspend,
|
||||
Reload => Command::Reload,
|
||||
HardReload => Command::HardReload,
|
||||
ToggleSlideIndex => Command::ToggleSlideIndex,
|
||||
@ -123,15 +125,18 @@ impl TryFrom<KeyBindingsConfig> for CommandKeyBindings {
|
||||
}
|
||||
let bindings: Vec<_> = iter::empty()
|
||||
.chain(zip(CommandDiscriminants::Next, config.next))
|
||||
.chain(zip(CommandDiscriminants::NextFast, config.next_fast))
|
||||
.chain(zip(CommandDiscriminants::Previous, config.previous))
|
||||
.chain(zip(CommandDiscriminants::PreviousFast, config.previous_fast))
|
||||
.chain(zip(CommandDiscriminants::FirstSlide, config.first_slide))
|
||||
.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))
|
||||
.chain(zip(CommandDiscriminants::RenderWidgets, config.execute_code))
|
||||
.chain(zip(CommandDiscriminants::RenderAsyncOperations, config.execute_code))
|
||||
.chain(zip(CommandDiscriminants::CloseModal, config.close_modal))
|
||||
.collect();
|
||||
Self::validate_conflicts(bindings.iter().map(|binding| &binding.0))?;
|
||||
@ -155,8 +160,11 @@ enum BindingMatch {
|
||||
None,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeserializeFromStr)]
|
||||
pub struct KeyBinding(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 {
|
||||
@ -532,7 +540,7 @@ mod test {
|
||||
)]
|
||||
fn match_number(#[case] pattern: &str, #[case] events: &[KeyEvent]) {
|
||||
let binding = KeyBinding::from_str(pattern).expect("failed to parse");
|
||||
let result = binding.match_events(&events);
|
||||
let result = binding.match_events(events);
|
||||
let BindingMatch::Full(MatchContext::Number(number)) = result else {
|
||||
panic!("unexpected match: {result:?}");
|
||||
};
|
101
src/commands/listener.rs
Normal file
101
src/commands/listener.rs
Normal file
@ -0,0 +1,101 @@
|
||||
use super::{
|
||||
keyboard::{CommandKeyBindings, KeyBindingsValidationError, KeyboardListener},
|
||||
speaker_notes::{SpeakerNotesEvent, SpeakerNotesEventListener},
|
||||
};
|
||||
use crate::{config::KeyBindingsConfig, presenter::PresentationError};
|
||||
use serde::Deserialize;
|
||||
use std::time::Duration;
|
||||
use strum::EnumDiscriminants;
|
||||
|
||||
/// 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 CommandListener {
|
||||
/// Create a new command source over the given presentation path.
|
||||
pub fn new(
|
||||
config: KeyBindingsConfig,
|
||||
speaker_notes_event_listener: Option<SpeakerNotesEventListener>,
|
||||
) -> Result<Self, KeyBindingsValidationError> {
|
||||
let bindings = CommandKeyBindings::try_from(config)?;
|
||||
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) -> 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A command.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, EnumDiscriminants)]
|
||||
#[strum_discriminants(derive(Deserialize))]
|
||||
pub(crate) enum Command {
|
||||
/// Redraw the presentation.
|
||||
///
|
||||
/// This can happen on terminal resize.
|
||||
Redraw,
|
||||
|
||||
/// Move forward in the presentation.
|
||||
Next,
|
||||
|
||||
/// Move to the next slide fast.
|
||||
NextFast,
|
||||
|
||||
/// Move backwards in the presentation.
|
||||
Previous,
|
||||
|
||||
/// Move to the previous slide fast.
|
||||
PreviousFast,
|
||||
|
||||
/// Go to the first slide.
|
||||
FirstSlide,
|
||||
|
||||
/// Go to the last slide.
|
||||
LastSlide,
|
||||
|
||||
/// Go to one particular slide.
|
||||
GoToSlide(u32),
|
||||
|
||||
/// Render any async render operations in the current slide.
|
||||
RenderAsyncOperations,
|
||||
|
||||
/// Exit the presentation.
|
||||
Exit,
|
||||
|
||||
/// Suspend the presentation.
|
||||
Suspend,
|
||||
|
||||
/// The presentation has changed and needs to be reloaded.
|
||||
Reload,
|
||||
|
||||
/// Hard reload the presentation.
|
||||
///
|
||||
/// Like [Command::Reload] but also reloads any external resources like images and themes.
|
||||
HardReload,
|
||||
|
||||
/// Toggle the slide index view.
|
||||
ToggleSlideIndex,
|
||||
|
||||
/// Toggle the key bindings config view.
|
||||
ToggleKeyBindingsConfig,
|
||||
|
||||
/// Hide the currently open modal, if any.
|
||||
CloseModal,
|
||||
}
|
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));
|
||||
}
|
||||
}
|
687
src/config.rs
Normal file
687
src/config.rs
Normal file
@ -0,0 +1,687 @@
|
||||
use crate::{
|
||||
code::snippet::SnippetLanguage,
|
||||
commands::keyboard::KeyBinding,
|
||||
terminal::{
|
||||
GraphicsMode, capabilities::TerminalCapabilities, emulator::TerminalEmulator,
|
||||
image::protocols::kitty::KittyMode,
|
||||
},
|
||||
};
|
||||
use clap::ValueEnum;
|
||||
use serde::Deserialize;
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
fs, io,
|
||||
net::{IpAddr, Ipv4Addr, SocketAddr},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
#[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)]
|
||||
pub defaults: DefaultsConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub typst: TypstConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub mermaid: MermaidConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub options: OptionsConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub bindings: KeyBindingsConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub snippet: SnippetConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub speaker_notes: SpeakerNotesConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub export: ExportConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub transition: Option<SlideTransitionConfig>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Load the config from a path.
|
||||
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 Err(ConfigLoadError::NotFound),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let config = serde_yaml::from_str(&contents)?;
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
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)]
|
||||
#[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_terminal_font_size")]
|
||||
#[cfg_attr(feature = "json-schema", validate(range(min = 1)))]
|
||||
pub terminal_font_size: u8,
|
||||
|
||||
/// The image protocol to use.
|
||||
#[serde(default)]
|
||||
pub image_protocol: ImageProtocol,
|
||||
|
||||
/// 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_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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
/// 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]
|
||||
Never,
|
||||
Always,
|
||||
WhenPresenting,
|
||||
WhenDeveloping,
|
||||
}
|
||||
|
||||
#[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.
|
||||
pub implicit_slide_ends: Option<bool>,
|
||||
|
||||
/// The prefix to use for commands.
|
||||
pub command_prefix: Option<String>,
|
||||
|
||||
/// The prefix to use for image attributes.
|
||||
pub image_attributes_prefix: Option<String>,
|
||||
|
||||
/// Show all lists incrementally, by implicitly adding pauses in between elements.
|
||||
pub incremental_lists: Option<bool>,
|
||||
|
||||
/// Whether to treat a thematic break as a slide end.
|
||||
pub end_slide_shorthand: Option<bool>,
|
||||
|
||||
/// 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)]
|
||||
#[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)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SnippetExecConfig {
|
||||
/// Whether to enable snippet execution.
|
||||
pub enable: bool,
|
||||
|
||||
/// Custom snippet executors.
|
||||
#[serde(default)]
|
||||
pub custom: BTreeMap<SnippetLanguage, LanguageSnippetExecutionConfig>,
|
||||
}
|
||||
|
||||
#[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.
|
||||
#[serde(default = "default_snippet_render_threads")]
|
||||
pub threads: usize,
|
||||
}
|
||||
|
||||
impl Default for SnippetRenderConfig {
|
||||
fn default() -> Self {
|
||||
Self { threads: default_snippet_render_threads() }
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn default_snippet_render_threads() -> usize {
|
||||
2
|
||||
}
|
||||
|
||||
#[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.
|
||||
#[serde(default = "default_typst_ppi")]
|
||||
pub ppi: u32,
|
||||
}
|
||||
|
||||
impl Default for TypstConfig {
|
||||
fn default() -> Self {
|
||||
Self { ppi: default_typst_ppi() }
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn default_typst_ppi() -> u32 {
|
||||
300
|
||||
}
|
||||
|
||||
#[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.
|
||||
#[serde(default = "default_mermaid_scale")]
|
||||
pub scale: u32,
|
||||
}
|
||||
|
||||
impl Default for MermaidConfig {
|
||||
fn default() -> Self {
|
||||
Self { scale: default_mermaid_scale() }
|
||||
}
|
||||
}
|
||||
|
||||
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)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
pub struct LanguageSnippetExecutionConfig {
|
||||
/// The filename to use for the snippet input file.
|
||||
pub filename: String,
|
||||
|
||||
/// The environment variables to set before invoking every command.
|
||||
#[serde(default)]
|
||||
pub environment: HashMap<String, String>,
|
||||
|
||||
/// 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)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ImageProtocol {
|
||||
/// Automatically detect the best image protocol to use.
|
||||
#[default]
|
||||
Auto,
|
||||
|
||||
/// Use the iTerm2 image protocol.
|
||||
Iterm2,
|
||||
|
||||
/// 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.
|
||||
KittyLocal,
|
||||
|
||||
/// 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.
|
||||
KittyRemote,
|
||||
|
||||
/// Use the sixel protocol. Note that this requires compiling presenterm using the --features
|
||||
/// sixel flag.
|
||||
Sixel,
|
||||
|
||||
/// The default image protocol to use when no other is specified.
|
||||
AsciiBlocks,
|
||||
}
|
||||
|
||||
pub struct SixelUnsupported;
|
||||
|
||||
impl TryFrom<&ImageProtocol> for GraphicsMode {
|
||||
type Error = SixelUnsupported;
|
||||
|
||||
fn try_from(protocol: &ImageProtocol) -> Result<Self, Self::Error> {
|
||||
let mode = match protocol {
|
||||
ImageProtocol::Auto => {
|
||||
let emulator = TerminalEmulator::detect();
|
||||
emulator.preferred_protocol()
|
||||
}
|
||||
ImageProtocol::Iterm2 => GraphicsMode::Iterm2,
|
||||
ImageProtocol::KittyLocal => {
|
||||
GraphicsMode::Kitty { mode: KittyMode::Local, inside_tmux: TerminalCapabilities::is_inside_tmux() }
|
||||
}
|
||||
ImageProtocol::KittyRemote => {
|
||||
GraphicsMode::Kitty { mode: KittyMode::Remote, inside_tmux: TerminalCapabilities::is_inside_tmux() }
|
||||
}
|
||||
ImageProtocol::AsciiBlocks => GraphicsMode::AsciiBlocks,
|
||||
#[cfg(feature = "sixel")]
|
||||
ImageProtocol::Sixel => GraphicsMode::Sixel,
|
||||
#[cfg(not(feature = "sixel"))]
|
||||
ImageProtocol::Sixel => return Err(SixelUnsupported),
|
||||
};
|
||||
Ok(mode)
|
||||
}
|
||||
}
|
||||
|
||||
#[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.
|
||||
#[serde(default = "default_next_bindings")]
|
||||
pub(crate) next: Vec<KeyBinding>,
|
||||
|
||||
/// The keys that cause the presentation to jump to the next slide "fast".
|
||||
///
|
||||
/// "fast" means for slides that contain pauses, we will only jump between the first and last
|
||||
/// pause rather than going through each individual one.
|
||||
#[serde(default = "default_next_fast_bindings")]
|
||||
pub(crate) next_fast: Vec<KeyBinding>,
|
||||
|
||||
/// The keys that cause the presentation to move backwards.
|
||||
#[serde(default = "default_previous_bindings")]
|
||||
pub(crate) previous: Vec<KeyBinding>,
|
||||
|
||||
/// The keys that cause the presentation to move backwards "fast".
|
||||
///
|
||||
/// "fast" means for slides that contain pauses, we will only jump between the first and last
|
||||
/// pause rather than going through each individual one.
|
||||
#[serde(default = "default_previous_fast_bindings")]
|
||||
pub(crate) previous_fast: Vec<KeyBinding>,
|
||||
|
||||
/// The key binding to jump to the first slide.
|
||||
#[serde(default = "default_first_slide_bindings")]
|
||||
pub(crate) first_slide: Vec<KeyBinding>,
|
||||
|
||||
/// The key binding to jump to the last slide.
|
||||
#[serde(default = "default_last_slide_bindings")]
|
||||
pub(crate) last_slide: Vec<KeyBinding>,
|
||||
|
||||
/// The key binding to jump to a specific slide.
|
||||
#[serde(default = "default_go_to_slide_bindings")]
|
||||
pub(crate) go_to_slide: Vec<KeyBinding>,
|
||||
|
||||
/// The key binding to execute a piece of shell code.
|
||||
#[serde(default = "default_execute_code_bindings")]
|
||||
pub(crate) execute_code: Vec<KeyBinding>,
|
||||
|
||||
/// The key binding to reload the presentation.
|
||||
#[serde(default = "default_reload_bindings")]
|
||||
pub(crate) reload: Vec<KeyBinding>,
|
||||
|
||||
/// The key binding to toggle the slide index modal.
|
||||
#[serde(default = "default_toggle_index_bindings")]
|
||||
pub(crate) toggle_slide_index: Vec<KeyBinding>,
|
||||
|
||||
/// The key binding to toggle the key bindings modal.
|
||||
#[serde(default = "default_toggle_bindings_modal_bindings")]
|
||||
pub(crate) toggle_bindings: Vec<KeyBinding>,
|
||||
|
||||
/// The key binding to close the currently open modal.
|
||||
#[serde(default = "default_close_modal_bindings")]
|
||||
pub(crate) close_modal: Vec<KeyBinding>,
|
||||
|
||||
/// 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 {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
next: default_next_bindings(),
|
||||
next_fast: default_next_fast_bindings(),
|
||||
previous: default_previous_bindings(),
|
||||
previous_fast: default_previous_fast_bindings(),
|
||||
first_slide: default_first_slide_bindings(),
|
||||
last_slide: default_last_slide_bindings(),
|
||||
go_to_slide: default_go_to_slide_bindings(),
|
||||
execute_code: default_execute_code_bindings(),
|
||||
reload: default_reload_bindings(),
|
||||
toggle_slide_index: default_toggle_index_bindings(),
|
||||
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 {
|
||||
bindings.push(binding.parse().expect("invalid binding"));
|
||||
}
|
||||
bindings
|
||||
}
|
||||
|
||||
fn default_next_bindings() -> Vec<KeyBinding> {
|
||||
make_keybindings(["l", "j", "<right>", "<page_down>", "<down>", " "])
|
||||
}
|
||||
|
||||
fn default_next_fast_bindings() -> Vec<KeyBinding> {
|
||||
make_keybindings(["n"])
|
||||
}
|
||||
|
||||
fn default_previous_bindings() -> Vec<KeyBinding> {
|
||||
make_keybindings(["h", "k", "<left>", "<page_up>", "<up>"])
|
||||
}
|
||||
|
||||
fn default_previous_fast_bindings() -> Vec<KeyBinding> {
|
||||
make_keybindings(["p"])
|
||||
}
|
||||
|
||||
fn default_first_slide_bindings() -> Vec<KeyBinding> {
|
||||
make_keybindings(["gg"])
|
||||
}
|
||||
|
||||
fn default_last_slide_bindings() -> Vec<KeyBinding> {
|
||||
make_keybindings(["G"])
|
||||
}
|
||||
|
||||
fn default_go_to_slide_bindings() -> Vec<KeyBinding> {
|
||||
make_keybindings(["<number>G"])
|
||||
}
|
||||
|
||||
fn default_execute_code_bindings() -> Vec<KeyBinding> {
|
||||
make_keybindings(["<c-e>"])
|
||||
}
|
||||
|
||||
fn default_reload_bindings() -> Vec<KeyBinding> {
|
||||
make_keybindings(["<c-r>"])
|
||||
}
|
||||
|
||||
fn default_toggle_index_bindings() -> Vec<KeyBinding> {
|
||||
make_keybindings(["<c-p>"])
|
||||
}
|
||||
|
||||
fn default_toggle_bindings_modal_bindings() -> Vec<KeyBinding> {
|
||||
make_keybindings(["?"])
|
||||
}
|
||||
|
||||
fn default_close_modal_bindings() -> Vec<KeyBinding> {
|
||||
make_keybindings(["<esc>"])
|
||||
}
|
||||
|
||||
fn default_exit_bindings() -> Vec<KeyBinding> {
|
||||
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::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");
|
||||
}
|
||||
}
|
281
src/custom.rs
281
src/custom.rs
@ -1,281 +0,0 @@
|
||||
use crate::{
|
||||
input::user::KeyBinding,
|
||||
media::{emulator::TerminalEmulator, kitty::KittyMode},
|
||||
GraphicsMode,
|
||||
};
|
||||
use clap::ValueEnum;
|
||||
use serde::Deserialize;
|
||||
use std::{fs, io, path::Path};
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Config {
|
||||
#[serde(default)]
|
||||
pub defaults: DefaultsConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub typst: TypstConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub options: OptionsConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub bindings: KeyBindingsConfig,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Load the config from a path.
|
||||
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) => return Err(e.into()),
|
||||
};
|
||||
let config = serde_yaml::from_str(&contents)?;
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ConfigLoadError {
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] io::Error),
|
||||
|
||||
#[error("invalid configuration: {0}")]
|
||||
Invalid(#[from] serde_yaml::Error),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DefaultsConfig {
|
||||
pub theme: Option<String>,
|
||||
|
||||
#[serde(default = "default_font_size")]
|
||||
pub terminal_font_size: u8,
|
||||
|
||||
#[serde(default)]
|
||||
pub image_protocol: ImageProtocol,
|
||||
|
||||
#[serde(default)]
|
||||
pub validate_overflows: ValidateOverflows,
|
||||
}
|
||||
|
||||
impl Default for DefaultsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
theme: Default::default(),
|
||||
terminal_font_size: default_font_size(),
|
||||
image_protocol: Default::default(),
|
||||
validate_overflows: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_font_size() -> u8 {
|
||||
16
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ValidateOverflows {
|
||||
#[default]
|
||||
Never,
|
||||
Always,
|
||||
WhenPresenting,
|
||||
WhenDeveloping,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct OptionsConfig {
|
||||
/// Whether slides are automatically terminated when a slide title is found.
|
||||
pub implicit_slide_ends: Option<bool>,
|
||||
|
||||
/// The prefix to use for commands.
|
||||
pub command_prefix: Option<String>,
|
||||
|
||||
/// Show all lists incrementally, by implicitly adding pauses in between elements.
|
||||
pub incremental_lists: Option<bool>,
|
||||
|
||||
/// Whether to treat a thematic break as a slide end.
|
||||
pub end_slide_shorthand: Option<bool>,
|
||||
|
||||
/// Whether to be strict about parsing the presentation's front matter.
|
||||
pub strict_front_matter_parsing: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TypstConfig {
|
||||
#[serde(default = "default_typst_ppi")]
|
||||
pub ppi: u32,
|
||||
}
|
||||
|
||||
impl Default for TypstConfig {
|
||||
fn default() -> Self {
|
||||
Self { ppi: default_typst_ppi() }
|
||||
}
|
||||
}
|
||||
|
||||
fn default_typst_ppi() -> u32 {
|
||||
300
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, ValueEnum)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ImageProtocol {
|
||||
#[default]
|
||||
Auto,
|
||||
Iterm2,
|
||||
KittyLocal,
|
||||
KittyRemote,
|
||||
Sixel,
|
||||
AsciiBlocks,
|
||||
}
|
||||
|
||||
pub struct SixelUnsupported;
|
||||
|
||||
impl TryFrom<&ImageProtocol> for GraphicsMode {
|
||||
type Error = SixelUnsupported;
|
||||
|
||||
fn try_from(protocol: &ImageProtocol) -> Result<Self, Self::Error> {
|
||||
let mode = match protocol {
|
||||
ImageProtocol::Auto => {
|
||||
let emulator = TerminalEmulator::detect();
|
||||
emulator.preferred_protocol()
|
||||
}
|
||||
ImageProtocol::Iterm2 => GraphicsMode::Iterm2,
|
||||
ImageProtocol::KittyLocal => {
|
||||
GraphicsMode::Kitty { mode: KittyMode::Local, inside_tmux: TerminalEmulator::is_inside_tmux() }
|
||||
}
|
||||
ImageProtocol::KittyRemote => {
|
||||
GraphicsMode::Kitty { mode: KittyMode::Remote, inside_tmux: TerminalEmulator::is_inside_tmux() }
|
||||
}
|
||||
ImageProtocol::AsciiBlocks => GraphicsMode::AsciiBlocks,
|
||||
#[cfg(feature = "sixel")]
|
||||
ImageProtocol::Sixel => GraphicsMode::Sixel,
|
||||
#[cfg(not(feature = "sixel"))]
|
||||
ImageProtocol::Sixel => return Err(SixelUnsupported),
|
||||
};
|
||||
Ok(mode)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct KeyBindingsConfig {
|
||||
#[serde(default = "default_next_bindings")]
|
||||
pub(crate) next: Vec<KeyBinding>,
|
||||
|
||||
#[serde(default = "default_previous_bindings")]
|
||||
pub(crate) previous: Vec<KeyBinding>,
|
||||
|
||||
#[serde(default = "default_first_slide_bindings")]
|
||||
pub(crate) first_slide: Vec<KeyBinding>,
|
||||
|
||||
#[serde(default = "default_last_slide_bindings")]
|
||||
pub(crate) last_slide: Vec<KeyBinding>,
|
||||
|
||||
#[serde(default = "default_go_to_slide_bindings")]
|
||||
pub(crate) go_to_slide: Vec<KeyBinding>,
|
||||
|
||||
#[serde(default = "default_execute_code_bindings")]
|
||||
pub(crate) execute_code: Vec<KeyBinding>,
|
||||
|
||||
#[serde(default = "default_reload_bindings")]
|
||||
pub(crate) reload: Vec<KeyBinding>,
|
||||
|
||||
#[serde(default = "default_toggle_index_bindings")]
|
||||
pub(crate) toggle_slide_index: Vec<KeyBinding>,
|
||||
|
||||
#[serde(default = "default_toggle_bindings_modal_bindings")]
|
||||
pub(crate) toggle_bindings: Vec<KeyBinding>,
|
||||
|
||||
#[serde(default = "default_close_modal_bindings")]
|
||||
pub(crate) close_modal: Vec<KeyBinding>,
|
||||
|
||||
#[serde(default = "default_exit_bindings")]
|
||||
pub(crate) exit: Vec<KeyBinding>,
|
||||
}
|
||||
|
||||
impl Default for KeyBindingsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
next: default_next_bindings(),
|
||||
previous: default_previous_bindings(),
|
||||
first_slide: default_first_slide_bindings(),
|
||||
last_slide: default_last_slide_bindings(),
|
||||
go_to_slide: default_go_to_slide_bindings(),
|
||||
execute_code: default_execute_code_bindings(),
|
||||
reload: default_reload_bindings(),
|
||||
toggle_slide_index: default_toggle_index_bindings(),
|
||||
toggle_bindings: default_toggle_bindings_modal_bindings(),
|
||||
close_modal: default_close_modal_bindings(),
|
||||
exit: default_exit_bindings(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn make_keybindings<const N: usize>(raw_bindings: [&str; N]) -> Vec<KeyBinding> {
|
||||
let mut bindings = Vec::new();
|
||||
for binding in raw_bindings {
|
||||
bindings.push(binding.parse().expect("invalid binding"));
|
||||
}
|
||||
bindings
|
||||
}
|
||||
|
||||
fn default_next_bindings() -> Vec<KeyBinding> {
|
||||
make_keybindings(["l", "j", "<right>", "<page_down>", "<down>", " "])
|
||||
}
|
||||
|
||||
fn default_previous_bindings() -> Vec<KeyBinding> {
|
||||
make_keybindings(["h", "k", "<left>", "<page_up>", "<up>"])
|
||||
}
|
||||
|
||||
fn default_first_slide_bindings() -> Vec<KeyBinding> {
|
||||
make_keybindings(["gg"])
|
||||
}
|
||||
|
||||
fn default_last_slide_bindings() -> Vec<KeyBinding> {
|
||||
make_keybindings(["G"])
|
||||
}
|
||||
|
||||
fn default_go_to_slide_bindings() -> Vec<KeyBinding> {
|
||||
make_keybindings(["<number>G"])
|
||||
}
|
||||
|
||||
fn default_execute_code_bindings() -> Vec<KeyBinding> {
|
||||
make_keybindings(["<c-e>"])
|
||||
}
|
||||
|
||||
fn default_reload_bindings() -> Vec<KeyBinding> {
|
||||
make_keybindings(["<c-r>"])
|
||||
}
|
||||
|
||||
fn default_toggle_index_bindings() -> Vec<KeyBinding> {
|
||||
make_keybindings(["<c-p>"])
|
||||
}
|
||||
|
||||
fn default_toggle_bindings_modal_bindings() -> Vec<KeyBinding> {
|
||||
make_keybindings(["?"])
|
||||
}
|
||||
|
||||
fn default_close_modal_bindings() -> Vec<KeyBinding> {
|
||||
make_keybindings(["<esc>"])
|
||||
}
|
||||
|
||||
fn default_exit_bindings() -> Vec<KeyBinding> {
|
||||
make_keybindings(["<c-c>"])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::input::user::CommandKeyBindings;
|
||||
|
||||
#[test]
|
||||
fn default_bindings() {
|
||||
let config = KeyBindingsConfig::default();
|
||||
CommandKeyBindings::try_from(config).expect("construction failed");
|
||||
}
|
||||
}
|
54
src/demo.rs
54
src/demo.rs
@ -1,15 +1,20 @@
|
||||
use crate::{
|
||||
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, TypstRender,
|
||||
presentation::{
|
||||
Presentation,
|
||||
builder::{BuildError, PresentationBuilder},
|
||||
},
|
||||
render::TerminalDrawer,
|
||||
terminal::emulator::TerminalEmulator,
|
||||
theme::raw::PresentationTheme,
|
||||
};
|
||||
use std::io;
|
||||
use std::{io, sync::Arc};
|
||||
|
||||
const PRESENTATION: &str = r#"
|
||||
# Header 1
|
||||
@ -36,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 })
|
||||
}
|
||||
|
||||
@ -61,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 {
|
||||
@ -98,19 +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 mut typst = TypstRender::default();
|
||||
let options = PresentationBuilderOptions::default();
|
||||
let resources = Resources::new("non_existent", "non_existent", image_registry.clone());
|
||||
let mut third_party = ThirdPartyRender::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,
|
||||
&mut typst,
|
||||
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)
|
||||
|
186
src/execute.rs
186
src/execute.rs
@ -1,186 +0,0 @@
|
||||
//! Code execution.
|
||||
|
||||
use crate::markdown::elements::{Code, CodeLanguage};
|
||||
use std::{
|
||||
io::{self, BufRead, BufReader, Write},
|
||||
process::{self, ChildStdout, Stdio},
|
||||
sync::{Arc, Mutex},
|
||||
thread::{self},
|
||||
};
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
/// Allows executing code.
|
||||
pub(crate) struct CodeExecuter;
|
||||
|
||||
impl CodeExecuter {
|
||||
/// Execute a piece of code.
|
||||
pub(crate) fn execute(code: &Code) -> Result<ExecutionHandle, CodeExecuteError> {
|
||||
if !code.language.supports_execution() {
|
||||
return Err(CodeExecuteError::UnsupportedExecution);
|
||||
}
|
||||
if !code.attributes.execute {
|
||||
return Err(CodeExecuteError::NotExecutableCode);
|
||||
}
|
||||
match &code.language {
|
||||
CodeLanguage::Shell(interpreter) => Self::execute_shell(interpreter, &code.contents),
|
||||
_ => Err(CodeExecuteError::UnsupportedExecution),
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_shell(interpreter: &str, code: &str) -> Result<ExecutionHandle, CodeExecuteError> {
|
||||
let mut output_file = NamedTempFile::new().map_err(CodeExecuteError::TempFile)?;
|
||||
output_file.write_all(code.as_bytes()).map_err(CodeExecuteError::TempFile)?;
|
||||
output_file.flush().map_err(CodeExecuteError::TempFile)?;
|
||||
let process_handle = process::Command::new("/usr/bin/env")
|
||||
.arg(interpreter)
|
||||
.arg(output_file.path())
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.map_err(CodeExecuteError::SpawnProcess)?;
|
||||
|
||||
let state: Arc<Mutex<ExecutionState>> = Default::default();
|
||||
let reader_handle = ProcessReader::spawn(process_handle, state.clone(), output_file);
|
||||
let handle = ExecutionHandle { state, reader_handle };
|
||||
Ok(handle)
|
||||
}
|
||||
}
|
||||
|
||||
/// An error during the execution of some code.
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub(crate) enum CodeExecuteError {
|
||||
#[error("code language doesn't support execution")]
|
||||
UnsupportedExecution,
|
||||
|
||||
#[error("code is not marked for execution")]
|
||||
NotExecutableCode,
|
||||
|
||||
#[error("error creating temporary file: {0}")]
|
||||
TempFile(io::Error),
|
||||
|
||||
#[error("error spawning process: {0}")]
|
||||
SpawnProcess(io::Error),
|
||||
}
|
||||
|
||||
/// A handle for the execution of a piece of code.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ExecutionHandle {
|
||||
state: Arc<Mutex<ExecutionState>>,
|
||||
#[allow(dead_code)]
|
||||
reader_handle: thread::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl ExecutionHandle {
|
||||
/// Get the current state of the process.
|
||||
pub(crate) fn state(&self) -> ExecutionState {
|
||||
self.state.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Consumes the output of a process and stores it in a shared state.
|
||||
struct ProcessReader {
|
||||
handle: process::Child,
|
||||
state: Arc<Mutex<ExecutionState>>,
|
||||
#[allow(dead_code)]
|
||||
file_handle: NamedTempFile,
|
||||
}
|
||||
|
||||
impl ProcessReader {
|
||||
fn spawn(
|
||||
handle: process::Child,
|
||||
state: Arc<Mutex<ExecutionState>>,
|
||||
file_handle: NamedTempFile,
|
||||
) -> thread::JoinHandle<()> {
|
||||
let reader = Self { handle, state, file_handle };
|
||||
thread::spawn(|| reader.run())
|
||||
}
|
||||
|
||||
fn run(mut self) {
|
||||
let stdout = self.handle.stdout.take().expect("no stdout");
|
||||
let stdout = BufReader::new(stdout);
|
||||
let _ = Self::process_output(self.state.clone(), stdout);
|
||||
let success = match self.handle.try_wait() {
|
||||
Ok(Some(code)) => code.success(),
|
||||
_ => false,
|
||||
};
|
||||
let status = match success {
|
||||
true => ProcessStatus::Success,
|
||||
false => ProcessStatus::Failure,
|
||||
};
|
||||
self.state.lock().unwrap().status = status;
|
||||
}
|
||||
|
||||
fn process_output(state: Arc<Mutex<ExecutionState>>, stdout: BufReader<ChildStdout>) -> io::Result<()> {
|
||||
for line in stdout.lines() {
|
||||
let line = line?;
|
||||
// TODO: consider not locking per line...
|
||||
state.lock().unwrap().output.push(line);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The state of the execution of a process.
|
||||
#[derive(Clone, Default, Debug)]
|
||||
pub(crate) struct ExecutionState {
|
||||
pub(crate) output: Vec<String>,
|
||||
pub(crate) status: ProcessStatus,
|
||||
}
|
||||
|
||||
/// The status of a process.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) enum ProcessStatus {
|
||||
#[default]
|
||||
Running,
|
||||
Success,
|
||||
Failure,
|
||||
}
|
||||
|
||||
impl ProcessStatus {
|
||||
/// Check whether the underlying process is finished.
|
||||
pub(crate) fn is_finished(&self) -> bool {
|
||||
matches!(self, ProcessStatus::Success | ProcessStatus::Failure)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::markdown::elements::CodeAttributes;
|
||||
|
||||
#[test]
|
||||
fn shell_code_execution() {
|
||||
let contents = r"
|
||||
echo 'hello world'
|
||||
echo 'bye'"
|
||||
.into();
|
||||
let code = Code {
|
||||
contents,
|
||||
language: CodeLanguage::Shell("sh".into()),
|
||||
attributes: CodeAttributes { execute: true, ..Default::default() },
|
||||
};
|
||||
let handle = CodeExecuter::execute(&code).expect("execution failed");
|
||||
let state = loop {
|
||||
let state = handle.state();
|
||||
if state.status.is_finished() {
|
||||
break state;
|
||||
}
|
||||
};
|
||||
|
||||
let expected_lines = vec!["hello world", "bye"];
|
||||
assert_eq!(state.output, expected_lines);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_executable_code_cant_be_executed() {
|
||||
let contents = String::new();
|
||||
let code = Code {
|
||||
contents,
|
||||
language: CodeLanguage::Shell("sh".into()),
|
||||
attributes: CodeAttributes { execute: false, ..Default::default() },
|
||||
};
|
||||
let result = CodeExecuter::execute(&code);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
357
src/export.rs
357
src/export.rs
@ -1,357 +0,0 @@
|
||||
use crate::{
|
||||
custom::KeyBindingsConfig,
|
||||
markdown::parse::ParseError,
|
||||
media::{
|
||||
image::{Image, ImageSource},
|
||||
printer::{ImageResource, ResourceProperties},
|
||||
},
|
||||
presentation::{Presentation, RenderOperation},
|
||||
processing::builder::{BuildError, PresentationBuilder, PresentationBuilderOptions, Themes},
|
||||
tools::{ExecutionError, ThirdPartyTools},
|
||||
typst::TypstRender,
|
||||
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::{self},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
const MINIMUM_EXPORTER_VERSION: Version = Version::new(0, 2, 0);
|
||||
|
||||
/// Allows exporting presentations into PDF.
|
||||
pub struct Exporter<'a> {
|
||||
parser: MarkdownParser<'a>,
|
||||
default_theme: &'a PresentationTheme,
|
||||
resources: Resources,
|
||||
typst: TypstRender,
|
||||
themes: Themes,
|
||||
options: PresentationBuilderOptions,
|
||||
}
|
||||
|
||||
impl<'a> Exporter<'a> {
|
||||
/// Construct a new exporter.
|
||||
pub fn new(
|
||||
parser: MarkdownParser<'a>,
|
||||
default_theme: &'a PresentationTheme,
|
||||
resources: Resources,
|
||||
typst: TypstRender,
|
||||
themes: Themes,
|
||||
options: PresentationBuilderOptions,
|
||||
) -> Self {
|
||||
Self { parser, default_theme, resources, typst, 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()?;
|
||||
|
||||
let metadata = self.generate_metadata(presentation_path)?;
|
||||
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.typst,
|
||||
&self.themes,
|
||||
Default::default(),
|
||||
KeyBindingsConfig::default(),
|
||||
self.options.clone(),
|
||||
)
|
||||
.build(elements)?;
|
||||
|
||||
let images = Self::build_image_metadata(&mut presentation)?;
|
||||
Self::validate_theme_colors(&presentation)?;
|
||||
let commands = Self::build_capture_commands(presentation);
|
||||
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, "--export"];
|
||||
args.extend(extra_args);
|
||||
args.push(&presentation_path);
|
||||
ThirdPartyTools::presenterm_export(&args).stdin(metadata).run()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_capture_commands(mut presentation: Presentation) -> 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();
|
||||
};
|
||||
for chunks in slide_chunks {
|
||||
for _ in 0..chunks - 1 {
|
||||
next_slide(&mut commands);
|
||||
}
|
||||
commands.push(CaptureCommand::Capture);
|
||||
next_slide(&mut commands);
|
||||
}
|
||||
commands
|
||||
}
|
||||
|
||||
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(),
|
||||
)?;
|
||||
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_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| {
|
||||
let RenderOperation::RenderImage(image, properties) = operation else {
|
||||
return;
|
||||
};
|
||||
let replacement = self.replace_image(image.clone());
|
||||
*operation = RenderOperation::RenderImage(replacement, properties.clone());
|
||||
};
|
||||
|
||||
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() }
|
||||
}
|
||||
}
|
||||
|
||||
#[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 typst = TypstRender::default();
|
||||
let themes = Themes::default();
|
||||
let options = PresentationBuilderOptions { allow_mutations: false, ..Default::default() };
|
||||
let mut exporter = Exporter::new(parser, &theme, resources, typst, 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;
|
@ -1,87 +0,0 @@
|
||||
use super::{
|
||||
fs::PresentationFileWatcher,
|
||||
user::{CommandKeyBindings, KeyBindingsValidationError, UserInput},
|
||||
};
|
||||
use crate::custom::KeyBindingsConfig;
|
||||
use serde::Deserialize;
|
||||
use std::{io, path::PathBuf, 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,
|
||||
}
|
||||
|
||||
impl CommandSource {
|
||||
/// Create a new command source over the given presentation path.
|
||||
pub fn new<P: Into<PathBuf>>(
|
||||
presentation_path: P,
|
||||
config: KeyBindingsConfig,
|
||||
) -> Result<Self, KeyBindingsValidationError> {
|
||||
let watcher = PresentationFileWatcher::new(presentation_path);
|
||||
let bindings = CommandKeyBindings::try_from(config)?;
|
||||
Ok(Self { watcher, user_input: UserInput::new(bindings) })
|
||||
}
|
||||
|
||||
/// 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) }
|
||||
}
|
||||
}
|
||||
|
||||
/// A command.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, EnumDiscriminants)]
|
||||
#[strum_discriminants(derive(Deserialize))]
|
||||
pub(crate) enum Command {
|
||||
/// Redraw the presentation.
|
||||
///
|
||||
/// This can happen on terminal resize.
|
||||
Redraw,
|
||||
|
||||
/// Move forward in the presentation.
|
||||
Next,
|
||||
|
||||
/// Move backwards in the presentation.
|
||||
Previous,
|
||||
|
||||
/// Go to the first slide.
|
||||
FirstSlide,
|
||||
|
||||
/// Go to the last slide.
|
||||
LastSlide,
|
||||
|
||||
/// Go to one particular slide.
|
||||
GoToSlide(u32),
|
||||
|
||||
/// Render any widgets in the currently visible slide.
|
||||
RenderWidgets,
|
||||
|
||||
/// Exit the presentation.
|
||||
Exit,
|
||||
|
||||
/// The presentation has changed and needs to be reloaded.
|
||||
Reload,
|
||||
|
||||
/// Hard reload the presentation.
|
||||
///
|
||||
/// Like [Command::Reload] but also reloads any external resources like images and themes.
|
||||
HardReload,
|
||||
|
||||
/// Toggle the slide index view.
|
||||
ToggleSlideIndex,
|
||||
|
||||
/// Toggle the key bindings config view.
|
||||
ToggleKeyBindingsConfig,
|
||||
|
||||
/// Hide the currently open modal, if any.
|
||||
CloseModal,
|
||||
}
|
36
src/lib.rs
36
src/lib.rs
@ -1,36 +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 tools;
|
||||
pub(crate) mod typst;
|
||||
|
||||
pub use crate::{
|
||||
custom::{Config, ImageProtocol, ValidateOverflows},
|
||||
demo::ThemesDemo,
|
||||
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},
|
||||
typst::TypstRender,
|
||||
};
|
507
src/main.rs
507
src/main.rs
@ -1,18 +1,62 @@
|
||||
use clap::{error::ErrorKind, CommandFactory, Parser};
|
||||
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 presenterm::{
|
||||
CommandSource, Config, Exporter, GraphicsMode, HighlightThemeSet, ImagePrinter, ImageProtocol, ImageRegistry,
|
||||
LoadThemeError, MarkdownParser, PresentMode, PresentationBuilderOptions, PresentationTheme, PresentationThemeSet,
|
||||
Presenter, PresenterOptions, Resources, Themes, ThemesDemo, TypstRender, ValidateOverflows,
|
||||
};
|
||||
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)]
|
||||
@ -24,16 +68,25 @@ 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,
|
||||
|
||||
/// Run in export mode.
|
||||
#[clap(long, hide = true)]
|
||||
export: 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,
|
||||
|
||||
/// Use presentation mode.
|
||||
#[clap(short, long, default_value_t = false)]
|
||||
@ -44,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,
|
||||
@ -59,9 +116,25 @@ struct Cli {
|
||||
#[clap(long)]
|
||||
validate_overflows: bool,
|
||||
|
||||
/// Enable code snippet execution.
|
||||
#[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 {
|
||||
@ -78,82 +151,199 @@ fn create_splash() -> String {
|
||||
)
|
||||
}
|
||||
|
||||
fn load_customizations(config_file_path: Option<PathBuf>) -> Result<(Config, Themes), 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)?;
|
||||
Ok((config, themes))
|
||||
#[derive(Default)]
|
||||
struct Customizations {
|
||||
config: Config,
|
||||
themes: Themes,
|
||||
themes_path: Option<PathBuf>,
|
||||
code_executor: SnippetExecutor,
|
||||
}
|
||||
|
||||
fn load_themes(config_path: &Path) -> Result<Themes, Box<dyn std::error::Error>> {
|
||||
let themes_path = config_path.join("themes");
|
||||
|
||||
let mut highlight_themes = HighlightThemeSet::default();
|
||||
highlight_themes.register_from_directory(&themes_path.join("highlighting"))?;
|
||||
|
||||
let mut presentation_themes = PresentationThemeSet::default();
|
||||
let register_result = presentation_themes.register_from_directory(&themes_path);
|
||||
if let Err(e @ (LoadThemeError::Duplicate(_) | LoadThemeError::Corrupted(..))) = register_result {
|
||||
return Err(e.into());
|
||||
}
|
||||
|
||||
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(),
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
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 select_graphics_mode(cli: &Cli, config: &Config) -> GraphicsMode {
|
||||
if cli.export || 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,
|
||||
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(_) => {
|
||||
Cli::command().error(ErrorKind::InvalidValue, "sixel support was not enabled during compilation").exit()
|
||||
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(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 presentation_themes = PresentationThemeRegistry::default();
|
||||
presentation_themes.register_from_directory(themes_path)?;
|
||||
|
||||
let themes = Themes { presentation: presentation_themes, highlight: highlight_themes };
|
||||
Ok(themes)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
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,
|
||||
@ -163,66 +353,120 @@ fn overflow_validation(mode: &PresentMode, config: &ValidateOverflows) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn run(mut cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (config, themes) = 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.export) {
|
||||
(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();
|
||||
return Ok(());
|
||||
} else if cli.list_themes {
|
||||
let bindings = config.bindings.try_into()?;
|
||||
let demo = ThemesDemo::new(themes, bindings, io::stdout())?;
|
||||
demo.run()?;
|
||||
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(());
|
||||
}
|
||||
if cli.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)?;
|
||||
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);
|
||||
let graphics_mode = select_graphics_mode(&cli, &config);
|
||||
let printer = Rc::new(ImagePrinter::new(graphics_mode.clone())?);
|
||||
let registry = ImageRegistry(printer.clone());
|
||||
let resources = Resources::new(resources_path, registry.clone());
|
||||
let typst = TypstRender::new(config.typst.ppi, registry);
|
||||
if cli.export_pdf || cli.generate_pdf_metadata {
|
||||
let mut exporter = Exporter::new(parser, &default_theme, resources, typst, 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]);
|
||||
}
|
||||
};
|
||||
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, parser, resources, typst, themes, printer, options);
|
||||
let presenter = Presenter::new(
|
||||
&default_theme,
|
||||
command_listener,
|
||||
parser,
|
||||
resources,
|
||||
third_party,
|
||||
code_executor,
|
||||
themes,
|
||||
printer,
|
||||
options,
|
||||
events_publisher,
|
||||
);
|
||||
presenter.present(&path)?;
|
||||
}
|
||||
Ok(())
|
||||
@ -231,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,310 +0,0 @@
|
||||
use super::elements::{Code, CodeAttributes, CodeLanguage, Highlight, HighlightGroup};
|
||||
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<Code> {
|
||||
let (language, attributes) = Self::parse_block_info(&code_block.info)?;
|
||||
let code = Code { contents: code_block.literal.clone(), language, attributes };
|
||||
Ok(code)
|
||||
}
|
||||
|
||||
fn parse_block_info(input: &str) -> ParseResult<(CodeLanguage, CodeAttributes)> {
|
||||
let (language, input) = Self::parse_language(input);
|
||||
let attributes = Self::parse_attributes(input)?;
|
||||
if attributes.execute && !language.supports_execution() {
|
||||
return Err(CodeBlockParseError::UnsupportedAttribute(language, "execution"));
|
||||
}
|
||||
if attributes.auto_render && !language.supports_auto_render() {
|
||||
return Err(CodeBlockParseError::UnsupportedAttribute(language, "rendering"));
|
||||
}
|
||||
Ok((language, attributes))
|
||||
}
|
||||
|
||||
fn parse_language(input: &str) -> (CodeLanguage, &str) {
|
||||
let token = Self::next_identifier(input);
|
||||
use CodeLanguage::*;
|
||||
let language = match token {
|
||||
"ada" => Ada,
|
||||
"asp" => Asp,
|
||||
"awk" => Awk,
|
||||
"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,
|
||||
"go" => Go,
|
||||
"haskell" => Haskell,
|
||||
"html" => Html,
|
||||
"java" => Java,
|
||||
"javascript" | "js" => JavaScript,
|
||||
"json" => Json,
|
||||
"kotlin" => Kotlin,
|
||||
"latex" => Latex,
|
||||
"lua" => Lua,
|
||||
"make" => Makefile,
|
||||
"markdown" => Markdown,
|
||||
"nix" => Nix,
|
||||
"ocaml" => OCaml,
|
||||
"perl" => Perl,
|
||||
"php" => Php,
|
||||
"protobuf" => Protobuf,
|
||||
"puppet" => Puppet,
|
||||
"python" => Python,
|
||||
"r" => R,
|
||||
"rust" => Rust,
|
||||
"scala" => Scala,
|
||||
"shell" => Shell("sh".into()),
|
||||
interpreter @ ("bash" | "sh" | "zsh" | "fish") => Shell(interpreter.into()),
|
||||
"sql" => Sql,
|
||||
"svelte" => Svelte,
|
||||
"swift" => Swift,
|
||||
"terraform" => Terraform,
|
||||
"typescript" | "ts" => TypeScript,
|
||||
"typst" => Typst,
|
||||
"xml" => Xml,
|
||||
"yaml" => Yaml,
|
||||
"vue" => Vue,
|
||||
"zig" => Zig,
|
||||
_ => Unknown,
|
||||
};
|
||||
let rest = &input[token.len()..];
|
||||
(language, rest)
|
||||
}
|
||||
|
||||
fn parse_attributes(mut input: &str) -> ParseResult<CodeAttributes> {
|
||||
let mut attributes = CodeAttributes::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,
|
||||
};
|
||||
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,
|
||||
_ => 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 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("duplicate attribute: {0}")]
|
||||
DuplicateAttribute(&'static str),
|
||||
|
||||
#[error("language {0:?} does not support {1}")]
|
||||
UnsupportedAttribute(CodeLanguage, &'static str),
|
||||
}
|
||||
|
||||
#[derive(EnumDiscriminants)]
|
||||
enum Attribute {
|
||||
LineNumbers,
|
||||
Exec,
|
||||
AutoRender,
|
||||
HighlightedLines(Vec<HighlightGroup>),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
use Highlight::*;
|
||||
|
||||
fn parse_language(input: &str) -> CodeLanguage {
|
||||
let (language, _) = CodeBlockParser::parse_block_info(input).expect("parse failed");
|
||||
language
|
||||
}
|
||||
|
||||
fn parse_attributes(input: &str) -> CodeAttributes {
|
||||
let (_, attributes) = CodeBlockParser::parse_block_info(input).expect("parse failed");
|
||||
attributes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_language() {
|
||||
assert_eq!(parse_language("potato"), CodeLanguage::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_attributes() {
|
||||
assert_eq!(parse_language("rust"), CodeLanguage::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)]));
|
||||
}
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
use crate::style::TextStyle;
|
||||
use std::{iter, ops::Range, path::PathBuf};
|
||||
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.
|
||||
@ -13,24 +14,33 @@ 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 },
|
||||
Image { path: PathBuf, title: String, source_position: SourcePosition },
|
||||
|
||||
/// A list.
|
||||
///
|
||||
/// All contiguous list items are merged into a single one, regardless of levels of nesting.
|
||||
List(Vec<ListItem>),
|
||||
|
||||
/// A block of code.
|
||||
Code(Code),
|
||||
/// A code 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),
|
||||
@ -41,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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -64,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,
|
||||
@ -76,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)]
|
||||
pub(crate) struct TextBlock(pub(crate) Vec<Text>);
|
||||
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 {
|
||||
@ -110,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()])
|
||||
}
|
||||
@ -120,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() }
|
||||
}
|
||||
@ -153,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,
|
||||
@ -166,138 +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 piece of code.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct Code {
|
||||
/// The code itself.
|
||||
pub(crate) contents: String,
|
||||
|
||||
/// The programming language this code is written in.
|
||||
pub(crate) language: CodeLanguage,
|
||||
|
||||
/// The attributes used for this code.
|
||||
pub(crate) attributes: CodeAttributes,
|
||||
}
|
||||
|
||||
/// The language of a piece of code.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, EnumIter)]
|
||||
pub(crate) enum CodeLanguage {
|
||||
Ada,
|
||||
Asp,
|
||||
Awk,
|
||||
Bash,
|
||||
BatchFile,
|
||||
C,
|
||||
CMake,
|
||||
Crontab,
|
||||
CSharp,
|
||||
Clojure,
|
||||
Cpp,
|
||||
Css,
|
||||
DLang,
|
||||
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,
|
||||
Rust,
|
||||
Scala,
|
||||
Shell(String),
|
||||
Sql,
|
||||
Swift,
|
||||
Svelte,
|
||||
Terraform,
|
||||
TypeScript,
|
||||
Typst,
|
||||
Unknown,
|
||||
Xml,
|
||||
Yaml,
|
||||
Vue,
|
||||
Zig,
|
||||
}
|
||||
|
||||
impl CodeLanguage {
|
||||
pub(crate) fn supports_execution(&self) -> bool {
|
||||
matches!(self, Self::Shell(_))
|
||||
}
|
||||
|
||||
pub(crate) fn supports_auto_render(&self) -> bool {
|
||||
matches!(self, Self::Latex | Self::Typst)
|
||||
}
|
||||
}
|
||||
|
||||
/// Attributes for code blocks.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct CodeAttributes {
|
||||
/// Whether the code block is marked as executable.
|
||||
pub(crate) execute: bool,
|
||||
|
||||
/// Whether a code block is marked to be auto rendered.
|
||||
///
|
||||
/// An auto rendered piece of code 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 code block should show line numbers.
|
||||
pub(crate) line_numbers: bool,
|
||||
|
||||
/// The groups of lines to highlight.
|
||||
pub(crate) highlight_groups: Vec<HighlightGroup>,
|
||||
}
|
||||
|
||||
#[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.
|
||||
@ -319,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)
|
||||
@ -328,4 +237,42 @@ 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)]
|
||||
pub(crate) struct Percent(pub(crate) u8);
|
||||
|
||||
impl Percent {
|
||||
pub(crate) fn as_ratio(&self) -> f64 {
|
||||
self.0 as f64 / 100.0
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Percent {
|
||||
type Err = PercentParseError;
|
||||
|
||||
fn from_str(input: &str) -> Result<Self, Self::Err> {
|
||||
let (prefix, suffix) = input.split_once('%').ok_or(PercentParseError::Unit)?;
|
||||
let value: u8 = prefix.parse().map_err(|_| PercentParseError::Value)?;
|
||||
if !(1..=100).contains(&value) {
|
||||
return Err(PercentParseError::Value);
|
||||
}
|
||||
if !suffix.is_empty() {
|
||||
return Err(PercentParseError::Trailer(suffix.into()));
|
||||
}
|
||||
Ok(Percent(value))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum PercentParseError {
|
||||
#[error("value must be a number between 1-100")]
|
||||
Value,
|
||||
|
||||
#[error("no unit provided")]
|
||||
Unit,
|
||||
|
||||
#[error("unexpected: '{0}'")]
|
||||
Trailer(String),
|
||||
}
|
||||
|
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,28 +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::{
|
||||
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);
|
||||
struct ParserOptions(comrak::Options<'static>);
|
||||
|
||||
impl Default for ParserOptions {
|
||||
fn default() -> Self {
|
||||
@ -31,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)
|
||||
}
|
||||
}
|
||||
@ -40,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,
|
||||
options: comrak::Options<'static>,
|
||||
}
|
||||
|
||||
impl<'a> MarkdownParser<'a> {
|
||||
@ -53,62 +56,66 @@ 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::Code(_)
|
||||
| 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(node: &'a AstNode<'a>) -> ParseResult<Vec<MarkdownElement>> {
|
||||
fn parse_node(&self, node: &'a AstNode<'a>) -> ParseResult<Vec<MarkdownElement>> {
|
||||
let data = node.data.borrow();
|
||||
let element = match &data.value {
|
||||
// Paragraphs are the only ones that can actually yield more than one.
|
||||
NodeValue::Paragraph => return Self::parse_paragraph(node),
|
||||
NodeValue::Paragraph => return self.parse_paragraph(node),
|
||||
NodeValue::FrontMatter(contents) => Self::parse_front_matter(contents)?,
|
||||
NodeValue::Heading(heading) => Self::parse_heading(heading, node)?,
|
||||
NodeValue::Heading(heading) => self.parse_heading(heading, node)?,
|
||||
NodeValue::List(list) => {
|
||||
let items = Self::parse_list(node, list.marker_offset as u8 / 2)?;
|
||||
let items = self.parse_list(node, list.marker_offset as u8 / 2)?;
|
||||
MarkdownElement::List(items)
|
||||
}
|
||||
NodeValue::Table(_) => Self::parse_table(node)?,
|
||||
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);
|
||||
@ -119,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 = "-->";
|
||||
@ -131,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))
|
||||
}
|
||||
@ -164,13 +158,20 @@ 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::Code(code))
|
||||
Ok(MarkdownElement::Snippet {
|
||||
info: block.info.clone(),
|
||||
code: block.literal.clone(),
|
||||
source_position: sourcepos.into(),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_heading(heading: &NodeHeading, node: &'a AstNode<'a>) -> ParseResult<MarkdownElement> {
|
||||
let text = Self::parse_text(node)?;
|
||||
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> {
|
||||
let text = self.parse_text(node)?;
|
||||
if heading.setext {
|
||||
Ok(MarkdownElement::SetexHeading { text })
|
||||
} else {
|
||||
@ -178,19 +179,23 @@ impl<'a> MarkdownParser<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_paragraph(node: &'a AstNode<'a>) -> ParseResult<Vec<MarkdownElement>> {
|
||||
fn parse_paragraph(&self, node: &'a AstNode<'a>) -> ParseResult<Vec<MarkdownElement>> {
|
||||
let mut elements = Vec::new();
|
||||
let inlines = InlinesParser::default().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::Image(path) => {
|
||||
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)));
|
||||
}
|
||||
elements.push(MarkdownElement::Image { path: path.into() });
|
||||
elements.push(MarkdownElement::Image {
|
||||
path: path.into(),
|
||||
title,
|
||||
source_position: node.data.borrow().sourcepos.into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -200,8 +205,8 @@ impl<'a> MarkdownParser<'a> {
|
||||
Ok(elements)
|
||||
}
|
||||
|
||||
fn parse_text(node: &'a AstNode<'a>) -> ParseResult<TextBlock> {
|
||||
let inlines = InlinesParser::default().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 {
|
||||
@ -212,16 +217,16 @@ impl<'a> MarkdownParser<'a> {
|
||||
}
|
||||
};
|
||||
}
|
||||
Ok(TextBlock(chunks))
|
||||
Ok(Line(chunks))
|
||||
}
|
||||
|
||||
fn parse_list(root: &'a AstNode<'a>, depth: u8) -> ParseResult<Vec<ListItem>> {
|
||||
fn parse_list(&self, root: &'a AstNode<'a>, depth: u8) -> ParseResult<Vec<ListItem>> {
|
||||
let mut elements = Vec::new();
|
||||
for node in root.children() {
|
||||
let data = node.data.borrow();
|
||||
match &data.value {
|
||||
NodeValue::Item(item) => {
|
||||
elements.extend(Self::parse_list_item(item, node, depth)?);
|
||||
elements.extend(self.parse_list_item(item, node, depth)?);
|
||||
}
|
||||
other => {
|
||||
return Err(ParseErrorKind::UnsupportedStructure {
|
||||
@ -235,22 +240,22 @@ impl<'a> MarkdownParser<'a> {
|
||||
Ok(elements)
|
||||
}
|
||||
|
||||
fn parse_list_item(item: &NodeList, root: &'a AstNode<'a>, depth: u8) -> ParseResult<Vec<ListItem>> {
|
||||
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() {
|
||||
let data = node.data.borrow();
|
||||
match &data.value {
|
||||
NodeValue::Paragraph => {
|
||||
let contents = Self::parse_text(node)?;
|
||||
let contents = self.parse_text(node)?;
|
||||
elements.push(ListItem { contents, depth, item_type: item_type.clone() });
|
||||
}
|
||||
NodeValue::List(_) => {
|
||||
elements.extend(Self::parse_list(node, depth + 1)?);
|
||||
elements.extend(self.parse_list(node, depth + 1)?);
|
||||
}
|
||||
other => {
|
||||
return Err(ParseErrorKind::UnsupportedStructure {
|
||||
@ -264,7 +269,7 @@ impl<'a> MarkdownParser<'a> {
|
||||
Ok(elements)
|
||||
}
|
||||
|
||||
fn parse_table(node: &'a AstNode<'a>) -> ParseResult<MarkdownElement> {
|
||||
fn parse_table(&self, node: &'a AstNode<'a>) -> ParseResult<MarkdownElement> {
|
||||
let mut header = TableRow(Vec::new());
|
||||
let mut rows = Vec::new();
|
||||
for node in node.children() {
|
||||
@ -276,7 +281,7 @@ impl<'a> MarkdownParser<'a> {
|
||||
}
|
||||
.with_sourcepos(data.sourcepos));
|
||||
};
|
||||
let row = Self::parse_table_row(node)?;
|
||||
let row = self.parse_table_row(node)?;
|
||||
if header.0.is_empty() {
|
||||
header = row;
|
||||
} else {
|
||||
@ -286,7 +291,7 @@ impl<'a> MarkdownParser<'a> {
|
||||
Ok(MarkdownElement::Table(Table { header, rows }))
|
||||
}
|
||||
|
||||
fn parse_table_row(node: &'a AstNode<'a>) -> ParseResult<TableRow> {
|
||||
fn parse_table_row(&self, node: &'a AstNode<'a>) -> ParseResult<TableRow> {
|
||||
let mut cells = Vec::new();
|
||||
for node in node.children() {
|
||||
let data = node.data.borrow();
|
||||
@ -297,21 +302,37 @@ impl<'a> MarkdownParser<'a> {
|
||||
}
|
||||
.with_sourcepos(data.sourcepos));
|
||||
};
|
||||
let text = Self::parse_text(node)?;
|
||||
let text = self.parse_text(node)?;
|
||||
cells.push(text);
|
||||
}
|
||||
Ok(TableRow(cells))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct InlinesParser {
|
||||
inlines: Vec<Inline>,
|
||||
pending_text: Vec<Text>,
|
||||
enum SoftBreak {
|
||||
Newline,
|
||||
Space,
|
||||
}
|
||||
|
||||
impl InlinesParser {
|
||||
fn parse<'a>(mut self, node: &'a AstNode<'a>) -> ParseResult<Vec<Inline>> {
|
||||
enum StringifyImages {
|
||||
Yes,
|
||||
No,
|
||||
}
|
||||
|
||||
struct InlinesParser<'a> {
|
||||
inlines: Vec<Inline>,
|
||||
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>>, 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>> {
|
||||
self.process_children(node, TextStyle::default())?;
|
||||
self.store_pending_text();
|
||||
Ok(self.inlines)
|
||||
@ -320,51 +341,150 @@ impl InlinesParser {
|
||||
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<'a>(&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();
|
||||
self.inlines.push(Inline::Image(link.url.clone()));
|
||||
|
||||
// The image "title" contains inlines so we create a dummy paragraph node that
|
||||
// contains it so we can flatten it back into text. We could walk the tree but this
|
||||
// is good enough.
|
||||
let mut buffer = Vec::new();
|
||||
let paragraph =
|
||||
self.arena.alloc(Node::new(RefCell::new(Ast::new(NodeValue::Paragraph, data.sourcepos.start))));
|
||||
for child in node.children() {
|
||||
paragraph.append(child);
|
||||
}
|
||||
format_commonmark(paragraph, &ParserOptions::default().0, &mut buffer)
|
||||
.map_err(|e| ParseErrorKind::Internal(e.to_string()).with_sourcepos(data.sourcepos))?;
|
||||
|
||||
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<'a>(&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),
|
||||
Image(String),
|
||||
Text(Line<RawColor>),
|
||||
Image { path: String, title: String },
|
||||
LineBreak,
|
||||
}
|
||||
|
||||
@ -372,7 +492,7 @@ impl Inline {
|
||||
fn kind(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Text(_) => "text",
|
||||
Self::Image(_) => "image",
|
||||
Self::Image { .. } => "image",
|
||||
Self::LineBreak => "line break",
|
||||
}
|
||||
}
|
||||
@ -390,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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -412,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),
|
||||
@ -427,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}"),
|
||||
}
|
||||
}
|
||||
@ -478,29 +598,44 @@ impl Identifier for NodeValue {
|
||||
NodeValue::Image(_) => "image",
|
||||
NodeValue::FootnoteReference(_) => "footnote reference",
|
||||
NodeValue::MultilineBlockQuote(_) => "multiline block quote",
|
||||
NodeValue::Math(_) => "math",
|
||||
NodeValue::Escaped => "escaped",
|
||||
NodeValue::WikiLink(_) => "wiki link",
|
||||
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 rstest::rstest;
|
||||
use crate::markdown::text_style::Color;
|
||||
|
||||
use super::*;
|
||||
use crate::markdown::elements::CodeLanguage;
|
||||
use rstest::rstest;
|
||||
use std::path::Path;
|
||||
|
||||
fn parse_single(input: &str) -> MarkdownElement {
|
||||
fn try_parse(input: &str) -> Result<Vec<MarkdownElement>, ParseError> {
|
||||
let arena = Arena::new();
|
||||
let result = MarkdownParser::new(&arena).parse(input).expect("parsing failed");
|
||||
assert_eq!(result.len(), 1, "more than one element: {result:?}");
|
||||
result.into_iter().next().unwrap()
|
||||
MarkdownParser::new(&arena).parse(input)
|
||||
}
|
||||
|
||||
fn parse_single(input: &str) -> MarkdownElement {
|
||||
let elements = try_parse(input).expect("failed to parse");
|
||||
assert_eq!(elements.len(), 1, "more than one element: {elements:?}");
|
||||
elements.into_iter().next().unwrap()
|
||||
}
|
||||
|
||||
fn parse_all(input: &str) -> Vec<MarkdownElement> {
|
||||
let arena = Arena::new();
|
||||
let result = MarkdownParser::new(&arena).parse(input).expect("parsing failed");
|
||||
result
|
||||
try_parse(input).expect("parsing failed")
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -534,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);
|
||||
}
|
||||
|
||||
@ -608,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(
|
||||
@ -622,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::Code(code) = parsed else { panic!("not a code block: {parsed:?}") };
|
||||
assert_eq!(code.language, CodeLanguage::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::Code(code) = parsed else { panic!("not a code block: {parsed:?}") };
|
||||
assert_eq!(code.language, CodeLanguage::Shell("bash".into()));
|
||||
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]
|
||||
@ -667,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);
|
||||
}
|
||||
|
||||
@ -717,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]
|
||||
@ -747,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]
|
||||
@ -783,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);
|
||||
}
|
||||
|
||||
@ -799,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);
|
||||
}
|
||||
|
||||
@ -814,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, rc::Rc};
|
||||
|
||||
/// An image.
|
||||
///
|
||||
/// This stores the image in an [std::rc::Rc] so it's cheap to clone.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Image {
|
||||
pub(crate) resource: Rc<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: Rc::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())?;
|
||||
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, rc::Rc};
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ImageRegistry(pub Rc<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,45 +0,0 @@
|
||||
use crate::render::properties::{CursorPosition, WindowSize};
|
||||
|
||||
pub(crate) fn scale_image(
|
||||
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 }
|
||||
}
|
||||
|
||||
pub(crate) struct TerminalRect {
|
||||
pub(crate) start_column: u16,
|
||||
pub(crate) columns: u16,
|
||||
pub(crate) rows: u16,
|
||||
}
|
@ -1,70 +0,0 @@
|
||||
use super::printer::{
|
||||
CreatePrinterError, PrintImage, PrintImageError, PrintOptions, RegisterImageError, ResourceProperties,
|
||||
};
|
||||
use image::{imageops::FilterType, DynamicImage, GenericImageView};
|
||||
use sixel_rs::{
|
||||
encoder::{Encoder, QuickFrameBuilder},
|
||||
optflags::EncodePolicy,
|
||||
sys::PixelFormat,
|
||||
};
|
||||
use std::{fs, io};
|
||||
|
||||
pub(crate) struct SixelResource(DynamicImage);
|
||||
|
||||
impl ResourceProperties for SixelResource {
|
||||
fn dimensions(&self) -> (u32, u32) {
|
||||
self.0.dimensions()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SixelPrinter {
|
||||
encoder: Encoder,
|
||||
}
|
||||
|
||||
impl SixelPrinter {
|
||||
pub(crate) fn new() -> Result<Self, CreatePrinterError> {
|
||||
let encoder =
|
||||
Encoder::new().map_err(|e| CreatePrinterError::Other(format!("creating sixel encoder: {e:?}")))?;
|
||||
encoder
|
||||
.set_encode_policy(EncodePolicy::Fast)
|
||||
.map_err(|e| CreatePrinterError::Other(format!("setting encoder policy: {e:?}")))?;
|
||||
Ok(Self { encoder })
|
||||
}
|
||||
}
|
||||
|
||||
impl PrintImage for SixelPrinter {
|
||||
type Resource = SixelResource;
|
||||
|
||||
fn register_image(&self, image: image::DynamicImage) -> Result<Self::Resource, RegisterImageError> {
|
||||
Ok(SixelResource(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(SixelResource(image))
|
||||
}
|
||||
|
||||
fn print<W>(&self, image: &Self::Resource, options: &PrintOptions, writer: &mut W) -> Result<(), PrintImageError>
|
||||
where
|
||||
W: io::Write,
|
||||
{
|
||||
// We're already positioned in the right place but we may not have flushed that yet.
|
||||
writer.flush()?;
|
||||
|
||||
// This check was taken from viuer: it seems to be a bug in xterm
|
||||
let width = (options.column_width * options.columns).min(1000);
|
||||
let height = options.row_height * options.rows;
|
||||
let image = image.0.resize_exact(width as u32, height as u32, FilterType::Triangle);
|
||||
let bytes = image.into_rgba8().into_raw();
|
||||
|
||||
let frame = QuickFrameBuilder::new()
|
||||
.width(width as usize)
|
||||
.height(height as usize)
|
||||
.format(PixelFormat::RGBA8888)
|
||||
.pixels(bytes);
|
||||
|
||||
self.encoder.encode_bytes(frame).map_err(|e| PrintImageError::other(format!("encoding sixel image: {e:?}")))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
1982
src/presentation/builder/mod.rs
Normal file
1982
src/presentation/builder/mod.rs
Normal file
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);
|
||||
}
|
||||
}
|
@ -71,8 +71,12 @@ impl ContentDiff for RenderOperation {
|
||||
(RenderText { alignment: original, .. }, RenderText { alignment: updated, .. }) if original != updated => {
|
||||
false
|
||||
}
|
||||
(RenderImage(original, _), RenderImage(updated, _)) if original != updated => true,
|
||||
(RenderPreformattedLine(original), RenderPreformattedLine(updated)) if original != updated => true,
|
||||
(RenderImage(original, original_properties), RenderImage(updated, updated_properties))
|
||||
if original != updated || original_properties != updated_properties =>
|
||||
{
|
||||
true
|
||||
}
|
||||
(RenderBlockLine(original), RenderBlockLine(updated)) if original != updated => true,
|
||||
(InitColumnLayout { columns: original }, InitColumnLayout { columns: updated }) if original != updated => {
|
||||
true
|
||||
}
|
||||
@ -81,6 +85,8 @@ impl ContentDiff for RenderOperation {
|
||||
(RenderDynamic(original), RenderDynamic(updated)) => {
|
||||
original.diffable_content() != updated.diffable_content()
|
||||
}
|
||||
(RenderAsync(original), RenderAsync(updated)) if original.type_id() != updated.type_id() => true,
|
||||
(RenderAsync(original), RenderAsync(updated)) => original.diffable_content() != updated.diffable_content(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@ -108,9 +114,15 @@ where
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::{
|
||||
presentation::{AsRenderOperations, PreformattedLine, Slide, SlideBuilder},
|
||||
render::properties::WindowSize,
|
||||
style::{Color, Colors},
|
||||
markdown::{
|
||||
text::WeightedLine,
|
||||
text_style::{Color, Colors},
|
||||
},
|
||||
presentation::{Slide, SlideBuilder},
|
||||
render::{
|
||||
operation::{AsRenderOperations, BlockLine, Pollable, RenderAsync, ToggleState},
|
||||
properties::WindowSize,
|
||||
},
|
||||
theme::{Alignment, Margin},
|
||||
};
|
||||
use rstest::rstest;
|
||||
@ -123,9 +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 pollable(&self) -> Box<dyn Pollable> {
|
||||
// Use some random one, we don't care
|
||||
Box::new(ToggleState::new(Default::default()))
|
||||
}
|
||||
}
|
||||
|
||||
@ -136,15 +151,19 @@ mod test {
|
||||
#[case(RenderOperation::RenderLineBreak)]
|
||||
#[case(RenderOperation::SetColors(Colors{background: None, foreground: None}))]
|
||||
#[case(RenderOperation::RenderText{line: String::from("asd").into(), alignment: Default::default()})]
|
||||
#[case(RenderOperation::RenderPreformattedLine(
|
||||
PreformattedLine{
|
||||
text: "asd".into(),
|
||||
#[case(RenderOperation::RenderBlockLine(
|
||||
BlockLine{
|
||||
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)))]
|
||||
#[case(RenderOperation::RenderAsync(Rc::new(Dynamic)))]
|
||||
#[case(RenderOperation::InitColumnLayout{ columns: vec![1, 2] })]
|
||||
#[case(RenderOperation::EnterColumn{ column: 1 })]
|
||||
#[case(RenderOperation::ExitLayout)]
|
@ -1,13 +1,16 @@
|
||||
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, fmt::Debug, ops::Deref, rc::Rc};
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
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 {
|
||||
@ -20,7 +23,7 @@ pub(crate) struct Modals {
|
||||
pub(crate) struct Presentation {
|
||||
slides: Vec<Slide>,
|
||||
modals: Modals,
|
||||
state: PresentationState,
|
||||
pub(crate) state: PresentationState,
|
||||
}
|
||||
|
||||
impl Presentation {
|
||||
@ -34,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()
|
||||
@ -45,7 +53,6 @@ impl Presentation {
|
||||
}
|
||||
|
||||
/// Consume this presentation and return its slides.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn into_slides(self) -> Vec<Slide> {
|
||||
self.slides
|
||||
}
|
||||
@ -66,14 +73,17 @@ impl Presentation {
|
||||
if current_slide.move_next() {
|
||||
return true;
|
||||
}
|
||||
let current_slide_index = self.current_slide_index();
|
||||
if current_slide_index < self.slides.len() - 1 {
|
||||
self.state.set_current_slide_index(current_slide_index + 1);
|
||||
// Going forward we show only the first chunk.
|
||||
self.current_slide_mut().show_first_chunk();
|
||||
true
|
||||
self.jump_next_slide()
|
||||
}
|
||||
|
||||
/// Show all chunks in this slide, or jump to the next if already applied.
|
||||
pub(crate) fn jump_next_fast(&mut self) -> bool {
|
||||
let current_slide = self.current_slide_mut();
|
||||
if current_slide.visible_chunks == current_slide.chunks.len() {
|
||||
self.jump_next_slide()
|
||||
} else {
|
||||
false
|
||||
current_slide.show_all_chunks();
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@ -83,14 +93,17 @@ impl Presentation {
|
||||
if current_slide.move_previous() {
|
||||
return true;
|
||||
}
|
||||
let current_slide_index = self.current_slide_index();
|
||||
if current_slide_index > 0 {
|
||||
self.state.set_current_slide_index(current_slide_index - 1);
|
||||
// Going backwards we show all chunks.
|
||||
self.current_slide_mut().show_all_chunks();
|
||||
self.jump_previous_slide()
|
||||
}
|
||||
|
||||
/// Show only the first chunk in this slide or jump to the previous slide if already there.
|
||||
pub(crate) fn jump_previous_fast(&mut self) -> bool {
|
||||
let current_slide = self.current_slide_mut();
|
||||
if current_slide.visible_chunks == current_slide.chunks.len() && current_slide.chunks.len() > 1 {
|
||||
current_slide.show_first_chunk();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
self.jump_previous_slide()
|
||||
}
|
||||
}
|
||||
|
||||
@ -127,50 +140,34 @@ impl Presentation {
|
||||
self.current_slide().current_chunk_index()
|
||||
}
|
||||
|
||||
/// Render all widgets in this slide.
|
||||
pub(crate) fn render_slide_widgets(&mut self) -> bool {
|
||||
let slide = self.current_slide_mut();
|
||||
let mut any_rendered = false;
|
||||
for operation in slide.iter_operations_mut() {
|
||||
if let RenderOperation::RenderOnDemand(operation) = operation {
|
||||
any_rendered = any_rendered || operation.start_render();
|
||||
}
|
||||
}
|
||||
any_rendered
|
||||
}
|
||||
|
||||
/// Poll every widget in the current slide and check whether they're rendered.
|
||||
pub(crate) fn widgets_rendered(&mut self) -> bool {
|
||||
let slide = self.current_slide_mut();
|
||||
let mut all_rendered = true;
|
||||
for operation in slide.iter_operations_mut() {
|
||||
if let RenderOperation::RenderOnDemand(operation) = operation {
|
||||
all_rendered = all_rendered && matches!(operation.poll_state(), RenderOnDemandState::Rendered);
|
||||
}
|
||||
}
|
||||
all_rendered
|
||||
}
|
||||
|
||||
/// 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]
|
||||
}
|
||||
|
||||
fn jump_next_slide(&mut self) -> bool {
|
||||
let current_slide_index = self.current_slide_index();
|
||||
if current_slide_index < self.slides.len() - 1 {
|
||||
self.state.set_current_slide_index(current_slide_index + 1);
|
||||
// Going forward we show only the first chunk.
|
||||
self.current_slide_mut().show_first_chunk();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn jump_previous_slide(&mut self) -> bool {
|
||||
let current_slide_index = self.current_slide_index();
|
||||
if current_slide_index > 0 {
|
||||
self.state.set_current_slide_index(current_slide_index - 1);
|
||||
// Going backwards we show all chunks.
|
||||
self.current_slide_mut().show_all_chunks();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Vec<Slide>> for Presentation {
|
||||
@ -180,9 +177,18 @@ impl From<Vec<Slide>> for Presentation {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct AsyncPresentationError {
|
||||
pub(crate) slide: usize,
|
||||
pub(crate) error: String,
|
||||
}
|
||||
|
||||
pub(crate) type AsyncPresentationErrorHolder = Arc<Mutex<Option<AsyncPresentationError>>>;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct PresentationStateInner {
|
||||
current_slide_index: usize,
|
||||
async_error_holder: AsyncPresentationErrorHolder,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@ -191,6 +197,10 @@ pub(crate) struct PresentationState {
|
||||
}
|
||||
|
||||
impl PresentationState {
|
||||
pub(crate) fn async_error_holder(&self) -> AsyncPresentationErrorHolder {
|
||||
self.inner.deref().borrow().async_error_holder.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn current_slide_index(&self) -> usize {
|
||||
self.inner.deref().borrow().current_slide_index
|
||||
}
|
||||
@ -240,10 +250,18 @@ impl Slide {
|
||||
}
|
||||
|
||||
pub(crate) fn iter_operations(&self) -> impl Iterator<Item = &RenderOperation> + Clone {
|
||||
self.chunks.iter().take(self.visible_chunks).flat_map(|chunk| chunk.operations.iter()).chain(self.footer.iter())
|
||||
self.chunks.iter().flat_map(|chunk| chunk.operations.iter()).chain(self.footer.iter())
|
||||
}
|
||||
|
||||
pub(crate) fn iter_operations_mut(&mut self) -> impl Iterator<Item = &mut RenderOperation> {
|
||||
self.chunks.iter_mut().flat_map(|chunk| chunk.operations.iter_mut()).chain(self.footer.iter_mut())
|
||||
}
|
||||
|
||||
pub(crate) fn iter_visible_operations(&self) -> impl Iterator<Item = &RenderOperation> + Clone {
|
||||
self.chunks.iter().take(self.visible_chunks).flat_map(|chunk| chunk.operations.iter()).chain(self.footer.iter())
|
||||
}
|
||||
|
||||
pub(crate) fn iter_visible_operations_mut(&mut self) -> impl Iterator<Item = &mut RenderOperation> {
|
||||
self.chunks
|
||||
.iter_mut()
|
||||
.take(self.visible_chunks)
|
||||
@ -257,11 +275,11 @@ impl Slide {
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn into_operations(self) -> Vec<RenderOperation> {
|
||||
self.chunks.into_iter().flat_map(|chunk| chunk.operations.into_iter()).chain(self.footer.into_iter()).collect()
|
||||
self.chunks.into_iter().flat_map(|chunk| chunk.operations.into_iter()).chain(self.footer).collect()
|
||||
}
|
||||
|
||||
fn jump_chunk(&mut self, chunk_index: usize) {
|
||||
self.visible_chunks = (chunk_index + 1).min(self.chunks.len());
|
||||
self.visible_chunks = chunk_index.saturating_add(1).min(self.chunks.len());
|
||||
for chunk in self.chunks.iter().take(self.visible_chunks - 1) {
|
||||
chunk.apply_all_mutations();
|
||||
}
|
||||
@ -280,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();
|
||||
@ -390,10 +408,26 @@ 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>,
|
||||
|
||||
/// The presentation authors.
|
||||
#[serde(default)]
|
||||
pub(crate) authors: Vec<String>,
|
||||
|
||||
/// The presentation's theme metadata.
|
||||
#[serde(default)]
|
||||
pub(crate) theme: PresentationThemeMetadata,
|
||||
@ -403,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 {
|
||||
@ -416,156 +463,23 @@ 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 PreformattedLine {
|
||||
pub(crate) text: String,
|
||||
pub(crate) unformatted_length: u16,
|
||||
pub(crate) block_length: u16,
|
||||
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 },
|
||||
|
||||
/// Render text.
|
||||
RenderText { line: WeightedTextBlock, alignment: Alignment },
|
||||
|
||||
/// Render a line break.
|
||||
RenderLineBreak,
|
||||
|
||||
/// Render an image.
|
||||
RenderImage(Image, ImageProperties),
|
||||
|
||||
/// Render a preformatted line.
|
||||
///
|
||||
/// The line will usually already have terminal escape codes that include colors and formatting
|
||||
/// embedded in it.
|
||||
RenderPreformattedLine(PreformattedLine),
|
||||
|
||||
/// 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 on demand.
|
||||
RenderOnDemand(Rc<dyn RenderOnDemand>),
|
||||
|
||||
/// 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)]
|
||||
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)]
|
||||
pub(crate) enum ImageSize {
|
||||
#[default]
|
||||
Scaled,
|
||||
Specific(u16, u16),
|
||||
}
|
||||
|
||||
/// 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>;
|
||||
}
|
||||
|
||||
/// A type that can be rendered on demand.
|
||||
pub(crate) trait RenderOnDemand: AsRenderOperations {
|
||||
/// Start the on demand render for this operation.
|
||||
fn start_render(&self) -> bool;
|
||||
|
||||
/// Poll and update the internal on demand state and return the latest.
|
||||
fn poll_state(&self) -> RenderOnDemandState;
|
||||
}
|
||||
|
||||
/// The state of a [RenderOnDemand].
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) enum RenderOnDemandState {
|
||||
#[default]
|
||||
NotStarted,
|
||||
Rendering,
|
||||
Rendered,
|
||||
pub(crate) overrides: Option<crate::theme::raw::PresentationTheme>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use std::cell::RefCell;
|
||||
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
use std::cell::RefCell;
|
||||
|
||||
#[derive(Clone)]
|
||||
enum Jump {
|
||||
First,
|
||||
Last,
|
||||
Next,
|
||||
NextFast,
|
||||
Previous,
|
||||
PreviousFast,
|
||||
Specific(usize),
|
||||
}
|
||||
|
||||
@ -576,7 +490,9 @@ mod test {
|
||||
First => presentation.jump_first_slide(),
|
||||
Last => presentation.jump_last_slide(),
|
||||
Next => presentation.jump_next(),
|
||||
NextFast => presentation.jump_next_fast(),
|
||||
Previous => presentation.jump_previous(),
|
||||
PreviousFast => presentation.jump_previous_fast(),
|
||||
Specific(index) => presentation.go_to_slide(*index),
|
||||
};
|
||||
}
|
||||
@ -635,9 +551,14 @@ mod test {
|
||||
#[rstest]
|
||||
#[case::previous_from_first(0, &[Jump::Previous], 0, 0)]
|
||||
#[case::next_from_first(0, &[Jump::Next], 0, 1)]
|
||||
#[case::next_next_from_first(0, &[Jump::Next, Jump::Next], 1, 0)]
|
||||
#[case::next_next_from_first(0, &[Jump::Next, Jump::Next], 0, 2)]
|
||||
#[case::next_next_next_from_first(0, &[Jump::Next, Jump::Next, Jump::Next], 1, 0)]
|
||||
#[case::next_fast_from_first(0, &[Jump::NextFast], 0, 2)]
|
||||
#[case::next_fast_twice_from_first(0, &[Jump::NextFast, Jump::NextFast], 1, 0)]
|
||||
#[case::last_from_first(0, &[Jump::Last], 2, 0)]
|
||||
#[case::previous_from_second(1, &[Jump::Previous], 0, 1)]
|
||||
#[case::previous_from_second(1, &[Jump::Previous], 0, 2)]
|
||||
#[case::previous_fast_from_second(1, &[Jump::PreviousFast], 0, 2)]
|
||||
#[case::previous_fast_twice_from_second(1, &[Jump::PreviousFast, Jump::PreviousFast], 0, 0)]
|
||||
#[case::next_from_second(1, &[Jump::Next], 1, 1)]
|
||||
#[case::specific_first_from_second(1, &[Jump::Specific(0)], 0, 0)]
|
||||
#[case::specific_last_from_second(1, &[Jump::Specific(2)], 2, 0)]
|
||||
@ -649,9 +570,9 @@ mod test {
|
||||
#[case] expected_chunk: usize,
|
||||
) {
|
||||
let mut presentation = Presentation::from(vec![
|
||||
Slide::new(vec![SlideChunk::from(SlideChunk::default()), SlideChunk::default()], vec![]),
|
||||
Slide::new(vec![SlideChunk::from(SlideChunk::default()), SlideChunk::default()], vec![]),
|
||||
Slide::new(vec![SlideChunk::from(SlideChunk::default()), SlideChunk::default()], vec![]),
|
||||
Slide::new(vec![SlideChunk::default(), SlideChunk::default(), SlideChunk::default()], vec![]),
|
||||
Slide::new(vec![SlideChunk::default(), SlideChunk::default()], vec![]),
|
||||
Slide::new(vec![SlideChunk::default(), SlideChunk::default()], vec![]),
|
||||
]);
|
||||
presentation.go_to_slide(from);
|
||||
|
||||
@ -688,18 +609,12 @@ mod test {
|
||||
let mut presentation = Presentation::from(vec![
|
||||
SlideBuilder::default()
|
||||
.chunks(vec![
|
||||
SlideChunk::from(SlideChunk::new(
|
||||
vec![],
|
||||
vec![Box::new(DummyMutator::new(1)), Box::new(DummyMutator::new(2))],
|
||||
)),
|
||||
SlideChunk::new(vec![], vec![Box::new(DummyMutator::new(1)), Box::new(DummyMutator::new(2))]),
|
||||
SlideChunk::default(),
|
||||
])
|
||||
.build(),
|
||||
SlideBuilder::default()
|
||||
.chunks(vec![
|
||||
SlideChunk::from(SlideChunk::new(vec![], vec![Box::new(DummyMutator::new(2))])),
|
||||
SlideChunk::default(),
|
||||
])
|
||||
.chunks(vec![SlideChunk::new(vec![], vec![Box::new(DummyMutator::new(2))]), SlideChunk::default()])
|
||||
.build(),
|
||||
]);
|
||||
presentation.go_to_slide(from);
|
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);
|
||||
}
|
||||
}
|
||||
}
|
484
src/presenter.rs
484
src/presenter.rs
@ -1,29 +1,48 @@
|
||||
use crate::{
|
||||
custom::KeyBindingsConfig,
|
||||
diff::PresentationDiffer,
|
||||
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,
|
||||
processing::builder::{BuildError, PresentationBuilder, PresentationBuilderOptions, Themes},
|
||||
presentation::{
|
||||
Presentation, Slide,
|
||||
builder::{BuildError, PresentationBuilder, PresentationBuilderOptions, Themes},
|
||||
diff::PresentationDiffer,
|
||||
poller::{PollableEffect, Poller, PollerCommand},
|
||||
},
|
||||
render::{
|
||||
draw::{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,
|
||||
typst::TypstRender,
|
||||
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 {
|
||||
@ -32,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.
|
||||
@ -39,15 +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,
|
||||
typst: TypstRender,
|
||||
third_party: ThirdPartyRender,
|
||||
code_executor: Arc<SnippetExecutor>,
|
||||
state: PresenterState,
|
||||
slides_with_pending_widgets: HashSet<usize>,
|
||||
image_printer: Rc<ImagePrinter>,
|
||||
image_printer: Arc<ImagePrinter>,
|
||||
themes: Themes,
|
||||
options: PresenterOptions,
|
||||
speaker_notes_event_publisher: Option<SpeakerNotesEventPublisher>,
|
||||
poller: Poller,
|
||||
}
|
||||
|
||||
impl<'a> Presenter<'a> {
|
||||
@ -55,87 +78,169 @@ 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,
|
||||
typst: TypstRender,
|
||||
third_party: ThirdPartyRender,
|
||||
code_executor: Arc<SnippetExecutor>,
|
||||
themes: Themes,
|
||||
image_printer: Rc<ImagePrinter>,
|
||||
image_printer: Arc<ImagePrinter>,
|
||||
options: PresenterOptions,
|
||||
speaker_notes_event_publisher: Option<SpeakerNotesEventPublisher>,
|
||||
) -> Self {
|
||||
Self {
|
||||
default_theme,
|
||||
commands,
|
||||
listener,
|
||||
parser,
|
||||
resources,
|
||||
typst,
|
||||
third_party,
|
||||
code_executor,
|
||||
state: PresenterState::Empty,
|
||||
slides_with_pending_widgets: 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 {
|
||||
// Poll async renders once before we draw just in case.
|
||||
self.render(&mut drawer)?;
|
||||
self.update_widgets(&mut drawer)?;
|
||||
|
||||
loop {
|
||||
self.update_widgets(&mut drawer)?;
|
||||
let Some(command) = self.commands.try_next_command()? else {
|
||||
continue;
|
||||
if self.process_poller_effects()? {
|
||||
self.render(&mut drawer)?;
|
||||
}
|
||||
|
||||
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::PollWidgets => {
|
||||
self.slides_with_pending_widgets.insert(self.state.presentation().current_slide_index());
|
||||
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 update_widgets(&mut self, drawer: &mut TerminalDrawer<Stdout>) -> RenderResult {
|
||||
let current_index = self.state.presentation().current_slide_index();
|
||||
if self.slides_with_pending_widgets.contains(¤t_index) {
|
||||
self.render(drawer)?;
|
||||
if self.state.presentation_mut().widgets_rendered() {
|
||||
// Render one last time just in case it _just_ rendered
|
||||
self.render(drawer)?;
|
||||
self.slides_with_pending_widgets.remove(¤t_index);
|
||||
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 render(&mut self, drawer: &mut TerminalDrawer<Stdout>) -> RenderResult {
|
||||
fn check_async_error(&mut self) -> bool {
|
||||
let error_holder = self.state.presentation().state.async_error_holder();
|
||||
let error_holder = error_holder.lock().unwrap();
|
||||
match error_holder.deref() {
|
||||
Some(error) => {
|
||||
let presentation = mem::take(&mut self.state).into_presentation();
|
||||
self.state = PresenterState::failure(
|
||||
&error.error,
|
||||
presentation,
|
||||
ErrorSource::Slide(error.slide),
|
||||
FailureMode::Other,
|
||||
);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
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, .. } => drawer.render_error(error),
|
||||
PresenterState::Failure { error, source, .. } => drawer.render_error(error, source),
|
||||
PresenterState::Empty => panic!("cannot render without state"),
|
||||
};
|
||||
// If the screen is too small, simply ignore this. Eventually the user will resize the
|
||||
@ -156,11 +261,14 @@ impl<'a> Presenter<'a> {
|
||||
return CommandSideEffect::Reload;
|
||||
}
|
||||
Command::Exit => return CommandSideEffect::Exit,
|
||||
Command::Suspend => return CommandSideEffect::Suspend,
|
||||
_ => (),
|
||||
};
|
||||
if matches!(command, Command::Redraw) {
|
||||
let presentation = mem::take(&mut self.state).into_presentation();
|
||||
self.state = self.validate_overflows(presentation);
|
||||
if !self.is_displaying_other_error() {
|
||||
let presentation = mem::take(&mut self.state).into_presentation();
|
||||
self.state = self.validate_overflows(presentation);
|
||||
}
|
||||
return CommandSideEffect::Redraw;
|
||||
}
|
||||
|
||||
@ -174,15 +282,38 @@ impl<'a> Presenter<'a> {
|
||||
}
|
||||
};
|
||||
let needs_redraw = match command {
|
||||
Command::Next => presentation.jump_next(),
|
||||
Command::Previous => presentation.jump_previous(),
|
||||
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 => {
|
||||
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::RenderWidgets => {
|
||||
if presentation.render_slide_widgets() {
|
||||
self.slides_with_pending_widgets.insert(self.state.presentation().current_slide_index());
|
||||
return CommandSideEffect::PollWidgets;
|
||||
Command::RenderAsyncOperations => {
|
||||
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;
|
||||
}
|
||||
@ -201,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_widgets.clear();
|
||||
self.poller.send(PollerCommand::Reset);
|
||||
self.resources.clear_watches();
|
||||
match self.load_presentation(path) {
|
||||
Ok(mut presentation) => {
|
||||
let current = self.state.presentation();
|
||||
@ -223,24 +355,57 @@ impl<'a> Presenter<'a> {
|
||||
presentation.go_to_slide(current.current_slide_index());
|
||||
presentation.jump_chunk(current.current_chunk());
|
||||
}
|
||||
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);
|
||||
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 {
|
||||
matches!(self.state, PresenterState::Failure { mode: FailureMode::Other, .. })
|
||||
}
|
||||
|
||||
fn validate_overflows(&self, presentation: Presentation) -> PresenterState {
|
||||
if self.options.validate_overflows {
|
||||
let dimensions = match WindowSize::current(self.options.font_size_fallback) {
|
||||
Ok(dimensions) => dimensions,
|
||||
Err(e) => return PresenterState::failure(e, presentation),
|
||||
Err(e) => {
|
||||
return PresenterState::failure(e, presentation, ErrorSource::Presentation, FailureMode::Other);
|
||||
}
|
||||
};
|
||||
match OverflowValidator::validate(&presentation, dimensions) {
|
||||
Ok(()) => PresenterState::Presenting(presentation),
|
||||
Err(e) => PresenterState::failure(e, presentation),
|
||||
Err(e) => PresenterState::failure(e, presentation, ErrorSource::Presentation, FailureMode::Overflow),
|
||||
}
|
||||
} else {
|
||||
PresenterState::Presenting(presentation)
|
||||
@ -250,21 +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,
|
||||
&mut self.typst,
|
||||
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)
|
||||
}
|
||||
|
||||
@ -289,13 +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,
|
||||
PollWidgets,
|
||||
Reload,
|
||||
AnimateNextSlide,
|
||||
AnimatePreviousSlide,
|
||||
None,
|
||||
}
|
||||
|
||||
@ -309,12 +615,19 @@ enum PresenterState {
|
||||
Failure {
|
||||
error: String,
|
||||
presentation: Presentation,
|
||||
source: ErrorSource,
|
||||
mode: FailureMode,
|
||||
},
|
||||
}
|
||||
|
||||
impl PresenterState {
|
||||
pub(crate) fn failure<E: Display>(error: E, presentation: Presentation) -> Self {
|
||||
PresenterState::Failure { error: error.to_string(), presentation }
|
||||
pub(crate) fn failure<E: Display>(
|
||||
error: E,
|
||||
presentation: Presentation,
|
||||
source: ErrorSource,
|
||||
mode: FailureMode,
|
||||
) -> Self {
|
||||
PresenterState::Failure { error: error.to_string(), presentation, source, mode }
|
||||
}
|
||||
|
||||
fn presentation(&self) -> &Presentation {
|
||||
@ -348,6 +661,11 @@ impl PresenterState {
|
||||
}
|
||||
}
|
||||
|
||||
enum FailureMode {
|
||||
Overflow,
|
||||
Other,
|
||||
}
|
||||
|
||||
/// This presentation mode.
|
||||
pub enum PresentMode {
|
||||
/// We are developing the presentation so we want live reloads when the input changes.
|
||||
@ -355,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.
|
||||
@ -371,6 +686,9 @@ pub enum LoadPresentationError {
|
||||
|
||||
#[error(transparent)]
|
||||
Processing(#[from] BuildError),
|
||||
|
||||
#[error("processing theme: {0}")]
|
||||
ProcessingTheme(#[from] ProcessingThemeError),
|
||||
}
|
||||
|
||||
/// An error during the presentation.
|
||||
@ -379,12 +697,6 @@ pub enum PresentationError {
|
||||
#[error(transparent)]
|
||||
Render(#[from] RenderError),
|
||||
|
||||
#[error(transparent)]
|
||||
LoadPresentation(#[from] LoadPresentationError),
|
||||
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] io::Error),
|
||||
|
||||
#[error("fatal error: {0}")]
|
||||
Fatal(String),
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,210 +0,0 @@
|
||||
use super::padding::NumberPadder;
|
||||
use crate::{
|
||||
markdown::elements::{Code, HighlightGroup},
|
||||
presentation::{AsRenderOperations, ChunkMutator, PreformattedLine, 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: &Code) -> 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: &Code, 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.contents.lines().count());
|
||||
for (index, line) in code.contents.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,
|
||||
padding_style: &Style,
|
||||
code_highlighter: &mut LanguageHighlighter,
|
||||
block_style: &CodeBlockStyle,
|
||||
) -> String {
|
||||
let mut output = StyledTokens { style: *padding_style, tokens: &self.prefix }.apply_style(block_style);
|
||||
output.push_str(&code_highlighter.highlight_line(&self.code, block_style));
|
||||
output.push_str(&StyledTokens { style: *padding_style, tokens: &self.suffix }.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::RenderPreformattedLine(PreformattedLine {
|
||||
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::{CodeAttributes, CodeLanguage};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn code_with_line_numbers() {
|
||||
let total_lines = 11;
|
||||
let input_lines = "hi\n".repeat(total_lines);
|
||||
let code = Code {
|
||||
contents: input_lines,
|
||||
language: CodeLanguage::Unknown,
|
||||
attributes: CodeAttributes { 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} "));
|
||||
}
|
||||
}
|
||||
}
|
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