mirror of
https://github.com/mfontanini/presenterm.git
synced 2025-05-05 15:32:58 +00:00
Compare commits
227 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 |
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@ -0,0 +1 @@
|
||||
github: mfontanini
|
21
.github/workflows/merge.yaml
vendored
21
.github/workflows/merge.yaml
vendored
@ -36,6 +36,27 @@ jobs:
|
||||
- name: Run cargo fmt
|
||||
run: cargo +nightly fmt --all -- --check
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
|
||||
- name: Install weasyprint
|
||||
run: |
|
||||
uv venv
|
||||
source ./.venv/bin/activate
|
||||
uv pip install weasyprint
|
||||
|
||||
- name: Export demo presentation as PDF and HTML
|
||||
run: |
|
||||
cat >/tmp/config.yaml <<EOL
|
||||
export:
|
||||
dimensions:
|
||||
rows: 35
|
||||
columns: 135
|
||||
EOL
|
||||
source ./.venv/bin/activate
|
||||
cargo run -- --export-pdf -c /tmp/config.yaml examples/demo.md
|
||||
cargo run -- --export-html -c /tmp/config.yaml examples/demo.md
|
||||
|
||||
nix-flake:
|
||||
name: Validate nix flake
|
||||
runs-on: ubuntu-latest
|
||||
|
102
.github/workflows/nightly.yaml
vendored
Normal file
102
.github/workflows/nightly.yaml
vendored
Normal file
@ -0,0 +1,102 @@
|
||||
name: Nightly build
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 0 * * *"
|
||||
|
||||
env:
|
||||
RELEASE_VERSION: nightly
|
||||
|
||||
jobs:
|
||||
vars:
|
||||
name: Set release variables
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
timestamp: ${{ steps.set.outputs.timestamp }}
|
||||
git_hash: ${{ steps.set.outputs.git_hash }}
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set variables
|
||||
id: set
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
timestamp=$(date -u)
|
||||
git_hash=$(git rev-parse HEAD)
|
||||
echo "timestamp=$timestamp" >> "$GITHUB_OUTPUT"
|
||||
echo "git_hash=$git_hash" >> "$GITHUB_OUTPUT"
|
||||
publish-github:
|
||||
name: Publish on GitHub
|
||||
runs-on: ${{ matrix.config.OS }}
|
||||
needs: vars
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
config:
|
||||
- { OS: ubuntu-latest, TARGET: "x86_64-unknown-linux-gnu" }
|
||||
- { OS: ubuntu-latest, TARGET: "x86_64-unknown-linux-musl" }
|
||||
- { OS: ubuntu-latest, TARGET: "i686-unknown-linux-gnu" }
|
||||
- { OS: ubuntu-latest, TARGET: "i686-unknown-linux-musl" }
|
||||
- { OS: ubuntu-latest, TARGET: "armv5te-unknown-linux-gnueabi" }
|
||||
- { OS: ubuntu-latest, TARGET: "armv7-unknown-linux-gnueabihf" }
|
||||
- { OS: ubuntu-latest, TARGET: "aarch64-unknown-linux-gnu" }
|
||||
- { OS: ubuntu-latest, TARGET: "aarch64-unknown-linux-musl" }
|
||||
- { OS: macos-latest, TARGET: "x86_64-apple-darwin" }
|
||||
- { OS: macos-latest, TARGET: "aarch64-apple-darwin" }
|
||||
- { OS: windows-latest, TARGET: "x86_64-pc-windows-msvc" }
|
||||
- { OS: windows-latest, TARGET: "i686-pc-windows-msvc" }
|
||||
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
target: ${{ matrix.config.TARGET }}
|
||||
override: true
|
||||
|
||||
- name: Build
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
use-cross: true
|
||||
command: build
|
||||
args: --release --locked --target ${{ matrix.config.TARGET }}
|
||||
|
||||
- name: Prepare release assets
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir release/
|
||||
cp {LICENSE,README.md} release/
|
||||
cp target/${{ matrix.config.TARGET }}/release/presenterm release/
|
||||
mv release/ presenterm-${{ env.RELEASE_VERSION }}/
|
||||
- name: Create release artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${{ matrix.config.OS }}" = "windows-latest" ]; then
|
||||
7z a -tzip "presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.zip" \
|
||||
presenterm-${{ env.RELEASE_VERSION }}
|
||||
sha512sum "presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.zip" \
|
||||
> presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.zip.sha512
|
||||
else
|
||||
tar -czvf presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.tar.gz \
|
||||
presenterm-${{ env.RELEASE_VERSION }}/
|
||||
shasum -a 512 presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.tar.gz \
|
||||
> presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.tar.gz.sha512
|
||||
fi
|
||||
- name: Upload the release
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
tag: nightly
|
||||
file: presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.*
|
||||
file_glob: true
|
||||
overwrite: true
|
||||
prerelease: true
|
||||
release_name: Nightly
|
||||
body: |
|
||||
This is a nightly build based on ref [${{ needs.vars.outputs.git_hash }}](https://github.com/mfontanini/presenterm/commit/${{ needs.vars.outputs.git_hash }})
|
||||
Generated on `${{ needs.vars.outputs.timestamp }}`
|
134
CHANGELOG.md
134
CHANGELOG.md
@ -1,3 +1,137 @@
|
||||
# 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
|
||||
|
854
Cargo.lock
generated
854
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
29
Cargo.toml
29
Cargo.toml
@ -4,52 +4,47 @@ authors = ["Matias Fontanini"]
|
||||
description = "A terminal slideshow presentation tool"
|
||||
repository = "https://github.com/mfontanini/presenterm"
|
||||
license = "BSD-2-Clause"
|
||||
version = "0.10.0"
|
||||
version = "0.13.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
ansi-parser = "0.9"
|
||||
base64 = "0.22"
|
||||
bincode = "1.3"
|
||||
clap = { version = "4.4", features = ["derive", "string"] }
|
||||
comrak = { version = "0.34", default-features = false }
|
||||
comrak = { version = "0.36", default-features = false }
|
||||
crossterm = { version = "0.28", features = ["serde"] }
|
||||
directories = "6.0"
|
||||
hex = "0.4"
|
||||
fastrand = "2.3"
|
||||
flate2 = "1.0"
|
||||
image = { version = "0.25", features = ["gif", "jpeg", "png", "rayon"], default-features = false }
|
||||
sixel-rs = { version = "0.4", 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.14"
|
||||
once_cell = "1.19"
|
||||
rand = "0.8.5"
|
||||
schemars = "0.8"
|
||||
semver = "1.0"
|
||||
schemars = { version = "0.8", optional = true }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_yaml = "0.9"
|
||||
serde_json = "1.0"
|
||||
serde_with = "3.6"
|
||||
syntect = { version = "5.2", features = ["parsing", "default-themes", "regex-onig", "plist-load"], default-features = false }
|
||||
socket2 = "0.5.8"
|
||||
strum = { version = "0.26", features = ["derive"] }
|
||||
tempfile = "3.10"
|
||||
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"
|
||||
|
||||
[dependencies.syntect]
|
||||
version = "5.2"
|
||||
default-features = false
|
||||
features = ["parsing", "default-themes", "regex-onig", "plist-load"]
|
||||
vte = "0.15"
|
||||
|
||||
[dev-dependencies]
|
||||
rstest = { version = "0.24", 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
@ -16,8 +16,9 @@ presenterm
|
||||
[scoop-package]: https://scoop.sh/#/apps?q=presenterm&id=a462290f824b50f180afbaa6d8c7c1e6e0952e3a
|
||||
|
||||
_presenterm_ lets you create presentations in markdown format and run them from your terminal, with support for image
|
||||
and animated gifs, highly customizable themes, code highlighting, exporting presentations into PDF format, and
|
||||
plenty of other features. This is how the [demo presentation](/examples/demo.md) looks like:
|
||||
and animated gifs, highly customizable themes, code highlighting, exporting presentations into PDF format, and plenty of
|
||||
other features. This is how the [demo presentation](/examples/demo.md) looks like when running in the [kitty
|
||||
terminal](https://sw.kovidgoyal.net/kitty/):
|
||||
|
||||

|
||||
|
||||
@ -25,53 +26,68 @@ Check the rest of the example presentations in the [examples directory](/example
|
||||
|
||||
# Documentation
|
||||
|
||||
Visit the [documentation][guide-introduction] to get started.
|
||||
Visit the [documentation][docs-introduction] to get started.
|
||||
|
||||
# Features
|
||||
|
||||
* Define your presentation in a single markdown file.
|
||||
* [Images and animated gifs][guide-images] on terminals like _kitty_, _iterm2_, and _wezterm_.
|
||||
* [Customizeable themes][guide-themes] including colors, margins, layout (left/center aligned content), footer for every
|
||||
slide, etc. Several [built-in themes][guide-builtin-themes] can give your presentation the look you want without
|
||||
* [Images and animated gifs][docs-images] on terminals like _kitty_, _iterm2_, and _wezterm_.
|
||||
* [Customizable themes][docs-themes] including colors, margins, layout (left/center aligned content), footer for every
|
||||
slide, etc. Several [built-in themes][docs-builtin-themes] can give your presentation the look you want without
|
||||
having to define your own.
|
||||
* Code highlighting for a [wide list of programming languages][guide-code-highlight].
|
||||
* [Selective/dynamic][guide-selective-highlight] code highlighting that only highlights portions of code at a time.
|
||||
* [Column layouts][guide-layout].
|
||||
* [mermaid graph rendering][guide-mermaid].
|
||||
* [_LaTeX_ and _typst_ formula rendering][guide-latex].
|
||||
* [Introduction slide][guide-intro-slide] that displays the presentation title and your name.
|
||||
* [Slide titles][guide-slide-titles].
|
||||
* [Snippet execution][guide-code-execute] for various programming languages.
|
||||
* [Export presentations to PDF][guide-pdf-export].
|
||||
* [Pause][guide-pauses] portions of your slides.
|
||||
* [Custom key bindings][guide-key-bindings].
|
||||
* [Automatically reload your presentation][guide-hot-reload] every time it changes for a fast development loop.
|
||||
* [Define speaker notes][guide-speaker-notes] to aid you during presentations.
|
||||
* Code highlighting for a [wide list of programming languages][docs-code-highlight].
|
||||
* [Font sizes][docs-font-sizes] for terminals that support them.
|
||||
* [Selective/dynamic][docs-selective-highlight] code highlighting that only highlights portions of code at a time.
|
||||
* [Column layouts][docs-layout].
|
||||
* [mermaid graph rendering][docs-mermaid].
|
||||
* [_LaTeX_ and _typst_ formula rendering][docs-latex].
|
||||
* [Introduction slide][docs-intro-slide] that displays the presentation title and your name.
|
||||
* [Slide titles][docs-slide-titles].
|
||||
* [Snippet execution][docs-code-execute] for various programming languages.
|
||||
* [Export presentations to PDF][docs-pdf-export].
|
||||
* [Slide transitions][docs-slide-transitions].
|
||||
* [Pause][docs-pauses] portions of your slides.
|
||||
* [Custom key bindings][docs-key-bindings].
|
||||
* [Automatically reload your presentation][docs-hot-reload] every time it changes for a fast development loop.
|
||||
* [Define speaker notes][docs-speaker-notes] to aid you during presentations.
|
||||
|
||||
See the [introduction page][guide-introduction] to learn more.
|
||||
See the [introduction page][docs-introduction] to learn more.
|
||||
|
||||
# Presenterm in action
|
||||
|
||||
Here are some talks and demos that feature _presenterm_:
|
||||
|
||||
- [Bringing Terminal Aesthetics to the Web With Rust][bringing-terminal-aesthetics] by [Orhun Parmaksız][orhun-github]
|
||||
- [7 Rust Terminal Tools That You Should Use][rust-terminal-tools] by [Orhun Parmaksız][orhun-github]
|
||||
- [Renaissance of Terminal User Interfaces with Rust][renaissance-tui] by [Orhun Parmaksız][orhun-github]
|
||||
|
||||
Gave a talk using _presenterm_? We would love to feature it here! Open a PR or issue to get it added.
|
||||
|
||||
<!-- links -->
|
||||
|
||||
[guide-introduction]: https://mfontanini.github.io/presenterm/
|
||||
[guide-installation]: https://mfontanini.github.io/presenterm/guides/installation.html
|
||||
[guide-basics]: https://mfontanini.github.io/presenterm/guides/basics.html
|
||||
[guide-intro-slide]: https://mfontanini.github.io/presenterm/guides/basics.html#introduction-slide
|
||||
[guide-slide-titles]: https://mfontanini.github.io/presenterm/guides/basics.html#slide-titles
|
||||
[guide-pauses]: https://mfontanini.github.io/presenterm/guides/basics.html#pauses
|
||||
[guide-images]: https://mfontanini.github.io/presenterm/guides/basics.html#images
|
||||
[guide-themes]: https://mfontanini.github.io/presenterm/guides/themes.html
|
||||
[guide-builtin-themes]: https://mfontanini.github.io/presenterm/guides/themes.html#built-in-themes
|
||||
[guide-code-highlight]: https://mfontanini.github.io/presenterm/guides/code-highlight.html
|
||||
[guide-code-execute]: https://mfontanini.github.io/presenterm/guides/code-highlight.html#executing-code
|
||||
[guide-selective-highlight]: https://mfontanini.github.io/presenterm/guides/code-highlight.html#selective-highlighting
|
||||
[guide-layout]: https://mfontanini.github.io/presenterm/guides/layout.html
|
||||
[guide-mermaid]: https://mfontanini.github.io/presenterm/guides/mermaid.html
|
||||
[guide-latex]: https://mfontanini.github.io/presenterm/guides/latex.html
|
||||
[guide-pdf-export]: https://mfontanini.github.io/presenterm/guides/pdf-export.html
|
||||
[guide-key-bindings]: https://mfontanini.github.io/presenterm/guides/configuration.html#key-bindings
|
||||
[guide-hot-reload]: https://mfontanini.github.io/presenterm/guides/basics.html#hot-reload
|
||||
[guide-speaker-notes]: https://mfontanini.github.io/presenterm/guides/speaker-notes.html
|
||||
[docs-introduction]: https://mfontanini.github.io/presenterm/
|
||||
[docs-basics]: https://mfontanini.github.io/presenterm/features/introduction.html
|
||||
[docs-intro-slide]: https://mfontanini.github.io/presenterm/features/introduction.html#introduction-slide
|
||||
[docs-slide-titles]: https://mfontanini.github.io/presenterm/features/introduction.html#slide-titles
|
||||
[docs-font-sizes]: https://mfontanini.github.io/presenterm/features/introduction.html#font-sizes
|
||||
[docs-pauses]: https://mfontanini.github.io/presenterm/features/commands.html#pauses
|
||||
[docs-images]: https://mfontanini.github.io/presenterm/features/images.html
|
||||
[docs-themes]: https://mfontanini.github.io/presenterm/features/themes/introduction.html
|
||||
[docs-builtin-themes]: https://mfontanini.github.io/presenterm/features/themes/introduction.html#built-in-themes
|
||||
[docs-code-highlight]: https://mfontanini.github.io/presenterm/features/code/highlighting.html
|
||||
[docs-code-execute]: https://mfontanini.github.io/presenterm/features/code/execution.html
|
||||
[docs-selective-highlight]: https://mfontanini.github.io/presenterm/features/code/highlighting.html#selective-highlighting
|
||||
[docs-slide-transitions]: https://mfontanini.github.io/presenterm/features/slide-transitons.html
|
||||
[docs-layout]: https://mfontanini.github.io/presenterm/features/layout.html
|
||||
[docs-mermaid]: https://mfontanini.github.io/presenterm/features/code/mermaid.html
|
||||
[docs-latex]: https://mfontanini.github.io/presenterm/features/code/latex.html
|
||||
[docs-pdf-export]: https://mfontanini.github.io/presenterm/features/pdf-export.html
|
||||
[docs-key-bindings]: https://mfontanini.github.io/presenterm/configuration/settings.html#key-bindings
|
||||
[docs-hot-reload]: https://mfontanini.github.io/presenterm/features/introduction.html#hot-reload
|
||||
[docs-speaker-notes]: https://mfontanini.github.io/presenterm/features/speaker-notes.html
|
||||
[bat]: https://github.com/sharkdp/bat
|
||||
[syntect]: https://github.com/trishume/syntect
|
||||
|
||||
|
||||
[bringing-terminal-aesthetics]: https://www.youtube.com/watch?v=iepbyYrF_YQ
|
||||
[rust-terminal-tools]: https://www.youtube.com/watch?v=ATiKwUiqnAU
|
||||
[renaissance-tui]: https://www.youtube.com/watch?v=hWG51Mc1DlM
|
||||
[orhun-github]: https://github.com/orhun
|
||||
|
@ -14,6 +14,9 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"export": {
|
||||
"$ref": "#/definitions/ExportConfig"
|
||||
},
|
||||
"mermaid": {
|
||||
"$ref": "#/definitions/MermaidConfig"
|
||||
},
|
||||
@ -26,6 +29,16 @@
|
||||
"speaker_notes": {
|
||||
"$ref": "#/definitions/SpeakerNotesConfig"
|
||||
},
|
||||
"transition": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/SlideTransitionConfig"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"typst": {
|
||||
"$ref": "#/definitions/TypstConfig"
|
||||
}
|
||||
@ -43,6 +56,14 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"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,
|
||||
@ -50,6 +71,29 @@
|
||||
"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,
|
||||
@ -75,6 +119,55 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ExportConfig": {
|
||||
"description": "The export configuration.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dimensions": {
|
||||
"description": "The dimensions to use for presentation exports.",
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ExportDimensionsConfig"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"pauses": {
|
||||
"description": "Whether pauses should create new slides.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/PauseExportPolicy"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ExportDimensionsConfig": {
|
||||
"description": "The dimensions to use for presentation exports.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"columns",
|
||||
"rows"
|
||||
],
|
||||
"properties": {
|
||||
"columns": {
|
||||
"description": "The number of columns.",
|
||||
"type": "integer",
|
||||
"format": "uint16",
|
||||
"minimum": 0.0
|
||||
},
|
||||
"rows": {
|
||||
"description": "The number of rows.",
|
||||
"type": "integer",
|
||||
"format": "uint16",
|
||||
"minimum": 0.0
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ImageProtocol": {
|
||||
"oneOf": [
|
||||
{
|
||||
@ -121,6 +214,29 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"IncrementalListsConfig": {
|
||||
"description": "The configuration for lists when incremental lists are enabled.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pause_after": {
|
||||
"description": "Whether to pause after a list ends.",
|
||||
"default": null,
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"pause_before": {
|
||||
"description": "Whether to pause before a list begins.",
|
||||
"default": null,
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"KeyBinding": {
|
||||
"type": "string"
|
||||
},
|
||||
@ -267,6 +383,58 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
@ -282,9 +450,6 @@
|
||||
},
|
||||
"OptionsConfig": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"auto_render_languages"
|
||||
],
|
||||
"properties": {
|
||||
"auto_render_languages": {
|
||||
"description": "Assume snippets for these languages contain `+render` and render them automatically.",
|
||||
@ -338,6 +503,108 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PauseExportPolicy": {
|
||||
"description": "The policy for pauses when exporting.",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "Whether to ignore pauses.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ignore"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Create a new slide when a pause is found.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"new_slide"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"SlideTransitionConfig": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"animation"
|
||||
],
|
||||
"properties": {
|
||||
"animation": {
|
||||
"description": "The slide transition style.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/SlideTransitionStyleConfig"
|
||||
}
|
||||
]
|
||||
},
|
||||
"duration_millis": {
|
||||
"description": "The amount of time to take to perform the transition.",
|
||||
"default": 1000,
|
||||
"type": "integer",
|
||||
"format": "uint16",
|
||||
"minimum": 0.0
|
||||
},
|
||||
"frames": {
|
||||
"description": "The number of frames in a transition.",
|
||||
"default": 30,
|
||||
"type": "integer",
|
||||
"format": "uint",
|
||||
"minimum": 0.0
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SlideTransitionStyleConfig": {
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "Slide horizontally.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"style"
|
||||
],
|
||||
"properties": {
|
||||
"style": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"slide_horizontal"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"description": "Fade the new slide into the previous one.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"style"
|
||||
],
|
||||
"properties": {
|
||||
"style": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"fade"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"description": "Collapse the current slide into the center of the screen.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"style"
|
||||
],
|
||||
"properties": {
|
||||
"style": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"collapse_horizontal"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"SnippetConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@ -435,6 +702,7 @@
|
||||
"Java",
|
||||
"JavaScript",
|
||||
"Json",
|
||||
"Julia",
|
||||
"Kotlin",
|
||||
"Latex",
|
||||
"Lua",
|
||||
|
@ -14,3 +14,11 @@ title = "presenterm documentation"
|
||||
[output.html]
|
||||
git-repository-url = "https://github.com/mfontanini/presenterm"
|
||||
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,18 +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)
|
||||
- [Speaker notes](./guides/speaker-notes.md)
|
||||
- [Mermaid](./guides/mermaid.md)
|
||||
- [LaTeX and typst](./guides/latex.md)
|
||||
- [Install](./install.md)
|
||||
- [Features](./features/introduction.md)
|
||||
- [Images](./features/images.md).
|
||||
- [Commands](./features/commands.md).
|
||||
- [Layout](./features/layout.md).
|
||||
- [Code](./features/code/highlighting.md)
|
||||
- [Execution](./features/code/execution.md)
|
||||
- [Mermaid diagrams](./features/code/mermaid.md)
|
||||
- [LaTeX and typst](./features/code/latex.md)
|
||||
- [Themes](./features/themes/introduction.md)
|
||||
- [Definition](./features/themes/definition.md)
|
||||
- [PDF export](./features/pdf-export.md)
|
||||
- [Slide transitions](./features/slide-transitions.md)
|
||||
- [Speaker notes](./features/speaker-notes.md)
|
||||
- [Configuration](./configuration/introduction.md)
|
||||
- [Options](./configuration/options.md)
|
||||
- [Settings](./configuration/settings.md)
|
||||
|
||||
# Internals
|
||||
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 282 KiB |
Binary file not shown.
Before Width: | Height: | Size: 477 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
|
||||
---
|
||||
```
|
||||
|
@ -1,190 +1,8 @@
|
||||
# Configuration
|
||||
# Settings
|
||||
|
||||
_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:
|
||||
As opposed to options, the rest of these settings **can only be configured via the configuration file**.
|
||||
|
||||
* `$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:
|
||||
|
||||
```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](basics.html#incremental-lists).
|
||||
|
||||
### strict_front_matter_parsing
|
||||
|
||||
This option tells _presenterm_ you don't care about extra parameters in presentation's front matter. This can be useful
|
||||
if you're trying to load a presentation made for another tool. The following presentation would only be successfully
|
||||
loaded if you set `strict_front_matter_parsing` to `false` in your configuration file:
|
||||
|
||||
```markdown
|
||||
---
|
||||
potato: 42
|
||||
---
|
||||
|
||||
# Hi
|
||||
```
|
||||
|
||||
### image_attributes_prefix
|
||||
|
||||
The [image size](basics.html#image-size) prefix (by default `image:`) can be configured to be anything you would want in
|
||||
case you don't like the default one. For example, if you'd like to set the image size by simply doing
|
||||
`` you would need to set:
|
||||
|
||||
```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
|
||||
---
|
||||
```
|
||||
|
||||
## Defaults
|
||||
|
||||
Defaults **can only be configured via the configuration file**.
|
||||
|
||||
### Default theme
|
||||
## 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:
|
||||
@ -194,7 +12,7 @@ defaults:
|
||||
theme: light
|
||||
```
|
||||
|
||||
### Terminal font size
|
||||
## 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
|
||||
@ -209,7 +27,7 @@ defaults:
|
||||
terminal_font_size: 16
|
||||
```
|
||||
|
||||
### Preferred image protocol
|
||||
## 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
|
||||
@ -229,7 +47,7 @@ Possible values are:
|
||||
* `iterm2`: use the iterm2 protocol.
|
||||
* `sixel`: use the sixel protocol. Note that this requires compiling _presenterm_ using the `--features sixel` flag.
|
||||
|
||||
### Maximum presentation width
|
||||
## 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
|
||||
@ -240,7 +58,63 @@ defaults:
|
||||
max_columns: 100
|
||||
```
|
||||
|
||||
## Key bindings
|
||||
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:
|
||||
@ -253,6 +127,16 @@ bindings:
|
||||
# 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"]
|
||||
|
||||
@ -287,13 +171,15 @@ bindings:
|
||||
You can choose to override any of them. Keep in mind these are overrides so if for example you change `next`, the
|
||||
default won't apply anymore and only what you've defined will be used.
|
||||
|
||||
## Snippet configurations
|
||||
# Snippet configurations
|
||||
|
||||
### Snippet execution
|
||||
The configurations that affect code snippets in presentations.
|
||||
|
||||
[Snippet execution](code-highlight.html#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:
|
||||
## 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:
|
||||
@ -303,11 +189,11 @@ snippet:
|
||||
|
||||
**Use this at your own risk**, especially if you're running someone else's presentations!
|
||||
|
||||
### Snippet execution + replace
|
||||
## Snippet execution + replace
|
||||
|
||||
[Snippet execution + replace](code-highlight.html#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:
|
||||
[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:
|
||||
@ -318,7 +204,7 @@ snippet:
|
||||
**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
|
||||
## 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
|
||||
@ -358,7 +244,7 @@ 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
|
||||
## 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
|
||||
@ -370,7 +256,7 @@ snippet:
|
||||
threads: 2
|
||||
```
|
||||
|
||||
### Mermaid scaling
|
||||
## 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:
|
||||
@ -381,13 +267,39 @@ mermaid:
|
||||
scale: 2
|
||||
```
|
||||
|
||||
### Enabling speaker note publishing
|
||||
## 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_pubblish: true
|
||||
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
|
||||
```
|
@ -1,135 +1,3 @@
|
||||
# Code highlighting
|
||||
|
||||
Code highlighting is supported for the following languages:
|
||||
|
||||
* ada
|
||||
* asp
|
||||
* awk
|
||||
* bash
|
||||
* batchfile
|
||||
* C
|
||||
* cmake
|
||||
* crontab
|
||||
* C#
|
||||
* clojure
|
||||
* C++
|
||||
* CSS
|
||||
* D
|
||||
* diff
|
||||
* docker
|
||||
* dotenv
|
||||
* elixir
|
||||
* elm
|
||||
* erlang
|
||||
* go
|
||||
* haskell
|
||||
* HTML
|
||||
* java
|
||||
* javascript
|
||||
* json
|
||||
* kotlin
|
||||
* latex
|
||||
* lua
|
||||
* makefile
|
||||
* markdown
|
||||
* nix
|
||||
* ocaml
|
||||
* perl
|
||||
* php
|
||||
* protobuf
|
||||
* puppet
|
||||
* python
|
||||
* R
|
||||
* ruby
|
||||
* rust
|
||||
* scala
|
||||
* shell
|
||||
* sql
|
||||
* swift
|
||||
* svelte
|
||||
* tcl
|
||||
* toml
|
||||
* terraform
|
||||
* typescript
|
||||
* xml
|
||||
* yaml
|
||||
* vue
|
||||
* zig
|
||||
|
||||
## Enabling line numbers
|
||||
|
||||
If you would like line numbers to be shown on the left of a code block use the `+line_numbers` switch after specifying
|
||||
the language in a code block:
|
||||
|
||||
~~~markdown
|
||||
```rust +line_numbers
|
||||
fn hello_world() {
|
||||
println!("Hello world");
|
||||
}
|
||||
```
|
||||
~~~
|
||||
|
||||
## Selective highlighting
|
||||
|
||||
By default, the entire code block will be syntax-highlighted. If instead you only wanted a subset of it to be
|
||||
highlighted, you can use braces and a list of either individual lines, or line ranges that you'd want to highlight.
|
||||
|
||||
~~~markdown
|
||||
```rust {1,3,5-7}
|
||||
fn potato() -> u32 { // 1: highlighted
|
||||
// 2: not highlighted
|
||||
println!("Hello world"); // 3: highlighted
|
||||
let mut q = 42; // 4: not highlighted
|
||||
q = q * 1337; // 5: highlighted
|
||||
q // 6: highlighted
|
||||
} // 7: highlighted
|
||||
```
|
||||
~~~
|
||||
|
||||
## Dynamic highlighting
|
||||
|
||||
Similar to the syntax used for selective highlighting, dynamic highlighting will change which lines of the code in a
|
||||
code block are highlighted every time you move to the next/previous slide.
|
||||
|
||||
This is achieved by using the separator `|` to indicate what sections of the code will be highlighted at a given time.
|
||||
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
|
||||
```
|
||||
~~~
|
||||
|
||||
## 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.
|
||||
|
||||
# Snippet execution
|
||||
|
||||
## Executing code blocks
|
||||
@ -149,40 +17,13 @@ Code execution **must be explicitly enabled** by using either:
|
||||
|
||||
* The `-x` command line parameter when running _presenterm_.
|
||||
* Setting the `snippet.exec.enable` property to `true` in your [_presenterm_ config
|
||||
file](configuration.html#snippet-execution).
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
The list of languages that support execution are:
|
||||
|
||||
* bash
|
||||
* c++
|
||||
* c
|
||||
* fish
|
||||
* go
|
||||
* haskell
|
||||
* java
|
||||
* js
|
||||
* kotlin
|
||||
* lua
|
||||
* nushell
|
||||
* perl
|
||||
* php
|
||||
* python
|
||||
* r
|
||||
* ruby
|
||||
* rust
|
||||
* rust-script: this highlights as normal Rust but uses [rust-script](https://rust-script.org/) to execute the snippet so
|
||||
it lets you use dependencies.
|
||||
* sh
|
||||
* zsh
|
||||
* c#
|
||||
|
||||
If there's a language that is not in this list and you would like it to be supported, please [create an
|
||||
issue](https://github.com/mfontanini/presenterm/issues/new) providing details on how to compile (if necessary) and run
|
||||
snippets for that language. You can also configure how to run code snippet for a language locally in your [config
|
||||
file](configuration.html#custom-snippet-executors).
|
||||
|
||||
[](https://asciinema.org/a/BbAY817esxagCgPtnKUwgYnHr)
|
||||
|
||||
> [!warning]
|
||||
@ -212,9 +53,11 @@ 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.
|
||||
|
||||
@ -278,21 +121,5 @@ is loaded. The languages that currently support this are _mermaid_, _LaTeX_, and
|
||||
block is transformed into an image, allowing you to define formulas as text in your presentation. This can be done by
|
||||
using the `+render` attribute on a code block.
|
||||
|
||||
See the [LaTeX and typst](latex.html) and [mermaid](mermaid.html) docs for more information.
|
||||
See the [LaTeX and typst](latex.md) and [mermaid](mermaid.md) docs for more information.
|
||||
|
||||
## 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
|
||||
```
|
||||
~~~
|
180
docs/src/features/code/highlighting.md
Normal file
180
docs/src/features/code/highlighting.md
Normal file
@ -0,0 +1,180 @@
|
||||
# Code highlighting
|
||||
|
||||
Code highlighting is supported for the following languages:
|
||||
|
||||
| Language | Execution support |
|
||||
| -----------|-------------------|
|
||||
| ada | |
|
||||
| asp | |
|
||||
| awk | |
|
||||
| bash | ✓ |
|
||||
| batchfile | |
|
||||
| C | ✓ |
|
||||
| cmake | |
|
||||
| crontab | |
|
||||
| C# | ✓ |
|
||||
| clojure | |
|
||||
| C++ | ✓ |
|
||||
| CSS | |
|
||||
| D | |
|
||||
| diff | |
|
||||
| docker | |
|
||||
| dotenv | |
|
||||
| elixir | |
|
||||
| elm | |
|
||||
| erlang | |
|
||||
| fish | ✓ |
|
||||
| go | ✓ |
|
||||
| haskell | ✓ |
|
||||
| HTML | |
|
||||
| java | ✓ |
|
||||
| javascript | ✓ |
|
||||
| json | |
|
||||
| julia | ✓ |
|
||||
| kotlin | ✓ |
|
||||
| latex | |
|
||||
| lua | ✓ |
|
||||
| makefile | |
|
||||
| markdown | |
|
||||
| nix | |
|
||||
| ocaml | |
|
||||
| perl | ✓ |
|
||||
| php | ✓ |
|
||||
| protobuf | |
|
||||
| puppet | |
|
||||
| python | ✓ |
|
||||
| R | ✓ |
|
||||
| ruby | ✓ |
|
||||
| rust | ✓ |
|
||||
| scala | |
|
||||
| shell | ✓ |
|
||||
| sql | |
|
||||
| swift | |
|
||||
| svelte | |
|
||||
| tcl | |
|
||||
| toml | |
|
||||
| terraform | |
|
||||
| typescript | |
|
||||
| xml | |
|
||||
| yaml | |
|
||||
| vue | |
|
||||
| zig | |
|
||||
| zsh | ✓ |
|
||||
|
||||
Other languages that are supported are:
|
||||
|
||||
* nushell, for which highlighting isn't supported but execution is.
|
||||
* rust-script, which is highlighted as rust but is executed via the [rust-script](https://rust-script.org/) tool,
|
||||
which lets you specify dependencies in your snippet.
|
||||
|
||||
If there's a language that is not in this list and you would like it to be supported, please [create an
|
||||
issue](https://github.com/mfontanini/presenterm/issues/new). If you'd also like code execution support, provide details
|
||||
on how to compile (if necessary) and run snippets for that language. You can also configure how to run code snippet for
|
||||
a language locally in your [config file](../../configuration/settings.md#custom-snippet-executors).
|
||||
|
||||
## Enabling line numbers
|
||||
|
||||
If you would like line numbers to be shown on the left of a code block use the `+line_numbers` switch after specifying
|
||||
the language in a code block:
|
||||
|
||||
~~~markdown
|
||||
```rust +line_numbers
|
||||
fn hello_world() {
|
||||
println!("Hello world");
|
||||
}
|
||||
```
|
||||
~~~
|
||||
|
||||
## Selective highlighting
|
||||
|
||||
By default, the entire code block will be syntax-highlighted. If instead you only wanted a subset of it to be
|
||||
highlighted, you can use braces and a list of either individual lines, or line ranges that you'd want to highlight.
|
||||
|
||||
~~~markdown
|
||||
```rust {1,3,5-7}
|
||||
fn potato() -> u32 { // 1: highlighted
|
||||
// 2: not highlighted
|
||||
println!("Hello world"); // 3: highlighted
|
||||
let mut q = 42; // 4: not highlighted
|
||||
q = q * 1337; // 5: highlighted
|
||||
q // 6: highlighted
|
||||
} // 7: highlighted
|
||||
```
|
||||
~~~
|
||||
|
||||
## Dynamic highlighting
|
||||
|
||||
Similar to the syntax used for selective highlighting, dynamic highlighting will change which lines of the code in a
|
||||
code block are highlighted every time you move to the next/previous slide.
|
||||
|
||||
This is achieved by using the separator `|` to indicate what sections of the code will be highlighted at a given time.
|
||||
You can also use `all` to highlight all lines for a particular frame.
|
||||
|
||||
~~~markdown
|
||||
```rust {1,3|5-7}
|
||||
fn potato() -> u32 {
|
||||
|
||||
println!("Hello world");
|
||||
let mut q = 42;
|
||||
q = q * 1337;
|
||||
q
|
||||
}
|
||||
```
|
||||
~~~
|
||||
|
||||
In this example, lines 1 and 3 will be highlighted initially. Then once you press a key to move to the next slide, lines
|
||||
1 and 3 will no longer be highlighted and instead lines 5 through 7 will. This allows you to create more dynamic
|
||||
presentations where you can display sections of the code to explain something specific about each of them.
|
||||
|
||||
See this real example of how this looks like.
|
||||
|
||||
[](https://asciinema.org/a/iCf4f6how1Ux3H8GNzksFUczI)
|
||||
|
||||
## Including external code snippets
|
||||
|
||||
The `file` snippet type can be used to specify an external code snippet that will be included and highlighted as usual.
|
||||
|
||||
~~~markdown
|
||||
```file +exec +line_numbers
|
||||
path: snippet.rs
|
||||
language: rust
|
||||
```
|
||||
~~~
|
||||
|
||||
If you'd like to include only a subset of the file, you can use the optional fields `start_line` and `end_line`:
|
||||
|
||||
~~~markdown
|
||||
```file +exec +line_numbers
|
||||
path: snippet.rs
|
||||
language: rust
|
||||
# Only shot lines 5-10
|
||||
start_line: 5
|
||||
end_line: 10
|
||||
```
|
||||
~~~
|
||||
|
||||
## Showing a snippet without a background
|
||||
|
||||
Using the `+no_background` flag will cause the snippet to have no background. This is useful when combining it with the
|
||||
`+exec_replace` flag described further down.
|
||||
|
||||
## Adding highlighting syntaxes for new languages
|
||||
|
||||
_presenterm_ uses the syntaxes supported by [bat](https://github.com/sharkdp/bat) to highlight code snippets, so any
|
||||
languages supported by _bat_ natively can be added to _presenterm_ easily. Please create a ticket or use
|
||||
[this](https://github.com/mfontanini/presenterm/pull/385) as a reference to submit a pull request to make a syntax
|
||||
officially supported by _presenterm_ as well.
|
||||
|
||||
If a language isn't natively supported by _bat_ but you'd like to use it, you can follow
|
||||
[this guide in the bat docs](https://github.com/sharkdp/bat#adding-new-syntaxes--language-definitions) and
|
||||
invoke _bat_ directly in a presentation:
|
||||
|
||||
~~~markdown
|
||||
```bash +exec_replace
|
||||
bat --color always script.py
|
||||
```
|
||||
~~~
|
||||
|
||||
> [!note]
|
||||
> Check the [code execution docs](execution.md#executing-and-replacing) for more details on how to allow the tool to run
|
||||
> `exec_replace` blocks.
|
@ -1,8 +1,22 @@
|
||||
# LaTeX and typst
|
||||
|
||||
`latex` and `typst` code blocks can be marked with the `+render` attribute (see [highlighting](code-highlight.html)) to
|
||||
have them rendered into images when the presentation is loaded. This allows you to define formulas in text rather than
|
||||
having to define them somewhere else, transform them into an image, and them embed it.
|
||||
`latex` and `typst` code blocks can be marked with the `+render` attribute (see [highlighting](highlighting.md)) to have
|
||||
them rendered into images when the presentation is loaded. This allows you to define formulas in text rather than having
|
||||
to define them somewhere else, transform them into an image, and them embed it.
|
||||
|
||||
For example, the following presentation:
|
||||
|
||||
~~~
|
||||
# Formulas
|
||||
|
||||
```latex +render
|
||||
\[ \sum_{n=1}^{\infty} 2^{-n} = 1 \]
|
||||
```
|
||||
~~~
|
||||
|
||||
Would be rendered like this:
|
||||
|
||||

|
||||
|
||||
## Dependencies
|
||||
|
||||
@ -13,9 +27,9 @@ 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:
|
||||
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_.
|
||||
@ -28,7 +42,7 @@ generated on the fly will have a fixed size. Configuring the PPI used during the
|
||||
higher the PPI, the larger the generated images will be.
|
||||
|
||||
Because as opposed to most configurations this is a very environment-specific config, the PPI parameter is not part of
|
||||
the theme definition but is instead has to be set in [_presenterm_'s config file](configuration.html):
|
||||
the theme definition but is instead has to be set in _presenterm_'s [config file](../../configuration/introduction.md):
|
||||
|
||||
```yaml
|
||||
typst:
|
||||
@ -72,19 +86,3 @@ typst:
|
||||
horizontal_margin: 2
|
||||
vertical_margin: 2
|
||||
```
|
||||
|
||||
# Example
|
||||
|
||||
The following example:
|
||||
|
||||
~~~
|
||||
# Formulas
|
||||
|
||||
```latex +render
|
||||
\[ \sum_{n=1}^{\infty} 2^{-n} = 1 \]
|
||||
```
|
||||
~~~
|
||||
|
||||
Is rendered like this:
|
||||
|
||||

|
@ -15,11 +15,11 @@ sequenceDiagram
|
||||
|
||||
Note that because the mermaid CLI will spin up a browser under the hood, this may not work in all environments and can
|
||||
also be a bit slow (e.g. ~2 seconds to generate every image). Mermaid graphs are rendered asynchronously by a number of
|
||||
threads that can be configured in the [configuration file](configuration.html#snippet-rendering-threads). This
|
||||
configuration value currently defaults to 2.
|
||||
threads that can be configured in the [configuration file](../../configuration/settings.md#snippet-rendering-threads).
|
||||
This configuration value currently defaults to 2.
|
||||
|
||||
The size of the rendered image can be configured by changing:
|
||||
* The `mermaid.scale` [configuration parameter](configuration.html#mermaid-scaling).
|
||||
* The `mermaid.scale` [configuration parameter](../../configuration/settings.md#mermaid-scaling).
|
||||
* Using the `+width:<number>%` attribute in the code snippet.
|
||||
|
||||
For example, this diagram will take up 50% of the width of the window and will preserve its aspect ratio:
|
||||
@ -38,12 +38,13 @@ cause the image to become blurry.
|
||||
|
||||
## Theme
|
||||
|
||||
The theme of the rendered mermaid diagrams can be changed through the following [theme](themes.html#mermaid) parameters:
|
||||
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 rendering
|
||||
## 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.html#auto_render_languages).
|
||||
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)
|
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)
|
@ -17,6 +17,11 @@ presenterm demo.md --publish-speaker-notes
|
||||
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:
|
||||
@ -51,6 +56,16 @@ time. Each instance will only listen to events for the presentation it was start
|
||||
|
||||
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
|
@ -1,106 +1,7 @@
|
||||
# Themes
|
||||
|
||||
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.
|
||||
|
||||
## 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:
|
||||
|
||||
```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.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.
|
||||
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.
|
||||
|
||||
## Root elements
|
||||
|
||||
@ -225,16 +126,75 @@ intro_slide:
|
||||
|
||||
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,28 +207,16 @@ 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
|
||||
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`.
|
||||
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 titles, as specified by using a setext header, has the following properties:
|
||||
@ -332,8 +280,8 @@ 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
|
||||
|
||||
@ -344,10 +292,6 @@ 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:
|
||||
@ -418,8 +362,9 @@ _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 is essentially a named list of colors. These can then be used both in
|
||||
other parts of the theme, as well as when styling text via `span` HTML tags.
|
||||
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:
|
||||
|
||||
@ -428,6 +373,10 @@ 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
|
||||
@ -437,4 +386,9 @@ 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,273 +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:
|
||||
|
||||
```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.
|
||||
|
||||
## Colored text
|
||||
|
||||
`span` HTML tags can be used to provide foreground and/or background colors to text. Currently only the `style`
|
||||
attribute is supported, and 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.html#color-palette) by using the `palette:<name>` or `p:<name` syntaxes.
|
||||
|
||||
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>
|
||||
```
|
||||
|
||||
> [!note]
|
||||
> Keep in mind **only `span` tags are supported**.
|
||||
|
||||
## 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!
|
||||
|
||||
### 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.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:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: My first presentation
|
||||
sub_title: (in presenterm!)
|
||||
author: Myself
|
||||
---
|
||||
```
|
||||
|
||||
All of these attributes are optional so you're not forced to set them all.
|
||||
|
||||
### Multiple authors
|
||||
|
||||
If you're creating a presentation in which there's multiple authors, you can use the `authors` key instead of `author`
|
||||
and list them all this way:
|
||||
|
||||
```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.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_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
|
||||
```
|
||||
|
||||
# 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.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 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,43 +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:
|
||||
|
||||
```bash
|
||||
pip install presenterm-export
|
||||
```
|
||||
|
||||
> [!tip]
|
||||
> 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:
|
||||
|
||||
```bash
|
||||
presenterm --export-pdf examples/demo.md
|
||||
```
|
||||
|
||||
The output PDF will be placed in `examples/demo.pdf`.
|
||||
|
||||
> [!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.
|
||||
|
||||
## Page sizes
|
||||
|
||||
The size of each page in the generated PDF will depend on the size of your terminal. Make sure to adjust accordingly
|
||||
before running the command above, and not to resize it while the generation is happening to avoid issues.
|
||||
|
||||
## Active tmux sessions bug
|
||||
|
||||
Because of a [bug in tmux <= 3.5a](https://github.com/tmux/tmux/issues/4268), exporting a PDF while having other tmux
|
||||
sessions running and attached will cause the size of the output PDF to match the size of those other sessions rather
|
||||
than the size of the terminal you're running _presenterm_ in. The workaround is to only have one attached tmux session
|
||||
and to run the PDF export from that session.
|
||||
|
||||
## 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,4 +1,4 @@
|
||||
# Installation
|
||||
# Installing _presenterm_
|
||||
|
||||
_presenterm_ works on Linux, macOS, and Windows and can be installed in different ways:
|
||||
|
@ -39,7 +39,7 @@ 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
|
||||
|
||||
|
195
examples/demo.md
195
examples/demo.md
@ -1,38 +1,34 @@
|
||||
---
|
||||
title: Introducing presenterm
|
||||
title: Introducing _presenterm_
|
||||
author: Matias
|
||||
---
|
||||
|
||||
Introduction slide
|
||||
Customizability
|
||||
---
|
||||
|
||||
An introduction slide can be defined by using a front matter at the beginning of the markdown file:
|
||||
_presenterm_ allows configuring almost anything about your presentation:
|
||||
|
||||
* The colors used.
|
||||
* Layouts.
|
||||
* Footers, including images in the footer.
|
||||
|
||||
<!-- pause -->
|
||||
|
||||
This is an example on how to configure a footer:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: My presentation title
|
||||
sub_title: An optional subtitle
|
||||
author: Your name which will appear somewhere in the bottom
|
||||
---
|
||||
```
|
||||
footer:
|
||||
style: template
|
||||
left:
|
||||
image: doge.png
|
||||
center: '<span class="noice">Colored</span> _footer_'
|
||||
right: "{current_slide} / {total_slides}"
|
||||
height: 5
|
||||
|
||||
The slide's theme can also be configured in the front matter:
|
||||
|
||||
```yaml
|
||||
---
|
||||
theme:
|
||||
# Specify it by name for built-in themes
|
||||
name: my-favorite-theme
|
||||
|
||||
# Otherwise specify the path for it
|
||||
path: /home/myself/themes/epic.yaml
|
||||
|
||||
# Or override parts of the theme right here
|
||||
override:
|
||||
default:
|
||||
colors:
|
||||
foreground: white
|
||||
---
|
||||
palette:
|
||||
classes:
|
||||
noice:
|
||||
foreground: red
|
||||
```
|
||||
|
||||
<!-- end_slide -->
|
||||
@ -40,58 +36,27 @@ theme:
|
||||
Headers
|
||||
---
|
||||
|
||||
Using commonmark setext headers allows you to set titles for your slides (like seen above!):
|
||||
Markdown headers can be used to set slide titles like:
|
||||
|
||||
```
|
||||
```markdown
|
||||
Headers
|
||||
---
|
||||
-------
|
||||
```
|
||||
|
||||
# Other headers
|
||||
# Headers
|
||||
|
||||
All other header types are simply treated as headers within your slide.
|
||||
Each header type can be styled differently.
|
||||
|
||||
## Subheaders
|
||||
|
||||
### And more
|
||||
|
||||
<!-- end_slide -->
|
||||
|
||||
Slide commands
|
||||
---
|
||||
|
||||
Certain commands in the form of HTML comments can be used:
|
||||
|
||||
# Ending slides
|
||||
|
||||
In order to end a single slide, use:
|
||||
|
||||
```html
|
||||
<!-- end_slide -->
|
||||
```
|
||||
|
||||
# Creating pauses
|
||||
|
||||
Slides can be paused by using the `pause` command:
|
||||
|
||||
```html
|
||||
<!-- pause -->
|
||||
```
|
||||
|
||||
This allows you to:
|
||||
|
||||
<!-- pause -->
|
||||
* Create suspense.
|
||||
<!-- pause -->
|
||||
* Have more interactive presentations.
|
||||
<!-- pause -->
|
||||
* Possibly more!
|
||||
|
||||
<!-- end_slide -->
|
||||
|
||||
Code highlighting
|
||||
---
|
||||
|
||||
Code highlighting is enabled for code blocks that include the most commonly used programming languages:
|
||||
Highlight code in 50+ programming languages:
|
||||
|
||||
```rust
|
||||
// Rust
|
||||
@ -106,22 +71,25 @@ def greet() -> str:
|
||||
return "hi mom"
|
||||
```
|
||||
|
||||
```cpp
|
||||
<!-- pause -->
|
||||
|
||||
-------
|
||||
|
||||
Code snippets can have different styles including no background:
|
||||
|
||||
```cpp +no_background +line_numbers
|
||||
// C++
|
||||
string greet() {
|
||||
return "hi mom";
|
||||
}
|
||||
```
|
||||
|
||||
And many more!
|
||||
|
||||
<!-- end_slide -->
|
||||
|
||||
Dynamic code highlighting
|
||||
---
|
||||
|
||||
Select specific subsets of lines to be highlighted dynamically as you move to the next slide. Optionally enable line
|
||||
numbers to make it easier to specify which lines you're referring to!
|
||||
Dynamically highlight different subsets of lines:
|
||||
|
||||
```rust {1-4|6-10|all} +line_numbers
|
||||
#[derive(Clone, Debug)]
|
||||
@ -141,12 +109,11 @@ impl Person {
|
||||
Snippet execution
|
||||
---
|
||||
|
||||
Code snippets can be executed:
|
||||
Code snippets can be executed on demand:
|
||||
|
||||
* For various languages, including compiled ones.
|
||||
* Their output is shown in real time.
|
||||
* Unimportant lines can be hidden so they don't clutter what you're trying to convey.
|
||||
* By default by pressing `<ctrl-e>`.
|
||||
* For 20+ languages, including compiled ones.
|
||||
* Display their output in real time.
|
||||
* Comment out unimportant lines to hide them.
|
||||
|
||||
```rust +exec
|
||||
# use std::thread::sleep;
|
||||
@ -165,12 +132,17 @@ fn main() {
|
||||
Images
|
||||
---
|
||||
|
||||
Image rendering is supported as long as you're using iterm2, your terminal supports
|
||||
the kitty graphics protocol (such as the kitty terminal itself!), or the sixel format.
|
||||
Images and animated gifs are supported in terminals such as:
|
||||
|
||||
* Include images in your slides by using ``.
|
||||
* Images will be rendered in **their original size**.
|
||||
* If they're too big they will be scaled down to fit the screen.
|
||||
* kitty
|
||||
* iterm2
|
||||
* wezterm
|
||||
* ghostty
|
||||
* Any sixel enabled terminal
|
||||
|
||||
<!-- column_layout: [1, 3, 1] -->
|
||||
|
||||
<!-- column: 1 -->
|
||||
|
||||

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

|
||||
|
||||
_Picture by Alexis Bailey / CC BY-NC 4.0_
|
||||
|
||||
<!-- reset_layout -->
|
||||
|
||||
Because we just reset the layout, this text is now below both of the columns. Code and any other element will now look
|
||||
like it usually does:
|
||||
---
|
||||
|
||||
Layouts can be reset at any time.
|
||||
|
||||
```python
|
||||
print("Hello world!")
|
||||
@ -220,23 +188,29 @@ 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/)
|
||||
* Text can be <span style="color: red">colored</span>.
|
||||
* Text background color can be <span style="color: blue; background-color: black">changed too</span>.
|
||||
* **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:
|
||||
Other markdown elements supported are:
|
||||
|
||||
# Block quotes
|
||||
|
||||
> Lorem ipsum dolor sit amet. Eos laudantium animi ut ipsam beataeet
|
||||
> et exercitationem deleniti et quia maiores a cumque enim et
|
||||
> aspernatur nesciunt sed adipisci quis.
|
||||
|
||||
# Alerts
|
||||
|
||||
@ -245,19 +219,14 @@ Other elements supported are:
|
||||
|
||||
# Tables
|
||||
|
||||
| Name | Taste |
|
||||
| Name | Taste |
|
||||
| ------ | ------ |
|
||||
| Potato | Great |
|
||||
| Carrot | Yuck |
|
||||
| Potato | Great |
|
||||
| Carrot | Yuck |
|
||||
|
||||
# Block quotes
|
||||
<!-- end_slide -->
|
||||
|
||||
> Lorem ipsum dolor sit amet. Eos laudantium animi ut ipsam beataeet
|
||||
> et exercitationem deleniti et quia maiores a cumque enim et
|
||||
> aspernatur nesciunt sed adipisci quis.
|
||||
|
||||
# Thematic breaks
|
||||
|
||||
A horizontal line by using `---`.
|
||||
<!-- 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
|
||||
|
@ -42,10 +42,15 @@ 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/script.kts"]
|
||||
- ["kotlinc", "-script", "$pwd/snippet.kts"]
|
||||
hidden_line_prefix: "/// "
|
||||
lua:
|
||||
filename: snippet.lua
|
||||
|
@ -1,10 +1,12 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
script_dir=$(dirname "$0")
|
||||
root_dir="${script_dir}/../"
|
||||
|
||||
current_schema=$(mktemp)
|
||||
cargo run -q -- --generate-config-file-schema >"$current_schema"
|
||||
cargo run --features json-schema -q -- --generate-config-file-schema >"$current_schema"
|
||||
|
||||
diff=$(diff --color=always -u "${root_dir}/config-file-schema.json" "$current_schema")
|
||||
if [ $? -ne 0 ]; then
|
||||
|
@ -1,5 +1,6 @@
|
||||
//! Code execution.
|
||||
|
||||
use super::snippet::{SnippetExec, SnippetRepr};
|
||||
use crate::{
|
||||
code::snippet::{Snippet, SnippetLanguage},
|
||||
config::LanguageSnippetExecutionConfig,
|
||||
@ -59,9 +60,9 @@ impl SnippetExecutor {
|
||||
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.image {
|
||||
true => OutputType::Binary,
|
||||
false => OutputType::Lines,
|
||||
let output_type = match snippet.attributes.representation {
|
||||
SnippetRepr::Image => OutputType::Binary,
|
||||
_ => OutputType::Lines,
|
||||
};
|
||||
let reader_handle = CommandsRunner::spawn(
|
||||
state.clone(),
|
||||
@ -107,7 +108,9 @@ impl SnippetExecutor {
|
||||
}
|
||||
|
||||
fn language_config(&self, snippet: &Snippet) -> Result<&LanguageSnippetExecutionConfig, CodeExecuteError> {
|
||||
if !snippet.attributes.execute && !snippet.attributes.execute_replace {
|
||||
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)
|
||||
@ -326,7 +329,7 @@ echo 'bye'"
|
||||
let code = Snippet {
|
||||
contents,
|
||||
language: SnippetLanguage::Shell,
|
||||
attributes: SnippetAttributes { execute: true, ..Default::default() },
|
||||
attributes: SnippetAttributes { execution: SnippetExec::Exec, ..Default::default() },
|
||||
};
|
||||
let handle = SnippetExecutor::default().execute_async(&code).expect("execution failed");
|
||||
let state = loop {
|
||||
@ -346,7 +349,7 @@ echo 'bye'"
|
||||
let code = Snippet {
|
||||
contents,
|
||||
language: SnippetLanguage::Shell,
|
||||
attributes: SnippetAttributes { execute: false, ..Default::default() },
|
||||
attributes: SnippetAttributes { execution: SnippetExec::None, ..Default::default() },
|
||||
};
|
||||
let result = SnippetExecutor::default().execute_async(&code);
|
||||
assert!(result.is_err());
|
||||
@ -362,7 +365,7 @@ echo 'hello world'
|
||||
let code = Snippet {
|
||||
contents,
|
||||
language: SnippetLanguage::Shell,
|
||||
attributes: SnippetAttributes { execute: true, ..Default::default() },
|
||||
attributes: SnippetAttributes { execution: SnippetExec::Exec, ..Default::default() },
|
||||
};
|
||||
let handle = SnippetExecutor::default().execute_async(&code).expect("execution failed");
|
||||
let state = loop {
|
||||
@ -387,7 +390,7 @@ echo 'hello world'
|
||||
let code = Snippet {
|
||||
contents,
|
||||
language: SnippetLanguage::Shell,
|
||||
attributes: SnippetAttributes { execute: true, ..Default::default() },
|
||||
attributes: SnippetAttributes { execution: SnippetExec::Exec, ..Default::default() },
|
||||
};
|
||||
let handle = SnippetExecutor::default().execute_async(&code).expect("execution failed");
|
||||
let state = loop {
|
||||
|
@ -131,6 +131,7 @@ impl SnippetHighlighter {
|
||||
Java => "java",
|
||||
JavaScript => "js",
|
||||
Json => "json",
|
||||
Julia => "jl",
|
||||
Kotlin => "kt",
|
||||
Latex => "tex",
|
||||
Lua => "lua",
|
||||
@ -207,7 +208,7 @@ pub(crate) struct StyledTokens<'a> {
|
||||
|
||||
impl<'a> StyledTokens<'a> {
|
||||
pub(crate) fn new(style: Style, tokens: &'a str, block_style: &CodeBlockStyle) -> Self {
|
||||
let has_background = block_style.background.unwrap_or(true);
|
||||
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();
|
||||
|
@ -3,7 +3,6 @@ use super::{
|
||||
padding::NumberPadder,
|
||||
};
|
||||
use crate::{
|
||||
PresentationTheme,
|
||||
markdown::{
|
||||
elements::{Percent, PercentParseError},
|
||||
text::{WeightedLine, WeightedText},
|
||||
@ -16,27 +15,25 @@ use crate::{
|
||||
},
|
||||
theme::{Alignment, CodeBlockStyle},
|
||||
};
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde_with::DeserializeFromStr;
|
||||
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> {
|
||||
theme: &'a PresentationTheme,
|
||||
style: &'a CodeBlockStyle,
|
||||
hidden_line_prefix: Option<&'a str>,
|
||||
}
|
||||
|
||||
impl<'a> SnippetSplitter<'a> {
|
||||
pub(crate) fn new(theme: &'a PresentationTheme, hidden_line_prefix: Option<&'a str>) -> Self {
|
||||
Self { theme, hidden_line_prefix }
|
||||
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.theme.code.padding.horizontal.unwrap_or(0);
|
||||
let vertical_padding = self.theme.code.padding.vertical.unwrap_or(0);
|
||||
let horizontal_padding = self.style.padding.horizontal;
|
||||
let vertical_padding = self.style.padding.vertical;
|
||||
if vertical_padding > 0 {
|
||||
lines.push(SnippetLine::empty());
|
||||
}
|
||||
@ -89,8 +86,11 @@ impl SnippetLine {
|
||||
&self,
|
||||
code_highlighter: &mut LanguageHighlighter,
|
||||
block_style: &CodeBlockStyle,
|
||||
font_size: u8,
|
||||
) -> WeightedLine {
|
||||
code_highlighter.highlight_line(&self.code, block_style).0.into()
|
||||
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 {
|
||||
@ -108,7 +108,7 @@ impl SnippetLine {
|
||||
pub(crate) struct HighlightContext {
|
||||
pub(crate) groups: Vec<HighlightGroup>,
|
||||
pub(crate) current: usize,
|
||||
pub(crate) block_length: usize,
|
||||
pub(crate) block_length: u16,
|
||||
pub(crate) alignment: Alignment,
|
||||
}
|
||||
|
||||
@ -139,8 +139,8 @@ impl AsRenderOperations for HighlightedLine {
|
||||
right_padding_length: self.right_padding_length,
|
||||
repeat_prefix_on_wrap: false,
|
||||
text,
|
||||
block_length: context.block_length as u16,
|
||||
alignment: context.alignment.clone(),
|
||||
block_length: context.block_length,
|
||||
alignment: context.alignment,
|
||||
block_color: self.block_color,
|
||||
}),
|
||||
RenderOperation::RenderLineBreak,
|
||||
@ -209,7 +209,7 @@ impl SnippetParser {
|
||||
fn parse_block_info(input: &str) -> ParseResult<(SnippetLanguage, SnippetAttributes)> {
|
||||
let (language, input) = Self::parse_language(input);
|
||||
let attributes = Self::parse_attributes(input)?;
|
||||
if attributes.width.is_some() && !attributes.render {
|
||||
if attributes.width.is_some() && !matches!(attributes.representation, SnippetRepr::Render) {
|
||||
return Err(SnippetBlockParseError::NotRenderSnippet("width"));
|
||||
}
|
||||
Ok((language, attributes))
|
||||
@ -231,19 +231,30 @@ impl SnippetParser {
|
||||
if processed_attributes.contains(&discriminant) {
|
||||
return Err(SnippetBlockParseError::DuplicateAttribute("duplicate attribute"));
|
||||
}
|
||||
use SnippetAttribute::*;
|
||||
match attribute {
|
||||
SnippetAttribute::LineNumbers => attributes.line_numbers = true,
|
||||
SnippetAttribute::Exec => attributes.execute = true,
|
||||
SnippetAttribute::ExecReplace => attributes.execute_replace = true,
|
||||
SnippetAttribute::Image => {
|
||||
attributes.execute_replace = true;
|
||||
attributes.image = true;
|
||||
ExecReplace | Image | Render if attributes.representation != SnippetRepr::Snippet => {
|
||||
return Err(SnippetBlockParseError::MultipleRepresentation);
|
||||
}
|
||||
SnippetAttribute::Render => attributes.render = true,
|
||||
SnippetAttribute::NoBackground => attributes.no_background = true,
|
||||
SnippetAttribute::AcquireTerminal => attributes.acquire_terminal = true,
|
||||
SnippetAttribute::HighlightedLines(lines) => attributes.highlight_groups = lines,
|
||||
SnippetAttribute::Width(width) => attributes.width = Some(width),
|
||||
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;
|
||||
@ -369,6 +380,9 @@ pub enum SnippetBlockParseError {
|
||||
#[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),
|
||||
}
|
||||
@ -424,7 +438,8 @@ impl Snippet {
|
||||
}
|
||||
|
||||
/// The language of a code snippet.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, EnumIter, PartialOrd, Ord, DeserializeFromStr, JsonSchema)]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, EnumIter, PartialOrd, Ord)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
pub enum SnippetLanguage {
|
||||
Ada,
|
||||
Asp,
|
||||
@ -454,6 +469,7 @@ pub enum SnippetLanguage {
|
||||
Java,
|
||||
JavaScript,
|
||||
Json,
|
||||
Julia,
|
||||
Kotlin,
|
||||
Latex,
|
||||
Lua,
|
||||
@ -492,6 +508,8 @@ pub enum SnippetLanguage {
|
||||
Zsh,
|
||||
}
|
||||
|
||||
crate::utils::impl_deserialize_from_str!(SnippetLanguage);
|
||||
|
||||
impl FromStr for SnippetLanguage {
|
||||
type Err = Infallible;
|
||||
|
||||
@ -525,6 +543,7 @@ impl FromStr for SnippetLanguage {
|
||||
"java" => Java,
|
||||
"javascript" | "js" => JavaScript,
|
||||
"json" => Json,
|
||||
"julia" => Julia,
|
||||
"kotlin" => Kotlin,
|
||||
"latex" => Latex,
|
||||
"lua" => Lua,
|
||||
@ -569,22 +588,11 @@ impl FromStr for SnippetLanguage {
|
||||
/// Attributes for code snippets.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct SnippetAttributes {
|
||||
/// Whether the snippet is marked as executable.
|
||||
pub(crate) execute: bool,
|
||||
/// The way the snippet should be represented.
|
||||
pub(crate) representation: SnippetRepr,
|
||||
|
||||
/// Whether the snippet is marked as an executable block that will be replaced with the output
|
||||
/// of its execution.
|
||||
pub(crate) execute_replace: bool,
|
||||
|
||||
/// Whether the snippet should be executed and its output should be considered to be an image
|
||||
/// and replaced with it.
|
||||
pub(crate) image: bool,
|
||||
|
||||
/// Whether a snippet is marked to be rendered.
|
||||
///
|
||||
/// A rendered snippet is transformed during parsing, leading to some visual
|
||||
/// representation of it being shown rather than the original code.
|
||||
pub(crate) render: bool,
|
||||
/// The way the snippet should be executed.
|
||||
pub(crate) execution: SnippetExec,
|
||||
|
||||
/// Whether the snippet should show line numbers.
|
||||
pub(crate) line_numbers: bool,
|
||||
@ -599,9 +607,23 @@ pub(crate) struct SnippetAttributes {
|
||||
|
||||
/// Whether to add no background to a snippet.
|
||||
pub(crate) no_background: bool,
|
||||
}
|
||||
|
||||
/// Whether this code snippet acquires the terminal when ran.
|
||||
pub(crate) acquire_terminal: 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)]
|
||||
@ -637,6 +659,8 @@ pub(crate) enum Highlight {
|
||||
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)]
|
||||
@ -697,17 +721,33 @@ mod test {
|
||||
#[test]
|
||||
fn one_attribute() {
|
||||
let attributes = parse_attributes("bash +exec");
|
||||
assert!(attributes.execute);
|
||||
assert_eq!(attributes.execution, SnippetExec::Exec);
|
||||
assert!(!attributes.line_numbers);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_attributes() {
|
||||
let attributes = parse_attributes("bash +exec +line_numbers");
|
||||
assert!(attributes.execute);
|
||||
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();
|
||||
@ -762,7 +802,7 @@ mod test {
|
||||
#[test]
|
||||
fn parse_width() {
|
||||
let attributes = parse_attributes("mermaid +width:50% +render");
|
||||
assert!(attributes.render);
|
||||
assert_eq!(attributes.representation, SnippetRepr::Render);
|
||||
assert_eq!(attributes.width, Some(Percent(50)));
|
||||
}
|
||||
|
||||
|
@ -1,8 +1,6 @@
|
||||
use super::listener::{Command, CommandDiscriminants};
|
||||
use crate::config::KeyBindingsConfig;
|
||||
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, poll, read};
|
||||
use schemars::JsonSchema;
|
||||
use serde_with::DeserializeFromStr;
|
||||
use std::{fmt, io, iter, mem, str::FromStr, time::Duration};
|
||||
|
||||
/// A keyboard command listener.
|
||||
@ -162,8 +160,11 @@ enum BindingMatch {
|
||||
None,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeserializeFromStr, JsonSchema)]
|
||||
pub struct KeyBinding(#[schemars(with = "String")] Vec<KeyMatcher>);
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
pub struct KeyBinding(#[cfg_attr(feature = "json-schema", schemars(with = "String"))] Vec<KeyMatcher>);
|
||||
|
||||
crate::utils::impl_deserialize_from_str!(KeyBinding);
|
||||
|
||||
impl KeyBinding {
|
||||
fn match_events(&self, mut events: &[KeyEvent]) -> BindingMatch {
|
||||
|
@ -36,7 +36,7 @@ impl CommandListener {
|
||||
return Ok(Some(command));
|
||||
}
|
||||
}
|
||||
match self.keyboard.poll_next_command(Duration::from_millis(250))? {
|
||||
match self.keyboard.poll_next_command(Duration::from_millis(100))? {
|
||||
Some(command) => Ok(Some(command)),
|
||||
None => Ok(None),
|
||||
}
|
||||
|
223
src/config.rs
223
src/config.rs
@ -2,11 +2,11 @@ use crate::{
|
||||
code::snippet::SnippetLanguage,
|
||||
commands::keyboard::KeyBinding,
|
||||
terminal::{
|
||||
GraphicsMode, emulator::TerminalEmulator, image::protocols::kitty::KittyMode, query::TerminalCapabilities,
|
||||
GraphicsMode, capabilities::TerminalCapabilities, emulator::TerminalEmulator,
|
||||
image::protocols::kitty::KittyMode,
|
||||
},
|
||||
};
|
||||
use clap::ValueEnum;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
@ -15,7 +15,8 @@ use std::{
|
||||
path::Path,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Config {
|
||||
/// The default configuration for the presentation.
|
||||
@ -39,6 +40,12 @@ pub struct Config {
|
||||
|
||||
#[serde(default)]
|
||||
pub speaker_notes: SpeakerNotesConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub export: ExportConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub transition: Option<SlideTransitionConfig>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@ -46,7 +53,7 @@ impl Config {
|
||||
pub fn load(path: &Path) -> Result<Self, ConfigLoadError> {
|
||||
let contents = match fs::read_to_string(path) {
|
||||
Ok(contents) => contents,
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Self::default()),
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => return Err(ConfigLoadError::NotFound),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let config = serde_yaml::from_str(&contents)?;
|
||||
@ -59,19 +66,23 @@ pub enum ConfigLoadError {
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] io::Error),
|
||||
|
||||
#[error("config file not found")]
|
||||
NotFound,
|
||||
|
||||
#[error("invalid configuration: {0}")]
|
||||
Invalid(#[from] serde_yaml::Error),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema)]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DefaultsConfig {
|
||||
/// The theme to use by default in every presentation unless overridden.
|
||||
pub theme: Option<String>,
|
||||
|
||||
/// Override the terminal font size when in windows or when using sixel.
|
||||
#[serde(default = "default_font_size")]
|
||||
#[validate(range(min = 1))]
|
||||
#[serde(default = "default_terminal_font_size")]
|
||||
#[cfg_attr(feature = "json-schema", validate(range(min = 1)))]
|
||||
pub terminal_font_size: u8,
|
||||
|
||||
/// The image protocol to use.
|
||||
@ -83,27 +94,96 @@ pub struct DefaultsConfig {
|
||||
pub validate_overflows: ValidateOverflows,
|
||||
|
||||
/// A max width in columns that the presentation must always be capped to.
|
||||
#[serde(default = "default_max_columns")]
|
||||
#[serde(default = "default_u16_max")]
|
||||
pub max_columns: u16,
|
||||
|
||||
/// The alignment the presentation should have if `max_columns` is set and the terminal is
|
||||
/// larger than that.
|
||||
#[serde(default)]
|
||||
pub max_columns_alignment: MaxColumnsAlignment,
|
||||
|
||||
/// A max height in rows that the presentation must always be capped to.
|
||||
#[serde(default = "default_u16_max")]
|
||||
pub max_rows: u16,
|
||||
|
||||
/// The alignment the presentation should have if `max_rows` is set and the terminal is
|
||||
/// larger than that.
|
||||
#[serde(default)]
|
||||
pub max_rows_alignment: MaxRowsAlignment,
|
||||
|
||||
/// The configuration for lists when incremental lists are enabled.
|
||||
#[serde(default)]
|
||||
pub incremental_lists: IncrementalListsConfig,
|
||||
}
|
||||
|
||||
impl Default for DefaultsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
theme: Default::default(),
|
||||
terminal_font_size: default_font_size(),
|
||||
terminal_font_size: default_terminal_font_size(),
|
||||
image_protocol: Default::default(),
|
||||
validate_overflows: Default::default(),
|
||||
max_columns: default_max_columns(),
|
||||
max_columns: default_u16_max(),
|
||||
max_columns_alignment: Default::default(),
|
||||
max_rows: default_u16_max(),
|
||||
max_rows_alignment: Default::default(),
|
||||
incremental_lists: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_font_size() -> u8 {
|
||||
/// The configuration for lists when incremental lists are enabled.
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct IncrementalListsConfig {
|
||||
/// Whether to pause before a list begins.
|
||||
#[serde(default)]
|
||||
pub pause_before: Option<bool>,
|
||||
|
||||
/// Whether to pause after a list ends.
|
||||
#[serde(default)]
|
||||
pub pause_after: Option<bool>,
|
||||
}
|
||||
|
||||
fn default_terminal_font_size() -> u8 {
|
||||
16
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
|
||||
/// The alignment to use when `defaults.max_columns` is set.
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MaxColumnsAlignment {
|
||||
/// Align the presentation to the left.
|
||||
Left,
|
||||
|
||||
/// Align the presentation on the center.
|
||||
#[default]
|
||||
Center,
|
||||
|
||||
/// Align the presentation to the right.
|
||||
Right,
|
||||
}
|
||||
|
||||
/// The alignment to use when `defaults.max_rows` is set.
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MaxRowsAlignment {
|
||||
/// Align the presentation to the top.
|
||||
Top,
|
||||
|
||||
/// Align the presentation on the center.
|
||||
#[default]
|
||||
Center,
|
||||
|
||||
/// Align the presentation to the bottom.
|
||||
Bottom,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ValidateOverflows {
|
||||
#[default]
|
||||
@ -113,7 +193,8 @@ pub enum ValidateOverflows {
|
||||
WhenDeveloping,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct OptionsConfig {
|
||||
/// Whether slides are automatically terminated when a slide title is found.
|
||||
@ -135,10 +216,12 @@ pub struct OptionsConfig {
|
||||
pub strict_front_matter_parsing: Option<bool>,
|
||||
|
||||
/// Assume snippets for these languages contain `+render` and render them automatically.
|
||||
#[serde(default)]
|
||||
pub auto_render_languages: Vec<SnippetLanguage>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SnippetConfig {
|
||||
/// The properties for snippet execution.
|
||||
@ -154,7 +237,8 @@ pub struct SnippetConfig {
|
||||
pub render: SnippetRenderConfig,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SnippetExecConfig {
|
||||
/// Whether to enable snippet execution.
|
||||
@ -165,7 +249,8 @@ pub struct SnippetExecConfig {
|
||||
pub custom: BTreeMap<SnippetLanguage, LanguageSnippetExecutionConfig>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SnippetExecReplaceConfig {
|
||||
/// Whether to enable snippet replace-executions, which automatically run code snippets without
|
||||
@ -173,7 +258,8 @@ pub struct SnippetExecReplaceConfig {
|
||||
pub enable: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema)]
|
||||
#[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.
|
||||
@ -191,7 +277,8 @@ pub(crate) fn default_snippet_render_threads() -> usize {
|
||||
2
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema)]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TypstConfig {
|
||||
/// The pixels per inch when rendering latex/typst formulas.
|
||||
@ -209,7 +296,8 @@ pub(crate) fn default_typst_ppi() -> u32 {
|
||||
300
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema)]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MermaidConfig {
|
||||
/// The scaling parameter to be used in the mermaid CLI.
|
||||
@ -227,12 +315,13 @@ pub(crate) fn default_mermaid_scale() -> u32 {
|
||||
2
|
||||
}
|
||||
|
||||
pub(crate) fn default_max_columns() -> u16 {
|
||||
pub(crate) fn default_u16_max() -> u16 {
|
||||
u16::MAX
|
||||
}
|
||||
|
||||
/// The snippet execution configuration for a specific programming language.
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema)]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
pub struct LanguageSnippetExecutionConfig {
|
||||
/// The filename to use for the snippet input file.
|
||||
pub filename: String,
|
||||
@ -248,7 +337,8 @@ pub struct LanguageSnippetExecutionConfig {
|
||||
pub hidden_line_prefix: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, ValueEnum, JsonSchema)]
|
||||
#[derive(Clone, Debug, Default, Deserialize, ValueEnum)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ImageProtocol {
|
||||
/// Automatically detect the best image protocol to use.
|
||||
@ -302,7 +392,8 @@ impl TryFrom<&ImageProtocol> for GraphicsMode {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema)]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct KeyBindingsConfig {
|
||||
/// The keys that cause the presentation to move forwards.
|
||||
@ -389,7 +480,8 @@ impl Default for KeyBindingsConfig {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema)]
|
||||
#[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.
|
||||
@ -415,6 +507,76 @@ impl Default for SpeakerNotesConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@ -479,6 +641,14 @@ 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)
|
||||
@ -509,4 +679,9 @@ mod test {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
30
src/demo.rs
30
src/demo.rs
@ -1,5 +1,5 @@
|
||||
use crate::{
|
||||
ImageRegistry, MarkdownParser, PresentationBuilderOptions, PresentationTheme, Resources, Themes, ThirdPartyRender,
|
||||
ImageRegistry, MarkdownParser, PresentationBuilderOptions, Resources, ThemeOptions, Themes, ThirdPartyRender,
|
||||
code::execute::SnippetExecutor,
|
||||
commands::{
|
||||
keyboard::{CommandKeyBindings, KeyboardListener},
|
||||
@ -11,9 +11,10 @@ use crate::{
|
||||
builder::{BuildError, PresentationBuilder},
|
||||
},
|
||||
render::TerminalDrawer,
|
||||
terminal::TerminalWrite,
|
||||
terminal::emulator::TerminalEmulator,
|
||||
theme::raw::PresentationTheme,
|
||||
};
|
||||
use std::{io, rc::Rc};
|
||||
use std::{io, sync::Arc};
|
||||
|
||||
const PRESENTATION: &str = r#"
|
||||
# Header 1
|
||||
@ -40,16 +41,16 @@ fn greet(name: &str) -> String {
|
||||
<!-- end_slide -->
|
||||
"#;
|
||||
|
||||
pub struct ThemesDemo<W: TerminalWrite> {
|
||||
pub struct ThemesDemo {
|
||||
themes: Themes,
|
||||
input: KeyboardListener,
|
||||
drawer: TerminalDrawer<W>,
|
||||
drawer: TerminalDrawer,
|
||||
}
|
||||
|
||||
impl<W: TerminalWrite> ThemesDemo<W> {
|
||||
pub fn new(themes: Themes, bindings: CommandKeyBindings, writer: W) -> io::Result<Self> {
|
||||
impl ThemesDemo {
|
||||
pub fn new(themes: Themes, bindings: CommandKeyBindings) -> io::Result<Self> {
|
||||
let input = KeyboardListener::new(bindings);
|
||||
let drawer = TerminalDrawer::new(writer, Default::default(), Default::default())?;
|
||||
let drawer = TerminalDrawer::new(Default::default(), Default::default())?;
|
||||
Ok(Self { themes, input, drawer })
|
||||
}
|
||||
|
||||
@ -102,21 +103,24 @@ impl<W: TerminalWrite> ThemesDemo<W> {
|
||||
theme: &PresentationTheme,
|
||||
) -> Result<Presentation, BuildError> {
|
||||
let image_registry = ImageRegistry::default();
|
||||
let mut resources = Resources::new("non_existent", image_registry.clone());
|
||||
let resources = Resources::new("non_existent", "non_existent", image_registry.clone());
|
||||
let mut third_party = ThirdPartyRender::default();
|
||||
let options = PresentationBuilderOptions::default();
|
||||
let executer = Rc::new(SnippetExecutor::default());
|
||||
let options = PresentationBuilderOptions {
|
||||
theme_options: ThemeOptions { font_size_supported: TerminalEmulator::capabilities().font_size },
|
||||
..Default::default()
|
||||
};
|
||||
let executer = Arc::new(SnippetExecutor::default());
|
||||
let bindings_config = Default::default();
|
||||
let builder = PresentationBuilder::new(
|
||||
theme,
|
||||
&mut resources,
|
||||
resources,
|
||||
&mut third_party,
|
||||
executer,
|
||||
&self.themes,
|
||||
image_registry,
|
||||
bindings_config,
|
||||
options,
|
||||
);
|
||||
)?;
|
||||
let mut elements = vec![MarkdownElement::SetexHeading { text: format!("theme: {theme_name}").into() }];
|
||||
elements.extend(base_elements.iter().cloned());
|
||||
builder.build(elements)
|
||||
|
421
src/export.rs
421
src/export.rs
@ -1,421 +0,0 @@
|
||||
use crate::{
|
||||
MarkdownParser, PresentationTheme, Resources,
|
||||
code::execute::SnippetExecutor,
|
||||
config::KeyBindingsConfig,
|
||||
markdown::parse::ParseError,
|
||||
presentation::{
|
||||
Presentation,
|
||||
builder::{BuildError, PresentationBuilder, PresentationBuilderOptions, Themes},
|
||||
},
|
||||
render::{
|
||||
operation::{AsRenderOperations, RenderAsyncState, RenderOperation},
|
||||
properties::WindowSize,
|
||||
},
|
||||
terminal::image::{
|
||||
Image, ImageSource,
|
||||
printer::{ImageProperties, TerminalImage},
|
||||
},
|
||||
third_party::ThirdPartyRender,
|
||||
tools::{ExecutionError, ThirdPartyTools},
|
||||
};
|
||||
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||
use image::{DynamicImage, ImageEncoder, ImageError, codecs::png::PngEncoder};
|
||||
use semver::Version;
|
||||
use serde::Serialize;
|
||||
use std::{
|
||||
env, fs, io, iter,
|
||||
path::{Path, PathBuf},
|
||||
rc::Rc,
|
||||
thread::sleep,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
const MINIMUM_EXPORTER_VERSION: Version = Version::new(0, 2, 0);
|
||||
const ASYNC_RENDER_WAIT_COUNT: usize = 8;
|
||||
|
||||
/// Allows exporting presentations into PDF.
|
||||
pub struct Exporter<'a> {
|
||||
parser: MarkdownParser<'a>,
|
||||
default_theme: &'a PresentationTheme,
|
||||
resources: Resources,
|
||||
third_party: ThirdPartyRender,
|
||||
code_executor: Rc<SnippetExecutor>,
|
||||
themes: Themes,
|
||||
options: PresentationBuilderOptions,
|
||||
}
|
||||
|
||||
impl<'a> Exporter<'a> {
|
||||
/// Construct a new exporter.
|
||||
pub fn new(
|
||||
parser: MarkdownParser<'a>,
|
||||
default_theme: &'a PresentationTheme,
|
||||
resources: Resources,
|
||||
third_party: ThirdPartyRender,
|
||||
code_executor: Rc<SnippetExecutor>,
|
||||
themes: Themes,
|
||||
options: PresentationBuilderOptions,
|
||||
) -> Self {
|
||||
Self { parser, default_theme, resources, third_party, code_executor, themes, options }
|
||||
}
|
||||
|
||||
/// Export the given presentation into PDF.
|
||||
///
|
||||
/// This uses a separate `presenterm-export` tool.
|
||||
pub fn export_pdf(&mut self, presentation_path: &Path, extra_args: &[&str]) -> Result<(), ExportError> {
|
||||
Self::validate_exporter_version()?;
|
||||
|
||||
println!("Analyzing presentation...");
|
||||
let metadata = self.generate_metadata(presentation_path)?;
|
||||
println!("Invoking presenterm-export...");
|
||||
Self::execute_exporter(metadata, extra_args)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generate the metadata for the given presentation.
|
||||
pub fn generate_metadata(&mut self, presentation_path: &Path) -> Result<ExportMetadata, ExportError> {
|
||||
let content = fs::read_to_string(presentation_path).map_err(ExportError::ReadPresentation)?;
|
||||
let metadata = self.extract_metadata(&content, presentation_path)?;
|
||||
Ok(metadata)
|
||||
}
|
||||
|
||||
fn validate_exporter_version() -> Result<(), ExportError> {
|
||||
let result = ThirdPartyTools::presenterm_export(&["--version"]).run_and_capture_stdout();
|
||||
let version = match result {
|
||||
Ok(version) => String::from_utf8(version).expect("not utf8"),
|
||||
Err(ExecutionError::Execution { .. }) => return Err(ExportError::MinimumVersion),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let version = Version::parse(version.trim()).map_err(|_| ExportError::MinimumVersion)?;
|
||||
if version >= MINIMUM_EXPORTER_VERSION { Ok(()) } else { Err(ExportError::MinimumVersion) }
|
||||
}
|
||||
|
||||
/// Extract the metadata necessary to make an export.
|
||||
fn extract_metadata(&mut self, content: &str, path: &Path) -> Result<ExportMetadata, ExportError> {
|
||||
let elements = self.parser.parse(content)?;
|
||||
let path = path.canonicalize().expect("canonicalize");
|
||||
let mut presentation = PresentationBuilder::new(
|
||||
self.default_theme,
|
||||
&mut self.resources,
|
||||
&mut self.third_party,
|
||||
self.code_executor.clone(),
|
||||
&self.themes,
|
||||
Default::default(),
|
||||
KeyBindingsConfig::default(),
|
||||
self.options.clone(),
|
||||
)
|
||||
.build(elements)?;
|
||||
|
||||
let async_renders = Self::count_async_render_operations(&presentation);
|
||||
let images = Self::build_image_metadata(&mut presentation)?;
|
||||
Self::validate_theme_colors(&presentation)?;
|
||||
let commands = Self::build_capture_commands(presentation, async_renders);
|
||||
let metadata = ExportMetadata { commands, presentation_path: path, images };
|
||||
Ok(metadata)
|
||||
}
|
||||
|
||||
fn execute_exporter(metadata: ExportMetadata, extra_args: &[&str]) -> Result<(), ExportError> {
|
||||
let presenterm_path = env::current_exe().map_err(ExportError::Io)?;
|
||||
let presenterm_path = presenterm_path.display().to_string();
|
||||
let presentation_path = metadata.presentation_path.display().to_string();
|
||||
let metadata = serde_json::to_vec(&metadata).expect("serialization failed");
|
||||
let mut args = vec![&presenterm_path, "--enable-export-mode"];
|
||||
args.extend(extra_args);
|
||||
args.push(&presentation_path);
|
||||
ThirdPartyTools::presenterm_export(&args).stdin(metadata).run()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_capture_commands(mut presentation: Presentation, async_renders: usize) -> Vec<CaptureCommand> {
|
||||
let mut commands = Vec::new();
|
||||
let slide_chunks: Vec<_> = presentation.iter_slides().map(|slide| slide.iter_chunks().count()).collect();
|
||||
let mut next_slide = |commands: &mut Vec<CaptureCommand>| {
|
||||
commands.push(CaptureCommand::SendKeys { keys: "l" });
|
||||
commands.push(CaptureCommand::WaitForChange);
|
||||
presentation.jump_next();
|
||||
};
|
||||
commands.extend(iter::repeat(CaptureCommand::WaitForChange).take(ASYNC_RENDER_WAIT_COUNT * async_renders));
|
||||
for chunks in slide_chunks {
|
||||
for _ in 0..chunks - 1 {
|
||||
next_slide(&mut commands);
|
||||
}
|
||||
commands.push(CaptureCommand::Capture);
|
||||
next_slide(&mut commands);
|
||||
}
|
||||
commands
|
||||
}
|
||||
|
||||
fn count_async_render_operations(presentation: &Presentation) -> usize {
|
||||
presentation
|
||||
.iter_slides()
|
||||
.map(|slide| {
|
||||
slide.iter_visible_operations().filter(|op| matches!(op, RenderOperation::RenderAsync(_))).count()
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
fn build_image_metadata(presentation: &mut Presentation) -> Result<Vec<ImageMetadata>, ExportError> {
|
||||
let mut replacer = ImageReplacer::default();
|
||||
replacer.replace_presentation_images(presentation);
|
||||
|
||||
let mut positions = Vec::new();
|
||||
for image in replacer.images {
|
||||
let meta = match image.original.source {
|
||||
ImageSource::Filesystem(path) => {
|
||||
let path = Some(path.canonicalize().map_err(ExportError::Io)?);
|
||||
ImageMetadata { path, color: image.color, contents: None }
|
||||
}
|
||||
ImageSource::Generated => {
|
||||
let mut buffer = Vec::new();
|
||||
let dimensions = image.original.dimensions();
|
||||
let TerminalImage::Ascii(resource) = image.original.image.as_ref() else {
|
||||
panic!("not in ascii mode")
|
||||
};
|
||||
PngEncoder::new(&mut buffer).write_image(
|
||||
resource.as_bytes(),
|
||||
dimensions.0,
|
||||
dimensions.1,
|
||||
resource.color().into(),
|
||||
)?;
|
||||
let contents = Some(STANDARD.encode(buffer));
|
||||
ImageMetadata { path: None, color: image.color, contents }
|
||||
}
|
||||
};
|
||||
positions.push(meta);
|
||||
}
|
||||
Ok(positions)
|
||||
}
|
||||
|
||||
fn validate_theme_colors(presentation: &Presentation) -> Result<(), ExportError> {
|
||||
for slide in presentation.iter_slides() {
|
||||
for operation in slide.iter_visible_operations() {
|
||||
let RenderOperation::SetColors(colors) = operation else {
|
||||
continue;
|
||||
};
|
||||
// The PDF requires a specific theme to be set, as "no background" means "what the
|
||||
// browser uses" which is likely white and it will probably look terrible. It's
|
||||
// better to err early and let you choose a theme that contains _some_ color.
|
||||
if colors.background.is_none() {
|
||||
return Err(ExportError::UnsupportedColor("background"));
|
||||
}
|
||||
if colors.foreground.is_none() {
|
||||
return Err(ExportError::UnsupportedColor("foreground"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum ExportError {
|
||||
#[error("failed to read presentation: {0}")]
|
||||
ReadPresentation(io::Error),
|
||||
|
||||
#[error("failed to parse presentation: {0}")]
|
||||
ParsePresentation(#[from] ParseError),
|
||||
|
||||
#[error("failed to build presentation: {0}")]
|
||||
BuildPresentation(#[from] BuildError),
|
||||
|
||||
#[error("unsupported {0} color in theme")]
|
||||
UnsupportedColor(&'static str),
|
||||
|
||||
#[error("generating images: {0}")]
|
||||
GeneratingImages(#[from] ImageError),
|
||||
|
||||
#[error(transparent)]
|
||||
Execution(#[from] ExecutionError),
|
||||
|
||||
#[error("minimum presenterm-export version ({MINIMUM_EXPORTER_VERSION}) not met")]
|
||||
MinimumVersion,
|
||||
|
||||
#[error("io: {0}")]
|
||||
Io(io::Error),
|
||||
}
|
||||
|
||||
/// The metadata necessary to export a presentation.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ExportMetadata {
|
||||
presentation_path: PathBuf,
|
||||
images: Vec<ImageMetadata>,
|
||||
commands: Vec<CaptureCommand>,
|
||||
}
|
||||
|
||||
/// Metadata about an image.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
struct ImageMetadata {
|
||||
path: Option<PathBuf>,
|
||||
contents: Option<String>,
|
||||
color: u32,
|
||||
}
|
||||
|
||||
/// A command to whoever is capturing us indicating what to do.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "snake_case", tag = "type")]
|
||||
enum CaptureCommand {
|
||||
Capture,
|
||||
SendKeys { keys: &'static str },
|
||||
WaitForChange,
|
||||
}
|
||||
|
||||
struct ReplacedImage {
|
||||
original: Image,
|
||||
color: u32,
|
||||
}
|
||||
|
||||
pub(crate) struct ImageReplacer {
|
||||
next_color: u32,
|
||||
images: Vec<ReplacedImage>,
|
||||
}
|
||||
|
||||
impl ImageReplacer {
|
||||
pub(crate) fn replace_presentation_images(&mut self, presentation: &mut Presentation) {
|
||||
let callback = |operation: &mut RenderOperation| {
|
||||
match operation {
|
||||
RenderOperation::RenderImage(image, properties) => {
|
||||
let replacement = self.replace_image(image.clone());
|
||||
*operation = RenderOperation::RenderImage(replacement, properties.clone());
|
||||
}
|
||||
RenderOperation::RenderAsync(inner) => {
|
||||
loop {
|
||||
match inner.poll_state() {
|
||||
RenderAsyncState::NotStarted => return,
|
||||
RenderAsyncState::Rendering { .. } => {
|
||||
sleep(Duration::from_millis(200));
|
||||
continue;
|
||||
}
|
||||
RenderAsyncState::Rendered | RenderAsyncState::JustFinishedRendering => break,
|
||||
};
|
||||
}
|
||||
|
||||
let window_size = WindowSize { rows: 0, columns: 0, width: 0, height: 0 };
|
||||
let mut new_operations = Vec::new();
|
||||
for operation in inner.as_render_operations(&window_size) {
|
||||
if let RenderOperation::RenderImage(image, properties) = operation {
|
||||
let image = self.replace_image(image);
|
||||
new_operations.push(RenderOperation::RenderImage(image, properties));
|
||||
} else {
|
||||
new_operations.push(operation);
|
||||
}
|
||||
}
|
||||
// Replace this operation with a new operation that contains the replaced image
|
||||
// and any other unmodified operations.
|
||||
*operation = RenderOperation::RenderDynamic(Rc::new(RenderMany(new_operations)));
|
||||
}
|
||||
_ => (),
|
||||
};
|
||||
};
|
||||
|
||||
presentation.mutate_operations(callback);
|
||||
}
|
||||
|
||||
fn replace_image(&mut self, image: Image) -> Image {
|
||||
let dimensions = image.dimensions();
|
||||
let color = self.allocate_color();
|
||||
let rgb_color = Self::as_rgb(color);
|
||||
|
||||
let mut replacement = DynamicImage::new_rgb8(dimensions.0, dimensions.1);
|
||||
let buffer = replacement.as_mut_rgb8().expect("not rgb8");
|
||||
for pixel in buffer.pixels_mut() {
|
||||
pixel.0 = rgb_color;
|
||||
}
|
||||
self.images.push(ReplacedImage { original: image, color });
|
||||
|
||||
Image::new(TerminalImage::Ascii(replacement.into()), ImageSource::Generated)
|
||||
}
|
||||
|
||||
fn allocate_color(&mut self) -> u32 {
|
||||
let color = self.next_color;
|
||||
self.next_color += 1;
|
||||
color
|
||||
}
|
||||
|
||||
fn as_rgb(color: u32) -> [u8; 3] {
|
||||
[(color >> 16) as u8, (color >> 8) as u8, (color & 0xff) as u8]
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ImageReplacer {
|
||||
fn default() -> Self {
|
||||
Self { next_color: 0xffbad3, images: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct RenderMany(Vec<RenderOperation>);
|
||||
|
||||
impl AsRenderOperations for RenderMany {
|
||||
fn as_render_operations(&self, _: &WindowSize) -> Vec<RenderOperation> {
|
||||
self.0.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::theme::PresentationThemeSet;
|
||||
use comrak::Arena;
|
||||
|
||||
fn extract_metadata(content: &str, path: &str) -> ExportMetadata {
|
||||
let arena = Arena::new();
|
||||
let parser = MarkdownParser::new(&arena);
|
||||
let theme = PresentationThemeSet::default().load_by_name("dark").unwrap();
|
||||
let resources = Resources::new("examples", Default::default());
|
||||
let third_party = ThirdPartyRender::default();
|
||||
let code_executor = Default::default();
|
||||
let themes = Themes::default();
|
||||
let options = PresentationBuilderOptions { allow_mutations: false, ..Default::default() };
|
||||
let mut exporter = Exporter::new(parser, &theme, resources, third_party, code_executor, themes, options);
|
||||
exporter.extract_metadata(content, Path::new(path)).expect("metadata extraction failed")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata() {
|
||||
let presentation = r"
|
||||
First
|
||||
|
||||
<!-- end_slide -->
|
||||
|
||||
hi
|
||||
<!-- pause -->
|
||||
mom
|
||||
|
||||
<!-- end_slide -->
|
||||
|
||||

|
||||
|
||||
<!-- end_slide -->
|
||||
|
||||
bye
|
||||
<!-- pause -->
|
||||
mom
|
||||
";
|
||||
|
||||
let meta = extract_metadata(presentation, "examples/demo.md");
|
||||
|
||||
use CaptureCommand::*;
|
||||
let expected_commands = vec![
|
||||
// First slide
|
||||
Capture,
|
||||
SendKeys { keys: "l" },
|
||||
WaitForChange,
|
||||
// Second slide...
|
||||
SendKeys { keys: "l" },
|
||||
WaitForChange,
|
||||
Capture,
|
||||
SendKeys { keys: "l" },
|
||||
WaitForChange,
|
||||
// Third slide...
|
||||
Capture,
|
||||
SendKeys { keys: "l" },
|
||||
WaitForChange,
|
||||
// Fourth slide...
|
||||
SendKeys { keys: "l" },
|
||||
WaitForChange,
|
||||
Capture,
|
||||
SendKeys { keys: "l" },
|
||||
WaitForChange,
|
||||
];
|
||||
assert_eq!(meta.commands, expected_commands);
|
||||
}
|
||||
}
|
313
src/export/exporter.rs
Normal file
313
src/export/exporter.rs
Normal file
@ -0,0 +1,313 @@
|
||||
use crate::{
|
||||
MarkdownParser, Resources,
|
||||
code::execute::SnippetExecutor,
|
||||
config::{KeyBindingsConfig, PauseExportPolicy},
|
||||
export::output::{ExportRenderer, OutputFormat},
|
||||
markdown::{parse::ParseError, text_style::Color},
|
||||
presentation::{
|
||||
Presentation,
|
||||
builder::{BuildError, PresentationBuilder, PresentationBuilderOptions, Themes},
|
||||
poller::{Poller, PollerCommand},
|
||||
},
|
||||
render::{
|
||||
RenderError,
|
||||
operation::{AsRenderOperations, PollableState, RenderOperation},
|
||||
properties::WindowSize,
|
||||
},
|
||||
theme::{ProcessingThemeError, raw::PresentationTheme},
|
||||
third_party::ThirdPartyRender,
|
||||
tools::{ExecutionError, ThirdPartyTools},
|
||||
};
|
||||
use crossterm::{
|
||||
cursor::{MoveToColumn, MoveToNextLine, MoveUp},
|
||||
execute,
|
||||
style::{Print, PrintStyledContent, Stylize},
|
||||
terminal::{Clear, ClearType},
|
||||
};
|
||||
use image::ImageError;
|
||||
use std::{
|
||||
fs, io,
|
||||
path::{Path, PathBuf},
|
||||
rc::Rc,
|
||||
sync::Arc,
|
||||
};
|
||||
use tempfile::TempDir;
|
||||
|
||||
pub enum OutputDirectory {
|
||||
Temporary(TempDir),
|
||||
External(PathBuf),
|
||||
}
|
||||
|
||||
impl OutputDirectory {
|
||||
pub fn temporary() -> io::Result<Self> {
|
||||
let dir = TempDir::with_suffix("presenterm")?;
|
||||
Ok(Self::Temporary(dir))
|
||||
}
|
||||
|
||||
pub fn external(path: PathBuf) -> io::Result<Self> {
|
||||
fs::create_dir_all(&path)?;
|
||||
Ok(Self::External(path))
|
||||
}
|
||||
|
||||
pub(crate) fn path(&self) -> &Path {
|
||||
match self {
|
||||
Self::Temporary(temp) => temp.path(),
|
||||
Self::External(path) => path,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Allows exporting presentations into PDF.
|
||||
pub struct Exporter<'a> {
|
||||
parser: MarkdownParser<'a>,
|
||||
default_theme: &'a PresentationTheme,
|
||||
resources: Resources,
|
||||
third_party: ThirdPartyRender,
|
||||
code_executor: Arc<SnippetExecutor>,
|
||||
themes: Themes,
|
||||
dimensions: WindowSize,
|
||||
options: PresentationBuilderOptions,
|
||||
}
|
||||
|
||||
impl<'a> Exporter<'a> {
|
||||
/// Construct a new exporter.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
parser: MarkdownParser<'a>,
|
||||
default_theme: &'a PresentationTheme,
|
||||
resources: Resources,
|
||||
third_party: ThirdPartyRender,
|
||||
code_executor: Arc<SnippetExecutor>,
|
||||
themes: Themes,
|
||||
mut options: PresentationBuilderOptions,
|
||||
mut dimensions: WindowSize,
|
||||
pause_policy: PauseExportPolicy,
|
||||
) -> Self {
|
||||
// We don't want dynamically highlighted code blocks.
|
||||
options.allow_mutations = false;
|
||||
options.theme_options.font_size_supported = true;
|
||||
options.pause_create_new_slide = match pause_policy {
|
||||
PauseExportPolicy::Ignore => false,
|
||||
PauseExportPolicy::NewSlide => true,
|
||||
};
|
||||
|
||||
// Make sure we have a 1:2 aspect ratio.
|
||||
let width = (0.5 * dimensions.columns as f64) / (dimensions.rows as f64 / dimensions.height as f64);
|
||||
dimensions.width = width as u16;
|
||||
|
||||
Self { parser, default_theme, resources, third_party, code_executor, themes, options, dimensions }
|
||||
}
|
||||
|
||||
fn build_renderer(
|
||||
&mut self,
|
||||
presentation_path: &Path,
|
||||
output_directory: OutputDirectory,
|
||||
renderer: OutputFormat,
|
||||
) -> Result<ExportRenderer, ExportError> {
|
||||
let content = fs::read_to_string(presentation_path).map_err(ExportError::ReadPresentation)?;
|
||||
let elements = self.parser.parse(&content)?;
|
||||
|
||||
let mut presentation = PresentationBuilder::new(
|
||||
self.default_theme,
|
||||
self.resources.clone(),
|
||||
&mut self.third_party,
|
||||
self.code_executor.clone(),
|
||||
&self.themes,
|
||||
Default::default(),
|
||||
KeyBindingsConfig::default(),
|
||||
self.options.clone(),
|
||||
)?
|
||||
.build(elements)?;
|
||||
Self::validate_theme_colors(&presentation)?;
|
||||
|
||||
let mut render = ExportRenderer::new(self.dimensions.clone(), output_directory, renderer);
|
||||
Self::log("waiting for images to be generated and code to be executed, if any...")?;
|
||||
Self::render_async_images(&mut presentation);
|
||||
|
||||
for (index, slide) in presentation.into_slides().into_iter().enumerate() {
|
||||
let index = index + 1;
|
||||
Self::log(&format!("processing slide {index}..."))?;
|
||||
render.process_slide(slide)?;
|
||||
}
|
||||
Self::log("invoking weasyprint...")?;
|
||||
|
||||
Ok(render)
|
||||
}
|
||||
|
||||
/// Export the given presentation into PDF.
|
||||
pub fn export_pdf(
|
||||
mut self,
|
||||
presentation_path: &Path,
|
||||
output_directory: OutputDirectory,
|
||||
output_path: Option<&Path>,
|
||||
) -> Result<(), ExportError> {
|
||||
println!(
|
||||
"exporting using rows={}, columns={}, width={}, height={}",
|
||||
self.dimensions.rows, self.dimensions.columns, self.dimensions.width, self.dimensions.height
|
||||
);
|
||||
|
||||
println!("checking for weasyprint...");
|
||||
Self::validate_weasyprint_exists()?;
|
||||
Self::log("weasyprint installation found")?;
|
||||
|
||||
let render = self.build_renderer(presentation_path, output_directory, OutputFormat::Pdf)?;
|
||||
|
||||
let pdf_path = match output_path {
|
||||
Some(path) => path.to_path_buf(),
|
||||
None => presentation_path.with_extension("pdf"),
|
||||
};
|
||||
|
||||
render.generate(&pdf_path)?;
|
||||
|
||||
execute!(
|
||||
io::stdout(),
|
||||
PrintStyledContent(
|
||||
format!("output file is at {}\n", pdf_path.display()).stylize().with(Color::Green.into())
|
||||
)
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Export the given presentation into HTML.
|
||||
pub fn export_html(
|
||||
mut self,
|
||||
presentation_path: &Path,
|
||||
output_directory: OutputDirectory,
|
||||
output_path: Option<&Path>,
|
||||
) -> Result<(), ExportError> {
|
||||
println!(
|
||||
"exporting using rows={}, columns={}, width={}, height={}",
|
||||
self.dimensions.rows, self.dimensions.columns, self.dimensions.width, self.dimensions.height
|
||||
);
|
||||
|
||||
let render = self.build_renderer(presentation_path, output_directory, OutputFormat::Html)?;
|
||||
|
||||
let output_path = match output_path {
|
||||
Some(path) => path.to_path_buf(),
|
||||
None => presentation_path.with_extension("html"),
|
||||
};
|
||||
|
||||
render.generate(&output_path)?;
|
||||
|
||||
execute!(
|
||||
io::stdout(),
|
||||
PrintStyledContent(
|
||||
format!("output file is at {}\n", output_path.display()).stylize().with(Color::Green.into())
|
||||
)
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_async_images(presentation: &mut Presentation) {
|
||||
let poller = Poller::launch();
|
||||
let mut pollables = Vec::new();
|
||||
for (index, slide) in presentation.iter_slides().enumerate() {
|
||||
for op in slide.iter_operations() {
|
||||
if let RenderOperation::RenderAsync(inner) = op {
|
||||
// Send a pollable to the poller and keep one for ourselves.
|
||||
poller.send(PollerCommand::Poll { pollable: inner.pollable(), slide: index });
|
||||
pollables.push(inner.pollable())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Poll until they're all done
|
||||
for mut pollable in pollables {
|
||||
while let PollableState::Unmodified | PollableState::Modified = pollable.poll() {}
|
||||
}
|
||||
|
||||
// Replace render asyncs with new operations that contains the replaced image
|
||||
// and any other unmodified operations.
|
||||
for slide in presentation.iter_slides_mut() {
|
||||
for op in slide.iter_operations_mut() {
|
||||
if let RenderOperation::RenderAsync(inner) = op {
|
||||
let window_size = WindowSize { rows: 0, columns: 0, width: 0, height: 0 };
|
||||
let new_operations = inner.as_render_operations(&window_size);
|
||||
*op = RenderOperation::RenderDynamic(Rc::new(RenderMany(new_operations)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_weasyprint_exists() -> Result<(), ExportError> {
|
||||
let result = ThirdPartyTools::weasyprint(&["--version"]).run_and_capture_stdout();
|
||||
match result {
|
||||
Ok(_) => Ok(()),
|
||||
Err(ExecutionError::Execution { .. }) => Err(ExportError::WeasyprintMissing),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_theme_colors(presentation: &Presentation) -> Result<(), ExportError> {
|
||||
for slide in presentation.iter_slides() {
|
||||
for operation in slide.iter_visible_operations() {
|
||||
let RenderOperation::SetColors(colors) = operation else {
|
||||
continue;
|
||||
};
|
||||
// The PDF requires a specific theme to be set, as "no background" means "what the
|
||||
// browser uses" which is likely white and it will probably look terrible. It's
|
||||
// better to err early and let you choose a theme that contains _some_ color.
|
||||
if colors.background.is_none() {
|
||||
return Err(ExportError::UnsupportedColor("background"));
|
||||
}
|
||||
if colors.foreground.is_none() {
|
||||
return Err(ExportError::UnsupportedColor("foreground"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn log(text: &str) -> io::Result<()> {
|
||||
execute!(
|
||||
io::stdout(),
|
||||
MoveUp(1),
|
||||
Clear(ClearType::CurrentLine),
|
||||
MoveToColumn(0),
|
||||
Print(text),
|
||||
MoveToNextLine(1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum ExportError {
|
||||
#[error("failed to read presentation: {0}")]
|
||||
ReadPresentation(io::Error),
|
||||
|
||||
#[error("failed to parse presentation: {0}")]
|
||||
ParsePresentation(#[from] ParseError),
|
||||
|
||||
#[error("failed to build presentation: {0}")]
|
||||
BuildPresentation(#[from] BuildError),
|
||||
|
||||
#[error("unsupported {0} color in theme")]
|
||||
UnsupportedColor(&'static str),
|
||||
|
||||
#[error("generating images: {0}")]
|
||||
GeneratingImages(#[from] ImageError),
|
||||
|
||||
#[error(transparent)]
|
||||
Execution(#[from] ExecutionError),
|
||||
|
||||
#[error("weasyprint not found")]
|
||||
WeasyprintMissing,
|
||||
|
||||
#[error("processing theme: {0}")]
|
||||
ProcessingTheme(#[from] ProcessingThemeError),
|
||||
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] io::Error),
|
||||
|
||||
#[error("render: {0}")]
|
||||
Render(#[from] RenderError),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct RenderMany(Vec<RenderOperation>);
|
||||
|
||||
impl AsRenderOperations for RenderMany {
|
||||
fn as_render_operations(&self, _: &WindowSize) -> Vec<RenderOperation> {
|
||||
self.0.clone()
|
||||
}
|
||||
}
|
122
src/export/html.rs
Normal file
122
src/export/html.rs
Normal file
@ -0,0 +1,122 @@
|
||||
use crate::markdown::text_style::{Color, TextAttribute, TextStyle};
|
||||
use std::{borrow::Cow, fmt};
|
||||
|
||||
pub(crate) enum HtmlText {
|
||||
Plain(String),
|
||||
Styled { text: String, style: String },
|
||||
}
|
||||
|
||||
impl HtmlText {
|
||||
pub(crate) fn new(text: &str, style: &TextStyle, font_size: FontSize) -> Self {
|
||||
if style == &TextStyle::default() {
|
||||
return Self::Plain(text.to_string());
|
||||
}
|
||||
let mut css_styles = Vec::new();
|
||||
let mut text_decorations = Vec::new();
|
||||
for attr in style.iter_attributes() {
|
||||
match attr {
|
||||
TextAttribute::Bold => css_styles.push(Cow::Borrowed("font-weight: bold")),
|
||||
TextAttribute::Italics => css_styles.push(Cow::Borrowed("font-style: italic")),
|
||||
TextAttribute::Strikethrough => text_decorations.push(Cow::Borrowed("line-through")),
|
||||
TextAttribute::Underlined => text_decorations.push(Cow::Borrowed("underline")),
|
||||
TextAttribute::ForegroundColor(color) => {
|
||||
let color = color_to_html(&color);
|
||||
css_styles.push(format!("color: {color}").into());
|
||||
}
|
||||
TextAttribute::BackgroundColor(color) => {
|
||||
let color = color_to_html(&color);
|
||||
css_styles.push(format!("background-color: {color}").into());
|
||||
}
|
||||
};
|
||||
}
|
||||
if !text_decorations.is_empty() {
|
||||
let text_decoration = text_decorations.join(" ");
|
||||
css_styles.push(format!("text-decoration: {text_decoration}").into());
|
||||
}
|
||||
if style.size > 1 {
|
||||
let font_size = font_size.scale(style.size);
|
||||
css_styles.push(format!("font-size: {font_size}").into());
|
||||
}
|
||||
let css_style = css_styles.join("; ");
|
||||
Self::Styled { text: text.to_string(), style: css_style }
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for HtmlText {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Plain(text) => write!(f, "{text}"),
|
||||
Self::Styled { text, style } => write!(f, "<span style=\"{style}\">{text}</span>"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) enum FontSize {
|
||||
Pixels(u16),
|
||||
}
|
||||
|
||||
impl FontSize {
|
||||
fn scale(&self, size: u8) -> String {
|
||||
match self {
|
||||
Self::Pixels(scale) => format!("{}px", scale * size as u16),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn color_to_html(color: &Color) -> String {
|
||||
match color {
|
||||
Color::Black => "#000000".into(),
|
||||
Color::DarkGrey => "#5a5a5a".into(),
|
||||
Color::Red => "#ff0000".into(),
|
||||
Color::DarkRed => "#8b0000".into(),
|
||||
Color::Green => "#00ff00".into(),
|
||||
Color::DarkGreen => "#006400".into(),
|
||||
Color::Yellow => "#ffff00".into(),
|
||||
Color::DarkYellow => "#8b8000".into(),
|
||||
Color::Blue => "#0000ff".into(),
|
||||
Color::DarkBlue => "#00008b".into(),
|
||||
Color::Magenta => "#ff00ff".into(),
|
||||
Color::DarkMagenta => "#8b008b".into(),
|
||||
Color::Cyan => "#00ffff".into(),
|
||||
Color::DarkCyan => "#008b8b".into(),
|
||||
Color::White => "#ffffff".into(),
|
||||
Color::Grey => "#808080".into(),
|
||||
Color::Rgb { r, g, b } => format!("#{r:02x}{g:02x}{b:02x}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
#[case::none(TextStyle::default(), "")]
|
||||
#[case::bold(TextStyle::default().bold(), "font-weight: bold")]
|
||||
#[case::italics(TextStyle::default().italics(), "font-style: italic")]
|
||||
#[case::bold_italics(TextStyle::default().bold().italics(), "font-weight: bold; font-style: italic")]
|
||||
#[case::strikethrough(TextStyle::default().strikethrough(), "text-decoration: line-through")]
|
||||
#[case::underlined(TextStyle::default().underlined(), "text-decoration: underline")]
|
||||
#[case::strikethrough_underlined(
|
||||
TextStyle::default().strikethrough().underlined(),
|
||||
"text-decoration: line-through underline"
|
||||
)]
|
||||
#[case::foreground_color(TextStyle::default().fg_color(Color::new(1,2,3)), "color: #010203")]
|
||||
#[case::background_color(TextStyle::default().bg_color(Color::new(1,2,3)), "background-color: #010203")]
|
||||
#[case::font_size(TextStyle::default().size(3), "font-size: 6px")]
|
||||
fn html_text(#[case] style: TextStyle, #[case] expected_style: &str) {
|
||||
let html_text = HtmlText::new("", &style, FontSize::Pixels(2));
|
||||
let style = match &html_text {
|
||||
HtmlText::Plain(_) => "",
|
||||
HtmlText::Styled { style, .. } => &style,
|
||||
};
|
||||
assert_eq!(style, expected_style);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_span() {
|
||||
let html_text = HtmlText::new("hi", &TextStyle::default().bold(), FontSize::Pixels(1));
|
||||
let rendered = html_text.to_string();
|
||||
assert_eq!(rendered, "<span style=\"font-weight: bold\">hi</span>");
|
||||
}
|
||||
}
|
3
src/export/mod.rs
Normal file
3
src/export/mod.rs
Normal file
@ -0,0 +1,3 @@
|
||||
pub mod exporter;
|
||||
pub(crate) mod html;
|
||||
pub(crate) mod output;
|
260
src/export/output.rs
Normal file
260
src/export/output.rs
Normal file
@ -0,0 +1,260 @@
|
||||
use super::{
|
||||
exporter::{ExportError, OutputDirectory},
|
||||
html::{FontSize, color_to_html},
|
||||
};
|
||||
use crate::{
|
||||
export::html::HtmlText,
|
||||
markdown::text_style::TextStyle,
|
||||
presentation::Slide,
|
||||
render::{engine::RenderEngine, properties::WindowSize},
|
||||
terminal::{
|
||||
image::printer::TerminalImage,
|
||||
virt::{TerminalGrid, VirtualTerminal},
|
||||
},
|
||||
tools::ThirdPartyTools,
|
||||
};
|
||||
use std::{
|
||||
fs, io,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
// A magical multiplier that converts a font size in pixels to a font width.
|
||||
//
|
||||
// There's probably something somewhere that specifies what the relationship
|
||||
// really is but I found this by trial and error an I'm okay with that.
|
||||
const FONT_SIZE_WIDTH: f64 = 0.605;
|
||||
|
||||
const FONT_SIZE: u16 = 10;
|
||||
const LINE_HEIGHT: u16 = 12;
|
||||
|
||||
struct HtmlSlide {
|
||||
rows: Vec<String>,
|
||||
background_color: Option<String>,
|
||||
}
|
||||
|
||||
impl HtmlSlide {
|
||||
fn new(grid: TerminalGrid) -> Result<Self, ExportError> {
|
||||
let mut rows = Vec::new();
|
||||
rows.push(String::from("<div class=\"container\">"));
|
||||
for (y, row) in grid.rows.into_iter().enumerate() {
|
||||
let mut finalized_row = "<div class=\"content-line\"><pre>".to_string();
|
||||
let mut current_style = row.first().map(|c| c.style).unwrap_or_default();
|
||||
let mut current_string = String::new();
|
||||
let mut x = 0;
|
||||
while x < row.len() {
|
||||
let c = row[x];
|
||||
if c.style != current_style {
|
||||
finalized_row.push_str(&Self::finalize_string(¤t_string, ¤t_style));
|
||||
current_string = String::new();
|
||||
current_style = c.style;
|
||||
}
|
||||
match c.character {
|
||||
'<' => current_string.push_str("<"),
|
||||
'>' => current_string.push_str(">"),
|
||||
other => current_string.push(other),
|
||||
}
|
||||
if let Some(image) = grid.images.get(&(y as u16, x as u16)) {
|
||||
let TerminalImage::Raw(raw_image) = image.image.image() else { panic!("not in raw image mode") };
|
||||
let image_contents = raw_image.to_inline_html();
|
||||
let width_pixels = (image.width_columns as f64 * FONT_SIZE as f64 * FONT_SIZE_WIDTH).ceil();
|
||||
let image_tag = format!(
|
||||
"<img width=\"{width_pixels}\" src=\"{image_contents}\" style=\"position: absolute\" />"
|
||||
);
|
||||
current_string.push_str(&image_tag);
|
||||
}
|
||||
x += c.style.size as usize;
|
||||
}
|
||||
if !current_string.is_empty() {
|
||||
finalized_row.push_str(&Self::finalize_string(¤t_string, ¤t_style));
|
||||
}
|
||||
finalized_row.push_str("</pre></div>");
|
||||
rows.push(finalized_row);
|
||||
}
|
||||
rows.push(String::from("</div>"));
|
||||
|
||||
Ok(HtmlSlide { rows, background_color: grid.background_color.as_ref().map(color_to_html) })
|
||||
}
|
||||
|
||||
fn finalize_string(s: &str, style: &TextStyle) -> String {
|
||||
HtmlText::new(s, style, FontSize::Pixels(FONT_SIZE)).to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct ContentManager {
|
||||
output_directory: OutputDirectory,
|
||||
}
|
||||
|
||||
impl ContentManager {
|
||||
pub(crate) fn new(output_directory: OutputDirectory) -> Self {
|
||||
Self { output_directory }
|
||||
}
|
||||
|
||||
fn persist_file(&self, name: &str, data: &[u8]) -> io::Result<PathBuf> {
|
||||
let path = self.output_directory.path().join(name);
|
||||
fs::write(&path, data)?;
|
||||
Ok(path)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) enum OutputFormat {
|
||||
Pdf,
|
||||
Html,
|
||||
}
|
||||
|
||||
pub(crate) struct ExportRenderer {
|
||||
content_manager: ContentManager,
|
||||
output_format: OutputFormat,
|
||||
dimensions: WindowSize,
|
||||
html_body: String,
|
||||
background_color: Option<String>,
|
||||
}
|
||||
|
||||
impl ExportRenderer {
|
||||
pub(crate) fn new(dimensions: WindowSize, output_directory: OutputDirectory, output_type: OutputFormat) -> Self {
|
||||
let image_manager = ContentManager::new(output_directory);
|
||||
Self {
|
||||
content_manager: image_manager,
|
||||
dimensions,
|
||||
html_body: "".to_string(),
|
||||
background_color: None,
|
||||
output_format: output_type,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn process_slide(&mut self, slide: Slide) -> Result<(), ExportError> {
|
||||
let mut terminal = VirtualTerminal::new(self.dimensions.clone(), Default::default());
|
||||
let engine = RenderEngine::new(&mut terminal, self.dimensions.clone(), Default::default());
|
||||
engine.render(slide.iter_operations())?;
|
||||
|
||||
let grid = terminal.into_contents();
|
||||
let slide = HtmlSlide::new(grid)?;
|
||||
if self.background_color.is_none() {
|
||||
self.background_color.clone_from(&slide.background_color);
|
||||
}
|
||||
for row in slide.rows {
|
||||
self.html_body.push_str(&row);
|
||||
self.html_body.push('\n');
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn generate(self, output_path: &Path) -> Result<(), ExportError> {
|
||||
let html_body = &self.html_body;
|
||||
let script = include_str!("script.js");
|
||||
let width = (self.dimensions.columns as f64 * FONT_SIZE as f64 * FONT_SIZE_WIDTH).ceil();
|
||||
let height = self.dimensions.rows * LINE_HEIGHT;
|
||||
let background_color = self.background_color.unwrap_or_else(|| "black".into());
|
||||
let container = match self.output_format {
|
||||
OutputFormat::Pdf => String::from("display: contents;"),
|
||||
OutputFormat::Html => String::from(
|
||||
"
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
",
|
||||
),
|
||||
};
|
||||
let css = format!(
|
||||
r"
|
||||
pre {{
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}}
|
||||
|
||||
span {{
|
||||
display: inline-block;
|
||||
}}
|
||||
|
||||
body {{
|
||||
margin: 0;
|
||||
font-size: {FONT_SIZE}px;
|
||||
line-height: {LINE_HEIGHT}px;
|
||||
width: {width}px;
|
||||
height: {height}px;
|
||||
transform-origin: top left;
|
||||
background-color: {background_color};
|
||||
}}
|
||||
|
||||
.container {{
|
||||
{container}
|
||||
}}
|
||||
|
||||
.content-line {{
|
||||
line-height: {LINE_HEIGHT}px;
|
||||
height: {LINE_HEIGHT}px;
|
||||
margin: 0px;
|
||||
width: {width}px;
|
||||
}}
|
||||
|
||||
.hidden {{
|
||||
display: none;
|
||||
}}
|
||||
|
||||
@page {{
|
||||
margin: 0;
|
||||
height: {height}px;
|
||||
width: {width}px;
|
||||
}}"
|
||||
);
|
||||
let html_script = match self.output_format {
|
||||
OutputFormat::Pdf => String::new(),
|
||||
OutputFormat::Html => {
|
||||
format!(
|
||||
"
|
||||
<script>
|
||||
let originalWidth = {width};
|
||||
let originalHeight = {height};
|
||||
{script}
|
||||
</script>"
|
||||
)
|
||||
}
|
||||
};
|
||||
let style = match self.output_format {
|
||||
OutputFormat::Pdf => String::new(),
|
||||
OutputFormat::Html => format!(
|
||||
"
|
||||
<head>
|
||||
<style>
|
||||
{css}
|
||||
</style>
|
||||
</head>
|
||||
"
|
||||
),
|
||||
};
|
||||
let html = format!(
|
||||
r"
|
||||
<html>
|
||||
{style}
|
||||
<body>
|
||||
{html_body}
|
||||
{html_script}
|
||||
</body>
|
||||
</html>"
|
||||
);
|
||||
|
||||
let html_path = self.content_manager.persist_file("index.html", html.as_bytes())?;
|
||||
let css_path = self.content_manager.persist_file("styles.css", css.as_bytes())?;
|
||||
|
||||
match self.output_format {
|
||||
OutputFormat::Pdf => {
|
||||
ThirdPartyTools::weasyprint(&[
|
||||
"-s",
|
||||
css_path.to_string_lossy().as_ref(),
|
||||
"--presentational-hints",
|
||||
"-e",
|
||||
"utf8",
|
||||
html_path.to_string_lossy().as_ref(),
|
||||
output_path.to_string_lossy().as_ref(),
|
||||
])
|
||||
.run()?;
|
||||
}
|
||||
OutputFormat::Html => {
|
||||
fs::write(output_path, html.as_bytes())?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
45
src/export/script.js
Normal file
45
src/export/script.js
Normal file
@ -0,0 +1,45 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const allLines = document.querySelectorAll('body > div');
|
||||
const pageBreakMarkers = document.querySelectorAll('.container');
|
||||
let currentPageIndex = 0;
|
||||
|
||||
|
||||
function showCurrentPage() {
|
||||
allLines.forEach((line) => {
|
||||
line.classList.add('hidden');
|
||||
});
|
||||
|
||||
allLines[currentPageIndex].classList.remove('hidden');
|
||||
}
|
||||
|
||||
|
||||
function scaler() {
|
||||
var w = document.documentElement.clientWidth;
|
||||
var h = document.documentElement.clientHeight;
|
||||
let widthScaledAmount= w/originalWidth;
|
||||
let heightScaledAmount= h/originalHeight;
|
||||
let scaledAmount = Math.min(widthScaledAmount, heightScaledAmount);
|
||||
document.querySelector("body").style.transform = `scale(${scaledAmount})`;
|
||||
}
|
||||
|
||||
function handleKeyPress(event) {
|
||||
if (event.key === 'ArrowLeft') {
|
||||
if (currentPageIndex > 0) {
|
||||
currentPageIndex--;
|
||||
showCurrentPage();
|
||||
}
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
if (currentPageIndex < pageBreakMarkers.length - 1) {
|
||||
currentPageIndex++;
|
||||
showCurrentPage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyPress);
|
||||
window.addEventListener("resize", scaler);
|
||||
|
||||
scaler();
|
||||
showCurrentPage();
|
||||
});
|
||||
|
180
src/main.rs
180
src/main.rs
@ -3,7 +3,7 @@ use crate::{
|
||||
commands::listener::CommandListener,
|
||||
config::{Config, ImageProtocol, ValidateOverflows},
|
||||
demo::ThemesDemo,
|
||||
export::Exporter,
|
||||
export::exporter::Exporter,
|
||||
markdown::parse::MarkdownParser,
|
||||
presentation::builder::{PresentationBuilderOptions, Themes},
|
||||
presenter::{PresentMode, Presenter, PresenterOptions},
|
||||
@ -12,21 +12,29 @@ use crate::{
|
||||
GraphicsMode,
|
||||
image::printer::{ImagePrinter, ImageRegistry},
|
||||
},
|
||||
theme::{PresentationTheme, PresentationThemeSet},
|
||||
theme::{raw::PresentationTheme, registry::PresentationThemeRegistry},
|
||||
third_party::{ThirdPartyConfigs, ThirdPartyRender},
|
||||
};
|
||||
use anyhow::anyhow;
|
||||
use clap::{CommandFactory, Parser, error::ErrorKind};
|
||||
use commands::speaker_notes::{SpeakerNotesEventListener, SpeakerNotesEventPublisher};
|
||||
use comrak::Arena;
|
||||
use config::ConfigLoadError;
|
||||
use crossterm::{
|
||||
execute,
|
||||
style::{PrintStyledContent, Stylize},
|
||||
};
|
||||
use directories::ProjectDirs;
|
||||
use export::exporter::OutputDirectory;
|
||||
use render::{engine::MaxSize, properties::WindowSize};
|
||||
use std::{
|
||||
env::{self, current_dir},
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
rc::Rc,
|
||||
sync::Arc,
|
||||
};
|
||||
use terminal::emulator::TerminalEmulator;
|
||||
use theme::ThemeOptions;
|
||||
|
||||
mod code;
|
||||
mod commands;
|
||||
@ -42,9 +50,13 @@ 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)]
|
||||
@ -56,21 +68,26 @@ struct Cli {
|
||||
path: Option<PathBuf>,
|
||||
|
||||
/// Export the presentation as a PDF rather than displaying it.
|
||||
#[clap(short, long)]
|
||||
#[clap(short, long, group = "export")]
|
||||
export_pdf: bool,
|
||||
|
||||
/// Generate the PDF metadata without generating the PDF itself.
|
||||
#[clap(long, hide = true)]
|
||||
generate_pdf_metadata: bool,
|
||||
/// Export the presentation as a HTML rather than displaying it.
|
||||
#[clap(long, group = "export")]
|
||||
export_html: bool,
|
||||
|
||||
/// The path in which to store temporary files used when exporting.
|
||||
#[clap(long, requires = "export")]
|
||||
export_temporary_path: Option<PathBuf>,
|
||||
|
||||
/// The output path for the exported PDF.
|
||||
#[clap(short = 'o', long = "output", requires = "export")]
|
||||
export_output: Option<PathBuf>,
|
||||
|
||||
/// Generate a JSON schema for the configuration file.
|
||||
#[clap(long)]
|
||||
#[cfg(feature = "json-schema")]
|
||||
generate_config_file_schema: bool,
|
||||
|
||||
/// Run in export mode.
|
||||
#[clap(long, hide = true)]
|
||||
enable_export_mode: bool,
|
||||
|
||||
/// Use presentation mode.
|
||||
#[clap(short, long, default_value_t = false)]
|
||||
present: bool,
|
||||
@ -80,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,
|
||||
@ -134,6 +155,7 @@ fn create_splash() -> String {
|
||||
struct Customizations {
|
||||
config: Config,
|
||||
themes: Themes,
|
||||
themes_path: Option<PathBuf>,
|
||||
code_executor: SnippetExecutor,
|
||||
}
|
||||
|
||||
@ -148,21 +170,25 @@ impl Customizations {
|
||||
project_dirs.config_dir().into()
|
||||
}
|
||||
};
|
||||
let themes = Self::load_themes(&configs_path)?;
|
||||
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 = Config::load(&config_file_path)?;
|
||||
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, code_executor })
|
||||
Ok(Customizations { config, themes, themes_path: Some(themes_path), code_executor })
|
||||
}
|
||||
|
||||
fn load_themes(config_path: &Path) -> Result<Themes, Box<dyn std::error::Error>> {
|
||||
let themes_path = config_path.join("themes");
|
||||
|
||||
fn load_themes(themes_path: &Path) -> Result<Themes, Box<dyn std::error::Error>> {
|
||||
let mut highlight_themes = HighlightThemeSet::default();
|
||||
highlight_themes.register_from_directory(themes_path.join("highlighting"))?;
|
||||
|
||||
let mut presentation_themes = PresentationThemeSet::default();
|
||||
presentation_themes.register_from_directory(&themes_path)?;
|
||||
let mut presentation_themes = PresentationThemeRegistry::default();
|
||||
presentation_themes.register_from_directory(themes_path)?;
|
||||
|
||||
let themes = Themes { presentation: presentation_themes, highlight: highlight_themes };
|
||||
Ok(themes)
|
||||
@ -171,7 +197,7 @@ impl Customizations {
|
||||
|
||||
struct CoreComponents {
|
||||
third_party: ThirdPartyRender,
|
||||
code_executor: Rc<SnippetExecutor>,
|
||||
code_executor: Arc<SnippetExecutor>,
|
||||
resources: Resources,
|
||||
printer: Arc<ImagePrinter>,
|
||||
builder_options: PresentationBuilderOptions,
|
||||
@ -190,19 +216,17 @@ impl CoreComponents {
|
||||
}
|
||||
let resources_path = resources_path.canonicalize().unwrap_or(resources_path);
|
||||
|
||||
let Customizations { config, themes, code_executor } =
|
||||
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.enable_export_mode) {
|
||||
(true, _) => PresentMode::Presentation,
|
||||
(false, true) => PresentMode::Export,
|
||||
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, &present_mode, force_default_theme, cli.listen_speaker_notes);
|
||||
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;
|
||||
}
|
||||
@ -211,15 +235,19 @@ impl CoreComponents {
|
||||
}
|
||||
let graphics_mode = Self::select_graphics_mode(cli, &config);
|
||||
let printer = Arc::new(ImagePrinter::new(graphics_mode.clone())?);
|
||||
let registry = ImageRegistry(printer.clone());
|
||||
let resources = Resources::new(resources_path.clone(), registry.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 = Rc::new(code_executor);
|
||||
let code_executor = Arc::new(code_executor);
|
||||
Ok(Self {
|
||||
third_party,
|
||||
code_executor,
|
||||
@ -236,12 +264,11 @@ impl CoreComponents {
|
||||
|
||||
fn make_builder_options(
|
||||
config: &Config,
|
||||
mode: &PresentMode,
|
||||
force_default_theme: bool,
|
||||
render_speaker_notes_only: bool,
|
||||
) -> PresentationBuilderOptions {
|
||||
PresentationBuilderOptions {
|
||||
allow_mutations: !matches!(mode, PresentMode::Export),
|
||||
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
|
||||
@ -258,12 +285,16 @@ impl CoreComponents {
|
||||
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.enable_export_mode || cli.export_pdf || cli.generate_pdf_metadata {
|
||||
GraphicsMode::AsciiBlocks
|
||||
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) {
|
||||
@ -322,39 +353,37 @@ fn overflow_validation_enabled(mode: &PresentMode, config: &ValidateOverflows) -
|
||||
}
|
||||
}
|
||||
|
||||
fn build_exporter_args(cli: &Cli) -> Vec<&str> {
|
||||
let mut args = Vec::new();
|
||||
if let Some(theme) = cli.theme.as_ref() {
|
||||
args.extend(["--theme", theme]);
|
||||
}
|
||||
if let Some(path) = cli.config_file.as_ref() {
|
||||
args.extend(["--config-file", path]);
|
||||
}
|
||||
if cli.enable_snippet_execution {
|
||||
args.push("-x");
|
||||
}
|
||||
if cli.enable_snippet_execution_replace {
|
||||
args.push("-X");
|
||||
}
|
||||
args
|
||||
}
|
||||
|
||||
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(());
|
||||
} else if cli.acknowledgements {
|
||||
}
|
||||
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, io::stdout())?;
|
||||
let demo = ThemesDemo::new(themes, bindings)?;
|
||||
demo.run()?;
|
||||
return Ok(());
|
||||
} else if cli.current_theme {
|
||||
let Customizations { config, .. } =
|
||||
Customizations::load(cli.config_file.clone().map(PathBuf::from), ¤t_dir()?)?;
|
||||
let theme_name =
|
||||
cli.theme.as_ref().or(config.defaults.theme.as_ref()).map(|s| s.as_str()).unwrap_or(DEFAULT_THEME);
|
||||
println!("{theme_name}");
|
||||
return Ok(());
|
||||
}
|
||||
// Disable this so we don't mess things up when generating PDFs
|
||||
if cli.export_pdf {
|
||||
TerminalEmulator::disable_capability_detection();
|
||||
}
|
||||
|
||||
let Some(path) = cli.path.clone() else {
|
||||
@ -376,15 +405,35 @@ fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
|
||||
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.generate_pdf_metadata {
|
||||
let mut exporter =
|
||||
Exporter::new(parser, &default_theme, resources, third_party, code_executor, themes, builder_options);
|
||||
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 {
|
||||
let args = build_exporter_args(&cli);
|
||||
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 SpeakerNotesComponents { events_listener, events_publisher } =
|
||||
@ -398,7 +447,13 @@ fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
|
||||
font_size_fallback: config.defaults.terminal_font_size,
|
||||
bindings: config.bindings,
|
||||
validate_overflows,
|
||||
max_columns: config.defaults.max_columns,
|
||||
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,
|
||||
@ -420,7 +475,8 @@ fn run(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,4 +1,5 @@
|
||||
use super::text_style::TextStyle;
|
||||
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;
|
||||
@ -13,13 +14,13 @@ pub(crate) enum MarkdownElement {
|
||||
FrontMatter(String),
|
||||
|
||||
/// A setex heading.
|
||||
SetexHeading { text: Line },
|
||||
SetexHeading { text: Line<RawColor> },
|
||||
|
||||
/// A normal heading.
|
||||
Heading { level: u8, text: Line },
|
||||
Heading { level: u8, text: Line<RawColor> },
|
||||
|
||||
/// A paragraph composed by a list of lines.
|
||||
Paragraph(Vec<Line>),
|
||||
Paragraph(Vec<Line<RawColor>>),
|
||||
|
||||
/// An image.
|
||||
Image { path: PathBuf, title: String, source_position: SourcePosition },
|
||||
@ -51,7 +52,7 @@ pub(crate) enum MarkdownElement {
|
||||
Comment { comment: String, source_position: SourcePosition },
|
||||
|
||||
/// A block quote containing a list of lines.
|
||||
BlockQuote(Vec<Line>),
|
||||
BlockQuote(Vec<Line<RawColor>>),
|
||||
|
||||
/// An alert.
|
||||
Alert {
|
||||
@ -62,7 +63,7 @@ pub(crate) enum MarkdownElement {
|
||||
title: Option<String>,
|
||||
|
||||
/// The content lines in this alert.
|
||||
lines: Vec<Line>,
|
||||
lines: Vec<Line<RawColor>>,
|
||||
},
|
||||
}
|
||||
|
||||
@ -98,15 +99,23 @@ impl From<comrak::nodes::LineColumn> for LineColumn {
|
||||
/// A text line.
|
||||
///
|
||||
/// Text is represented as a series of chunks, each with their own formatting.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Default)]
|
||||
pub(crate) struct Line(pub(crate) Vec<Text>);
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct Line<C = Color>(pub(crate) Vec<Text<C>>);
|
||||
|
||||
impl Line {
|
||||
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 {
|
||||
@ -115,7 +124,19 @@ impl Line {
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Into<Text>> From<T> for Line {
|
||||
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()])
|
||||
}
|
||||
@ -125,25 +146,36 @@ impl<T: Into<Text>> From<T> for Line {
|
||||
///
|
||||
/// 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() }
|
||||
}
|
||||
@ -158,7 +190,7 @@ pub(crate) struct ListItem {
|
||||
pub(crate) depth: u8,
|
||||
|
||||
/// The contents of this list item.
|
||||
pub(crate) contents: Line,
|
||||
pub(crate) contents: Line<RawColor>,
|
||||
|
||||
/// The type of list item.
|
||||
pub(crate) item_type: ListItemType,
|
||||
@ -171,10 +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,
|
||||
OrderedPeriod(usize),
|
||||
}
|
||||
|
||||
/// A table.
|
||||
@ -196,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 = &Line> {
|
||||
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)
|
||||
@ -205,7 +237,7 @@ impl Table {
|
||||
|
||||
/// A table row.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct TableRow(pub(crate) Vec<Line>);
|
||||
pub(crate) struct TableRow(pub(crate) Vec<Line<RawColor>>);
|
||||
|
||||
/// A percentage.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
|
@ -1,4 +1,5 @@
|
||||
use super::text_style::{Color, ParseColorError, TextStyle};
|
||||
use super::text_style::{Color, TextStyle};
|
||||
use crate::theme::raw::{ParseColorError, RawColor};
|
||||
use std::{borrow::Cow, str, str::Utf8Error};
|
||||
use tl::Attributes;
|
||||
|
||||
@ -37,12 +38,16 @@ impl HtmlParser {
|
||||
Ok(HtmlInline::OpenSpan { style })
|
||||
}
|
||||
|
||||
fn parse_attributes(&self, attributes: &Attributes) -> Result<TextStyle, ParseHtmlError> {
|
||||
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()));
|
||||
@ -53,7 +58,7 @@ impl HtmlParser {
|
||||
Ok(style)
|
||||
}
|
||||
|
||||
fn parse_css_attribute(&self, attribute: &str, style: &mut TextStyle) -> Result<(), ParseHtmlError> {
|
||||
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() {
|
||||
@ -63,8 +68,8 @@ impl HtmlParser {
|
||||
let key = key.trim();
|
||||
let value = value.trim();
|
||||
match key {
|
||||
"color" => *style = style.fg_color(Self::parse_color(value)?),
|
||||
"background-color" => *style = style.bg_color(Self::parse_color(value)?),
|
||||
"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()));
|
||||
@ -75,13 +80,13 @@ impl HtmlParser {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_color(input: &str) -> Result<Color, ParseHtmlError> {
|
||||
fn parse_color(input: &str) -> Result<RawColor, ParseHtmlError> {
|
||||
if input.starts_with('#') {
|
||||
let color = input.strip_prefix('#').unwrap().parse()?;
|
||||
if matches!(color, Color::Rgb { .. }) { Ok(color) } else { Ok(input.parse()?) }
|
||||
if matches!(color, RawColor::Color(Color::Rgb { .. })) { Ok(color) } else { Ok(input.parse()?) }
|
||||
} else {
|
||||
let color = input.parse::<Color>()?;
|
||||
if matches!(color, Color::Rgb { .. }) {
|
||||
let color = input.parse::<RawColor>()?;
|
||||
if matches!(color, RawColor::Color(Color::Rgb { .. })) {
|
||||
Err(ParseHtmlError::InvalidColor("missing '#' in rgb color".into()))
|
||||
} else {
|
||||
Ok(color)
|
||||
@ -92,7 +97,7 @@ impl HtmlParser {
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum HtmlInline {
|
||||
OpenSpan { style: TextStyle },
|
||||
OpenSpan { style: TextStyle<RawColor> },
|
||||
CloseSpan,
|
||||
}
|
||||
|
||||
@ -135,17 +140,28 @@ impl From<ParseColorError> for ParseHtmlError {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::markdown::text_style::Color;
|
||||
use rstest::rstest;
|
||||
|
||||
#[test]
|
||||
fn parse() {
|
||||
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");
|
||||
@ -166,8 +182,8 @@ mod tests {
|
||||
#[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: Color = HtmlParser::parse_color(input).expect("parse failed");
|
||||
assert_eq!(color, expected);
|
||||
let color = HtmlParser::parse_color(input).expect("parse failed");
|
||||
assert_eq!(color, expected.into());
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
|
@ -3,6 +3,7 @@ use super::{
|
||||
html::{HtmlInline, HtmlParser, ParseHtmlError},
|
||||
text_style::TextStyle,
|
||||
};
|
||||
use crate::theme::raw::RawColor;
|
||||
use comrak::{
|
||||
Arena, ComrakOptions,
|
||||
arena_tree::Node,
|
||||
@ -32,6 +33,7 @@ impl Default for ParserOptions {
|
||||
options.extension.strikethrough = true;
|
||||
options.extension.multiline_block_quotes = true;
|
||||
options.extension.alerts = true;
|
||||
options.extension.wikilinks_title_before_pipe = true;
|
||||
Self(options)
|
||||
}
|
||||
}
|
||||
@ -61,6 +63,35 @@ impl<'a> MarkdownParser<'a> {
|
||||
Ok(elements)
|
||||
}
|
||||
|
||||
/// Parse inlines in a markdown input.
|
||||
pub(crate) fn parse_inlines(&self, line: &str) -> Result<Line<RawColor>, ParseInlinesError> {
|
||||
let node = parse_document(self.arena, line, &self.options);
|
||||
if node.children().count() == 0 {
|
||||
return Ok(Default::default());
|
||||
}
|
||||
if node.children().count() > 1 {
|
||||
return Err(ParseInlinesError("inline must be simple text".into()));
|
||||
}
|
||||
let node = node.first_child().expect("must have one child");
|
||||
let data = node.data.borrow();
|
||||
let NodeValue::Paragraph = &data.value else {
|
||||
return Err(ParseInlinesError("inline must be simple text".into()));
|
||||
};
|
||||
let parser = InlinesParser::new(self.arena, SoftBreak::Space, StringifyImages::No);
|
||||
let inlines = parser.parse(node).map_err(|e| ParseInlinesError(e.to_string()))?;
|
||||
let mut output = Line::default();
|
||||
for inline in inlines {
|
||||
match inline {
|
||||
Inline::Text(line) => {
|
||||
output.0.extend(line.0);
|
||||
}
|
||||
Inline::Image { .. } => return Err(ParseInlinesError("images not supported".into())),
|
||||
Inline::LineBreak => return Err(ParseInlinesError("line breaks not supported".into())),
|
||||
};
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn parse_node(&self, node: &'a AstNode<'a>) -> ParseResult<Vec<MarkdownElement>> {
|
||||
let data = node.data.borrow();
|
||||
let element = match &data.value {
|
||||
@ -117,7 +148,7 @@ impl<'a> MarkdownParser<'a> {
|
||||
Inline::Image { .. } => {}
|
||||
}
|
||||
}
|
||||
if lines.last() == Some(&Line::from("")) {
|
||||
if lines.last() == Some(&Line::<RawColor>::from("")) {
|
||||
lines.pop();
|
||||
}
|
||||
Ok(MarkdownElement::BlockQuote(lines))
|
||||
@ -174,7 +205,7 @@ impl<'a> MarkdownParser<'a> {
|
||||
Ok(elements)
|
||||
}
|
||||
|
||||
fn parse_text(&self, node: &'a AstNode<'a>) -> ParseResult<Line> {
|
||||
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 {
|
||||
@ -212,8 +243,8 @@ impl<'a> MarkdownParser<'a> {
|
||||
fn parse_list_item(&self, item: &NodeList, root: &'a AstNode<'a>, depth: u8) -> ParseResult<Vec<ListItem>> {
|
||||
let item_type = match (item.list_type, item.delimiter) {
|
||||
(ListType::Bullet, _) => ListItemType::Unordered,
|
||||
(ListType::Ordered, ListDelimType::Paren) => ListItemType::OrderedParens,
|
||||
(ListType::Ordered, ListDelimType::Period) => ListItemType::OrderedPeriod,
|
||||
(ListType::Ordered, ListDelimType::Paren) => ListItemType::OrderedParens(item.start),
|
||||
(ListType::Ordered, ListDelimType::Period) => ListItemType::OrderedPeriod(item.start),
|
||||
};
|
||||
let mut elements = Vec::new();
|
||||
for node in root.children() {
|
||||
@ -290,7 +321,7 @@ enum StringifyImages {
|
||||
|
||||
struct InlinesParser<'a> {
|
||||
inlines: Vec<Inline>,
|
||||
pending_text: Vec<Text>,
|
||||
pending_text: Vec<Text<RawColor>>,
|
||||
arena: &'a Arena<AstNode<'a>>,
|
||||
soft_break: SoftBreak,
|
||||
stringify_images: StringifyImages,
|
||||
@ -318,7 +349,7 @@ impl<'a> InlinesParser<'a> {
|
||||
&mut self,
|
||||
node: &'a AstNode<'a>,
|
||||
parent: &'a AstNode<'a>,
|
||||
style: TextStyle,
|
||||
style: TextStyle<RawColor>,
|
||||
) -> ParseResult<Option<HtmlStyle>> {
|
||||
let data = node.data.borrow();
|
||||
match &data.value {
|
||||
@ -355,6 +386,9 @@ impl<'a> InlinesParser<'a> {
|
||||
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);
|
||||
@ -422,18 +456,18 @@ impl<'a> InlinesParser<'a> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn process_children(&mut self, root: &'a AstNode<'a>, base_style: TextStyle) -> ParseResult<()> {
|
||||
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;
|
||||
let mut style = base_style.clone();
|
||||
for node in root.children() {
|
||||
if let Some(html_style) = self.process_node(node, root, style)? {
|
||||
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;
|
||||
style = base_style.clone();
|
||||
for html_style in html_styles.iter().rev() {
|
||||
style.merge(html_style);
|
||||
}
|
||||
@ -444,12 +478,12 @@ impl<'a> InlinesParser<'a> {
|
||||
}
|
||||
|
||||
enum HtmlStyle {
|
||||
Add(TextStyle),
|
||||
Add(TextStyle<RawColor>),
|
||||
Remove,
|
||||
}
|
||||
|
||||
enum Inline {
|
||||
Text(Line),
|
||||
Text(Line<RawColor>),
|
||||
Image { path: String, title: String },
|
||||
LineBreak,
|
||||
}
|
||||
@ -577,10 +611,15 @@ impl Identifier for NodeValue {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("invalid markdown line: {0}")]
|
||||
pub(crate) struct ParseInlinesError(String);
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::markdown::text_style::Color;
|
||||
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
use std::path::Path;
|
||||
|
||||
@ -713,6 +752,16 @@ boop
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image() {
|
||||
let parsed = parse_single("");
|
||||
@ -773,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(
|
||||
@ -993,4 +1062,19 @@ mom
|
||||
};
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
@ -12,6 +12,7 @@ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
||||
pub(crate) struct WeightedLine {
|
||||
text: Vec<WeightedText>,
|
||||
width: usize,
|
||||
font_size: u8,
|
||||
}
|
||||
|
||||
impl WeightedLine {
|
||||
@ -25,6 +26,11 @@ impl WeightedLine {
|
||||
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> {
|
||||
@ -43,6 +49,7 @@ impl From<Vec<Text>> for WeightedLine {
|
||||
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(""));
|
||||
@ -52,11 +59,13 @@ impl From<Vec<Text>> for WeightedLine {
|
||||
target.content.push_str(¤t_content);
|
||||
current += 1;
|
||||
}
|
||||
width += target.content.width();
|
||||
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 { text: output, width }
|
||||
Self { text: output, width, font_size }
|
||||
}
|
||||
}
|
||||
|
||||
@ -64,7 +73,7 @@ impl From<String> for WeightedLine {
|
||||
fn from(text: String) -> Self {
|
||||
let width = text.width();
|
||||
let text = vec![WeightedText::from(text)];
|
||||
Self { text, width }
|
||||
Self { text, width, font_size: 1 }
|
||||
}
|
||||
}
|
||||
|
||||
@ -192,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,
|
||||
@ -223,7 +233,7 @@ impl<'a> WeightedTextRef<'a> {
|
||||
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 {
|
||||
@ -294,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");
|
||||
@ -325,7 +344,11 @@ mod test {
|
||||
|
||||
#[test]
|
||||
fn no_split_necessary() {
|
||||
let text = WeightedLine { text: vec![WeightedText::from("short"), WeightedText::from("text")], width: 0 };
|
||||
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);
|
||||
@ -333,7 +356,8 @@ mod test {
|
||||
|
||||
#[test]
|
||||
fn split_lines_single() {
|
||||
let text = WeightedLine { text: vec![WeightedText::from("this is a slightly long line")], width: 0 };
|
||||
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);
|
||||
@ -348,6 +372,7 @@ mod test {
|
||||
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"];
|
||||
@ -363,6 +388,7 @@ mod test {
|
||||
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"];
|
||||
|
@ -1,24 +1,34 @@
|
||||
use crate::theme::ColorPalette;
|
||||
use crossterm::style::Stylize;
|
||||
use hex::{FromHex, FromHexError};
|
||||
use crate::theme::{ColorPalette, raw::RawColor};
|
||||
use crossterm::style::{StyledContent, Stylize};
|
||||
use hex::FromHexError;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_with::{DeserializeFromStr, SerializeDisplay};
|
||||
use std::{
|
||||
fmt::{self, Display},
|
||||
ops::Deref,
|
||||
str::FromStr,
|
||||
};
|
||||
use std::fmt::{self, Display};
|
||||
|
||||
/// The style of a piece of text.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct TextStyle {
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct TextStyle<C = Color> {
|
||||
flags: u8,
|
||||
pub(crate) colors: Colors,
|
||||
pub(crate) colors: Colors<C>,
|
||||
pub(crate) size: u8,
|
||||
}
|
||||
|
||||
impl TextStyle {
|
||||
pub(crate) fn colored(colors: Colors) -> Self {
|
||||
Self { flags: Default::default(), colors }
|
||||
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.
|
||||
@ -61,32 +71,22 @@ impl TextStyle {
|
||||
self.italics().underlined()
|
||||
}
|
||||
|
||||
/// Set the colors for this text style.
|
||||
pub(crate) fn colors(mut self, colors: Colors) -> Self {
|
||||
self.colors = colors;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the background color for this text style.
|
||||
pub(crate) fn bg_color(mut self, color: Color) -> Self {
|
||||
self.colors.background = Some(color);
|
||||
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(mut self, color: Color) -> Self {
|
||||
self.colors.foreground = Some(color);
|
||||
pub(crate) fn fg_color<U: Into<C>>(mut self, color: U) -> Self {
|
||||
self.colors.foreground = Some(color.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Check whether this text style is bold.
|
||||
pub(crate) fn is_bold(&self) -> bool {
|
||||
self.has_flag(TextFormatFlags::Bold)
|
||||
}
|
||||
|
||||
/// Check whether this text style has italics.
|
||||
pub(crate) fn is_italics(&self) -> bool {
|
||||
self.has_flag(TextFormatFlags::Italics)
|
||||
/// 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.
|
||||
@ -94,46 +94,18 @@ impl TextStyle {
|
||||
self.has_flag(TextFormatFlags::Code)
|
||||
}
|
||||
|
||||
/// Check whether this text style is strikethrough.
|
||||
pub(crate) fn is_strikethrough(&self) -> bool {
|
||||
self.has_flag(TextFormatFlags::Strikethrough)
|
||||
}
|
||||
|
||||
/// Check whether this text style is underlined.
|
||||
pub(crate) fn is_underlined(&self) -> bool {
|
||||
self.has_flag(TextFormatFlags::Underlined)
|
||||
}
|
||||
|
||||
/// Merge this style with another one.
|
||||
pub(crate) fn merge(&mut self, other: &TextStyle) {
|
||||
pub(crate) fn merge(&mut self, other: &TextStyle<C>) {
|
||||
self.flags |= other.flags;
|
||||
self.colors.background = self.colors.background.or(other.colors.background);
|
||||
self.colors.foreground = self.colors.foreground.or(other.colors.foreground);
|
||||
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());
|
||||
}
|
||||
|
||||
/// Apply this style to a piece of text.
|
||||
pub(crate) fn apply<T: Into<String>>(&self, text: T) -> Result<<String as Stylize>::Styled, PaletteColorError> {
|
||||
let text: String = text.into();
|
||||
let mut styled = text.stylize();
|
||||
if self.is_bold() {
|
||||
styled = styled.bold();
|
||||
}
|
||||
if self.is_italics() {
|
||||
styled = styled.italic();
|
||||
}
|
||||
if self.is_strikethrough() {
|
||||
styled = styled.crossed_out();
|
||||
}
|
||||
if self.is_underlined() {
|
||||
styled = styled.underlined();
|
||||
}
|
||||
if let Some(color) = self.colors.background {
|
||||
styled = styled.on(color.try_into()?);
|
||||
}
|
||||
if let Some(color) = self.colors.foreground {
|
||||
styled = styled.with(color.try_into()?);
|
||||
}
|
||||
Ok(styled)
|
||||
/// 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 {
|
||||
@ -146,7 +118,118 @@ impl TextStyle {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
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,
|
||||
@ -155,7 +238,7 @@ enum TextFormatFlags {
|
||||
Underlined = 16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq, SerializeDisplay, DeserializeFromStr)]
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum Color {
|
||||
Black,
|
||||
DarkGrey,
|
||||
@ -174,7 +257,6 @@ pub(crate) enum Color {
|
||||
White,
|
||||
Grey,
|
||||
Rgb { r: u8, g: u8, b: u8 },
|
||||
Palette(FixedStr),
|
||||
}
|
||||
|
||||
impl Color {
|
||||
@ -182,11 +264,6 @@ impl Color {
|
||||
Self::Rgb { r, g, b }
|
||||
}
|
||||
|
||||
pub(crate) fn new_palette(name: &str) -> Result<Self, ParseColorError> {
|
||||
let color: FixedStr = name.try_into().map_err(|_| ParseColorError::PaletteColorLength(name.to_string()))?;
|
||||
if color.is_empty() { Err(ParseColorError::PaletteColorEmpty) } else { Ok(Self::Palette(color)) }
|
||||
}
|
||||
|
||||
pub(crate) fn as_rgb(&self) -> Option<(u8, u8, u8)> {
|
||||
match self {
|
||||
Self::Rgb { r, g, b } => Some((*r, *g, *b)),
|
||||
@ -208,79 +285,12 @@ impl Color {
|
||||
};
|
||||
Some(color)
|
||||
}
|
||||
|
||||
pub(crate) fn resolve(&self, palette: &ColorPalette) -> Result<Color, UndefinedPaletteColorError> {
|
||||
match self {
|
||||
Color::Palette(name) => palette.colors.get(name).cloned().ok_or(UndefinedPaletteColorError(*name)),
|
||||
_ => Ok(*self),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Color {
|
||||
type Err = ParseColorError;
|
||||
|
||||
fn from_str(input: &str) -> Result<Self, Self::Err> {
|
||||
let output = match input {
|
||||
"black" => Self::Black,
|
||||
"white" => Self::White,
|
||||
"grey" => Self::Grey,
|
||||
"dark_grey" => Self::DarkGrey,
|
||||
"red" => Self::Red,
|
||||
"dark_red" => Self::DarkRed,
|
||||
"green" => Self::Green,
|
||||
"dark_green" => Self::DarkGreen,
|
||||
"blue" => Self::Blue,
|
||||
"dark_blue" => Self::DarkBlue,
|
||||
"yellow" => Self::Yellow,
|
||||
"dark_yellow" => Self::DarkYellow,
|
||||
"magenta" => Self::Magenta,
|
||||
"dark_magenta" => Self::DarkMagenta,
|
||||
"cyan" => Self::Cyan,
|
||||
"dark_cyan" => Self::DarkCyan,
|
||||
other if other.starts_with("palette:") => Self::new_palette(other.trim_start_matches("palette:"))?,
|
||||
other if other.starts_with("p:") => Self::new_palette(other.trim_start_matches("p:"))?,
|
||||
// Fallback to hex-encoded rgb
|
||||
_ => {
|
||||
let values = <[u8; 3]>::from_hex(input)?;
|
||||
Self::Rgb { r: values[0], g: values[1], b: values[2] }
|
||||
}
|
||||
};
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Color {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Rgb { r, g, b } => write!(f, "{}", hex::encode([*r, *g, *b])),
|
||||
Self::Black => write!(f, "black"),
|
||||
Self::White => write!(f, "white"),
|
||||
Self::Grey => write!(f, "grey"),
|
||||
Self::DarkGrey => write!(f, "dark_grey"),
|
||||
Self::Red => write!(f, "red"),
|
||||
Self::DarkRed => write!(f, "dark_red"),
|
||||
Self::Green => write!(f, "green"),
|
||||
Self::DarkGreen => write!(f, "dark_green"),
|
||||
Self::Blue => write!(f, "blue"),
|
||||
Self::DarkBlue => write!(f, "dark_blue"),
|
||||
Self::Yellow => write!(f, "yellow"),
|
||||
Self::DarkYellow => write!(f, "dark_yellow"),
|
||||
Self::Magenta => write!(f, "magenta"),
|
||||
Self::DarkMagenta => write!(f, "dark_magenta"),
|
||||
Self::Cyan => write!(f, "cyan"),
|
||||
Self::DarkCyan => write!(f, "dark_cyan"),
|
||||
Self::Palette(name) => write!(f, "palette:{name}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<Color> for crossterm::style::Color {
|
||||
type Error = PaletteColorError;
|
||||
|
||||
fn try_from(value: Color) -> Result<Self, Self::Error> {
|
||||
impl From<Color> for crossterm::style::Color {
|
||||
fn from(value: Color) -> Self {
|
||||
use crossterm::style::Color as C;
|
||||
let output = match value {
|
||||
match value {
|
||||
Color::Black => C::Black,
|
||||
Color::DarkGrey => C::DarkGrey,
|
||||
Color::Red => C::Red,
|
||||
@ -298,105 +308,47 @@ impl TryFrom<Color> for crossterm::style::Color {
|
||||
Color::White => C::White,
|
||||
Color::Grey => C::Grey,
|
||||
Color::Rgb { r, g, b } => C::Rgb { r, g, b },
|
||||
Color::Palette(color) => return Err(PaletteColorError(color)),
|
||||
};
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, SerializeDisplay, DeserializeFromStr)]
|
||||
pub(crate) struct FixedStr<const N: usize = 16> {
|
||||
data: [u8; N],
|
||||
length: u8,
|
||||
}
|
||||
|
||||
impl<const N: usize> TryFrom<&str> for FixedStr<N> {
|
||||
type Error = &'static str;
|
||||
|
||||
fn try_from(value: &str) -> Result<Self, Self::Error> {
|
||||
let data = value.as_bytes();
|
||||
if data.len() <= N {
|
||||
let mut this = Self { data: [0; N], length: data.len() as u8 };
|
||||
this.data[0..data.len()].copy_from_slice(data);
|
||||
Ok(this)
|
||||
} else {
|
||||
Err("string is too long")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<const N: usize> FromStr for FixedStr<N> {
|
||||
type Err = &'static str;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Self::try_from(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl<const N: usize> Deref for FixedStr<N> {
|
||||
type Target = str;
|
||||
|
||||
fn deref(&self) -> &str {
|
||||
let data = &self.data[0..self.length as usize];
|
||||
std::str::from_utf8(data).expect("invalid utf8")
|
||||
}
|
||||
}
|
||||
|
||||
impl<const N: usize> fmt::Debug for FixedStr<N> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.deref())
|
||||
}
|
||||
}
|
||||
|
||||
impl<const N: usize> fmt::Display for FixedStr<N> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.deref())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("unresolved palette color: {0}")]
|
||||
pub(crate) struct PaletteColorError(FixedStr);
|
||||
pub(crate) struct PaletteColorError(String);
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("undefined palette color: {0}")]
|
||||
pub(crate) struct UndefinedPaletteColorError(FixedStr);
|
||||
pub(crate) struct UndefinedPaletteColorError(pub(crate) String);
|
||||
|
||||
/// Text colors.
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq, Serialize)]
|
||||
pub(crate) struct Colors {
|
||||
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)]
|
||||
pub(crate) struct Colors<C = Color> {
|
||||
/// The background color.
|
||||
pub(crate) background: Option<Color>,
|
||||
pub(crate) background: Option<C>,
|
||||
|
||||
/// The foreground color.
|
||||
pub(crate) foreground: Option<Color>,
|
||||
pub(crate) foreground: Option<C>,
|
||||
}
|
||||
|
||||
impl Colors {
|
||||
pub(crate) fn merge(&self, other: &Colors) -> Self {
|
||||
let background = self.background.or(other.background);
|
||||
let foreground = self.foreground.or(other.foreground);
|
||||
Self { background, foreground }
|
||||
}
|
||||
|
||||
pub(crate) fn resolve(mut self, palette: &ColorPalette) -> Result<Self, UndefinedPaletteColorError> {
|
||||
if let Some(color) = self.foreground.as_mut() {
|
||||
*color = color.resolve(palette)?;
|
||||
}
|
||||
if let Some(color) = self.background.as_mut() {
|
||||
*color = color.resolve(palette)?;
|
||||
}
|
||||
Ok(self)
|
||||
impl<C> Default for Colors<C> {
|
||||
fn default() -> Self {
|
||||
Self { background: None, foreground: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<Colors> for crossterm::style::Colors {
|
||||
type Error = PaletteColorError;
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
fn try_from(value: Colors) -> Result<Self, Self::Error> {
|
||||
let foreground = value.foreground.map(Color::try_into).transpose()?;
|
||||
let background = value.background.map(Color::try_into).transpose()?;
|
||||
Ok(Self { 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 }
|
||||
}
|
||||
}
|
||||
|
||||
@ -404,51 +356,35 @@ impl TryFrom<Colors> for crossterm::style::Colors {
|
||||
pub(crate) enum ParseColorError {
|
||||
#[error("invalid hex color: {0}")]
|
||||
Hex(#[from] FromHexError),
|
||||
|
||||
#[error("palette color name is too long: {0}")]
|
||||
PaletteColorLength(String),
|
||||
|
||||
#[error("palette color name is empty")]
|
||||
PaletteColorEmpty,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
|
||||
#[test]
|
||||
fn color_serde() {
|
||||
let color: Color = "beef42".parse().unwrap();
|
||||
assert_eq!(color.to_string(), "beef42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_fixed_str() {
|
||||
FixedStr::<1>::try_from("AB").unwrap_err();
|
||||
FixedStr::<1>::try_from("🚀").unwrap_err();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_fixed_str() {
|
||||
let str = FixedStr::<3>::try_from("ABC").unwrap();
|
||||
assert_eq!(str.to_string(), "ABC");
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::empty1("p:")]
|
||||
#[case::empty2("palette:")]
|
||||
#[case::too_long("palette:12345678901234567")]
|
||||
fn invalid_palette_color_names(#[case] input: &str) {
|
||||
Color::from_str(input).expect_err("not an error");
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::short("p:hi", "hi")]
|
||||
#[case::long("palette:bye", "bye")]
|
||||
fn valid_palette_color_names(#[case] input: &str, #[case] expected: &str) {
|
||||
let color = Color::from_str(input).expect("failed to parse");
|
||||
let Color::Palette(name) = color else { panic!("not a palette color") };
|
||||
assert_eq!(name.deref(), expected);
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
|
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);
|
||||
}
|
||||
}
|
@ -120,7 +120,7 @@ mod test {
|
||||
},
|
||||
presentation::{Slide, SlideBuilder},
|
||||
render::{
|
||||
operation::{AsRenderOperations, BlockLine, RenderAsync, RenderAsyncState},
|
||||
operation::{AsRenderOperations, BlockLine, Pollable, RenderAsync, ToggleState},
|
||||
properties::WindowSize,
|
||||
},
|
||||
theme::{Alignment, Margin},
|
||||
@ -138,12 +138,9 @@ mod test {
|
||||
}
|
||||
|
||||
impl RenderAsync for Dynamic {
|
||||
fn start_render(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn poll_state(&self) -> RenderAsyncState {
|
||||
RenderAsyncState::Rendered
|
||||
fn pollable(&self) -> Box<dyn Pollable> {
|
||||
// Use some random one, we don't care
|
||||
Box::new(ToggleState::new(Default::default()))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,12 +1,7 @@
|
||||
use crate::{
|
||||
config::OptionsConfig,
|
||||
render::operation::{RenderAsyncState, RenderOperation},
|
||||
theme::PresentationTheme,
|
||||
};
|
||||
use crate::{config::OptionsConfig, render::operation::RenderOperation};
|
||||
use serde::Deserialize;
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::HashSet,
|
||||
fmt::Debug,
|
||||
ops::Deref,
|
||||
rc::Rc,
|
||||
@ -15,6 +10,7 @@ use std::{
|
||||
|
||||
pub(crate) mod builder;
|
||||
pub(crate) mod diff;
|
||||
pub(crate) mod poller;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Modals {
|
||||
@ -41,6 +37,11 @@ impl Presentation {
|
||||
self.slides.iter()
|
||||
}
|
||||
|
||||
/// Iterate the slides in this presentation.
|
||||
pub(crate) fn iter_slides_mut(&mut self) -> impl Iterator<Item = &mut Slide> {
|
||||
self.slides.iter_mut()
|
||||
}
|
||||
|
||||
/// Iterate the operations that render the slide index.
|
||||
pub(crate) fn iter_slide_index_operations(&self) -> impl Iterator<Item = &RenderOperation> {
|
||||
self.modals.slide_index.iter()
|
||||
@ -52,7 +53,6 @@ impl Presentation {
|
||||
}
|
||||
|
||||
/// Consume this presentation and return its slides.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn into_slides(self) -> Vec<Slide> {
|
||||
self.slides
|
||||
}
|
||||
@ -140,92 +140,7 @@ impl Presentation {
|
||||
self.current_slide().current_chunk_index()
|
||||
}
|
||||
|
||||
/// Trigger async render operations in all slides.
|
||||
pub(crate) fn trigger_all_async_renders(&mut self) -> HashSet<usize> {
|
||||
let mut triggered_slides = HashSet::new();
|
||||
for (index, slide) in self.slides.iter_mut().enumerate() {
|
||||
for operation in slide.iter_operations_mut() {
|
||||
if let RenderOperation::RenderAsync(operation) = operation {
|
||||
if operation.start_render() {
|
||||
triggered_slides.insert(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
triggered_slides
|
||||
}
|
||||
|
||||
/// Trigger async render operations in this slide.
|
||||
pub(crate) fn trigger_slide_async_renders(&mut self) -> bool {
|
||||
let slide = self.current_slide_mut();
|
||||
let mut any_rendered = false;
|
||||
for operation in slide.iter_visible_operations_mut() {
|
||||
if let RenderOperation::RenderAsync(operation) = operation {
|
||||
let is_rendered = operation.start_render();
|
||||
any_rendered = any_rendered || is_rendered;
|
||||
}
|
||||
}
|
||||
any_rendered
|
||||
}
|
||||
|
||||
// Get all slides that contain async render operations.
|
||||
pub(crate) fn slides_with_async_renders(&self) -> HashSet<usize> {
|
||||
let mut indexes = HashSet::new();
|
||||
for (index, slide) in self.slides.iter().enumerate() {
|
||||
for operation in slide.iter_operations() {
|
||||
if let RenderOperation::RenderAsync(operation) = operation {
|
||||
if matches!(operation.poll_state(), RenderAsyncState::Rendering { .. }) {
|
||||
indexes.insert(index);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
indexes
|
||||
}
|
||||
|
||||
/// Poll every async render operation in the current slide and check whether they're completed.
|
||||
pub(crate) fn poll_slide_async_renders(&mut self) -> RenderAsyncState {
|
||||
let slide = self.current_slide_mut();
|
||||
let mut slide_state = RenderAsyncState::Rendered;
|
||||
for operation in slide.iter_operations_mut() {
|
||||
if let RenderOperation::RenderAsync(operation) = operation {
|
||||
let state = operation.poll_state();
|
||||
slide_state = match (&slide_state, &state) {
|
||||
// If one finished rendering and another one still is rendering, claim that we
|
||||
// are still rendering and there's modifications.
|
||||
(RenderAsyncState::JustFinishedRendering, RenderAsyncState::Rendering { .. })
|
||||
| (RenderAsyncState::Rendering { .. }, RenderAsyncState::JustFinishedRendering) => {
|
||||
RenderAsyncState::Rendering { modified: true }
|
||||
}
|
||||
// Render + modified overrides anything, rendering overrides only "rendered".
|
||||
(_, RenderAsyncState::Rendering { modified: true })
|
||||
| (RenderAsyncState::Rendered, RenderAsyncState::Rendering { .. })
|
||||
| (_, RenderAsyncState::JustFinishedRendering) => state,
|
||||
_ => slide_state,
|
||||
};
|
||||
}
|
||||
}
|
||||
slide_state
|
||||
}
|
||||
|
||||
/// Run a callback through every operation and let it mutate it in place.
|
||||
///
|
||||
/// This should be used with care!
|
||||
pub(crate) fn mutate_operations<F>(&mut self, mut callback: F)
|
||||
where
|
||||
F: FnMut(&mut RenderOperation),
|
||||
{
|
||||
for slide in &mut self.slides {
|
||||
for chunk in &mut slide.chunks {
|
||||
for operation in &mut chunk.operations {
|
||||
callback(operation);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn current_slide_mut(&mut self) -> &mut Slide {
|
||||
pub(crate) fn current_slide_mut(&mut self) -> &mut Slide {
|
||||
let index = self.current_slide_index();
|
||||
&mut self.slides[index]
|
||||
}
|
||||
@ -383,7 +298,7 @@ impl Slide {
|
||||
self.current_chunk().reset_mutations();
|
||||
}
|
||||
|
||||
fn show_all_chunks(&mut self) {
|
||||
pub(crate) fn show_all_chunks(&mut self) {
|
||||
self.visible_chunks = self.chunks.len();
|
||||
for chunk in &self.chunks {
|
||||
chunk.apply_all_mutations();
|
||||
@ -548,7 +463,7 @@ pub(crate) struct PresentationThemeMetadata {
|
||||
|
||||
/// Any specific overrides for the presentation's theme.
|
||||
#[serde(default, rename = "override")]
|
||||
pub(crate) overrides: Option<PresentationTheme>,
|
||||
pub(crate) overrides: Option<crate::theme::raw::PresentationTheme>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
118
src/presentation/poller.rs
Normal file
118
src/presentation/poller.rs
Normal file
@ -0,0 +1,118 @@
|
||||
use crate::render::operation::{Pollable, PollableState};
|
||||
use std::{
|
||||
sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel},
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
const POLL_INTERVAL: Duration = Duration::from_millis(25);
|
||||
|
||||
pub(crate) struct Poller {
|
||||
sender: Sender<PollerCommand>,
|
||||
receiver: Receiver<PollableEffect>,
|
||||
}
|
||||
|
||||
impl Poller {
|
||||
pub(crate) fn launch() -> Self {
|
||||
let (command_sender, command_receiver) = channel();
|
||||
let (effect_sender, effect_receiver) = channel();
|
||||
let worker = PollerWorker::new(command_receiver, effect_sender);
|
||||
thread::spawn(move || {
|
||||
worker.run();
|
||||
});
|
||||
Self { sender: command_sender, receiver: effect_receiver }
|
||||
}
|
||||
|
||||
pub(crate) fn send(&self, command: PollerCommand) {
|
||||
let _ = self.sender.send(command);
|
||||
}
|
||||
|
||||
pub(crate) fn next_effect(&mut self) -> Option<PollableEffect> {
|
||||
self.receiver.try_recv().ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// An effect caused by a pollable.
|
||||
#[derive(Clone)]
|
||||
pub(crate) enum PollableEffect {
|
||||
/// Refresh the given slide.
|
||||
RefreshSlide(usize),
|
||||
|
||||
/// Display an error for the given slide.
|
||||
DisplayError { slide: usize, error: String },
|
||||
}
|
||||
|
||||
/// A poller command.
|
||||
pub(crate) enum PollerCommand {
|
||||
/// Start polling a pollable that's positioned in the given slide.
|
||||
Poll { pollable: Box<dyn Pollable>, slide: usize },
|
||||
|
||||
/// Reset all pollables.
|
||||
Reset,
|
||||
}
|
||||
|
||||
struct PollerWorker {
|
||||
receiver: Receiver<PollerCommand>,
|
||||
sender: Sender<PollableEffect>,
|
||||
pollables: Vec<(Box<dyn Pollable>, usize)>,
|
||||
}
|
||||
|
||||
impl PollerWorker {
|
||||
fn new(receiver: Receiver<PollerCommand>, sender: Sender<PollableEffect>) -> Self {
|
||||
Self { receiver, sender, pollables: Default::default() }
|
||||
}
|
||||
|
||||
fn run(mut self) {
|
||||
loop {
|
||||
match self.receiver.recv_timeout(POLL_INTERVAL) {
|
||||
Ok(command) => self.process_command(command),
|
||||
// TODO don't loop forever.
|
||||
Err(RecvTimeoutError::Timeout) => self.poll(),
|
||||
Err(RecvTimeoutError::Disconnected) => break,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
fn process_command(&mut self, command: PollerCommand) {
|
||||
match command {
|
||||
PollerCommand::Poll { mut pollable, slide } => {
|
||||
// Poll and only insert if it's still running.
|
||||
match pollable.poll() {
|
||||
PollableState::Unmodified | PollableState::Modified => {
|
||||
self.pollables.push((pollable, slide));
|
||||
}
|
||||
PollableState::Done => {
|
||||
let _ = self.sender.send(PollableEffect::RefreshSlide(slide));
|
||||
}
|
||||
PollableState::Failed { error } => {
|
||||
let _ = self.sender.send(PollableEffect::DisplayError { slide, error });
|
||||
}
|
||||
};
|
||||
}
|
||||
PollerCommand::Reset => self.pollables.clear(),
|
||||
}
|
||||
}
|
||||
|
||||
fn poll(&mut self) {
|
||||
let mut removables = Vec::new();
|
||||
for (index, (pollable, slide)) in self.pollables.iter_mut().enumerate() {
|
||||
let slide = *slide;
|
||||
let (effect, remove) = match pollable.poll() {
|
||||
PollableState::Unmodified => (None, false),
|
||||
PollableState::Modified => (Some(PollableEffect::RefreshSlide(slide)), false),
|
||||
PollableState::Done => (Some(PollableEffect::RefreshSlide(slide)), true),
|
||||
PollableState::Failed { error } => (Some(PollableEffect::DisplayError { slide, error }), true),
|
||||
};
|
||||
if let Some(effect) = effect {
|
||||
let _ = self.sender.send(effect);
|
||||
}
|
||||
if remove {
|
||||
removables.push(index);
|
||||
}
|
||||
}
|
||||
// Walk back and swap remove to avoid invalidating indexes.
|
||||
for index in removables.iter().rev() {
|
||||
self.pollables.swap_remove(*index);
|
||||
}
|
||||
}
|
||||
}
|
352
src/presenter.rs
352
src/presenter.rs
@ -4,33 +4,45 @@ use crate::{
|
||||
listener::{Command, CommandListener},
|
||||
speaker_notes::{SpeakerNotesEvent, SpeakerNotesEventPublisher},
|
||||
},
|
||||
config::KeyBindingsConfig,
|
||||
export::ImageReplacer,
|
||||
config::{KeyBindingsConfig, SlideTransitionConfig, SlideTransitionStyleConfig},
|
||||
markdown::parse::{MarkdownParser, ParseError},
|
||||
presentation::{
|
||||
Presentation,
|
||||
Presentation, Slide,
|
||||
builder::{BuildError, PresentationBuilder, PresentationBuilderOptions, Themes},
|
||||
diff::PresentationDiffer,
|
||||
poller::{PollableEffect, Poller, PollerCommand},
|
||||
},
|
||||
render::{
|
||||
ErrorSource, RenderError, RenderResult, TerminalDrawer, TerminalDrawerOptions, operation::RenderAsyncState,
|
||||
properties::WindowSize, validate::OverflowValidator,
|
||||
ErrorSource, RenderError, RenderResult, TerminalDrawer, TerminalDrawerOptions,
|
||||
ascii_scaler::AsciiScaler,
|
||||
engine::{MaxSize, RenderEngine, RenderEngineOptions},
|
||||
operation::{Pollable, RenderAsyncStartPolicy, RenderOperation},
|
||||
properties::WindowSize,
|
||||
validate::OverflowValidator,
|
||||
},
|
||||
resource::Resources,
|
||||
terminal::image::printer::{ImagePrinter, ImageRegistry},
|
||||
theme::PresentationTheme,
|
||||
terminal::{
|
||||
image::printer::{ImagePrinter, ImageRegistry},
|
||||
printer::{TerminalCommand, TerminalIo},
|
||||
virt::{ImageBehavior, TerminalGrid, VirtualTerminal},
|
||||
},
|
||||
theme::{ProcessingThemeError, raw::PresentationTheme},
|
||||
third_party::ThirdPartyRender,
|
||||
transitions::{
|
||||
AnimateTransition, AnimationFrame, LinesFrame, TransitionDirection,
|
||||
collapse_horizontal::CollapseHorizontalAnimation, fade::FadeAnimation,
|
||||
slide_horizontal::SlideHorizontalAnimation,
|
||||
},
|
||||
};
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
fmt::Display,
|
||||
fs,
|
||||
io::{self, Stdout},
|
||||
io::{self},
|
||||
mem,
|
||||
ops::Deref,
|
||||
path::Path,
|
||||
rc::Rc,
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
pub struct PresenterOptions {
|
||||
@ -39,7 +51,8 @@ pub struct PresenterOptions {
|
||||
pub font_size_fallback: u8,
|
||||
pub bindings: KeyBindingsConfig,
|
||||
pub validate_overflows: bool,
|
||||
pub max_columns: u16,
|
||||
pub max_size: MaxSize,
|
||||
pub transition: Option<SlideTransitionConfig>,
|
||||
}
|
||||
|
||||
/// A slideshow presenter.
|
||||
@ -51,13 +64,13 @@ pub struct Presenter<'a> {
|
||||
parser: MarkdownParser<'a>,
|
||||
resources: Resources,
|
||||
third_party: ThirdPartyRender,
|
||||
code_executor: Rc<SnippetExecutor>,
|
||||
code_executor: Arc<SnippetExecutor>,
|
||||
state: PresenterState,
|
||||
slides_with_pending_async_renders: HashSet<usize>,
|
||||
image_printer: Arc<ImagePrinter>,
|
||||
themes: Themes,
|
||||
options: PresenterOptions,
|
||||
speaker_notes_event_publisher: Option<SpeakerNotesEventPublisher>,
|
||||
poller: Poller,
|
||||
}
|
||||
|
||||
impl<'a> Presenter<'a> {
|
||||
@ -69,7 +82,7 @@ impl<'a> Presenter<'a> {
|
||||
parser: MarkdownParser<'a>,
|
||||
resources: Resources,
|
||||
third_party: ThirdPartyRender,
|
||||
code_executor: Rc<SnippetExecutor>,
|
||||
code_executor: Arc<SnippetExecutor>,
|
||||
themes: Themes,
|
||||
image_printer: Arc<ImagePrinter>,
|
||||
options: PresenterOptions,
|
||||
@ -83,11 +96,11 @@ impl<'a> Presenter<'a> {
|
||||
third_party,
|
||||
code_executor,
|
||||
state: PresenterState::Empty,
|
||||
slides_with_pending_async_renders: HashSet::new(),
|
||||
image_printer,
|
||||
themes,
|
||||
options,
|
||||
speaker_notes_event_publisher,
|
||||
poller: Poller::launch(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -97,25 +110,19 @@ impl<'a> Presenter<'a> {
|
||||
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 drawer_options = TerminalDrawerOptions {
|
||||
font_size_fallback: self.options.font_size_fallback,
|
||||
max_columns: self.options.max_columns,
|
||||
max_size: self.options.max_size.clone(),
|
||||
};
|
||||
let mut drawer = TerminalDrawer::new(io::stdout(), self.image_printer.clone(), drawer_options)?;
|
||||
let mut drawer = TerminalDrawer::new(self.image_printer.clone(), drawer_options)?;
|
||||
loop {
|
||||
if matches!(self.options.mode, PresentMode::Export) {
|
||||
if let PresenterState::Failure { error, .. } = &self.state {
|
||||
return Err(PresentationError::Fatal(format!("failed to run presentation: {error}")));
|
||||
}
|
||||
}
|
||||
// Poll async renders once before we draw just in case.
|
||||
self.poll_async_renders()?;
|
||||
self.render(&mut drawer)?;
|
||||
|
||||
loop {
|
||||
if self.poll_async_renders()? {
|
||||
if self.process_poller_effects()? {
|
||||
self.render(&mut drawer)?;
|
||||
}
|
||||
|
||||
@ -141,10 +148,19 @@ impl<'a> Presenter<'a> {
|
||||
break;
|
||||
}
|
||||
CommandSideEffect::Reload => {
|
||||
self.try_reload(path, false);
|
||||
self.try_reload(path, false)?;
|
||||
break;
|
||||
}
|
||||
CommandSideEffect::Redraw => {
|
||||
self.try_scale_transition_images()?;
|
||||
break;
|
||||
}
|
||||
CommandSideEffect::AnimateNextSlide => {
|
||||
self.animate_next_slide(&mut drawer)?;
|
||||
break;
|
||||
}
|
||||
CommandSideEffect::AnimatePreviousSlide => {
|
||||
self.animate_previous_slide(&mut drawer)?;
|
||||
break;
|
||||
}
|
||||
CommandSideEffect::None => (),
|
||||
@ -156,6 +172,36 @@ impl<'a> Presenter<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
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)?;
|
||||
@ -181,28 +227,7 @@ impl<'a> Presenter<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_async_renders(&mut self) -> Result<bool, RenderError> {
|
||||
if matches!(self.state, PresenterState::Failure { .. }) {
|
||||
return Ok(false);
|
||||
}
|
||||
let current_index = self.state.presentation().current_slide_index();
|
||||
if self.slides_with_pending_async_renders.contains(¤t_index) {
|
||||
let state = self.state.presentation_mut().poll_slide_async_renders();
|
||||
match state {
|
||||
RenderAsyncState::NotStarted | RenderAsyncState::Rendering { modified: false } => (),
|
||||
RenderAsyncState::Rendering { modified: true } => {
|
||||
return Ok(true);
|
||||
}
|
||||
RenderAsyncState::Rendered | RenderAsyncState::JustFinishedRendering => {
|
||||
self.slides_with_pending_async_renders.remove(¤t_index);
|
||||
return Ok(true);
|
||||
}
|
||||
};
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn render(&mut self, drawer: &mut TerminalDrawer<Stdout>) -> RenderResult {
|
||||
fn render(&mut self, drawer: &mut TerminalDrawer) -> RenderResult {
|
||||
let result = match &self.state {
|
||||
PresenterState::Presenting(presentation) => {
|
||||
drawer.render_operations(presentation.current_slide().iter_visible_operations())
|
||||
@ -257,16 +282,37 @@ impl<'a> Presenter<'a> {
|
||||
}
|
||||
};
|
||||
let needs_redraw = match command {
|
||||
Command::Next => presentation.jump_next(),
|
||||
Command::Next => {
|
||||
let current_slide = presentation.current_slide_index();
|
||||
if !presentation.jump_next() {
|
||||
false
|
||||
} else if presentation.current_slide_index() != current_slide {
|
||||
return CommandSideEffect::AnimateNextSlide;
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
Command::NextFast => presentation.jump_next_fast(),
|
||||
Command::Previous => presentation.jump_previous(),
|
||||
Command::Previous => {
|
||||
let current_slide = presentation.current_slide_index();
|
||||
if !presentation.jump_previous() {
|
||||
false
|
||||
} else if presentation.current_slide_index() != current_slide {
|
||||
return CommandSideEffect::AnimatePreviousSlide;
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
Command::PreviousFast => presentation.jump_previous_fast(),
|
||||
Command::FirstSlide => presentation.jump_first_slide(),
|
||||
Command::LastSlide => presentation.jump_last_slide(),
|
||||
Command::GoToSlide(number) => presentation.go_to_slide(number.saturating_sub(1) as usize),
|
||||
Command::RenderAsyncOperations => {
|
||||
if presentation.trigger_slide_async_renders() {
|
||||
self.slides_with_pending_async_renders.insert(self.state.presentation().current_slide_index());
|
||||
let pollables = Self::trigger_slide_async_renders(presentation);
|
||||
if !pollables.is_empty() {
|
||||
for pollable in pollables {
|
||||
self.poller.send(PollerCommand::Poll { pollable, slide: presentation.current_slide_index() });
|
||||
}
|
||||
return CommandSideEffect::Redraw;
|
||||
} else {
|
||||
return CommandSideEffect::None;
|
||||
@ -293,11 +339,11 @@ impl<'a> Presenter<'a> {
|
||||
if needs_redraw { CommandSideEffect::Redraw } else { CommandSideEffect::None }
|
||||
}
|
||||
|
||||
fn try_reload(&mut self, path: &Path, force: bool) {
|
||||
fn try_reload(&mut self, path: &Path, force: bool) -> RenderResult {
|
||||
if matches!(self.options.mode, PresentMode::Presentation) && !force {
|
||||
return;
|
||||
return Ok(());
|
||||
}
|
||||
self.slides_with_pending_async_renders.clear();
|
||||
self.poller.send(PollerCommand::Reset);
|
||||
self.resources.clear_watches();
|
||||
match self.load_presentation(path) {
|
||||
Ok(mut presentation) => {
|
||||
@ -309,22 +355,40 @@ impl<'a> Presenter<'a> {
|
||||
presentation.go_to_slide(current.current_slide_index());
|
||||
presentation.jump_chunk(current.current_chunk());
|
||||
}
|
||||
self.slides_with_pending_async_renders = match self.options.mode {
|
||||
PresentMode::Development | PresentMode::Presentation => {
|
||||
presentation.slides_with_async_renders().into_iter().collect()
|
||||
}
|
||||
// Trigger all async renders so we get snippet execution output in the PDF
|
||||
// file.
|
||||
PresentMode::Export => presentation.trigger_all_async_renders(),
|
||||
};
|
||||
|
||||
self.start_automatic_async_renders(&mut presentation);
|
||||
self.state = self.validate_overflows(presentation);
|
||||
self.try_scale_transition_images()?;
|
||||
}
|
||||
Err(e) => {
|
||||
let presentation = mem::take(&mut self.state).into_presentation();
|
||||
self.state = PresenterState::failure(e, presentation, ErrorSource::Presentation, FailureMode::Other);
|
||||
}
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn try_scale_transition_images(&self) -> RenderResult {
|
||||
if self.options.transition.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let options = RenderEngineOptions { max_size: self.options.max_size.clone(), ..Default::default() };
|
||||
let scaler = AsciiScaler::new(options);
|
||||
let dimensions = WindowSize::current(self.options.font_size_fallback)?;
|
||||
scaler.process(self.state.presentation(), &dimensions)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn trigger_slide_async_renders(presentation: &mut Presentation) -> Vec<Box<dyn Pollable>> {
|
||||
let slide = presentation.current_slide_mut();
|
||||
let mut pollables = Vec::new();
|
||||
for operation in slide.iter_visible_operations_mut() {
|
||||
if let RenderOperation::RenderAsync(operation) = operation {
|
||||
if let RenderAsyncStartPolicy::OnDemand = operation.start_policy() {
|
||||
pollables.push(operation.pollable());
|
||||
}
|
||||
}
|
||||
}
|
||||
pollables
|
||||
}
|
||||
|
||||
fn is_displaying_other_error(&self) -> bool {
|
||||
@ -351,22 +415,17 @@ impl<'a> Presenter<'a> {
|
||||
fn load_presentation(&mut self, path: &Path) -> Result<Presentation, LoadPresentationError> {
|
||||
let content = fs::read_to_string(path).map_err(LoadPresentationError::Reading)?;
|
||||
let elements = self.parser.parse(&content)?;
|
||||
let export_mode = matches!(self.options.mode, PresentMode::Export);
|
||||
let mut presentation = PresentationBuilder::new(
|
||||
let presentation = PresentationBuilder::new(
|
||||
self.default_theme,
|
||||
&mut self.resources,
|
||||
self.resources.clone(),
|
||||
&mut self.third_party,
|
||||
self.code_executor.clone(),
|
||||
&self.themes,
|
||||
ImageRegistry(self.image_printer.clone()),
|
||||
ImageRegistry::new(self.image_printer.clone()),
|
||||
self.options.bindings.clone(),
|
||||
self.options.builder_options.clone(),
|
||||
)
|
||||
)?
|
||||
.build(elements)?;
|
||||
if export_mode {
|
||||
ImageReplacer::default().replace_presentation_images(&mut presentation);
|
||||
}
|
||||
|
||||
Ok(presentation)
|
||||
}
|
||||
|
||||
@ -392,7 +451,7 @@ impl<'a> Presenter<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
fn suspend(&self, drawer: &mut TerminalDrawer<Stdout>) {
|
||||
fn suspend(&self, drawer: &mut TerminalDrawer) {
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
drawer.terminal.suspend();
|
||||
@ -400,6 +459,140 @@ impl<'a> Presenter<'a> {
|
||||
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 {
|
||||
@ -407,6 +600,8 @@ enum CommandSideEffect {
|
||||
Suspend,
|
||||
Redraw,
|
||||
Reload,
|
||||
AnimateNextSlide,
|
||||
AnimatePreviousSlide,
|
||||
None,
|
||||
}
|
||||
|
||||
@ -478,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.
|
||||
@ -494,6 +686,9 @@ pub enum LoadPresentationError {
|
||||
|
||||
#[error(transparent)]
|
||||
Processing(#[from] BuildError),
|
||||
|
||||
#[error("processing theme: {0}")]
|
||||
ProcessingTheme(#[from] ProcessingThemeError),
|
||||
}
|
||||
|
||||
/// An error during the presentation.
|
||||
@ -504,7 +699,4 @@ pub enum PresentationError {
|
||||
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] io::Error),
|
||||
|
||||
#[error("fatal error: {0}")]
|
||||
Fatal(String),
|
||||
}
|
||||
|
102
src/render/ascii_scaler.rs
Normal file
102
src/render/ascii_scaler.rs
Normal file
@ -0,0 +1,102 @@
|
||||
use super::{
|
||||
RenderError,
|
||||
engine::{RenderEngine, RenderEngineOptions},
|
||||
};
|
||||
use crate::{
|
||||
WindowSize,
|
||||
presentation::Presentation,
|
||||
terminal::{
|
||||
image::Image,
|
||||
printer::{TerminalCommand, TerminalError, TerminalIo},
|
||||
},
|
||||
};
|
||||
use std::thread;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
pub(crate) struct AsciiScaler {
|
||||
options: RenderEngineOptions,
|
||||
}
|
||||
|
||||
impl AsciiScaler {
|
||||
pub(crate) fn new(options: RenderEngineOptions) -> Self {
|
||||
Self { options }
|
||||
}
|
||||
|
||||
pub(crate) fn process(self, presentation: &Presentation, dimensions: &WindowSize) -> Result<(), RenderError> {
|
||||
let mut collector = ImageCollector::default();
|
||||
for slide in presentation.iter_slides() {
|
||||
let engine = RenderEngine::new(&mut collector, dimensions.clone(), self.options.clone());
|
||||
engine.render(slide.iter_operations())?;
|
||||
}
|
||||
thread::spawn(move || Self::scale(collector.images));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn scale(images: Vec<ScalableImage>) {
|
||||
for image in images {
|
||||
let ascii_image = image.image.to_ascii();
|
||||
ascii_image.cache_scaling(image.columns, image.rows);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ScalableImage {
|
||||
image: Image,
|
||||
rows: u16,
|
||||
columns: u16,
|
||||
}
|
||||
|
||||
struct ImageCollector {
|
||||
current_column: u16,
|
||||
current_row: u16,
|
||||
current_row_height: u16,
|
||||
images: Vec<ScalableImage>,
|
||||
}
|
||||
|
||||
impl Default for ImageCollector {
|
||||
fn default() -> Self {
|
||||
Self { current_row: 0, current_column: 0, current_row_height: 1, images: Default::default() }
|
||||
}
|
||||
}
|
||||
|
||||
impl TerminalIo for ImageCollector {
|
||||
fn execute(&mut self, command: &TerminalCommand<'_>) -> Result<(), TerminalError> {
|
||||
use TerminalCommand::*;
|
||||
match command {
|
||||
MoveTo { column, row } => {
|
||||
self.current_column = *column;
|
||||
self.current_row = *row;
|
||||
}
|
||||
MoveToRow(row) => self.current_row = *row,
|
||||
MoveToColumn(column) => self.current_column = *column,
|
||||
MoveDown(amount) => self.current_row = self.current_row.saturating_add(*amount),
|
||||
MoveRight(amount) => self.current_column = self.current_column.saturating_add(*amount),
|
||||
MoveLeft(amount) => self.current_column = self.current_column.saturating_sub(*amount),
|
||||
MoveToNextLine => {
|
||||
self.current_row = self.current_row.saturating_add(1);
|
||||
self.current_column = 0;
|
||||
self.current_row_height = 1;
|
||||
}
|
||||
PrintText { content, style } => {
|
||||
self.current_column = self.current_column.saturating_add(content.width() as u16);
|
||||
self.current_row_height = self.current_row_height.max(style.size as u16);
|
||||
}
|
||||
PrintImage { image, options } => {
|
||||
// we can only really cache filesystem images for now
|
||||
let image = ScalableImage { image: image.clone(), rows: options.rows * 2, columns: options.columns };
|
||||
self.images.push(image);
|
||||
}
|
||||
ClearScreen => {
|
||||
self.current_column = 0;
|
||||
self.current_row = 0;
|
||||
self.current_row_height = 1;
|
||||
}
|
||||
BeginUpdate | EndUpdate | Flush | SetColors(_) | SetBackgroundColor(_) => (),
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cursor_row(&self) -> u16 {
|
||||
self.current_row
|
||||
}
|
||||
}
|
@ -1,5 +1,8 @@
|
||||
use super::{RenderError, RenderResult, layout::Layout, properties::CursorPosition, text::TextDrawer};
|
||||
use super::{
|
||||
RenderError, RenderResult, layout::Layout, operation::ImagePosition, properties::CursorPosition, text::TextDrawer,
|
||||
};
|
||||
use crate::{
|
||||
config::{MaxColumnsAlignment, MaxRowsAlignment},
|
||||
markdown::{text::WeightedLine, text_style::Colors},
|
||||
render::{
|
||||
layout::Positioning,
|
||||
@ -10,51 +13,70 @@ use crate::{
|
||||
properties::WindowSize,
|
||||
},
|
||||
terminal::{
|
||||
Terminal, TerminalWrite,
|
||||
image::{
|
||||
Image,
|
||||
printer::{ImageProperties, PrintOptions},
|
||||
scale::{fit_image_to_window, scale_image},
|
||||
scale::{ImageScaler, ScaleImage},
|
||||
},
|
||||
printer::{TerminalCommand, TerminalIo},
|
||||
},
|
||||
theme::Alignment,
|
||||
};
|
||||
use std::mem;
|
||||
|
||||
#[derive(Debug)]
|
||||
const MINIMUM_LINE_LENGTH: u16 = 10;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct MaxSize {
|
||||
pub(crate) max_columns: u16,
|
||||
pub(crate) max_columns_alignment: MaxColumnsAlignment,
|
||||
pub(crate) max_rows: u16,
|
||||
pub(crate) max_rows_alignment: MaxRowsAlignment,
|
||||
}
|
||||
|
||||
impl Default for MaxSize {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_columns: u16::MAX,
|
||||
max_columns_alignment: Default::default(),
|
||||
max_rows: u16::MAX,
|
||||
max_rows_alignment: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct RenderEngineOptions {
|
||||
pub(crate) validate_overflows: bool,
|
||||
pub(crate) max_columns: u16,
|
||||
pub(crate) max_size: MaxSize,
|
||||
pub(crate) column_layout_margin: u16,
|
||||
}
|
||||
|
||||
impl Default for RenderEngineOptions {
|
||||
fn default() -> Self {
|
||||
Self { validate_overflows: false, max_columns: u16::MAX }
|
||||
Self { validate_overflows: false, max_size: Default::default(), column_layout_margin: 4 }
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct RenderEngine<'a, W>
|
||||
pub(crate) struct RenderEngine<'a, T>
|
||||
where
|
||||
W: TerminalWrite,
|
||||
T: TerminalIo,
|
||||
{
|
||||
terminal: &'a mut Terminal<W>,
|
||||
terminal: &'a mut T,
|
||||
window_rects: Vec<WindowRect>,
|
||||
colors: Colors,
|
||||
max_modified_row: u16,
|
||||
layout: LayoutState,
|
||||
options: RenderEngineOptions,
|
||||
image_scaler: Box<dyn ScaleImage>,
|
||||
}
|
||||
|
||||
impl<'a, W> RenderEngine<'a, W>
|
||||
impl<'a, T> RenderEngine<'a, T>
|
||||
where
|
||||
W: TerminalWrite,
|
||||
T: TerminalIo,
|
||||
{
|
||||
pub(crate) fn new(
|
||||
terminal: &'a mut Terminal<W>,
|
||||
window_dimensions: WindowSize,
|
||||
options: RenderEngineOptions,
|
||||
) -> Self {
|
||||
let max_modified_row = terminal.cursor_row;
|
||||
pub(crate) fn new(terminal: &'a mut T, window_dimensions: WindowSize, options: RenderEngineOptions) -> Self {
|
||||
let max_modified_row = terminal.cursor_row();
|
||||
let current_rect = Self::starting_rect(window_dimensions, &options);
|
||||
let window_rects = vec![current_rect.clone()];
|
||||
Self {
|
||||
@ -64,26 +86,46 @@ where
|
||||
max_modified_row,
|
||||
layout: Default::default(),
|
||||
options,
|
||||
image_scaler: Box::<ImageScaler>::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn starting_rect(window_dimensions: WindowSize, options: &RenderEngineOptions) -> WindowRect {
|
||||
if window_dimensions.columns > options.max_columns {
|
||||
let extra_width = window_dimensions.columns - options.max_columns;
|
||||
let dimensions = window_dimensions.shrink_columns(extra_width);
|
||||
WindowRect { dimensions, start_column: extra_width / 2 }
|
||||
} else {
|
||||
WindowRect { dimensions: window_dimensions, start_column: 0 }
|
||||
fn starting_rect(mut dimensions: WindowSize, options: &RenderEngineOptions) -> WindowRect {
|
||||
let mut start_row = 0;
|
||||
let mut start_column = 0;
|
||||
if dimensions.columns > options.max_size.max_columns {
|
||||
let extra_width = dimensions.columns - options.max_size.max_columns;
|
||||
dimensions = dimensions.shrink_columns(extra_width);
|
||||
start_column = match options.max_size.max_columns_alignment {
|
||||
MaxColumnsAlignment::Left => 0,
|
||||
MaxColumnsAlignment::Center => extra_width / 2,
|
||||
MaxColumnsAlignment::Right => extra_width,
|
||||
};
|
||||
}
|
||||
if dimensions.rows > options.max_size.max_rows {
|
||||
let extra_height = dimensions.rows - options.max_size.max_rows;
|
||||
dimensions = dimensions.shrink_rows(extra_height);
|
||||
start_row = match options.max_size.max_rows_alignment {
|
||||
MaxRowsAlignment::Top => 0,
|
||||
MaxRowsAlignment::Center => extra_height / 2,
|
||||
MaxRowsAlignment::Bottom => extra_height,
|
||||
};
|
||||
}
|
||||
WindowRect { dimensions, start_column, start_row }
|
||||
}
|
||||
|
||||
pub(crate) fn render<'b>(mut self, operations: impl Iterator<Item = &'b RenderOperation>) -> RenderResult {
|
||||
self.terminal.begin_update()?;
|
||||
let current_rect = self.current_rect().clone();
|
||||
self.terminal.execute(&TerminalCommand::BeginUpdate)?;
|
||||
if current_rect.start_row != 0 || current_rect.start_column != 0 {
|
||||
self.terminal
|
||||
.execute(&TerminalCommand::MoveTo { column: current_rect.start_column, row: current_rect.start_row })?;
|
||||
}
|
||||
for operation in operations {
|
||||
self.render_one(operation)?;
|
||||
}
|
||||
self.terminal.end_update()?;
|
||||
self.terminal.flush()?;
|
||||
self.terminal.execute(&TerminalCommand::EndUpdate)?;
|
||||
self.terminal.execute(&TerminalCommand::Flush)?;
|
||||
if self.options.validate_overflows && self.max_modified_row > self.window_rects[0].dimensions.rows {
|
||||
return Err(RenderError::VerticalOverflow);
|
||||
}
|
||||
@ -99,7 +141,8 @@ where
|
||||
RenderOperation::JumpToVerticalCenter => self.jump_to_vertical_center(),
|
||||
RenderOperation::JumpToRow { index } => self.jump_to_row(*index),
|
||||
RenderOperation::JumpToBottomRow { index } => self.jump_to_bottom(*index),
|
||||
RenderOperation::RenderText { line, alignment } => self.render_text(line, alignment),
|
||||
RenderOperation::JumpToColumn { index } => self.jump_to_column(*index),
|
||||
RenderOperation::RenderText { line, alignment } => self.render_text(line, *alignment),
|
||||
RenderOperation::RenderLineBreak => self.render_line_break(),
|
||||
RenderOperation::RenderImage(image, properties) => self.render_image(image, properties),
|
||||
RenderOperation::RenderBlockLine(operation) => self.render_block_line(operation),
|
||||
@ -110,9 +153,9 @@ where
|
||||
RenderOperation::ExitLayout => self.exit_layout(),
|
||||
}?;
|
||||
if let LayoutState::EnteredColumn { column, columns } = &mut self.layout {
|
||||
columns[*column].current_row = self.terminal.cursor_row;
|
||||
columns[*column].current_row = self.terminal.cursor_row();
|
||||
};
|
||||
self.max_modified_row = self.max_modified_row.max(self.terminal.cursor_row);
|
||||
self.max_modified_row = self.max_modified_row.max(self.terminal.cursor_row());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -126,17 +169,21 @@ where
|
||||
}
|
||||
|
||||
fn clear_screen(&mut self) -> RenderResult {
|
||||
self.terminal.clear_screen()?;
|
||||
self.terminal.move_to(0, 0)?;
|
||||
let current = self.current_rect().clone();
|
||||
self.terminal.execute(&TerminalCommand::ClearScreen)?;
|
||||
self.terminal.execute(&TerminalCommand::MoveTo { column: current.start_column, row: current.start_row })?;
|
||||
self.max_modified_row = 0;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_margin(&mut self, properties: &MarginProperties) -> RenderResult {
|
||||
let MarginProperties { horizontal_margin, bottom_slide_margin } = properties;
|
||||
let MarginProperties { horizontal: horizontal_margin, top, bottom } = properties;
|
||||
let current = self.current_rect();
|
||||
let margin = horizontal_margin.as_characters(current.dimensions.columns);
|
||||
let new_rect = current.apply_margin(margin).shrink_rows(*bottom_slide_margin);
|
||||
let new_rect = current.shrink_horizontal(margin).shrink_bottom(*bottom).shrink_top(*top);
|
||||
if new_rect.start_row != self.terminal.cursor_row() {
|
||||
self.terminal.execute(&TerminalCommand::MoveToRow(new_rect.start_row))?;
|
||||
}
|
||||
self.window_rects.push(new_rect);
|
||||
Ok(())
|
||||
}
|
||||
@ -155,78 +202,113 @@ where
|
||||
}
|
||||
|
||||
fn apply_colors(&mut self) -> RenderResult {
|
||||
self.terminal.set_colors(self.colors)?;
|
||||
self.terminal.execute(&TerminalCommand::SetColors(self.colors))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn jump_to_vertical_center(&mut self) -> RenderResult {
|
||||
let center_row = self.current_dimensions().rows / 2;
|
||||
self.terminal.move_to_row(center_row)?;
|
||||
let current = self.current_rect();
|
||||
let center_row = current.dimensions.rows / 2;
|
||||
let center_row = center_row.saturating_add(current.start_row);
|
||||
self.terminal.execute(&TerminalCommand::MoveToRow(center_row))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn jump_to_row(&mut self, index: u16) -> RenderResult {
|
||||
self.terminal.move_to_row(index)?;
|
||||
fn jump_to_row(&mut self, row: u16) -> RenderResult {
|
||||
// Make this relative to the beginning of the current rect.
|
||||
let row = self.current_rect().start_row.saturating_add(row);
|
||||
self.terminal.execute(&TerminalCommand::MoveToRow(row))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn jump_to_bottom(&mut self, index: u16) -> RenderResult {
|
||||
let target_row = self.current_dimensions().rows.saturating_sub(index).saturating_sub(1);
|
||||
self.terminal.move_to_row(target_row)?;
|
||||
let current = self.current_rect();
|
||||
let target_row = current.dimensions.rows.saturating_sub(index).saturating_sub(1);
|
||||
let target_row = target_row.saturating_add(current.start_row);
|
||||
self.terminal.execute(&TerminalCommand::MoveToRow(target_row))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_text(&mut self, text: &WeightedLine, alignment: &Alignment) -> RenderResult {
|
||||
let layout = self.build_layout(alignment.clone());
|
||||
fn jump_to_column(&mut self, column: u16) -> RenderResult {
|
||||
// Make this relative to the beginning of the current rect.
|
||||
let column = self.current_rect().start_column.saturating_add(column);
|
||||
self.terminal.execute(&TerminalCommand::MoveToColumn(column))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_text(&mut self, text: &WeightedLine, alignment: Alignment) -> RenderResult {
|
||||
let layout = self.build_layout(alignment);
|
||||
let dimensions = self.current_dimensions();
|
||||
let positioning = layout.compute(dimensions, text.width() as u16);
|
||||
let prefix = "".into();
|
||||
let text_drawer = TextDrawer::new(&prefix, 0, text, positioning, &self.colors)?;
|
||||
let text_drawer = TextDrawer::new(&prefix, 0, text, positioning, &self.colors, MINIMUM_LINE_LENGTH)?;
|
||||
let center_newlines = matches!(alignment, Alignment::Center { .. });
|
||||
let text_drawer = text_drawer.center_newlines(center_newlines);
|
||||
text_drawer.draw(self.terminal)?;
|
||||
// Restore colors
|
||||
self.apply_colors()
|
||||
}
|
||||
|
||||
fn render_line_break(&mut self) -> RenderResult {
|
||||
self.terminal.move_to_next_line(1)?;
|
||||
self.terminal.execute(&TerminalCommand::MoveToNextLine)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_image(&mut self, image: &Image, properties: &ImageRenderProperties) -> RenderResult {
|
||||
let rect = self.current_rect();
|
||||
let starting_position = CursorPosition { row: self.terminal.cursor_row, column: rect.start_column };
|
||||
let rect = self.current_rect().clone();
|
||||
let starting_row = self.terminal.cursor_row();
|
||||
let starting_cursor =
|
||||
CursorPosition { row: starting_row.saturating_sub(rect.start_row), column: rect.start_column };
|
||||
|
||||
let (width, height) = image.dimensions();
|
||||
let (cursor_position, columns, rows) = match properties.size {
|
||||
let (width, height) = image.image().dimensions();
|
||||
let (columns, rows) = match properties.size {
|
||||
ImageSize::ShrinkIfNeeded => {
|
||||
let scale = fit_image_to_window(&rect.dimensions, width, height, &starting_position);
|
||||
(CursorPosition { row: starting_position.row, column: scale.start_column }, scale.columns, scale.rows)
|
||||
let image_scale =
|
||||
self.image_scaler.fit_image_to_rect(&rect.dimensions, width, height, &starting_cursor);
|
||||
(image_scale.columns, image_scale.rows)
|
||||
}
|
||||
ImageSize::Specific(columns, rows) => (starting_position.clone(), columns, rows),
|
||||
ImageSize::Specific(columns, rows) => (columns, rows),
|
||||
ImageSize::WidthScaled { ratio } => {
|
||||
let extra_columns = (rect.dimensions.columns as f64 * (1.0 - ratio)).ceil() as u16;
|
||||
let dimensions = rect.dimensions.shrink_columns(extra_columns);
|
||||
let scale = scale_image(&dimensions, &rect.dimensions, width, height, &starting_position);
|
||||
(CursorPosition { row: starting_position.row, column: scale.start_column }, scale.columns, scale.rows)
|
||||
let image_scale =
|
||||
self.image_scaler.scale_image(&dimensions, &rect.dimensions, width, height, &starting_cursor);
|
||||
(image_scale.columns, image_scale.rows)
|
||||
}
|
||||
};
|
||||
let cursor = match &properties.position {
|
||||
ImagePosition::Cursor => starting_cursor.clone(),
|
||||
ImagePosition::Center => Self::center_cursor(columns, &rect.dimensions, &starting_cursor),
|
||||
ImagePosition::Right => Self::align_cursor_right(columns, &rect.dimensions, &starting_cursor),
|
||||
};
|
||||
self.terminal.execute(&TerminalCommand::MoveToColumn(cursor.column))?;
|
||||
|
||||
let options = PrintOptions {
|
||||
columns,
|
||||
rows,
|
||||
cursor_position,
|
||||
z_index: properties.z_index,
|
||||
column_width: rect.dimensions.pixels_per_column() as u16,
|
||||
row_height: rect.dimensions.pixels_per_row() as u16,
|
||||
background_color: properties.background_color,
|
||||
};
|
||||
self.terminal.print_image(image, &options)?;
|
||||
self.terminal.execute(&TerminalCommand::PrintImage { image: image.clone(), options })?;
|
||||
if properties.restore_cursor {
|
||||
self.terminal.move_to(starting_position.column, starting_position.row)?;
|
||||
self.terminal.execute(&TerminalCommand::MoveTo { column: starting_cursor.column, row: starting_row })?;
|
||||
} else {
|
||||
self.terminal.move_to_row(starting_position.row + rows)?;
|
||||
self.terminal.execute(&TerminalCommand::MoveToRow(starting_row + rows))?;
|
||||
}
|
||||
Ok(())
|
||||
self.apply_colors()
|
||||
}
|
||||
|
||||
fn center_cursor(columns: u16, window: &WindowSize, cursor: &CursorPosition) -> CursorPosition {
|
||||
let start_column = window.columns / 2 - (columns / 2);
|
||||
let start_column = start_column + cursor.column;
|
||||
CursorPosition { row: cursor.row, column: start_column }
|
||||
}
|
||||
|
||||
fn align_cursor_right(columns: u16, window: &WindowSize, cursor: &CursorPosition) -> CursorPosition {
|
||||
let start_column = window.columns.saturating_sub(columns).saturating_add(cursor.column);
|
||||
CursorPosition { row: cursor.row, column: start_column }
|
||||
}
|
||||
|
||||
fn render_block_line(&mut self, operation: &BlockLine) -> RenderResult {
|
||||
@ -239,7 +321,7 @@ where
|
||||
right_padding_length,
|
||||
repeat_prefix_on_wrap,
|
||||
} = operation;
|
||||
let layout = self.build_layout(alignment.clone());
|
||||
let layout = self.build_layout(*alignment).with_font_size(text.font_size());
|
||||
|
||||
let dimensions = self.current_dimensions();
|
||||
let Positioning { max_line_length, start_column } = layout.compute(dimensions, *block_length);
|
||||
@ -247,12 +329,13 @@ where
|
||||
return Err(RenderError::HorizontalOverflow);
|
||||
}
|
||||
|
||||
self.terminal.move_to_column(start_column)?;
|
||||
self.terminal.execute(&TerminalCommand::MoveToColumn(start_column))?;
|
||||
|
||||
let positioning = Positioning { max_line_length, start_column };
|
||||
let text_drawer = TextDrawer::new(prefix, *right_padding_length, text, positioning, &self.colors)?
|
||||
.with_surrounding_block(*block_color)
|
||||
.repeat_prefix_on_wrap(*repeat_prefix_on_wrap);
|
||||
let text_drawer =
|
||||
TextDrawer::new(prefix, *right_padding_length, text, positioning, &self.colors, MINIMUM_LINE_LENGTH)?
|
||||
.with_surrounding_block(*block_color)
|
||||
.repeat_prefix_on_wrap(*repeat_prefix_on_wrap);
|
||||
text_drawer.draw(self.terminal)?;
|
||||
|
||||
// Restore colors
|
||||
@ -282,7 +365,7 @@ where
|
||||
}
|
||||
let columns = columns
|
||||
.iter()
|
||||
.map(|width| Column { width: *width as u16, current_row: self.terminal.cursor_row })
|
||||
.map(|width| Column { width: *width as u16, current_row: self.terminal.cursor_row() })
|
||||
.collect();
|
||||
self.layout = LayoutState::InitializedColumn { columns };
|
||||
Ok(())
|
||||
@ -308,20 +391,24 @@ where
|
||||
let current_rect = self.current_rect();
|
||||
let unit_width = current_rect.dimensions.columns as f64 / total_column_units as f64;
|
||||
let start_column = current_rect.start_column + (unit_width * column_units_before as f64) as u16;
|
||||
let start_row = columns[column_index].current_row;
|
||||
let new_column_count = (total_column_units - columns[column_index].width) * unit_width as u16;
|
||||
let new_size = current_rect.dimensions.shrink_columns(new_column_count);
|
||||
let mut dimensions = WindowRect { dimensions: new_size, start_column };
|
||||
let new_size = current_rect
|
||||
.dimensions
|
||||
.shrink_columns(new_column_count)
|
||||
.shrink_rows(start_row.saturating_sub(current_rect.start_row));
|
||||
let mut dimensions = WindowRect { dimensions: new_size, start_column, start_row };
|
||||
// Shrink every column's right edge except for last
|
||||
if column_index < columns.len() - 1 {
|
||||
dimensions = dimensions.shrink_right(4);
|
||||
dimensions = dimensions.shrink_right(self.options.column_layout_margin);
|
||||
}
|
||||
// Shrink every column's left edge except for first
|
||||
if column_index > 0 {
|
||||
dimensions = dimensions.shrink_left(4);
|
||||
dimensions = dimensions.shrink_left(self.options.column_layout_margin);
|
||||
}
|
||||
|
||||
self.window_rects.push(dimensions);
|
||||
self.terminal.move_to_row(columns[column_index].current_row)?;
|
||||
self.terminal.execute(&TerminalCommand::MoveToRow(start_row))?;
|
||||
self.layout = LayoutState::EnteredColumn { column: column_index, columns };
|
||||
Ok(())
|
||||
}
|
||||
@ -330,7 +417,7 @@ where
|
||||
match &self.layout {
|
||||
LayoutState::Default | LayoutState::InitializedColumn { .. } => Ok(()),
|
||||
LayoutState::EnteredColumn { .. } => {
|
||||
self.terminal.move_to(0, self.max_modified_row)?;
|
||||
self.terminal.execute(&TerminalCommand::MoveTo { column: 0, row: self.max_modified_row })?;
|
||||
self.layout = LayoutState::Default;
|
||||
self.pop_margin()?;
|
||||
Ok(())
|
||||
@ -365,28 +452,490 @@ struct Column {
|
||||
struct WindowRect {
|
||||
dimensions: WindowSize,
|
||||
start_column: u16,
|
||||
start_row: u16,
|
||||
}
|
||||
|
||||
impl WindowRect {
|
||||
fn apply_margin(&self, margin: u16) -> Self {
|
||||
fn shrink_horizontal(&self, margin: u16) -> Self {
|
||||
let dimensions = self.dimensions.shrink_columns(margin.saturating_mul(2));
|
||||
let start_column = self.start_column + margin;
|
||||
Self { dimensions, start_column }
|
||||
Self { dimensions, start_column, start_row: self.start_row }
|
||||
}
|
||||
|
||||
fn shrink_left(&self, size: u16) -> Self {
|
||||
let dimensions = self.dimensions.shrink_columns(size);
|
||||
let start_column = self.start_column.saturating_add(size);
|
||||
Self { dimensions, start_column }
|
||||
Self { dimensions, start_column, start_row: self.start_row }
|
||||
}
|
||||
|
||||
fn shrink_right(&self, size: u16) -> Self {
|
||||
let dimensions = self.dimensions.shrink_columns(size);
|
||||
Self { dimensions, start_column: self.start_column }
|
||||
Self { dimensions, start_column: self.start_column, start_row: self.start_row }
|
||||
}
|
||||
|
||||
fn shrink_rows(&self, rows: u16) -> Self {
|
||||
fn shrink_top(&self, rows: u16) -> Self {
|
||||
let dimensions = self.dimensions.shrink_rows(rows);
|
||||
Self { dimensions, start_column: self.start_column }
|
||||
let start_row = self.start_row.saturating_add(rows);
|
||||
Self { dimensions, start_column: self.start_column, start_row }
|
||||
}
|
||||
|
||||
fn shrink_bottom(&self, rows: u16) -> Self {
|
||||
let dimensions = self.dimensions.shrink_rows(rows);
|
||||
Self { dimensions, start_column: self.start_column, start_row: self.start_row }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
markdown::text_style::{Color, TextStyle},
|
||||
terminal::{
|
||||
image::{
|
||||
ImageSource,
|
||||
printer::{PrintImageError, TerminalImage},
|
||||
scale::TerminalRect,
|
||||
},
|
||||
printer::TerminalError,
|
||||
},
|
||||
theme::Margin,
|
||||
};
|
||||
use ::image::{ColorType, DynamicImage};
|
||||
use rstest::rstest;
|
||||
use std::io;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum Instruction {
|
||||
MoveTo(u16, u16),
|
||||
MoveToRow(u16),
|
||||
MoveToColumn(u16),
|
||||
MoveDown(u16),
|
||||
MoveRight(u16),
|
||||
MoveLeft(u16),
|
||||
MoveToNextLine,
|
||||
PrintText(String),
|
||||
ClearScreen,
|
||||
SetBackgroundColor(Color),
|
||||
PrintImage(PrintOptions),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct TerminalBuf {
|
||||
instructions: Vec<Instruction>,
|
||||
cursor_row: u16,
|
||||
}
|
||||
|
||||
impl TerminalBuf {
|
||||
fn push(&mut self, instruction: Instruction) -> io::Result<()> {
|
||||
self.instructions.push(instruction);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn move_to(&mut self, column: u16, row: u16) -> io::Result<()> {
|
||||
self.cursor_row = row;
|
||||
self.push(Instruction::MoveTo(column, row))
|
||||
}
|
||||
|
||||
fn move_to_row(&mut self, row: u16) -> io::Result<()> {
|
||||
self.cursor_row = row;
|
||||
self.push(Instruction::MoveToRow(row))
|
||||
}
|
||||
|
||||
fn move_to_column(&mut self, column: u16) -> io::Result<()> {
|
||||
self.push(Instruction::MoveToColumn(column))
|
||||
}
|
||||
|
||||
fn move_down(&mut self, amount: u16) -> io::Result<()> {
|
||||
self.push(Instruction::MoveDown(amount))
|
||||
}
|
||||
|
||||
fn move_right(&mut self, amount: u16) -> io::Result<()> {
|
||||
self.push(Instruction::MoveRight(amount))
|
||||
}
|
||||
|
||||
fn move_left(&mut self, amount: u16) -> io::Result<()> {
|
||||
self.push(Instruction::MoveLeft(amount))
|
||||
}
|
||||
|
||||
fn move_to_next_line(&mut self) -> io::Result<()> {
|
||||
self.push(Instruction::MoveToNextLine)
|
||||
}
|
||||
|
||||
fn print_text(&mut self, content: &str, _style: &TextStyle) -> io::Result<()> {
|
||||
let content = content.to_string();
|
||||
if content.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
self.cursor_row = content.width() as u16;
|
||||
self.push(Instruction::PrintText(content))
|
||||
}
|
||||
|
||||
fn clear_screen(&mut self) -> io::Result<()> {
|
||||
self.cursor_row = 0;
|
||||
self.push(Instruction::ClearScreen)
|
||||
}
|
||||
|
||||
fn set_colors(&mut self, _colors: Colors) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_background_color(&mut self, color: Color) -> io::Result<()> {
|
||||
self.push(Instruction::SetBackgroundColor(color))
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_image(&mut self, _image: &Image, options: &PrintOptions) -> Result<(), PrintImageError> {
|
||||
let _ = self.push(Instruction::PrintImage(options.clone()));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl TerminalIo for TerminalBuf {
|
||||
fn execute(&mut self, command: &TerminalCommand<'_>) -> Result<(), TerminalError> {
|
||||
use TerminalCommand::*;
|
||||
match command {
|
||||
BeginUpdate => (),
|
||||
EndUpdate => (),
|
||||
MoveTo { column, row } => self.move_to(*column, *row)?,
|
||||
MoveToRow(row) => self.move_to_row(*row)?,
|
||||
MoveToColumn(column) => self.move_to_column(*column)?,
|
||||
MoveDown(amount) => self.move_down(*amount)?,
|
||||
MoveRight(amount) => self.move_right(*amount)?,
|
||||
MoveLeft(amount) => self.move_left(*amount)?,
|
||||
MoveToNextLine => self.move_to_next_line()?,
|
||||
PrintText { content, style } => self.print_text(content, style)?,
|
||||
ClearScreen => self.clear_screen()?,
|
||||
SetColors(colors) => self.set_colors(*colors)?,
|
||||
SetBackgroundColor(color) => self.set_background_color(*color)?,
|
||||
Flush => self.flush()?,
|
||||
PrintImage { image, options } => self.print_image(image, options)?,
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cursor_row(&self) -> u16 {
|
||||
self.cursor_row
|
||||
}
|
||||
}
|
||||
|
||||
struct DummyImageScaler;
|
||||
|
||||
impl ScaleImage for DummyImageScaler {
|
||||
fn scale_image(
|
||||
&self,
|
||||
_scale_size: &WindowSize,
|
||||
_window_dimensions: &WindowSize,
|
||||
image_width: u32,
|
||||
image_height: u32,
|
||||
_position: &CursorPosition,
|
||||
) -> TerminalRect {
|
||||
TerminalRect { rows: image_width as u16, columns: image_height as u16 }
|
||||
}
|
||||
|
||||
fn fit_image_to_rect(
|
||||
&self,
|
||||
_dimensions: &WindowSize,
|
||||
image_width: u32,
|
||||
image_height: u32,
|
||||
_position: &CursorPosition,
|
||||
) -> TerminalRect {
|
||||
TerminalRect { rows: image_width as u16, columns: image_height as u16 }
|
||||
}
|
||||
}
|
||||
|
||||
fn do_render(max_size: MaxSize, operations: &[RenderOperation]) -> Vec<Instruction> {
|
||||
let mut buf = TerminalBuf::default();
|
||||
let dimensions = WindowSize { rows: 100, columns: 100, height: 200, width: 200 };
|
||||
let options = RenderEngineOptions { validate_overflows: false, max_size, column_layout_margin: 0 };
|
||||
let mut engine = RenderEngine::new(&mut buf, dimensions, options);
|
||||
engine.image_scaler = Box::new(DummyImageScaler);
|
||||
engine.render(operations.iter()).expect("render failed");
|
||||
buf.instructions
|
||||
}
|
||||
|
||||
fn render(operations: &[RenderOperation]) -> Vec<Instruction> {
|
||||
do_render(Default::default(), operations)
|
||||
}
|
||||
|
||||
fn render_with_max_size(operations: &[RenderOperation]) -> Vec<Instruction> {
|
||||
let max_size = MaxSize {
|
||||
max_rows: 10,
|
||||
max_rows_alignment: MaxRowsAlignment::Center,
|
||||
max_columns: 20,
|
||||
max_columns_alignment: MaxColumnsAlignment::Center,
|
||||
};
|
||||
do_render(max_size, operations)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn columns() {
|
||||
let ops = render(&[
|
||||
RenderOperation::InitColumnLayout { columns: vec![1, 1] },
|
||||
// print on column 0
|
||||
RenderOperation::EnterColumn { column: 0 },
|
||||
RenderOperation::RenderText { line: "A".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },
|
||||
// print on column 1
|
||||
RenderOperation::EnterColumn { column: 1 },
|
||||
RenderOperation::RenderText { line: "B".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },
|
||||
// go back to column 0 and print
|
||||
RenderOperation::EnterColumn { column: 0 },
|
||||
RenderOperation::RenderText { line: "1".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },
|
||||
]);
|
||||
let expected = [
|
||||
Instruction::MoveToRow(0),
|
||||
Instruction::MoveToColumn(0),
|
||||
Instruction::PrintText("A".into()),
|
||||
Instruction::MoveToRow(0),
|
||||
Instruction::MoveToColumn(50),
|
||||
Instruction::PrintText("B".into()),
|
||||
// when we go back we should proceed from where we left off (row == 1)
|
||||
Instruction::MoveToRow(1),
|
||||
Instruction::MoveToColumn(0),
|
||||
Instruction::PrintText("1".into()),
|
||||
];
|
||||
assert_eq!(ops, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bottom_margin() {
|
||||
let ops = render(&[
|
||||
RenderOperation::ApplyMargin(MarginProperties { horizontal: Margin::Fixed(1), top: 0, bottom: 10 }),
|
||||
RenderOperation::RenderText { line: "A".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },
|
||||
RenderOperation::JumpToBottomRow { index: 0 },
|
||||
RenderOperation::RenderText { line: "B".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },
|
||||
]);
|
||||
let expected = [
|
||||
Instruction::MoveToColumn(1),
|
||||
Instruction::PrintText("A".into()),
|
||||
// 100 - 10 (bottom margin)
|
||||
Instruction::MoveToRow(89),
|
||||
Instruction::MoveToColumn(1),
|
||||
Instruction::PrintText("B".into()),
|
||||
];
|
||||
assert_eq!(ops, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_margin() {
|
||||
let ops = render(&[
|
||||
RenderOperation::ApplyMargin(MarginProperties { horizontal: Margin::Fixed(1), top: 3, bottom: 0 }),
|
||||
RenderOperation::RenderText { line: "A".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },
|
||||
]);
|
||||
let expected = [Instruction::MoveToRow(3), Instruction::MoveToColumn(1), Instruction::PrintText("A".into())];
|
||||
assert_eq!(ops, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn margins() {
|
||||
let ops = render(&[
|
||||
RenderOperation::ApplyMargin(MarginProperties { horizontal: Margin::Fixed(1), top: 3, bottom: 10 }),
|
||||
RenderOperation::JumpToRow { index: 0 },
|
||||
RenderOperation::RenderText { line: "A".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },
|
||||
RenderOperation::JumpToBottomRow { index: 0 },
|
||||
RenderOperation::RenderText { line: "B".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },
|
||||
]);
|
||||
let expected = [
|
||||
Instruction::MoveToRow(3),
|
||||
Instruction::MoveToRow(3),
|
||||
Instruction::MoveToColumn(1),
|
||||
Instruction::PrintText("A".into()),
|
||||
// 100 - 10 (bottom margin)
|
||||
Instruction::MoveToRow(89),
|
||||
Instruction::MoveToColumn(1),
|
||||
Instruction::PrintText("B".into()),
|
||||
];
|
||||
assert_eq!(ops, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_margins() {
|
||||
let ops = render(&[
|
||||
RenderOperation::ApplyMargin(MarginProperties { horizontal: Margin::Fixed(1), top: 0, bottom: 10 }),
|
||||
RenderOperation::ApplyMargin(MarginProperties { horizontal: Margin::Fixed(1), top: 0, bottom: 10 }),
|
||||
RenderOperation::RenderText { line: "A".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },
|
||||
RenderOperation::JumpToBottomRow { index: 0 },
|
||||
RenderOperation::RenderText { line: "B".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },
|
||||
// pop and go to bottom, this should go back up to the end of the first margin
|
||||
RenderOperation::PopMargin,
|
||||
RenderOperation::JumpToBottomRow { index: 0 },
|
||||
RenderOperation::RenderText { line: "C".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },
|
||||
]);
|
||||
let expected = [
|
||||
Instruction::MoveToColumn(2),
|
||||
Instruction::PrintText("A".into()),
|
||||
// 100 - 10 (margin) - 10 (second margin)
|
||||
Instruction::MoveToRow(79),
|
||||
Instruction::MoveToColumn(2),
|
||||
Instruction::PrintText("B".into()),
|
||||
// 100 - 10 (margin)
|
||||
Instruction::MoveToRow(89),
|
||||
Instruction::MoveToColumn(1),
|
||||
Instruction::PrintText("C".into()),
|
||||
];
|
||||
assert_eq!(ops, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn margin_with_max_size() {
|
||||
let ops = render_with_max_size(&[
|
||||
RenderOperation::RenderText { line: "A".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },
|
||||
RenderOperation::ApplyMargin(MarginProperties { horizontal: Margin::Fixed(1), top: 2, bottom: 1 }),
|
||||
RenderOperation::RenderText { line: "B".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },
|
||||
RenderOperation::JumpToBottomRow { index: 0 },
|
||||
RenderOperation::RenderText { line: "C".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },
|
||||
]);
|
||||
let expected = [
|
||||
// centered 20x10
|
||||
Instruction::MoveTo(40, 45),
|
||||
Instruction::MoveToColumn(40),
|
||||
Instruction::PrintText("A".into()),
|
||||
// jump 2 down because of top margin
|
||||
Instruction::MoveToRow(47),
|
||||
// jump 1 right because of horizontal margin
|
||||
Instruction::MoveToColumn(41),
|
||||
Instruction::PrintText("B".into()),
|
||||
// rows go from 47 to 53 (7 total)
|
||||
Instruction::MoveToRow(53),
|
||||
Instruction::MoveToColumn(41),
|
||||
Instruction::PrintText("C".into()),
|
||||
];
|
||||
assert_eq!(ops, expected);
|
||||
}
|
||||
|
||||
// print the same 2x2 image with all size configs, they should all yield the same
|
||||
#[rstest]
|
||||
#[case::shrink(ImageSize::ShrinkIfNeeded)]
|
||||
#[case::specific(ImageSize::Specific(2, 2))]
|
||||
#[case::width_scaled(ImageSize::WidthScaled { ratio: 1.0 })]
|
||||
fn image(#[case] size: ImageSize) {
|
||||
let image = DynamicImage::new(2, 2, ColorType::Rgba8);
|
||||
let image = Image::new(TerminalImage::Ascii(image.into()), ImageSource::Generated);
|
||||
let properties = ImageRenderProperties {
|
||||
z_index: 0,
|
||||
size,
|
||||
restore_cursor: false,
|
||||
background_color: None,
|
||||
position: ImagePosition::Cursor,
|
||||
};
|
||||
let ops = render_with_max_size(&[RenderOperation::RenderImage(image, properties)]);
|
||||
let expected = [
|
||||
// centered 20x10, the image is 2x2 so we stand one away from center
|
||||
Instruction::MoveTo(40, 45),
|
||||
Instruction::MoveToColumn(40),
|
||||
Instruction::PrintImage(PrintOptions {
|
||||
columns: 2,
|
||||
rows: 2,
|
||||
z_index: 0,
|
||||
background_color: None,
|
||||
column_width: 2,
|
||||
row_height: 2,
|
||||
}),
|
||||
// place cursor after the image
|
||||
Instruction::MoveToRow(47),
|
||||
];
|
||||
assert_eq!(ops, expected);
|
||||
}
|
||||
|
||||
// same as the above but center it
|
||||
#[rstest]
|
||||
#[case::shrink(ImageSize::ShrinkIfNeeded)]
|
||||
#[case::specific(ImageSize::Specific(2, 2))]
|
||||
#[case::width_scaled(ImageSize::WidthScaled { ratio: 1.0 })]
|
||||
fn centered_image(#[case] size: ImageSize) {
|
||||
let image = DynamicImage::new(2, 2, ColorType::Rgba8);
|
||||
let image = Image::new(TerminalImage::Ascii(image.into()), ImageSource::Generated);
|
||||
let properties = ImageRenderProperties {
|
||||
z_index: 0,
|
||||
size,
|
||||
restore_cursor: false,
|
||||
background_color: None,
|
||||
position: ImagePosition::Center,
|
||||
};
|
||||
let ops = render_with_max_size(&[RenderOperation::RenderImage(image, properties)]);
|
||||
let expected = [
|
||||
// centered 20x10, the image is 2x2 so we stand one away from center
|
||||
Instruction::MoveTo(40, 45),
|
||||
Instruction::MoveToColumn(49),
|
||||
Instruction::PrintImage(PrintOptions {
|
||||
columns: 2,
|
||||
rows: 2,
|
||||
z_index: 0,
|
||||
background_color: None,
|
||||
column_width: 2,
|
||||
row_height: 2,
|
||||
}),
|
||||
// place cursor after the image
|
||||
Instruction::MoveToRow(47),
|
||||
];
|
||||
assert_eq!(ops, expected);
|
||||
}
|
||||
|
||||
// same as the above but use right alignment
|
||||
#[rstest]
|
||||
#[case::shrink(ImageSize::ShrinkIfNeeded)]
|
||||
#[case::specific(ImageSize::Specific(2, 2))]
|
||||
#[case::width_scaled(ImageSize::WidthScaled { ratio: 1.0 })]
|
||||
fn right_aligned_image(#[case] size: ImageSize) {
|
||||
let image = DynamicImage::new(2, 2, ColorType::Rgba8);
|
||||
let image = Image::new(TerminalImage::Ascii(image.into()), ImageSource::Generated);
|
||||
let properties = ImageRenderProperties {
|
||||
z_index: 0,
|
||||
size,
|
||||
restore_cursor: false,
|
||||
background_color: None,
|
||||
position: ImagePosition::Right,
|
||||
};
|
||||
let ops = render_with_max_size(&[RenderOperation::RenderImage(image, properties)]);
|
||||
let expected = [
|
||||
// right aligned 20x10, the image is 2x2 so we stand one away from the right
|
||||
Instruction::MoveTo(40, 45),
|
||||
Instruction::MoveToColumn(58),
|
||||
Instruction::PrintImage(PrintOptions {
|
||||
columns: 2,
|
||||
rows: 2,
|
||||
z_index: 0,
|
||||
background_color: None,
|
||||
column_width: 2,
|
||||
row_height: 2,
|
||||
}),
|
||||
// place cursor after the image
|
||||
Instruction::MoveToRow(47),
|
||||
];
|
||||
assert_eq!(ops, expected);
|
||||
}
|
||||
|
||||
// same as the above but center it
|
||||
#[rstest]
|
||||
fn restore_cursor_after_image() {
|
||||
let image = DynamicImage::new(2, 2, ColorType::Rgba8);
|
||||
let image = Image::new(TerminalImage::Ascii(image.into()), ImageSource::Generated);
|
||||
let properties = ImageRenderProperties {
|
||||
z_index: 0,
|
||||
size: ImageSize::ShrinkIfNeeded,
|
||||
restore_cursor: true,
|
||||
background_color: None,
|
||||
position: ImagePosition::Center,
|
||||
};
|
||||
let ops = render_with_max_size(&[RenderOperation::RenderImage(image, properties)]);
|
||||
let expected = [
|
||||
// centered 20x10, the image is 2x2 so we stand one away from center
|
||||
Instruction::MoveTo(40, 45),
|
||||
Instruction::MoveToColumn(49),
|
||||
Instruction::PrintImage(PrintOptions {
|
||||
columns: 2,
|
||||
rows: 2,
|
||||
z_index: 0,
|
||||
background_color: None,
|
||||
column_width: 2,
|
||||
row_height: 2,
|
||||
}),
|
||||
// place cursor after the image
|
||||
Instruction::MoveTo(40, 45),
|
||||
];
|
||||
assert_eq!(ops, expected);
|
||||
}
|
||||
}
|
||||
|
@ -4,11 +4,12 @@ use crate::{render::properties::WindowSize, theme::Alignment};
|
||||
pub(crate) struct Layout {
|
||||
alignment: Alignment,
|
||||
start_column_offset: u16,
|
||||
font_size: u16,
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
pub(crate) fn new(alignment: Alignment) -> Self {
|
||||
Self { alignment, start_column_offset: 0 }
|
||||
Self { alignment, start_column_offset: 0, font_size: 1 }
|
||||
}
|
||||
|
||||
pub(crate) fn with_start_column(mut self, column: u16) -> Self {
|
||||
@ -16,7 +17,13 @@ impl Layout {
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_font_size(mut self, font_size: u8) -> Self {
|
||||
self.font_size = font_size as u16;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn compute(&self, dimensions: &WindowSize, text_length: u16) -> Positioning {
|
||||
let text_length = text_length * self.font_size;
|
||||
let max_line_length;
|
||||
let mut start_column;
|
||||
match &self.alignment {
|
||||
|
@ -1,3 +1,4 @@
|
||||
pub(crate) mod ascii_scaler;
|
||||
pub(crate) mod engine;
|
||||
pub(crate) mod layout;
|
||||
pub(crate) mod operation;
|
||||
@ -13,43 +14,44 @@ use crate::{
|
||||
},
|
||||
render::{operation::RenderOperation, properties::WindowSize},
|
||||
terminal::{
|
||||
Terminal, TerminalWrite,
|
||||
Terminal,
|
||||
image::printer::{ImagePrinter, PrintImageError},
|
||||
printer::TerminalError,
|
||||
},
|
||||
theme::{Alignment, Margin},
|
||||
};
|
||||
use engine::{RenderEngine, RenderEngineOptions};
|
||||
use std::{io, sync::Arc};
|
||||
use engine::{MaxSize, RenderEngine, RenderEngineOptions};
|
||||
use operation::AsRenderOperations;
|
||||
use std::{
|
||||
io::{self, Stdout},
|
||||
iter,
|
||||
rc::Rc,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
/// The result of a render operation.
|
||||
pub(crate) type RenderResult = Result<(), RenderError>;
|
||||
|
||||
pub(crate) struct TerminalDrawerOptions {
|
||||
/// The font size to fall back to if we can't find the window size in pixels.
|
||||
pub(crate) font_size_fallback: u8,
|
||||
|
||||
/// The max width in columns that the presentation should be capped to.
|
||||
pub(crate) max_columns: u16,
|
||||
pub(crate) max_size: MaxSize,
|
||||
}
|
||||
|
||||
impl Default for TerminalDrawerOptions {
|
||||
fn default() -> Self {
|
||||
Self { font_size_fallback: 1, max_columns: u16::MAX }
|
||||
Self { font_size_fallback: 1, max_size: Default::default() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Allows drawing on the terminal.
|
||||
pub(crate) struct TerminalDrawer<W: TerminalWrite> {
|
||||
pub(crate) terminal: Terminal<W>,
|
||||
pub(crate) struct TerminalDrawer {
|
||||
pub(crate) terminal: Terminal<Stdout>,
|
||||
options: TerminalDrawerOptions,
|
||||
}
|
||||
|
||||
impl<W> TerminalDrawer<W>
|
||||
where
|
||||
W: TerminalWrite,
|
||||
{
|
||||
pub(crate) fn new(handle: W, image_printer: Arc<ImagePrinter>, options: TerminalDrawerOptions) -> io::Result<Self> {
|
||||
let terminal = Terminal::new(handle, image_printer)?;
|
||||
impl TerminalDrawer {
|
||||
pub(crate) fn new(image_printer: Arc<ImagePrinter>, options: TerminalDrawerOptions) -> io::Result<Self> {
|
||||
let terminal = Terminal::new(io::stdout(), image_printer)?;
|
||||
Ok(Self { terminal, options })
|
||||
}
|
||||
|
||||
@ -64,41 +66,20 @@ where
|
||||
}
|
||||
|
||||
pub(crate) fn render_error(&mut self, message: &str, source: &ErrorSource) -> RenderResult {
|
||||
let operation = RenderErrorOperation { message: message.into(), source: source.clone() };
|
||||
let operation = RenderOperation::RenderDynamic(Rc::new(operation));
|
||||
let dimensions = WindowSize::current(self.options.font_size_fallback)?;
|
||||
let heading_text = match source {
|
||||
ErrorSource::Presentation => "Error loading presentation".to_string(),
|
||||
ErrorSource::Slide(slide) => {
|
||||
format!("Error in slide {slide}")
|
||||
}
|
||||
};
|
||||
let heading = vec![Text::new(heading_text, TextStyle::default().bold()), Text::from(": ")];
|
||||
let total_lines = message.lines().count();
|
||||
let starting_row = (dimensions.rows / 2).saturating_sub(total_lines as u16 / 2 + 3);
|
||||
let alignment = Alignment::Left { margin: Margin::Percent(25) };
|
||||
|
||||
let mut operations = vec![
|
||||
RenderOperation::SetColors(Colors {
|
||||
foreground: Some(Color::new(255, 0, 0)),
|
||||
background: Some(Color::new(0, 0, 0)),
|
||||
}),
|
||||
RenderOperation::ClearScreen,
|
||||
RenderOperation::JumpToRow { index: starting_row },
|
||||
RenderOperation::RenderText { line: WeightedLine::from(heading), alignment: alignment.clone() },
|
||||
RenderOperation::RenderLineBreak,
|
||||
RenderOperation::RenderLineBreak,
|
||||
];
|
||||
for line in message.lines() {
|
||||
let error = vec![Text::from(line)];
|
||||
let op = RenderOperation::RenderText { line: WeightedLine::from(error), alignment: alignment.clone() };
|
||||
operations.extend([op, RenderOperation::RenderLineBreak]);
|
||||
}
|
||||
let engine = self.create_engine(dimensions);
|
||||
engine.render(operations.iter())?;
|
||||
engine.render(iter::once(&operation))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_engine(&mut self, dimensions: WindowSize) -> RenderEngine<W> {
|
||||
let options = RenderEngineOptions { max_columns: self.options.max_columns, ..Default::default() };
|
||||
pub(crate) fn render_engine_options(&self) -> RenderEngineOptions {
|
||||
RenderEngineOptions { max_size: self.options.max_size.clone(), ..Default::default() }
|
||||
}
|
||||
|
||||
fn create_engine(&mut self, dimensions: WindowSize) -> RenderEngine<Terminal<Stdout>> {
|
||||
let options = self.render_engine_options();
|
||||
RenderEngine::new(&mut self.terminal, dimensions, options)
|
||||
}
|
||||
}
|
||||
@ -109,6 +90,9 @@ pub(crate) enum RenderError {
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] io::Error),
|
||||
|
||||
#[error("terminal: {0}")]
|
||||
Terminal(#[from] TerminalError),
|
||||
|
||||
#[error("screen is too small")]
|
||||
TerminalTooSmall,
|
||||
|
||||
@ -131,7 +115,47 @@ pub(crate) enum RenderError {
|
||||
PaletteColor(#[from] PaletteColorError),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum ErrorSource {
|
||||
Presentation,
|
||||
Slide(usize),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct RenderErrorOperation {
|
||||
message: String,
|
||||
source: ErrorSource,
|
||||
}
|
||||
|
||||
impl AsRenderOperations for RenderErrorOperation {
|
||||
fn as_render_operations(&self, dimensions: &WindowSize) -> Vec<RenderOperation> {
|
||||
let heading_text = match self.source {
|
||||
ErrorSource::Presentation => "Error loading presentation".to_string(),
|
||||
ErrorSource::Slide(slide) => {
|
||||
format!("Error in slide {slide}")
|
||||
}
|
||||
};
|
||||
let heading = vec![Text::new(heading_text, TextStyle::default().bold()), Text::from(": ")];
|
||||
let total_lines = self.message.lines().count();
|
||||
let starting_row = (dimensions.rows / 2).saturating_sub(total_lines as u16 / 2 + 3);
|
||||
let alignment = Alignment::Left { margin: Margin::Percent(25) };
|
||||
|
||||
let mut operations = vec![
|
||||
RenderOperation::SetColors(Colors {
|
||||
foreground: Some(Color::new(255, 0, 0)),
|
||||
background: Some(Color::new(0, 0, 0)),
|
||||
}),
|
||||
RenderOperation::ClearScreen,
|
||||
RenderOperation::JumpToRow { index: starting_row },
|
||||
RenderOperation::RenderText { line: WeightedLine::from(heading), alignment },
|
||||
RenderOperation::RenderLineBreak,
|
||||
RenderOperation::RenderLineBreak,
|
||||
];
|
||||
for line in self.message.lines() {
|
||||
let error = vec![Text::from(line)];
|
||||
let op = RenderOperation::RenderText { line: WeightedLine::from(error), alignment };
|
||||
operations.extend([op, RenderOperation::RenderLineBreak]);
|
||||
}
|
||||
operations
|
||||
}
|
||||
}
|
||||
|
@ -7,7 +7,13 @@ use crate::{
|
||||
terminal::image::Image,
|
||||
theme::{Alignment, Margin},
|
||||
};
|
||||
use std::{fmt::Debug, rc::Rc};
|
||||
use std::{
|
||||
fmt::Debug,
|
||||
rc::Rc,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
const DEFAULT_IMAGE_Z_INDEX: i32 = -2;
|
||||
|
||||
/// A line of preformatted text to be rendered.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@ -46,6 +52,9 @@ pub(crate) enum RenderOperation {
|
||||
/// The index is zero based where 0 represents the bottom row.
|
||||
JumpToBottomRow { index: u16 },
|
||||
|
||||
/// Jump to the N-th column in the current layout.
|
||||
JumpToColumn { index: u16 },
|
||||
|
||||
/// Render text.
|
||||
RenderText { line: WeightedLine, alignment: Alignment },
|
||||
|
||||
@ -96,6 +105,26 @@ pub(crate) struct ImageRenderProperties {
|
||||
pub(crate) size: ImageSize,
|
||||
pub(crate) restore_cursor: bool,
|
||||
pub(crate) background_color: Option<Color>,
|
||||
pub(crate) position: ImagePosition,
|
||||
}
|
||||
|
||||
impl Default for ImageRenderProperties {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
z_index: DEFAULT_IMAGE_Z_INDEX,
|
||||
size: Default::default(),
|
||||
restore_cursor: false,
|
||||
background_color: None,
|
||||
position: ImagePosition::Center,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) enum ImagePosition {
|
||||
Cursor,
|
||||
Center,
|
||||
Right,
|
||||
}
|
||||
|
||||
/// The size used when printing an image.
|
||||
@ -113,10 +142,13 @@ pub(crate) enum ImageSize {
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct MarginProperties {
|
||||
/// The horizontal margin.
|
||||
pub(crate) horizontal_margin: Margin,
|
||||
pub(crate) horizontal: Margin,
|
||||
|
||||
/// The margin at the bottom of the slide.
|
||||
pub(crate) bottom_slide_margin: u16,
|
||||
/// The margin at the top.
|
||||
pub(crate) top: u16,
|
||||
|
||||
/// The margin at the bottom.
|
||||
pub(crate) bottom: u16,
|
||||
}
|
||||
|
||||
/// A type that can generate render operations.
|
||||
@ -132,24 +164,57 @@ pub(crate) trait AsRenderOperations: Debug + 'static {
|
||||
|
||||
/// An operation that can be rendered asynchronously.
|
||||
pub(crate) trait RenderAsync: AsRenderOperations {
|
||||
/// Start the render for this operation.
|
||||
/// Create a pollable for this render async.
|
||||
///
|
||||
/// Should return true if the invocation triggered the rendering (aka if rendering wasn't
|
||||
/// already started before).
|
||||
fn start_render(&self) -> bool;
|
||||
/// The pollable will be used to poll this by a separate thread, so all state that will
|
||||
/// be loaded asynchronously should be shared between this operation and any pollables
|
||||
/// generated from it.
|
||||
fn pollable(&self) -> Box<dyn Pollable>;
|
||||
|
||||
/// Get the start policy for this render.
|
||||
fn start_policy(&self) -> RenderAsyncStartPolicy {
|
||||
RenderAsyncStartPolicy::OnDemand
|
||||
}
|
||||
}
|
||||
|
||||
/// The start policy for an async render.
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub(crate) enum RenderAsyncStartPolicy {
|
||||
/// Start automatically.
|
||||
Automatic,
|
||||
|
||||
/// Start on demand.
|
||||
OnDemand,
|
||||
}
|
||||
|
||||
/// A pollable that can be used to pull and update the state of an operation asynchronously.
|
||||
pub(crate) trait Pollable: Send + 'static {
|
||||
/// Update the internal state and return the updated state.
|
||||
fn poll_state(&self) -> RenderAsyncState;
|
||||
fn poll(&mut self) -> PollableState;
|
||||
}
|
||||
|
||||
/// The state of a [RenderAsync].
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) enum RenderAsyncState {
|
||||
#[default]
|
||||
NotStarted,
|
||||
Rendering {
|
||||
modified: bool,
|
||||
},
|
||||
Rendered,
|
||||
JustFinishedRendering,
|
||||
/// The state of a [Pollable].
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum PollableState {
|
||||
Unmodified,
|
||||
Modified,
|
||||
Done,
|
||||
Failed { error: String },
|
||||
}
|
||||
|
||||
pub(crate) struct ToggleState {
|
||||
toggled: Arc<Mutex<bool>>,
|
||||
}
|
||||
|
||||
impl ToggleState {
|
||||
pub(crate) fn new(toggled: Arc<Mutex<bool>>) -> Self {
|
||||
Self { toggled }
|
||||
}
|
||||
}
|
||||
|
||||
impl Pollable for ToggleState {
|
||||
fn poll(&mut self) -> PollableState {
|
||||
*self.toggled.lock().unwrap() = true;
|
||||
PollableState::Done
|
||||
}
|
||||
}
|
||||
|
@ -27,10 +27,10 @@ impl WindowSize {
|
||||
};
|
||||
let font_size_fallback = font_size_fallback as u16;
|
||||
if size.width == 0 {
|
||||
size.width = size.columns * font_size_fallback;
|
||||
size.width = size.columns * font_size_fallback.max(1);
|
||||
}
|
||||
if size.height == 0 {
|
||||
size.height = size.rows * font_size_fallback * 2;
|
||||
size.height = size.rows * font_size_fallback.max(1) * 2;
|
||||
}
|
||||
Ok(size)
|
||||
}
|
||||
@ -72,6 +72,11 @@ impl WindowSize {
|
||||
pub(crate) fn pixels_per_row(&self) -> f64 {
|
||||
self.height as f64 / self.rows as f64
|
||||
}
|
||||
|
||||
/// The aspect ratio for this size.
|
||||
pub(crate) fn aspect_ratio(&self) -> f64 {
|
||||
(self.rows as f64 / self.height as f64) / (self.columns as f64 / self.width as f64)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crossterm::terminal::WindowSize> for WindowSize {
|
||||
@ -87,7 +92,7 @@ impl From<(u16, u16)> for WindowSize {
|
||||
}
|
||||
|
||||
/// The cursor's position.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub(crate) struct CursorPosition {
|
||||
pub(crate) column: u16,
|
||||
pub(crate) row: u16,
|
||||
|
@ -2,14 +2,12 @@ use crate::{
|
||||
markdown::{
|
||||
elements::Text,
|
||||
text::{WeightedLine, WeightedText},
|
||||
text_style::{Color, Colors},
|
||||
text_style::{Color, Colors, TextStyle},
|
||||
},
|
||||
render::{RenderError, RenderResult, layout::Positioning},
|
||||
terminal::{Terminal, TerminalWrite},
|
||||
terminal::printer::{TerminalCommand, TerminalIo},
|
||||
};
|
||||
|
||||
const MINIMUM_LINE_LENGTH: u16 = 10;
|
||||
|
||||
/// Draws text on the screen.
|
||||
///
|
||||
/// This deals with splitting words and doing word wrapping based on the given positioning.
|
||||
@ -18,11 +16,12 @@ pub(crate) struct TextDrawer<'a> {
|
||||
right_padding_length: u16,
|
||||
line: &'a WeightedLine,
|
||||
positioning: Positioning,
|
||||
prefix_length: u16,
|
||||
prefix_width: u16,
|
||||
default_colors: &'a Colors,
|
||||
draw_block: bool,
|
||||
block_color: Option<Color>,
|
||||
repeat_prefix: bool,
|
||||
center_newlines: bool,
|
||||
}
|
||||
|
||||
impl<'a> TextDrawer<'a> {
|
||||
@ -32,17 +31,18 @@ impl<'a> TextDrawer<'a> {
|
||||
line: &'a WeightedLine,
|
||||
positioning: Positioning,
|
||||
default_colors: &'a Colors,
|
||||
minimum_line_length: u16,
|
||||
) -> Result<Self, RenderError> {
|
||||
let text_length = (line.width() + prefix.width() + right_padding_length as usize) as u16;
|
||||
// If our line doesn't fit and it's just too small then abort
|
||||
if text_length > positioning.max_line_length && positioning.max_line_length <= MINIMUM_LINE_LENGTH {
|
||||
if text_length > positioning.max_line_length && positioning.max_line_length <= minimum_line_length {
|
||||
Err(RenderError::TerminalTooSmall)
|
||||
} else {
|
||||
let prefix_length = prefix.width() as u16;
|
||||
let prefix_width = prefix.width() as u16;
|
||||
let positioning = Positioning {
|
||||
max_line_length: positioning
|
||||
.max_line_length
|
||||
.saturating_sub(prefix_length)
|
||||
.saturating_sub(prefix_width)
|
||||
.saturating_sub(right_padding_length),
|
||||
start_column: positioning.start_column,
|
||||
};
|
||||
@ -51,11 +51,12 @@ impl<'a> TextDrawer<'a> {
|
||||
right_padding_length,
|
||||
line,
|
||||
positioning,
|
||||
prefix_length,
|
||||
prefix_width,
|
||||
default_colors,
|
||||
draw_block: false,
|
||||
block_color: None,
|
||||
repeat_prefix: false,
|
||||
center_newlines: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -71,53 +72,68 @@ impl<'a> TextDrawer<'a> {
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn center_newlines(mut self, value: bool) -> Self {
|
||||
self.center_newlines = value;
|
||||
self
|
||||
}
|
||||
|
||||
/// Draw text on the given handle.
|
||||
///
|
||||
/// This performs word splitting and word wrapping.
|
||||
pub(crate) fn draw<W>(self, terminal: &mut Terminal<W>) -> RenderResult
|
||||
pub(crate) fn draw<T>(self, terminal: &mut T) -> RenderResult
|
||||
where
|
||||
W: TerminalWrite,
|
||||
T: TerminalIo,
|
||||
{
|
||||
let mut line_length: u16 = 0;
|
||||
terminal.execute(&TerminalCommand::MoveToColumn(self.positioning.start_column))?;
|
||||
let font_size = self.line.font_size();
|
||||
|
||||
// Print the prefix at the beginning of the line.
|
||||
let styled_prefix = {
|
||||
if self.prefix_width > 0 {
|
||||
let Text { content, style } = self.prefix.text();
|
||||
style.apply(content)?
|
||||
};
|
||||
terminal.move_to_column(self.positioning.start_column)?;
|
||||
terminal.print_styled_line(styled_prefix.clone())?;
|
||||
|
||||
let start_column = self.positioning.start_column + self.prefix_length;
|
||||
terminal.execute(&TerminalCommand::PrintText { content, style: *style })?;
|
||||
}
|
||||
for (line_index, line) in self.line.split(self.positioning.max_line_length as usize).enumerate() {
|
||||
if line_index > 0 {
|
||||
// Complete the current line's block to the right before moving down.
|
||||
self.print_block_background(line_length, terminal)?;
|
||||
terminal.move_down(1)?;
|
||||
terminal.execute(&TerminalCommand::MoveDown(font_size as u16))?;
|
||||
let start_column = match self.center_newlines {
|
||||
true => {
|
||||
let line_width = line.iter().map(|l| l.width()).sum::<usize>() as u16;
|
||||
let extra_space = self.positioning.max_line_length.saturating_sub(line_width);
|
||||
self.positioning.start_column + extra_space / 2
|
||||
}
|
||||
false => self.positioning.start_column,
|
||||
};
|
||||
terminal.execute(&TerminalCommand::MoveToColumn(start_column))?;
|
||||
line_length = 0;
|
||||
|
||||
// Complete the new line in this block to the left where the prefix would be.
|
||||
if self.prefix_length > 0 {
|
||||
terminal.move_to_column(self.positioning.start_column)?;
|
||||
if self.prefix_width > 0 {
|
||||
if self.repeat_prefix {
|
||||
terminal.print_styled_line(styled_prefix.clone())?;
|
||||
let Text { content, style } = self.prefix.text();
|
||||
terminal.execute(&TerminalCommand::PrintText { content, style: *style })?;
|
||||
} else {
|
||||
self.print_block_background(self.prefix_length, terminal)?;
|
||||
if let Some(color) = self.block_color {
|
||||
terminal.execute(&TerminalCommand::SetBackgroundColor(color))?;
|
||||
}
|
||||
let text = " ".repeat(self.prefix_width as usize / font_size as usize);
|
||||
let style = TextStyle::default().size(font_size);
|
||||
terminal.execute(&TerminalCommand::PrintText { content: &text, style })?;
|
||||
}
|
||||
}
|
||||
}
|
||||
terminal.move_to_column(start_column)?;
|
||||
for chunk in line {
|
||||
line_length = line_length.saturating_add(chunk.width() as u16);
|
||||
|
||||
let (text, style) = chunk.into_parts();
|
||||
let text = style.apply(text)?;
|
||||
terminal.print_styled_line(text)?;
|
||||
terminal.execute(&TerminalCommand::PrintText { content: text, style })?;
|
||||
|
||||
// Crossterm resets colors if any attributes are set so let's just re-apply colors
|
||||
// if the format has anything on it at all.
|
||||
if style != Default::default() {
|
||||
terminal.set_colors(*self.default_colors)?;
|
||||
terminal.execute(&TerminalCommand::SetColors(*self.default_colors))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -125,21 +141,238 @@ impl<'a> TextDrawer<'a> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_block_background<W>(&self, line_length: u16, terminal: &mut Terminal<W>) -> RenderResult
|
||||
fn print_block_background<T>(&self, line_length: u16, terminal: &mut T) -> RenderResult
|
||||
where
|
||||
W: TerminalWrite,
|
||||
T: TerminalIo,
|
||||
{
|
||||
if self.draw_block {
|
||||
let remaining =
|
||||
self.positioning.max_line_length.saturating_sub(line_length).saturating_add(self.right_padding_length);
|
||||
if remaining > 0 {
|
||||
let font_size = self.line.font_size();
|
||||
if let Some(color) = self.block_color {
|
||||
terminal.set_background_color(color)?;
|
||||
terminal.execute(&TerminalCommand::SetBackgroundColor(color))?;
|
||||
}
|
||||
let text = " ".repeat(remaining as usize);
|
||||
terminal.print_line(&text)?;
|
||||
let text = " ".repeat(remaining as usize / font_size as usize);
|
||||
let style = TextStyle::default().size(font_size);
|
||||
terminal.execute(&TerminalCommand::PrintText { content: &text, style })?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::terminal::printer::TerminalError;
|
||||
use std::io;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum Instruction {
|
||||
MoveDown(u16),
|
||||
MoveToColumn(u16),
|
||||
PrintText { content: String, font_size: u8 },
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct TerminalBuf {
|
||||
instructions: Vec<Instruction>,
|
||||
cursor_row: u16,
|
||||
}
|
||||
|
||||
impl TerminalBuf {
|
||||
fn push(&mut self, instruction: Instruction) -> io::Result<()> {
|
||||
self.instructions.push(instruction);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn move_to_column(&mut self, column: u16) -> std::io::Result<()> {
|
||||
self.push(Instruction::MoveToColumn(column))
|
||||
}
|
||||
|
||||
fn move_down(&mut self, amount: u16) -> std::io::Result<()> {
|
||||
self.push(Instruction::MoveDown(amount))
|
||||
}
|
||||
|
||||
fn print_text(&mut self, content: &str, style: &TextStyle) -> io::Result<()> {
|
||||
let content = content.to_string();
|
||||
if content.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
self.cursor_row = content.width() as u16;
|
||||
self.push(Instruction::PrintText { content, font_size: style.size })?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn clear_screen(&mut self) -> std::io::Result<()> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
fn set_colors(&mut self, _colors: Colors) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_background_color(&mut self, _color: Color) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl TerminalIo for TerminalBuf {
|
||||
fn execute(&mut self, command: &TerminalCommand<'_>) -> Result<(), TerminalError> {
|
||||
use TerminalCommand::*;
|
||||
match command {
|
||||
BeginUpdate
|
||||
| EndUpdate
|
||||
| MoveToRow(_)
|
||||
| MoveToNextLine
|
||||
| MoveTo { .. }
|
||||
| MoveRight(_)
|
||||
| MoveLeft(_)
|
||||
| PrintImage { .. } => {
|
||||
unimplemented!()
|
||||
}
|
||||
MoveToColumn(column) => self.move_to_column(*column)?,
|
||||
MoveDown(amount) => self.move_down(*amount)?,
|
||||
PrintText { content, style } => self.print_text(content, style)?,
|
||||
ClearScreen => self.clear_screen()?,
|
||||
SetColors(colors) => self.set_colors(*colors)?,
|
||||
SetBackgroundColor(color) => self.set_background_color(*color)?,
|
||||
Flush => self.flush()?,
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cursor_row(&self) -> u16 {
|
||||
self.cursor_row
|
||||
}
|
||||
}
|
||||
|
||||
struct TestDrawer {
|
||||
prefix: WeightedText,
|
||||
positioning: Positioning,
|
||||
right_padding_length: u16,
|
||||
repeat_prefix_on_wrap: bool,
|
||||
center_newlines: bool,
|
||||
}
|
||||
|
||||
impl TestDrawer {
|
||||
fn prefix<T: Into<WeightedText>>(mut self, prefix: T) -> Self {
|
||||
self.prefix = prefix.into();
|
||||
self
|
||||
}
|
||||
|
||||
fn start_column(mut self, column: u16) -> Self {
|
||||
self.positioning.start_column = column;
|
||||
self
|
||||
}
|
||||
|
||||
fn max_line_length(mut self, length: u16) -> Self {
|
||||
self.positioning.max_line_length = length;
|
||||
self
|
||||
}
|
||||
|
||||
fn repeat_prefix_on_wrap(mut self) -> Self {
|
||||
self.repeat_prefix_on_wrap = true;
|
||||
self
|
||||
}
|
||||
|
||||
fn center_newlines(mut self) -> Self {
|
||||
self.center_newlines = true;
|
||||
self
|
||||
}
|
||||
|
||||
fn draw<L: Into<WeightedLine>>(self, line: L) -> Vec<Instruction> {
|
||||
let line = line.into();
|
||||
let colors = Default::default();
|
||||
let drawer = TextDrawer::new(&self.prefix, self.right_padding_length, &line, self.positioning, &colors, 0)
|
||||
.expect("failed to create drawer")
|
||||
.repeat_prefix_on_wrap(self.repeat_prefix_on_wrap)
|
||||
.center_newlines(self.center_newlines);
|
||||
let mut buf = TerminalBuf::default();
|
||||
drawer.draw(&mut buf).expect("drawing failed");
|
||||
buf.instructions
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TestDrawer {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
prefix: WeightedText::from(""),
|
||||
positioning: Positioning { max_line_length: 100, start_column: 0 },
|
||||
right_padding_length: 0,
|
||||
repeat_prefix_on_wrap: false,
|
||||
center_newlines: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefix_on_long_line() {
|
||||
let instructions = TestDrawer::default().prefix("P").max_line_length(3).start_column(1).draw("AAAA");
|
||||
let expected = &[
|
||||
Instruction::MoveToColumn(1),
|
||||
Instruction::PrintText { content: "P".into(), font_size: 1 },
|
||||
Instruction::PrintText { content: "AA".into(), font_size: 1 },
|
||||
Instruction::MoveDown(1),
|
||||
Instruction::MoveToColumn(1),
|
||||
Instruction::PrintText { content: " ".into(), font_size: 1 },
|
||||
Instruction::PrintText { content: "AA".into(), font_size: 1 },
|
||||
];
|
||||
assert_eq!(instructions, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefix_on_long_line_with_font_size() {
|
||||
let text = WeightedLine::from(vec![Text::new("AAAA", TextStyle::default().size(2))]);
|
||||
let prefix = WeightedText::from(Text::new("P", TextStyle::default().size(2)));
|
||||
let instructions = TestDrawer::default().prefix(prefix).max_line_length(6).start_column(1).draw(text);
|
||||
let expected = &[
|
||||
Instruction::MoveToColumn(1),
|
||||
Instruction::PrintText { content: "P".into(), font_size: 2 },
|
||||
Instruction::PrintText { content: "AA".into(), font_size: 2 },
|
||||
Instruction::MoveDown(2),
|
||||
Instruction::MoveToColumn(1),
|
||||
Instruction::PrintText { content: " ".into(), font_size: 2 },
|
||||
Instruction::PrintText { content: "AA".into(), font_size: 2 },
|
||||
];
|
||||
assert_eq!(instructions, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefix_on_long_line_with_font_size_and_repeat_prefix() {
|
||||
let text = WeightedLine::from(vec![Text::new("AAAA", TextStyle::default().size(2))]);
|
||||
let prefix = WeightedText::from(Text::new("P", TextStyle::default().size(2)));
|
||||
let instructions =
|
||||
TestDrawer::default().prefix(prefix).max_line_length(6).start_column(1).repeat_prefix_on_wrap().draw(text);
|
||||
let expected = &[
|
||||
Instruction::MoveToColumn(1),
|
||||
Instruction::PrintText { content: "P".into(), font_size: 2 },
|
||||
Instruction::PrintText { content: "AA".into(), font_size: 2 },
|
||||
Instruction::MoveDown(2),
|
||||
Instruction::MoveToColumn(1),
|
||||
Instruction::PrintText { content: "P".into(), font_size: 2 },
|
||||
Instruction::PrintText { content: "AA".into(), font_size: 2 },
|
||||
];
|
||||
assert_eq!(instructions, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn center_newlines() {
|
||||
let text = WeightedLine::from(vec![Text::from("hello world foo")]);
|
||||
let instructions = TestDrawer::default().center_newlines().max_line_length(11).draw(text);
|
||||
let expected = &[
|
||||
Instruction::MoveToColumn(0),
|
||||
Instruction::PrintText { content: "hello world".into(), font_size: 1 },
|
||||
Instruction::MoveDown(1),
|
||||
Instruction::MoveToColumn(4),
|
||||
Instruction::PrintText { content: "foo".into(), font_size: 1 },
|
||||
];
|
||||
assert_eq!(instructions, expected);
|
||||
}
|
||||
}
|
||||
|
110
src/resource.rs
110
src/resource.rs
@ -1,14 +1,16 @@
|
||||
use crate::{
|
||||
terminal::image::{
|
||||
Image,
|
||||
printer::{ImageRegistry, RegisterImageError},
|
||||
printer::{ImageRegistry, ImageSpec, RegisterImageError},
|
||||
},
|
||||
theme::{LoadThemeError, PresentationTheme},
|
||||
theme::{raw::PresentationTheme, registry::LoadThemeError},
|
||||
};
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::HashMap,
|
||||
fs, io, mem,
|
||||
path::{Path, PathBuf},
|
||||
rc::Rc,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
@ -20,100 +22,119 @@ use std::{
|
||||
|
||||
const LOOP_INTERVAL: Duration = Duration::from_millis(250);
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ResourcesInner {
|
||||
themes: HashMap<PathBuf, PresentationTheme>,
|
||||
external_snippets: HashMap<PathBuf, String>,
|
||||
base_path: PathBuf,
|
||||
themes_path: PathBuf,
|
||||
image_registry: ImageRegistry,
|
||||
watcher: FileWatcherHandle,
|
||||
}
|
||||
|
||||
/// Manages resources pulled from the filesystem such as images.
|
||||
///
|
||||
/// All resources are cached so once a specific resource is loaded, looking it up with the same
|
||||
/// path will involve an in-memory lookup.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Resources {
|
||||
base_path: PathBuf,
|
||||
images: HashMap<PathBuf, Image>,
|
||||
themes: HashMap<PathBuf, PresentationTheme>,
|
||||
external_snippets: HashMap<PathBuf, String>,
|
||||
image_registry: ImageRegistry,
|
||||
watcher: FileWatcherHandle,
|
||||
inner: Rc<RefCell<ResourcesInner>>,
|
||||
}
|
||||
|
||||
impl Resources {
|
||||
/// Construct a new resource manager over the provided based path.
|
||||
///
|
||||
/// Any relative paths will be assumed to be relative to the given base.
|
||||
pub fn new<P: Into<PathBuf>>(base_path: P, image_registry: ImageRegistry) -> Self {
|
||||
pub fn new<P1, P2>(base_path: P1, themes_path: P2, image_registry: ImageRegistry) -> Self
|
||||
where
|
||||
P1: Into<PathBuf>,
|
||||
P2: Into<PathBuf>,
|
||||
{
|
||||
let watcher = FileWatcher::spawn();
|
||||
Self {
|
||||
let inner = ResourcesInner {
|
||||
base_path: base_path.into(),
|
||||
images: Default::default(),
|
||||
themes_path: themes_path.into(),
|
||||
themes: Default::default(),
|
||||
external_snippets: Default::default(),
|
||||
image_registry,
|
||||
watcher,
|
||||
}
|
||||
};
|
||||
Self { inner: Rc::new(RefCell::new(inner)) }
|
||||
}
|
||||
|
||||
pub(crate) fn watch_presentation_file(&self, path: PathBuf) {
|
||||
self.watcher.send(WatchEvent::WatchFile { path, watch_forever: true });
|
||||
let inner = self.inner.borrow();
|
||||
inner.watcher.send(WatchEvent::WatchFile { path, watch_forever: true });
|
||||
}
|
||||
|
||||
/// Get the image at the given path.
|
||||
pub(crate) fn image<P: AsRef<Path>>(&mut self, path: P) -> Result<Image, LoadImageError> {
|
||||
let path = self.base_path.join(path);
|
||||
if let Some(image) = self.images.get(&path) {
|
||||
return Ok(image.clone());
|
||||
}
|
||||
pub(crate) fn image<P: AsRef<Path>>(&self, path: P) -> Result<Image, RegisterImageError> {
|
||||
let inner = self.inner.borrow();
|
||||
let path = inner.base_path.join(path);
|
||||
let image = inner.image_registry.register(ImageSpec::Filesystem(path.clone()))?;
|
||||
Ok(image)
|
||||
}
|
||||
|
||||
let image = self.image_registry.register_resource(path.clone())?;
|
||||
self.images.insert(path, image.clone());
|
||||
pub(crate) fn theme_image<P: AsRef<Path>>(&self, path: P) -> Result<Image, RegisterImageError> {
|
||||
match self.image(&path) {
|
||||
Ok(image) => return Ok(image),
|
||||
Err(RegisterImageError::Io(e)) if e.kind() != io::ErrorKind::NotFound => return Err(e.into()),
|
||||
_ => (),
|
||||
};
|
||||
|
||||
let inner = self.inner.borrow();
|
||||
let path = inner.themes_path.join(path);
|
||||
let image = inner.image_registry.register(ImageSpec::Filesystem(path.clone()))?;
|
||||
Ok(image)
|
||||
}
|
||||
|
||||
/// Get the theme at the given path.
|
||||
pub(crate) fn theme<P: AsRef<Path>>(&mut self, path: P) -> Result<PresentationTheme, LoadThemeError> {
|
||||
let path = self.base_path.join(path);
|
||||
if let Some(theme) = self.themes.get(&path) {
|
||||
pub(crate) fn theme<P: AsRef<Path>>(&self, path: P) -> Result<PresentationTheme, LoadThemeError> {
|
||||
let mut inner = self.inner.borrow_mut();
|
||||
let path = inner.base_path.join(path);
|
||||
if let Some(theme) = inner.themes.get(&path) {
|
||||
return Ok(theme.clone());
|
||||
}
|
||||
|
||||
let theme = PresentationTheme::from_path(&path)?;
|
||||
self.themes.insert(path, theme.clone());
|
||||
inner.themes.insert(path, theme.clone());
|
||||
Ok(theme)
|
||||
}
|
||||
|
||||
/// Get the external snippet at the given path.
|
||||
pub(crate) fn external_snippet<P: AsRef<Path>>(&mut self, path: P) -> io::Result<String> {
|
||||
let path = self.base_path.join(path);
|
||||
if let Some(contents) = self.external_snippets.get(&path) {
|
||||
pub(crate) fn external_snippet<P: AsRef<Path>>(&self, path: P) -> io::Result<String> {
|
||||
let mut inner = self.inner.borrow_mut();
|
||||
let path = inner.base_path.join(path);
|
||||
if let Some(contents) = inner.external_snippets.get(&path) {
|
||||
return Ok(contents.clone());
|
||||
}
|
||||
|
||||
let contents = fs::read_to_string(&path)?;
|
||||
self.watcher.send(WatchEvent::WatchFile { path: path.clone(), watch_forever: false });
|
||||
self.external_snippets.insert(path, contents.clone());
|
||||
inner.watcher.send(WatchEvent::WatchFile { path: path.clone(), watch_forever: false });
|
||||
inner.external_snippets.insert(path, contents.clone());
|
||||
Ok(contents)
|
||||
}
|
||||
|
||||
pub(crate) fn resources_modified(&mut self) -> bool {
|
||||
self.watcher.has_modifications()
|
||||
pub(crate) fn resources_modified(&self) -> bool {
|
||||
let mut inner = self.inner.borrow_mut();
|
||||
inner.watcher.has_modifications()
|
||||
}
|
||||
|
||||
pub(crate) fn clear_watches(&mut self) {
|
||||
self.watcher.send(WatchEvent::ClearWatches);
|
||||
pub(crate) fn clear_watches(&self) {
|
||||
let mut inner = self.inner.borrow_mut();
|
||||
inner.watcher.send(WatchEvent::ClearWatches);
|
||||
// We could do better than this but this works for now.
|
||||
self.external_snippets.clear();
|
||||
inner.external_snippets.clear();
|
||||
}
|
||||
|
||||
/// Clears all resources.
|
||||
pub(crate) fn clear(&mut self) {
|
||||
self.images.clear();
|
||||
self.themes.clear();
|
||||
pub(crate) fn clear(&self) {
|
||||
let mut inner = self.inner.borrow_mut();
|
||||
inner.image_registry.clear();
|
||||
inner.themes.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// An error loading an image.
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum LoadImageError {
|
||||
#[error(transparent)]
|
||||
RegisterImage(#[from] RegisterImageError),
|
||||
}
|
||||
|
||||
/// Watches for file changes.
|
||||
///
|
||||
/// This uses polling rather than something fancier like `inotify`. The latter turned out to make
|
||||
@ -190,6 +211,7 @@ struct WatchMetadata {
|
||||
watch_forever: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct FileWatcherHandle {
|
||||
sender: Sender<WatchEvent>,
|
||||
modifications: Arc<AtomicBool>,
|
||||
|
@ -3,114 +3,61 @@ use crate::markdown::{
|
||||
text::WeightedLine,
|
||||
text_style::{Color, TextStyle},
|
||||
};
|
||||
use ansi_parser::{AnsiParser, AnsiSequence, Output};
|
||||
use std::mem;
|
||||
use vte::{ParamsIter, Parser, Perform};
|
||||
|
||||
pub(crate) struct AnsiSplitter {
|
||||
lines: Vec<WeightedLine>,
|
||||
current_line: Line,
|
||||
current_style: TextStyle,
|
||||
starting_style: TextStyle,
|
||||
}
|
||||
|
||||
impl AnsiSplitter {
|
||||
pub(crate) fn new(current_style: TextStyle) -> Self {
|
||||
Self { lines: Default::default(), current_line: Default::default(), current_style }
|
||||
Self { starting_style: current_style }
|
||||
}
|
||||
|
||||
pub(crate) fn split_lines(mut self, lines: &[String]) -> (Vec<WeightedLine>, TextStyle) {
|
||||
pub(crate) fn split_lines<I, S>(self, lines: I) -> (Vec<WeightedLine>, TextStyle)
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<str>,
|
||||
{
|
||||
let mut output_lines = Vec::new();
|
||||
let mut style = self.starting_style;
|
||||
for line in lines {
|
||||
for p in line.ansi_parse() {
|
||||
match p {
|
||||
Output::TextBlock(text) => {
|
||||
self.current_line.0.push(Text::new(text, self.current_style));
|
||||
}
|
||||
Output::Escape(s) => self.handle_escape(&s),
|
||||
}
|
||||
}
|
||||
let current_line = std::mem::take(&mut self.current_line);
|
||||
self.lines.push(current_line.into());
|
||||
}
|
||||
(self.lines, self.current_style)
|
||||
}
|
||||
let mut handler = Handler::new(style);
|
||||
let mut parser = Parser::new();
|
||||
parser.advance(&mut handler, line.as_ref().as_bytes());
|
||||
|
||||
fn handle_escape(&mut self, s: &AnsiSequence) {
|
||||
match s {
|
||||
AnsiSequence::SetGraphicsMode(code) => {
|
||||
let code = GraphicsCode(code);
|
||||
code.update(&mut self.current_style);
|
||||
}
|
||||
AnsiSequence::EraseDisplay => {
|
||||
self.lines.clear();
|
||||
self.current_line.0.clear();
|
||||
}
|
||||
_ => (),
|
||||
let (line, ending_style) = handler.into_parts();
|
||||
output_lines.push(line.into());
|
||||
style = ending_style;
|
||||
}
|
||||
(output_lines, style)
|
||||
}
|
||||
}
|
||||
|
||||
struct GraphicsCode<'a>(&'a [u8]);
|
||||
struct Handler {
|
||||
line: Line,
|
||||
pending_text: Text,
|
||||
style: TextStyle,
|
||||
}
|
||||
|
||||
impl GraphicsCode<'_> {
|
||||
fn update(&self, style: &mut TextStyle) {
|
||||
let codes = self.0;
|
||||
match codes {
|
||||
[] | [0] => *style = Default::default(),
|
||||
[1] => *style = style.bold(),
|
||||
[3] => *style = style.italics(),
|
||||
[4] => *style = style.underlined(),
|
||||
[9] => *style = style.strikethrough(),
|
||||
[39] => style.colors.foreground = None,
|
||||
[49] => style.colors.background = None,
|
||||
[value] | [1, value] => match value {
|
||||
30..=37 => {
|
||||
if let Some(color) = Self::as_standard_color(value - 30) {
|
||||
*style = style.fg_color(color);
|
||||
}
|
||||
}
|
||||
40..=47 => {
|
||||
if let Some(color) = Self::as_standard_color(value - 40) {
|
||||
*style = style.bg_color(color);
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
},
|
||||
[38, 2, r, g, b] => {
|
||||
*style = style.fg_color(Color::new(*r, *g, *b));
|
||||
}
|
||||
[38, 5, value] => {
|
||||
if let Some(color) = Self::parse_color(*value) {
|
||||
*style = style.fg_color(color);
|
||||
}
|
||||
}
|
||||
[48, 2, r, g, b] => {
|
||||
*style = style.bg_color(Color::new(*r, *g, *b));
|
||||
}
|
||||
[48, 5, value] => {
|
||||
if let Some(color) = Self::parse_color(*value) {
|
||||
*style = style.bg_color(color);
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
};
|
||||
impl Handler {
|
||||
fn new(style: TextStyle) -> Self {
|
||||
Self { line: Default::default(), pending_text: Default::default(), style }
|
||||
}
|
||||
|
||||
fn parse_color(value: u8) -> Option<Color> {
|
||||
match value {
|
||||
0..=15 => Self::as_standard_color(value),
|
||||
16..=231 => {
|
||||
let mapping = [0, 95, 95 + 40, 95 + 80, 95 + 120, 95 + 160];
|
||||
let mut value = value - 16;
|
||||
let b = (value % 6) as usize;
|
||||
value /= 6;
|
||||
let g = (value % 6) as usize;
|
||||
value /= 6;
|
||||
let r = (value % 6) as usize;
|
||||
Some(Color::new(mapping[r], mapping[g], mapping[b]))
|
||||
}
|
||||
_ => None,
|
||||
fn into_parts(mut self) -> (Line, TextStyle) {
|
||||
self.save_pending_text();
|
||||
(self.line, self.style)
|
||||
}
|
||||
|
||||
fn save_pending_text(&mut self) {
|
||||
if !self.pending_text.content.is_empty() {
|
||||
self.line.0.push(mem::take(&mut self.pending_text));
|
||||
}
|
||||
}
|
||||
|
||||
fn as_standard_color(value: u8) -> Option<Color> {
|
||||
fn parse_standard_color(value: u16) -> Option<Color> {
|
||||
let color = match value {
|
||||
0 | 8 => Color::Black,
|
||||
1 | 9 => Color::Red,
|
||||
@ -124,4 +71,204 @@ impl GraphicsCode<'_> {
|
||||
};
|
||||
Some(color)
|
||||
}
|
||||
|
||||
fn parse_color(iter: &mut ParamsIter) -> Option<Color> {
|
||||
match iter.next()? {
|
||||
[2] => {
|
||||
let r = iter.next()?.first()?;
|
||||
let g = iter.next()?.first()?;
|
||||
let b = iter.next()?.first()?;
|
||||
Self::try_build_rgb_color(*r, *g, *b)
|
||||
}
|
||||
[5] => {
|
||||
let color = *iter.next()?.first()?;
|
||||
match color {
|
||||
0..=15 => Self::parse_standard_color(color),
|
||||
16..=231 => {
|
||||
let mapping = [0, 95, 95 + 40, 95 + 80, 95 + 120, 95 + 160];
|
||||
let mut value = color - 16;
|
||||
let b = (value % 6) as usize;
|
||||
value /= 6;
|
||||
let g = (value % 6) as usize;
|
||||
value /= 6;
|
||||
let r = (value % 6) as usize;
|
||||
Some(Color::new(mapping[r], mapping[g], mapping[b]))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn try_build_rgb_color(r: u16, g: u16, b: u16) -> Option<Color> {
|
||||
let r = r.try_into().ok()?;
|
||||
let g = g.try_into().ok()?;
|
||||
let b = b.try_into().ok()?;
|
||||
Some(Color::new(r, g, b))
|
||||
}
|
||||
|
||||
fn update_style(&self, mut codes: ParamsIter) -> TextStyle {
|
||||
let mut style = self.style;
|
||||
loop {
|
||||
let Some(&[next]) = codes.next() else {
|
||||
break;
|
||||
};
|
||||
match next {
|
||||
0 => style = Default::default(),
|
||||
1 => style = style.bold(),
|
||||
3 => style = style.italics(),
|
||||
4 => style = style.underlined(),
|
||||
9 => style = style.strikethrough(),
|
||||
39 => {
|
||||
style.colors.foreground = None;
|
||||
}
|
||||
49 => {
|
||||
style.colors.background = None;
|
||||
}
|
||||
30..=37 => {
|
||||
if let Some(color) = Self::parse_standard_color(next - 30) {
|
||||
style = style.fg_color(color);
|
||||
}
|
||||
}
|
||||
40..=47 => {
|
||||
if let Some(color) = Self::parse_standard_color(next - 40) {
|
||||
style = style.bg_color(color);
|
||||
}
|
||||
}
|
||||
38 => {
|
||||
if let Some(color) = Self::parse_color(&mut codes) {
|
||||
style = style.fg_color(color);
|
||||
}
|
||||
}
|
||||
48 => {
|
||||
if let Some(color) = Self::parse_color(&mut codes) {
|
||||
style = style.bg_color(color);
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
};
|
||||
}
|
||||
style
|
||||
}
|
||||
}
|
||||
|
||||
impl Perform for Handler {
|
||||
fn print(&mut self, c: char) {
|
||||
self.pending_text.content.push(c);
|
||||
}
|
||||
|
||||
fn csi_dispatch(&mut self, params: &vte::Params, _intermediates: &[u8], _ignore: bool, action: char) {
|
||||
if action == 'm' {
|
||||
self.save_pending_text();
|
||||
self.style = self.update_style(params.iter());
|
||||
self.pending_text.style = self.style;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
#[case::text("hi", Line::from("hi"))]
|
||||
#[case::single_attribute("\x1b[1mhi", Line::from(Text::new("hi", TextStyle::default().bold())))]
|
||||
#[case::two_attributes("\x1b[1;3mhi", Line::from(Text::new("hi", TextStyle::default().bold().italics())))]
|
||||
#[case::three_attributes("\x1b[1;3;4mhi", Line::from(Text::new("hi", TextStyle::default().bold().italics().underlined())))]
|
||||
#[case::four_attributes(
|
||||
"\x1b[1;3;4;9mhi",
|
||||
Line::from(Text::new("hi", TextStyle::default().bold().italics().underlined().strikethrough()))
|
||||
)]
|
||||
#[case::standard_foreground1(
|
||||
"\x1b[38;5;1mhi",
|
||||
Line::from(Text::new("hi", TextStyle::default().fg_color(Color::Red)))
|
||||
)]
|
||||
#[case::standard_foreground2(
|
||||
"\x1b[31mhi",
|
||||
Line::from(Text::new("hi", TextStyle::default().fg_color(Color::Red)))
|
||||
)]
|
||||
#[case::rgb_foreground(
|
||||
"\x1b[38;2;3;4;5mhi",
|
||||
Line::from(Text::new("hi", TextStyle::default().fg_color(Color::new(3, 4, 5))))
|
||||
)]
|
||||
#[case::standard_background1(
|
||||
"\x1b[48;5;1mhi",
|
||||
Line::from(Text::new("hi", TextStyle::default().bg_color(Color::Red)))
|
||||
)]
|
||||
#[case::standard_background2(
|
||||
"\x1b[41mhi",
|
||||
Line::from(Text::new("hi", TextStyle::default().bg_color(Color::Red)))
|
||||
)]
|
||||
#[case::rgb_background(
|
||||
"\x1b[48;2;3;4;5mhi",
|
||||
Line::from(Text::new("hi", TextStyle::default().bg_color(Color::new(3, 4, 5))))
|
||||
)]
|
||||
#[case::accumulate(
|
||||
"\x1b[1mhi\x1b[3mbye",
|
||||
Line(vec![
|
||||
Text::new("hi", TextStyle::default().bold()),
|
||||
Text::new("bye", TextStyle::default().bold().italics())
|
||||
])
|
||||
)]
|
||||
#[case::reset(
|
||||
"\x1b[1mhi\x1b[0;3mbye",
|
||||
Line(vec![
|
||||
Text::new("hi", TextStyle::default().bold()),
|
||||
Text::new("bye", TextStyle::default().italics())
|
||||
])
|
||||
)]
|
||||
#[case::different_action(
|
||||
"\x1b[01m\x1b[Khi",
|
||||
Line::from(Text::new("hi", TextStyle::default().bold()))
|
||||
)]
|
||||
fn parse_single(#[case] input: &str, #[case] expected: Line) {
|
||||
let splitter = AnsiSplitter::new(Default::default());
|
||||
let (lines, _) = splitter.split_lines(&[input]);
|
||||
assert_eq!(lines, vec![expected.into()]);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::reset_all("\x1b[0mhi", Line::from("hi"))]
|
||||
#[case::reset_foreground(
|
||||
"\x1b[39mhi",
|
||||
Line::from(
|
||||
Text::new(
|
||||
"hi",
|
||||
TextStyle::default()
|
||||
.bold()
|
||||
.italics()
|
||||
.underlined()
|
||||
.strikethrough()
|
||||
.bg_color(Color::Black)
|
||||
)
|
||||
)
|
||||
)]
|
||||
#[case::reset_background(
|
||||
"\x1b[49mhi",
|
||||
Line::from(
|
||||
Text::new(
|
||||
"hi",
|
||||
TextStyle::default()
|
||||
.bold()
|
||||
.italics()
|
||||
.underlined()
|
||||
.strikethrough()
|
||||
.fg_color(Color::Red)
|
||||
)
|
||||
)
|
||||
)]
|
||||
fn resets(#[case] input: &str, #[case] expected: Line) {
|
||||
let style = TextStyle::default()
|
||||
.bold()
|
||||
.italics()
|
||||
.underlined()
|
||||
.strikethrough()
|
||||
.fg_color(Color::Red)
|
||||
.bg_color(Color::Black);
|
||||
let splitter = AnsiSplitter::new(style);
|
||||
let (lines, _) = splitter.split_lines(&[input]);
|
||||
assert_eq!(lines, vec![expected.into()]);
|
||||
}
|
||||
}
|
||||
|
@ -1,19 +1,31 @@
|
||||
use super::image::protocols::kitty::{Action, ControlCommand, ControlOption, ImageFormat, TransmissionMedium};
|
||||
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
|
||||
use crossterm::{
|
||||
QueueableCommand,
|
||||
cursor::{self},
|
||||
style::Print,
|
||||
terminal,
|
||||
};
|
||||
use image::{DynamicImage, EncodableLayout};
|
||||
use std::{
|
||||
env,
|
||||
io::{self, Write},
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
#[derive(Default, Debug, Clone)]
|
||||
pub(crate) struct TerminalCapabilities {
|
||||
pub(crate) kitty_local: bool,
|
||||
pub(crate) kitty_remote: bool,
|
||||
pub(crate) sixel: bool,
|
||||
pub(crate) tmux: bool,
|
||||
pub(crate) font_size: bool,
|
||||
}
|
||||
|
||||
impl TerminalCapabilities {
|
||||
@ -33,7 +45,7 @@ impl TerminalCapabilities {
|
||||
};
|
||||
let encoded_path = STANDARD.encode(path);
|
||||
|
||||
let base_image_id = rand::random();
|
||||
let base_image_id = fastrand::u32(0..=u32::MAX);
|
||||
let ids = KittyImageIds { local: base_image_id, remote: base_image_id.wrapping_add(1) };
|
||||
Self::write_kitty_local_query(ids.local, encoded_path, tmux)?;
|
||||
Self::write_kitty_remote_query(ids.remote, image_bytes, tmux)?;
|
||||
@ -46,11 +58,37 @@ impl TerminalCapabilities {
|
||||
write!(stdout, "{start}{sequence}[c{end}")?;
|
||||
stdout.flush()?;
|
||||
|
||||
let mut response = Self::parse_response(io::stdin(), ids)?;
|
||||
// Spawn a thread to "save us" in case we don't get an answer from the terminal.
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
Self::launch_timeout_trigger(running.clone());
|
||||
|
||||
let response = Self::build_capabilities(ids);
|
||||
running.store(false, Ordering::Relaxed);
|
||||
|
||||
let mut response = response?;
|
||||
response.tmux = tmux;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn build_capabilities(ids: KittyImageIds) -> io::Result<TerminalCapabilities> {
|
||||
let mut response = Self::parse_response(io::stdin(), ids)?;
|
||||
|
||||
// Use kitty's font size protocol to write 1 character using size 2. If after writing the
|
||||
// cursor has moves 2 columns, the protocol is supported.
|
||||
let mut stdout = io::stdout();
|
||||
stdout.queue(terminal::EnterAlternateScreen)?;
|
||||
stdout.queue(cursor::MoveTo(0, 0))?;
|
||||
stdout.queue(Print("\x1b]66;s=2; \x1b\\"))?;
|
||||
stdout.flush()?;
|
||||
let position = cursor::position()?;
|
||||
if position.0 == 2 {
|
||||
response.font_size = true;
|
||||
}
|
||||
stdout.queue(terminal::LeaveAlternateScreen)?;
|
||||
stdout.flush()?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn write_kitty_local_query(image_id: u32, path: String, tmux: bool) -> io::Result<()> {
|
||||
let options = &[
|
||||
ControlOption::Format(ImageFormat::Rgba),
|
||||
@ -103,24 +141,42 @@ impl TerminalCapabilities {
|
||||
capabilities.sixel = sixel;
|
||||
return Ok(capabilities);
|
||||
}
|
||||
Response::StatusReport => {
|
||||
return Ok(capabilities);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn launch_timeout_trigger(running: Arc<AtomicBool>) {
|
||||
// Spawn a thread that will wait a second and if we still are running, will request the
|
||||
// device status report straight from whoever is on top of us (tmux or terminal if no
|
||||
// tmux), which will cause it to answer and wake up our main thread that's reading on
|
||||
// stdin.
|
||||
thread::spawn(move || {
|
||||
thread::sleep(Duration::from_secs(1));
|
||||
if !running.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let _ = write!(io::stdout(), "\x1b[5n");
|
||||
let _ = io::stdout().flush();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
struct RawModeGuard;
|
||||
|
||||
impl RawModeGuard {
|
||||
fn new() -> io::Result<Self> {
|
||||
enable_raw_mode()?;
|
||||
terminal::enable_raw_mode()?;
|
||||
Ok(Self)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RawModeGuard {
|
||||
fn drop(&mut self) {
|
||||
let _ = disable_raw_mode();
|
||||
let _ = terminal::disable_raw_mode();
|
||||
}
|
||||
}
|
||||
|
||||
@ -142,6 +198,9 @@ impl QueryParseState {
|
||||
("[", '?') => {
|
||||
self.current = ResponseType::Capabilities;
|
||||
}
|
||||
("[", '0') => {
|
||||
self.current = ResponseType::StatusReport;
|
||||
}
|
||||
("_Gi", '=') => {
|
||||
self.current = ResponseType::Kitty;
|
||||
}
|
||||
@ -163,11 +222,18 @@ impl QueryParseState {
|
||||
'c' => {
|
||||
let mut caps = self.data[2..].split(';');
|
||||
let sixel = caps.any(|cap| cap == "4");
|
||||
|
||||
*self = Default::default();
|
||||
return Some(Response::Capabilities { sixel });
|
||||
}
|
||||
_ => self.data.push(next),
|
||||
},
|
||||
ResponseType::StatusReport => match next {
|
||||
'n' => {
|
||||
*self = Default::default();
|
||||
return Some(Response::StatusReport);
|
||||
}
|
||||
_ => self.data.push(next),
|
||||
},
|
||||
};
|
||||
None
|
||||
}
|
||||
@ -189,11 +255,13 @@ enum ResponseType {
|
||||
Unknown,
|
||||
Kitty,
|
||||
Capabilities,
|
||||
StatusReport,
|
||||
}
|
||||
|
||||
enum Response {
|
||||
KittySupported { image_id: u32 },
|
||||
Capabilities { sixel: bool },
|
||||
StatusReport,
|
||||
}
|
||||
|
||||
struct KittyImageIds {
|
@ -1,7 +1,9 @@
|
||||
use super::{GraphicsMode, image::protocols::kitty::KittyMode, query::TerminalCapabilities};
|
||||
use std::env;
|
||||
use super::{GraphicsMode, capabilities::TerminalCapabilities, image::protocols::kitty::KittyMode};
|
||||
use std::{env, sync::OnceLock};
|
||||
use strum::IntoEnumIterator;
|
||||
|
||||
static CAPABILITIES: OnceLock<TerminalCapabilities> = OnceLock::new();
|
||||
|
||||
#[derive(Debug, strum::EnumIter)]
|
||||
pub enum TerminalEmulator {
|
||||
Iterm2,
|
||||
@ -30,8 +32,16 @@ impl TerminalEmulator {
|
||||
TerminalEmulator::Unknown
|
||||
}
|
||||
|
||||
pub(crate) fn capabilities() -> TerminalCapabilities {
|
||||
CAPABILITIES.get_or_init(|| TerminalCapabilities::query().unwrap_or_default()).clone()
|
||||
}
|
||||
|
||||
pub(crate) fn disable_capability_detection() {
|
||||
CAPABILITIES.get_or_init(TerminalCapabilities::default);
|
||||
}
|
||||
|
||||
pub fn preferred_protocol(&self) -> GraphicsMode {
|
||||
let capabilities = TerminalCapabilities::query().unwrap_or_default();
|
||||
let capabilities = Self::capabilities();
|
||||
let modes = [
|
||||
GraphicsMode::Iterm2,
|
||||
GraphicsMode::Kitty { mode: KittyMode::Local, inside_tmux: capabilities.tmux },
|
||||
|
@ -1,23 +1,59 @@
|
||||
use self::printer::{ImageProperties, TerminalImage};
|
||||
use std::{fmt::Debug, ops::Deref, path::PathBuf, sync::Arc};
|
||||
use image::DynamicImage;
|
||||
use protocols::ascii::AsciiImage;
|
||||
use std::{
|
||||
fmt::Debug,
|
||||
ops::Deref,
|
||||
path::PathBuf,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
pub(crate) mod printer;
|
||||
pub(crate) mod protocols;
|
||||
pub(crate) mod scale;
|
||||
|
||||
struct Inner {
|
||||
image: TerminalImage,
|
||||
ascii_image: Mutex<Option<AsciiImage>>,
|
||||
}
|
||||
|
||||
/// An image.
|
||||
///
|
||||
/// This stores the image in an [std::sync::Arc] so it's cheap to clone.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Image {
|
||||
pub(crate) image: Arc<TerminalImage>,
|
||||
inner: Arc<Inner>,
|
||||
pub(crate) source: ImageSource,
|
||||
}
|
||||
|
||||
impl Image {
|
||||
/// Constructs a new image.
|
||||
pub(crate) fn new(image: TerminalImage, source: ImageSource) -> Self {
|
||||
Self { image: Arc::new(image), source }
|
||||
let inner = Inner { image, ascii_image: Default::default() };
|
||||
Self { inner: Arc::new(inner), source }
|
||||
}
|
||||
|
||||
pub(crate) fn to_ascii(&self) -> AsciiImage {
|
||||
let mut ascii_image = self.inner.ascii_image.lock().unwrap();
|
||||
match ascii_image.deref() {
|
||||
Some(image) => image.clone(),
|
||||
None => {
|
||||
let image = match &self.inner.image {
|
||||
TerminalImage::Ascii(image) => image.clone(),
|
||||
TerminalImage::Kitty(image) => DynamicImage::from(image.as_rgba8()).into(),
|
||||
TerminalImage::Iterm(image) => DynamicImage::from(image.as_rgba8()).into(),
|
||||
TerminalImage::Raw(_) => unreachable!("raw is only used for exports"),
|
||||
#[cfg(feature = "sixel")]
|
||||
TerminalImage::Sixel(image) => DynamicImage::from(image.as_rgba8()).into(),
|
||||
};
|
||||
*ascii_image = Some(image.clone());
|
||||
image
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn image(&self) -> &TerminalImage {
|
||||
&self.inner.image
|
||||
}
|
||||
}
|
||||
|
||||
@ -29,19 +65,11 @@ impl PartialEq for Image {
|
||||
|
||||
impl Debug for Image {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let (width, height) = self.image.dimensions();
|
||||
let (width, height) = self.inner.image.dimensions();
|
||||
write!(f, "Image<{width}x{height}>")
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for Image {
|
||||
type Target = TerminalImage;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.image
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) enum ImageSource {
|
||||
Filesystem(PathBuf),
|
||||
|
@ -4,44 +4,44 @@ use super::{
|
||||
ascii::{AsciiImage, AsciiPrinter},
|
||||
iterm::{ItermImage, ItermPrinter},
|
||||
kitty::{KittyImage, KittyMode, KittyPrinter},
|
||||
raw::{RawImage, RawPrinter},
|
||||
},
|
||||
};
|
||||
use crate::{
|
||||
markdown::text_style::{Color, PaletteColorError},
|
||||
render::properties::CursorPosition,
|
||||
terminal::GraphicsMode,
|
||||
terminal::{
|
||||
GraphicsMode,
|
||||
printer::{TerminalError, TerminalIo},
|
||||
},
|
||||
};
|
||||
use image::{DynamicImage, ImageError};
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
collections::HashMap,
|
||||
fmt, io,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
path::PathBuf,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
pub(crate) trait PrintImage {
|
||||
type Image: ImageProperties;
|
||||
|
||||
/// Register an image.
|
||||
fn register(&self, image: DynamicImage) -> Result<Self::Image, RegisterImageError>;
|
||||
fn register(&self, spec: ImageSpec) -> Result<Self::Image, RegisterImageError>;
|
||||
|
||||
/// Load and register an image from the given path.
|
||||
fn register_from_path<P: AsRef<Path>>(&self, path: P) -> Result<Self::Image, RegisterImageError>;
|
||||
|
||||
fn print<W>(&self, image: &Self::Image, options: &PrintOptions, writer: &mut W) -> Result<(), PrintImageError>
|
||||
fn print<T>(&self, image: &Self::Image, options: &PrintOptions, terminal: &mut T) -> Result<(), PrintImageError>
|
||||
where
|
||||
W: io::Write;
|
||||
T: TerminalIo;
|
||||
}
|
||||
|
||||
pub(crate) trait ImageProperties {
|
||||
fn dimensions(&self) -> (u32, u32);
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
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.
|
||||
@ -55,6 +55,7 @@ pub(crate) enum TerminalImage {
|
||||
Kitty(KittyImage),
|
||||
Iterm(ItermImage),
|
||||
Ascii(AsciiImage),
|
||||
Raw(RawImage),
|
||||
#[cfg(feature = "sixel")]
|
||||
Sixel(super::protocols::sixel::SixelImage),
|
||||
}
|
||||
@ -65,6 +66,7 @@ impl ImageProperties for TerminalImage {
|
||||
Self::Kitty(image) => image.dimensions(),
|
||||
Self::Iterm(image) => image.dimensions(),
|
||||
Self::Ascii(image) => image.dimensions(),
|
||||
Self::Raw(image) => image.dimensions(),
|
||||
#[cfg(feature = "sixel")]
|
||||
Self::Sixel(image) => image.dimensions(),
|
||||
}
|
||||
@ -75,6 +77,7 @@ pub enum ImagePrinter {
|
||||
Kitty(KittyPrinter),
|
||||
Iterm(ItermPrinter),
|
||||
Ascii(AsciiPrinter),
|
||||
Raw(RawPrinter),
|
||||
Null,
|
||||
#[cfg(feature = "sixel")]
|
||||
Sixel(super::protocols::sixel::SixelPrinter),
|
||||
@ -92,6 +95,7 @@ impl ImagePrinter {
|
||||
GraphicsMode::Kitty { mode, inside_tmux } => Self::new_kitty(mode, inside_tmux)?,
|
||||
GraphicsMode::Iterm2 => Self::new_iterm(),
|
||||
GraphicsMode::AsciiBlocks => Self::new_ascii(),
|
||||
GraphicsMode::Raw => Self::new_raw(),
|
||||
#[cfg(feature = "sixel")]
|
||||
GraphicsMode::Sixel => Self::new_sixel()?,
|
||||
};
|
||||
@ -103,13 +107,17 @@ impl ImagePrinter {
|
||||
}
|
||||
|
||||
fn new_iterm() -> Self {
|
||||
Self::Iterm(ItermPrinter::default())
|
||||
Self::Iterm(ItermPrinter)
|
||||
}
|
||||
|
||||
fn new_ascii() -> Self {
|
||||
Self::Ascii(AsciiPrinter)
|
||||
}
|
||||
|
||||
fn new_raw() -> Self {
|
||||
Self::Raw(RawPrinter)
|
||||
}
|
||||
|
||||
#[cfg(feature = "sixel")]
|
||||
fn new_sixel() -> Result<Self, CreatePrinterError> {
|
||||
Ok(Self::Sixel(super::protocols::sixel::SixelPrinter::new()?))
|
||||
@ -119,56 +127,56 @@ impl ImagePrinter {
|
||||
impl PrintImage for ImagePrinter {
|
||||
type Image = TerminalImage;
|
||||
|
||||
fn register(&self, image: DynamicImage) -> Result<Self::Image, RegisterImageError> {
|
||||
fn register(&self, spec: ImageSpec) -> Result<Self::Image, RegisterImageError> {
|
||||
let image = match self {
|
||||
Self::Kitty(printer) => TerminalImage::Kitty(printer.register(image)?),
|
||||
Self::Iterm(printer) => TerminalImage::Iterm(printer.register(image)?),
|
||||
Self::Ascii(printer) => TerminalImage::Ascii(printer.register(image)?),
|
||||
Self::Kitty(printer) => TerminalImage::Kitty(printer.register(spec)?),
|
||||
Self::Iterm(printer) => TerminalImage::Iterm(printer.register(spec)?),
|
||||
Self::Ascii(printer) => TerminalImage::Ascii(printer.register(spec)?),
|
||||
Self::Null => return Err(RegisterImageError::Unsupported),
|
||||
Self::Raw(printer) => TerminalImage::Raw(printer.register(spec)?),
|
||||
#[cfg(feature = "sixel")]
|
||||
Self::Sixel(printer) => TerminalImage::Sixel(printer.register(image)?),
|
||||
Self::Sixel(printer) => TerminalImage::Sixel(printer.register(spec)?),
|
||||
};
|
||||
Ok(image)
|
||||
}
|
||||
|
||||
fn register_from_path<P: AsRef<Path>>(&self, path: P) -> Result<Self::Image, RegisterImageError> {
|
||||
let image = match self {
|
||||
Self::Kitty(printer) => TerminalImage::Kitty(printer.register_from_path(path)?),
|
||||
Self::Iterm(printer) => TerminalImage::Iterm(printer.register_from_path(path)?),
|
||||
Self::Ascii(printer) => TerminalImage::Ascii(printer.register_from_path(path)?),
|
||||
Self::Null => return Err(RegisterImageError::Unsupported),
|
||||
#[cfg(feature = "sixel")]
|
||||
Self::Sixel(printer) => TerminalImage::Sixel(printer.register_from_path(path)?),
|
||||
};
|
||||
Ok(image)
|
||||
}
|
||||
|
||||
fn print<W>(&self, image: &Self::Image, options: &PrintOptions, writer: &mut W) -> Result<(), PrintImageError>
|
||||
fn print<T>(&self, image: &Self::Image, options: &PrintOptions, terminal: &mut T) -> Result<(), PrintImageError>
|
||||
where
|
||||
W: io::Write,
|
||||
T: TerminalIo,
|
||||
{
|
||||
match (self, image) {
|
||||
(Self::Kitty(printer), TerminalImage::Kitty(image)) => printer.print(image, options, writer),
|
||||
(Self::Iterm(printer), TerminalImage::Iterm(image)) => printer.print(image, options, writer),
|
||||
(Self::Ascii(printer), TerminalImage::Ascii(image)) => printer.print(image, options, writer),
|
||||
(Self::Kitty(printer), TerminalImage::Kitty(image)) => printer.print(image, options, terminal),
|
||||
(Self::Iterm(printer), TerminalImage::Iterm(image)) => printer.print(image, options, terminal),
|
||||
(Self::Ascii(printer), TerminalImage::Ascii(image)) => printer.print(image, options, terminal),
|
||||
(Self::Null, _) => Ok(()),
|
||||
(Self::Raw(printer), TerminalImage::Raw(image)) => printer.print(image, options, terminal),
|
||||
#[cfg(feature = "sixel")]
|
||||
(Self::Sixel(printer), TerminalImage::Sixel(image)) => printer.print(image, options, writer),
|
||||
(Self::Sixel(printer), TerminalImage::Sixel(image)) => printer.print(image, options, terminal),
|
||||
_ => Err(PrintImageError::Unsupported),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct ImageRegistry(pub Arc<ImagePrinter>);
|
||||
pub(crate) struct ImageRegistry {
|
||||
printer: Arc<ImagePrinter>,
|
||||
images: Arc<Mutex<HashMap<PathBuf, Image>>>,
|
||||
}
|
||||
|
||||
impl ImageRegistry {
|
||||
pub fn new(printer: Arc<ImagePrinter>) -> Self {
|
||||
Self { printer, images: Default::default() }
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for ImageRegistry {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let inner = match self.0.as_ref() {
|
||||
let inner = match self.printer.as_ref() {
|
||||
ImagePrinter::Kitty(_) => "Kitty",
|
||||
ImagePrinter::Iterm(_) => "Iterm",
|
||||
ImagePrinter::Ascii(_) => "Ascii",
|
||||
ImagePrinter::Null => "Null",
|
||||
ImagePrinter::Raw(_) => "Raw",
|
||||
#[cfg(feature = "sixel")]
|
||||
ImagePrinter::Sixel(_) => "Sixel",
|
||||
};
|
||||
@ -177,19 +185,36 @@ impl fmt::Debug for ImageRegistry {
|
||||
}
|
||||
|
||||
impl ImageRegistry {
|
||||
pub(crate) fn register_image(&self, image: DynamicImage) -> Result<Image, RegisterImageError> {
|
||||
let resource = self.0.register(image)?;
|
||||
let image = Image::new(resource, ImageSource::Generated);
|
||||
pub(crate) fn register(&self, spec: ImageSpec) -> Result<Image, RegisterImageError> {
|
||||
let mut images = self.images.lock().unwrap();
|
||||
let (source, cache_key) = match &spec {
|
||||
ImageSpec::Generated(_) => (ImageSource::Generated, None),
|
||||
ImageSpec::Filesystem(path) => {
|
||||
// Return if already cached
|
||||
if let Some(image) = images.get(path) {
|
||||
return Ok(image.clone());
|
||||
}
|
||||
(ImageSource::Filesystem(path.clone()), Some(path.clone()))
|
||||
}
|
||||
};
|
||||
let resource = self.printer.register(spec)?;
|
||||
let image = Image::new(resource, source);
|
||||
if let Some(key) = cache_key {
|
||||
images.insert(key.clone(), image.clone());
|
||||
}
|
||||
Ok(image)
|
||||
}
|
||||
|
||||
pub(crate) fn register_resource(&self, path: PathBuf) -> Result<Image, RegisterImageError> {
|
||||
let resource = self.0.register_from_path(&path)?;
|
||||
let image = Image::new(resource, ImageSource::Filesystem(path));
|
||||
Ok(image)
|
||||
pub(crate) fn clear(&self) {
|
||||
self.images.lock().unwrap().clear();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) enum ImageSpec {
|
||||
Generated(DynamicImage),
|
||||
Filesystem(PathBuf),
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(crate) enum CreatePrinterError {
|
||||
#[error("io: {0}")]
|
||||
@ -217,6 +242,15 @@ impl From<PaletteColorError> for PrintImageError {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TerminalError> for PrintImageError {
|
||||
fn from(e: TerminalError) -> Self {
|
||||
match e {
|
||||
TerminalError::Io(e) => Self::Io(e),
|
||||
TerminalError::Image(e) => e,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(crate) enum RegisterImageError {
|
||||
#[error(transparent)]
|
||||
|
@ -1,36 +1,54 @@
|
||||
use crate::terminal::image::printer::{ImageProperties, PrintImage, PrintImageError, PrintOptions, RegisterImageError};
|
||||
use crossterm::{
|
||||
QueueableCommand,
|
||||
cursor::{MoveRight, MoveToColumn},
|
||||
style::{Color, Stylize},
|
||||
use crate::{
|
||||
markdown::text_style::{Color, Colors, TextStyle},
|
||||
terminal::{
|
||||
image::printer::{ImageProperties, ImageSpec, PrintImage, PrintImageError, PrintOptions, RegisterImageError},
|
||||
printer::{TerminalCommand, TerminalIo},
|
||||
},
|
||||
};
|
||||
use image::{DynamicImage, GenericImageView, Pixel, Rgba, imageops::FilterType};
|
||||
use image::{DynamicImage, GenericImageView, Pixel, Rgba, RgbaImage, imageops::FilterType};
|
||||
use itertools::Itertools;
|
||||
use std::{fs, ops::Deref};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
const TOP_CHAR: char = '▀';
|
||||
const BOTTOM_CHAR: char = '▄';
|
||||
const TOP_CHAR: &str = "▀";
|
||||
const BOTTOM_CHAR: &str = "▄";
|
||||
|
||||
pub(crate) struct AsciiImage(DynamicImage);
|
||||
struct Inner {
|
||||
image: DynamicImage,
|
||||
cached_sizes: Mutex<HashMap<(u16, u16), RgbaImage>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct AsciiImage {
|
||||
inner: Arc<Inner>,
|
||||
}
|
||||
|
||||
impl AsciiImage {
|
||||
pub(crate) fn cache_scaling(&self, columns: u16, rows: u16) {
|
||||
let mut cached_sizes = self.inner.cached_sizes.lock().unwrap();
|
||||
// lookup on cache/resize the image and store it in cache
|
||||
let cache_key = (columns, rows);
|
||||
if cached_sizes.get(&cache_key).is_none() {
|
||||
let image = self.inner.image.resize_exact(columns as u32, rows as u32, FilterType::Triangle);
|
||||
cached_sizes.insert(cache_key, image.into_rgba8());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ImageProperties for AsciiImage {
|
||||
fn dimensions(&self) -> (u32, u32) {
|
||||
self.0.dimensions()
|
||||
self.inner.image.dimensions()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DynamicImage> for AsciiImage {
|
||||
fn from(image: DynamicImage) -> Self {
|
||||
let image = image.into_rgba8();
|
||||
Self(image.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for AsciiImage {
|
||||
type Target = DynamicImage;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
let inner = Inner { image: image.into(), cached_sizes: Default::default() };
|
||||
Self { inner: Arc::new(inner) }
|
||||
}
|
||||
}
|
||||
|
||||
@ -64,32 +82,36 @@ impl AsciiPrinter {
|
||||
impl PrintImage for AsciiPrinter {
|
||||
type Image = AsciiImage;
|
||||
|
||||
fn register(&self, image: image::DynamicImage) -> Result<Self::Image, RegisterImageError> {
|
||||
Ok(AsciiImage(image))
|
||||
fn register(&self, spec: ImageSpec) -> Result<Self::Image, RegisterImageError> {
|
||||
let image = match spec {
|
||||
ImageSpec::Generated(image) => image,
|
||||
ImageSpec::Filesystem(path) => {
|
||||
let contents = fs::read(path)?;
|
||||
image::load_from_memory(&contents)?
|
||||
}
|
||||
};
|
||||
Ok(AsciiImage::from(image))
|
||||
}
|
||||
|
||||
fn register_from_path<P: AsRef<std::path::Path>>(&self, path: P) -> Result<Self::Image, RegisterImageError> {
|
||||
let contents = fs::read(path)?;
|
||||
let image = image::load_from_memory(&contents)?;
|
||||
Ok(AsciiImage(image))
|
||||
}
|
||||
|
||||
fn print<W>(&self, image: &Self::Image, options: &PrintOptions, writer: &mut W) -> Result<(), PrintImageError>
|
||||
fn print<T>(&self, image: &Self::Image, options: &PrintOptions, terminal: &mut T) -> Result<(), PrintImageError>
|
||||
where
|
||||
W: std::io::Write,
|
||||
T: TerminalIo,
|
||||
{
|
||||
// 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::try_from).transpose()?;
|
||||
let columns = options.columns;
|
||||
let rows = options.rows * 2;
|
||||
// Scale it first
|
||||
image.cache_scaling(columns, rows);
|
||||
|
||||
// lookup on cache/resize the image and store it in cache
|
||||
let cache_key = (columns, rows);
|
||||
let cached_sizes = image.inner.cached_sizes.lock().unwrap();
|
||||
let image = cached_sizes.get(&cache_key).expect("scaled image no longer there");
|
||||
|
||||
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 {
|
||||
@ -101,36 +123,26 @@ impl PrintImage for AsciiPrinter {
|
||||
let background = default_background;
|
||||
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))?;
|
||||
}
|
||||
let command = match (top, bottom) {
|
||||
(Some(top), Some(bottom)) => TerminalCommand::PrintText {
|
||||
content: TOP_CHAR,
|
||||
style: TextStyle::default().fg_color(top).bg_color(bottom),
|
||||
},
|
||||
(Some(top), None) => TerminalCommand::PrintText {
|
||||
content: TOP_CHAR,
|
||||
style: TextStyle::colored(Colors { foreground: Some(top), background: default_background }),
|
||||
},
|
||||
(None, Some(bottom)) => TerminalCommand::PrintText {
|
||||
content: BOTTOM_CHAR,
|
||||
style: TextStyle::colored(Colors { foreground: Some(bottom), background: default_background }),
|
||||
},
|
||||
(None, None) => TerminalCommand::MoveRight(1),
|
||||
};
|
||||
terminal.execute(&command)?;
|
||||
}
|
||||
writeln!(writer)?;
|
||||
terminal.execute(&TerminalCommand::MoveDown(1))?;
|
||||
terminal.execute(&TerminalCommand::MoveLeft(options.columns))?;
|
||||
}
|
||||
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,7 +1,10 @@
|
||||
use crate::terminal::image::printer::{ImageProperties, PrintImage, PrintImageError, PrintOptions, RegisterImageError};
|
||||
use crate::terminal::{
|
||||
image::printer::{ImageProperties, ImageSpec, PrintImage, PrintImageError, PrintOptions, RegisterImageError},
|
||||
printer::{TerminalCommand, TerminalIo},
|
||||
};
|
||||
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||
use image::{GenericImageView, ImageEncoder, codecs::png::PngEncoder};
|
||||
use std::{env, fs, path::Path};
|
||||
use image::{GenericImageView, ImageEncoder, RgbaImage, codecs::png::PngEncoder};
|
||||
use std::fs;
|
||||
|
||||
pub(crate) struct ItermImage {
|
||||
dimensions: (u32, u32),
|
||||
@ -15,6 +18,12 @@ impl ItermImage {
|
||||
let base64_contents = STANDARD.encode(&contents);
|
||||
Self { dimensions, raw_length, base64_contents }
|
||||
}
|
||||
|
||||
pub(crate) fn as_rgba8(&self) -> RgbaImage {
|
||||
let contents = STANDARD.decode(&self.base64_contents).expect("base64 must be valid");
|
||||
let image = image::load_from_memory(&contents).expect("image must have been originally valid");
|
||||
image.to_rgba8()
|
||||
}
|
||||
}
|
||||
|
||||
impl ImageProperties for ItermImage {
|
||||
@ -23,58 +32,41 @@ impl ImageProperties for ItermImage {
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct ItermPrinter;
|
||||
|
||||
impl PrintImage for ItermPrinter {
|
||||
type Image = ItermImage;
|
||||
|
||||
fn register(&self, image: image::DynamicImage) -> Result<Self::Image, RegisterImageError> {
|
||||
let dimensions = image.dimensions();
|
||||
let mut contents = Vec::new();
|
||||
let encoder = PngEncoder::new(&mut contents);
|
||||
encoder.write_image(image.as_bytes(), dimensions.0, dimensions.1, image.color().into())?;
|
||||
Ok(ItermImage::new(contents, dimensions))
|
||||
fn register(&self, spec: ImageSpec) -> Result<Self::Image, RegisterImageError> {
|
||||
match spec {
|
||||
ImageSpec::Generated(image) => {
|
||||
let dimensions = image.dimensions();
|
||||
let mut contents = Vec::new();
|
||||
let encoder = PngEncoder::new(&mut contents);
|
||||
encoder.write_image(image.as_bytes(), dimensions.0, dimensions.1, image.color().into())?;
|
||||
Ok(ItermImage::new(contents, dimensions))
|
||||
}
|
||||
ImageSpec::Filesystem(path) => {
|
||||
let contents = fs::read(path)?;
|
||||
let image = image::load_from_memory(&contents)?;
|
||||
Ok(ItermImage::new(contents, image.dimensions()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn register_from_path<P: AsRef<Path>>(&self, path: P) -> Result<Self::Image, RegisterImageError> {
|
||||
let contents = fs::read(path)?;
|
||||
let image = image::load_from_memory(&contents)?;
|
||||
Ok(ItermImage::new(contents, image.dimensions()))
|
||||
}
|
||||
|
||||
fn print<W>(&self, image: &Self::Image, options: &PrintOptions, writer: &mut W) -> Result<(), PrintImageError>
|
||||
fn print<T>(&self, image: &Self::Image, options: &PrintOptions, terminal: &mut T) -> Result<(), PrintImageError>
|
||||
where
|
||||
W: std::io::Write,
|
||||
T: TerminalIo,
|
||||
{
|
||||
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)?;
|
||||
}
|
||||
let content = format!(
|
||||
"\x1b]1337;File=size={size};width={columns};height={rows};inline=1;preserveAspectRatio=1:{contents}\x07"
|
||||
);
|
||||
terminal.execute(&TerminalCommand::PrintText { content: &content, style: Default::default() })?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
@ -1,15 +1,16 @@
|
||||
use crate::{
|
||||
markdown::text_style::Color,
|
||||
terminal::image::printer::{ImageProperties, PrintImage, PrintImageError, PrintOptions, RegisterImageError},
|
||||
markdown::text_style::{Color, TextStyle},
|
||||
terminal::{
|
||||
image::printer::{ImageProperties, ImageSpec, PrintImage, PrintImageError, PrintOptions, RegisterImageError},
|
||||
printer::{TerminalCommand, TerminalIo},
|
||||
},
|
||||
};
|
||||
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||
use crossterm::{QueueableCommand, cursor::MoveToColumn, style::SetForegroundColor};
|
||||
use image::{AnimationDecoder, Delay, DynamicImage, EncodableLayout, ImageReader, RgbaImage, codecs::gif::GifDecoder};
|
||||
use rand::Rng;
|
||||
use image::{AnimationDecoder, Delay, EncodableLayout, ImageReader, RgbaImage, codecs::gif::GifDecoder};
|
||||
use std::{
|
||||
fmt,
|
||||
fs::{self, File},
|
||||
io::{self, BufReader, Write},
|
||||
io::{self, BufReader},
|
||||
path::{Path, PathBuf},
|
||||
sync::atomic::{AtomicU32, Ordering},
|
||||
};
|
||||
@ -72,6 +73,25 @@ pub(crate) struct KittyImage {
|
||||
resource: GenericResource<KittyBuffer>,
|
||||
}
|
||||
|
||||
impl KittyImage {
|
||||
pub(crate) fn as_rgba8(&self) -> RgbaImage {
|
||||
let first_frame = match &self.resource {
|
||||
GenericResource::Image(buffer) => buffer,
|
||||
GenericResource::Gif(gif_frames) => &gif_frames[0].buffer,
|
||||
};
|
||||
let buffer = match first_frame {
|
||||
KittyBuffer::Filesystem(path) => {
|
||||
let Ok(contents) = fs::read(path) else {
|
||||
return RgbaImage::default();
|
||||
};
|
||||
contents
|
||||
}
|
||||
KittyBuffer::Memory(buffer) => buffer.clone(),
|
||||
};
|
||||
RgbaImage::from_raw(self.dimensions.0, self.dimensions.1, buffer).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl ImageProperties for KittyImage {
|
||||
fn dimensions(&self) -> (u32, u32) {
|
||||
self.dimensions
|
||||
@ -145,18 +165,18 @@ impl KittyPrinter {
|
||||
}
|
||||
|
||||
fn generate_image_id() -> u32 {
|
||||
rand::thread_rng().gen_range(1..u32::MAX)
|
||||
fastrand::u32(1..u32::MAX)
|
||||
}
|
||||
|
||||
fn print_image<W>(
|
||||
fn print_image<T>(
|
||||
&self,
|
||||
dimensions: (u32, u32),
|
||||
buffer: &KittyBuffer,
|
||||
writer: &mut W,
|
||||
terminal: &mut T,
|
||||
print_options: &PrintOptions,
|
||||
) -> Result<(), PrintImageError>
|
||||
where
|
||||
W: io::Write,
|
||||
T: TerminalIo,
|
||||
{
|
||||
let mut options = vec![
|
||||
ControlOption::Format(ImageFormat::Rgba),
|
||||
@ -175,25 +195,25 @@ impl KittyPrinter {
|
||||
}
|
||||
|
||||
match &buffer {
|
||||
KittyBuffer::Filesystem(path) => self.print_local(options, path, writer)?,
|
||||
KittyBuffer::Memory(buffer) => self.print_remote(options, buffer, writer, false)?,
|
||||
KittyBuffer::Filesystem(path) => self.print_local(options, path, terminal)?,
|
||||
KittyBuffer::Memory(buffer) => self.print_remote(options, buffer, terminal, false)?,
|
||||
};
|
||||
if self.tmux {
|
||||
self.print_unicode_placeholders(writer, print_options, image_id)?;
|
||||
self.print_unicode_placeholders(terminal, print_options, image_id)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_gif<W>(
|
||||
fn print_gif<T>(
|
||||
&self,
|
||||
dimensions: (u32, u32),
|
||||
frames: &[GifFrame<KittyBuffer>],
|
||||
writer: &mut W,
|
||||
terminal: &mut T,
|
||||
print_options: &PrintOptions,
|
||||
) -> Result<(), PrintImageError>
|
||||
where
|
||||
W: io::Write,
|
||||
T: TerminalIo,
|
||||
{
|
||||
let image_id = Self::generate_image_id();
|
||||
for (frame_id, frame) in frames.iter().enumerate() {
|
||||
@ -223,8 +243,8 @@ impl KittyPrinter {
|
||||
|
||||
let is_frame = frame_id > 0;
|
||||
match &frame.buffer {
|
||||
KittyBuffer::Filesystem(path) => self.print_local(options, path, writer)?,
|
||||
KittyBuffer::Memory(buffer) => self.print_remote(options, buffer, writer, is_frame)?,
|
||||
KittyBuffer::Filesystem(path) => self.print_local(options, path, terminal)?,
|
||||
KittyBuffer::Memory(buffer) => self.print_remote(options, buffer, terminal, is_frame)?,
|
||||
};
|
||||
|
||||
if frame_id == 0 {
|
||||
@ -234,8 +254,8 @@ impl KittyPrinter {
|
||||
ControlOption::FrameId(1),
|
||||
ControlOption::Loops(1),
|
||||
];
|
||||
let command = self.make_command(options, "");
|
||||
write!(writer, "{command}")?;
|
||||
let command = self.make_command(options, "").to_string();
|
||||
terminal.execute(&TerminalCommand::PrintText { content: &command, style: Default::default() })?;
|
||||
} else if frame_id == 1 {
|
||||
let options = &[
|
||||
ControlOption::Action(Action::Animate),
|
||||
@ -243,12 +263,12 @@ impl KittyPrinter {
|
||||
ControlOption::FrameId(1),
|
||||
ControlOption::AnimationState(2),
|
||||
];
|
||||
let command = self.make_command(options, "");
|
||||
write!(writer, "{command}")?;
|
||||
let command = self.make_command(options, "").to_string();
|
||||
terminal.execute(&TerminalCommand::PrintText { content: &command, style: Default::default() })?;
|
||||
}
|
||||
}
|
||||
if self.tmux {
|
||||
self.print_unicode_placeholders(writer, print_options, image_id)?;
|
||||
self.print_unicode_placeholders(terminal, print_options, image_id)?;
|
||||
}
|
||||
let options = &[
|
||||
ControlOption::Action(Action::Animate),
|
||||
@ -258,8 +278,8 @@ impl KittyPrinter {
|
||||
ControlOption::Loops(1),
|
||||
ControlOption::Quiet(2),
|
||||
];
|
||||
let command = self.make_command(options, "");
|
||||
write!(writer, "{command}")?;
|
||||
let command = self.make_command(options, "").to_string();
|
||||
terminal.execute(&TerminalCommand::PrintText { content: &command, style: Default::default() })?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -267,14 +287,14 @@ impl KittyPrinter {
|
||||
ControlCommand { options, payload, tmux: self.tmux }
|
||||
}
|
||||
|
||||
fn print_local<W>(
|
||||
fn print_local<T>(
|
||||
&self,
|
||||
mut options: Vec<ControlOption>,
|
||||
path: &Path,
|
||||
writer: &mut W,
|
||||
terminal: &mut T,
|
||||
) -> Result<(), PrintImageError>
|
||||
where
|
||||
W: io::Write,
|
||||
T: TerminalIo,
|
||||
{
|
||||
let Some(path) = path.to_str() else {
|
||||
return Err(PrintImageError::other("path is not valid utf8"));
|
||||
@ -282,20 +302,20 @@ impl KittyPrinter {
|
||||
let encoded_path = STANDARD.encode(path);
|
||||
options.push(ControlOption::Medium(TransmissionMedium::LocalFile));
|
||||
|
||||
let command = self.make_command(&options, &encoded_path);
|
||||
write!(writer, "{command}")?;
|
||||
let command = self.make_command(&options, &encoded_path).to_string();
|
||||
terminal.execute(&TerminalCommand::PrintText { content: &command, style: Default::default() })?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_remote<W>(
|
||||
fn print_remote<T>(
|
||||
&self,
|
||||
mut options: Vec<ControlOption>,
|
||||
frame: &[u8],
|
||||
writer: &mut W,
|
||||
terminal: &mut T,
|
||||
is_frame: bool,
|
||||
) -> Result<(), PrintImageError>
|
||||
where
|
||||
W: io::Write,
|
||||
T: TerminalIo,
|
||||
{
|
||||
options.push(ControlOption::Medium(TransmissionMedium::Direct));
|
||||
|
||||
@ -311,8 +331,8 @@ impl KittyPrinter {
|
||||
options.push(ControlOption::MoreData(more));
|
||||
|
||||
let payload = &payload[start..end];
|
||||
let command = self.make_command(&options, payload);
|
||||
write!(writer, "{command}")?;
|
||||
let command = self.make_command(&options, payload).to_string();
|
||||
terminal.execute(&TerminalCommand::PrintText { content: &command, style: Default::default() })?;
|
||||
|
||||
options.clear();
|
||||
if is_frame {
|
||||
@ -322,14 +342,17 @@ impl KittyPrinter {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_unicode_placeholders<W: Write>(
|
||||
fn print_unicode_placeholders<T>(
|
||||
&self,
|
||||
writer: &mut W,
|
||||
terminal: &mut T,
|
||||
options: &PrintOptions,
|
||||
image_id: u32,
|
||||
) -> Result<(), PrintImageError> {
|
||||
) -> Result<(), PrintImageError>
|
||||
where
|
||||
T: TerminalIo,
|
||||
{
|
||||
let color = Color::new((image_id >> 16) as u8, (image_id >> 8) as u8, image_id as u8);
|
||||
writer.queue(SetForegroundColor(color.try_into()?))?;
|
||||
let style = TextStyle::default().fg_color(color);
|
||||
if options.rows.max(options.columns) >= DIACRITICS.len() as u16 {
|
||||
return Err(PrintImageError::other("image is too large to fit in tmux"));
|
||||
}
|
||||
@ -339,12 +362,13 @@ impl KittyPrinter {
|
||||
let row_diacritic = char::from_u32(DIACRITICS[row as usize]).unwrap();
|
||||
for column in 0..options.columns {
|
||||
let column_diacritic = char::from_u32(DIACRITICS[column as usize]).unwrap();
|
||||
write!(writer, "{IMAGE_PLACEHOLDER}{row_diacritic}{column_diacritic}{last_byte}")?;
|
||||
let content = format!("{IMAGE_PLACEHOLDER}{row_diacritic}{column_diacritic}{last_byte}");
|
||||
terminal.execute(&TerminalCommand::PrintText { content: &content, style })?;
|
||||
}
|
||||
if row != options.rows - 1 {
|
||||
writeln!(writer)?;
|
||||
terminal.execute(&TerminalCommand::MoveDown(1))?;
|
||||
}
|
||||
writer.queue(MoveToColumn(options.cursor_position.column))?;
|
||||
terminal.execute(&TerminalCommand::MoveLeft(options.columns))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@ -371,35 +395,26 @@ impl KittyPrinter {
|
||||
impl PrintImage for KittyPrinter {
|
||||
type Image = KittyImage;
|
||||
|
||||
fn register(&self, image: DynamicImage) -> Result<Self::Image, RegisterImageError> {
|
||||
let resource = RawResource::Image(image.into_rgba8());
|
||||
fn register(&self, spec: ImageSpec) -> Result<Self::Image, RegisterImageError> {
|
||||
let image = match spec {
|
||||
ImageSpec::Generated(image) => RawResource::Image(image.into_rgba8()),
|
||||
ImageSpec::Filesystem(path) => Self::load_raw_resource(&path)?,
|
||||
};
|
||||
let resource = match &self.mode {
|
||||
KittyMode::Local => self.persist_resource(resource)?,
|
||||
KittyMode::Remote => resource.into_memory_resource(),
|
||||
KittyMode::Local => self.persist_resource(image)?,
|
||||
KittyMode::Remote => image.into_memory_resource(),
|
||||
};
|
||||
Ok(resource)
|
||||
}
|
||||
|
||||
fn register_from_path<P: AsRef<Path>>(&self, path: P) -> Result<Self::Image, RegisterImageError> {
|
||||
let resource = Self::load_raw_resource(path.as_ref())?;
|
||||
let resource = match &self.mode {
|
||||
KittyMode::Local => self.persist_resource(resource)?,
|
||||
KittyMode::Remote => resource.into_memory_resource(),
|
||||
};
|
||||
Ok(resource)
|
||||
}
|
||||
|
||||
fn print<W: std::io::Write>(
|
||||
&self,
|
||||
image: &Self::Image,
|
||||
options: &PrintOptions,
|
||||
writer: &mut W,
|
||||
) -> Result<(), PrintImageError> {
|
||||
fn print<T>(&self, image: &Self::Image, options: &PrintOptions, terminal: &mut T) -> Result<(), PrintImageError>
|
||||
where
|
||||
T: TerminalIo,
|
||||
{
|
||||
match &image.resource {
|
||||
GenericResource::Image(resource) => self.print_image(image.dimensions, resource, writer, options)?,
|
||||
GenericResource::Gif(frames) => self.print_gif(image.dimensions, frames, writer, options)?,
|
||||
GenericResource::Image(resource) => self.print_image(image.dimensions, resource, terminal, options)?,
|
||||
GenericResource::Gif(frames) => self.print_gif(image.dimensions, frames, terminal, options)?,
|
||||
};
|
||||
writeln!(writer)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
pub(crate) mod ascii;
|
||||
pub(crate) mod iterm;
|
||||
pub(crate) mod kitty;
|
||||
pub(crate) mod raw;
|
||||
#[cfg(feature = "sixel")]
|
||||
pub(crate) mod sixel;
|
||||
|
61
src/terminal/image/protocols/raw.rs
Normal file
61
src/terminal/image/protocols/raw.rs
Normal file
@ -0,0 +1,61 @@
|
||||
use crate::terminal::{
|
||||
image::printer::{ImageProperties, ImageSpec, PrintImage, PrintImageError, PrintOptions, RegisterImageError},
|
||||
printer::TerminalIo,
|
||||
};
|
||||
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||
use image::{GenericImageView, ImageEncoder, ImageFormat, codecs::png::PngEncoder};
|
||||
use std::fs;
|
||||
|
||||
pub(crate) struct RawImage {
|
||||
contents: Vec<u8>,
|
||||
format: ImageFormat,
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
|
||||
impl RawImage {
|
||||
pub(crate) fn to_inline_html(&self) -> String {
|
||||
let mime_type = self.format.to_mime_type();
|
||||
let data = STANDARD.encode(&self.contents);
|
||||
format!("data:{mime_type};base64,{data}")
|
||||
}
|
||||
}
|
||||
|
||||
impl ImageProperties for RawImage {
|
||||
fn dimensions(&self) -> (u32, u32) {
|
||||
(self.width, self.height)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct RawPrinter;
|
||||
|
||||
impl PrintImage for RawPrinter {
|
||||
type Image = RawImage;
|
||||
|
||||
fn register(&self, spec: ImageSpec) -> Result<Self::Image, RegisterImageError> {
|
||||
let image = match spec {
|
||||
ImageSpec::Generated(image) => {
|
||||
let mut contents = Vec::new();
|
||||
let encoder = PngEncoder::new(&mut contents);
|
||||
let (width, height) = image.dimensions();
|
||||
encoder.write_image(image.as_bytes(), width, height, image.color().into())?;
|
||||
RawImage { contents, format: ImageFormat::Png, width, height }
|
||||
}
|
||||
ImageSpec::Filesystem(path) => {
|
||||
let contents = fs::read(path)?;
|
||||
let format = image::guess_format(&contents)?;
|
||||
let image = image::load_from_memory_with_format(&contents, format)?;
|
||||
let (width, height) = image.dimensions();
|
||||
RawImage { contents, format, width, height }
|
||||
}
|
||||
};
|
||||
Ok(image)
|
||||
}
|
||||
|
||||
fn print<T>(&self, _image: &Self::Image, _options: &PrintOptions, _terminal: &mut T) -> Result<(), PrintImageError>
|
||||
where
|
||||
T: TerminalIo,
|
||||
{
|
||||
Err(PrintImageError::Other("raw images can't be printed".into()))
|
||||
}
|
||||
}
|
@ -1,16 +1,25 @@
|
||||
use crate::terminal::image::printer::{
|
||||
CreatePrinterError, ImageProperties, PrintImage, PrintImageError, PrintOptions, RegisterImageError,
|
||||
use crate::terminal::{
|
||||
image::printer::{
|
||||
CreatePrinterError, ImageProperties, ImageSpec, PrintImage, PrintImageError, PrintOptions, RegisterImageError,
|
||||
},
|
||||
printer::{TerminalCommand, TerminalIo},
|
||||
};
|
||||
use image::{DynamicImage, GenericImageView, imageops::FilterType};
|
||||
use image::{DynamicImage, GenericImageView, RgbaImage, imageops::FilterType};
|
||||
use sixel_rs::{
|
||||
encoder::{Encoder, QuickFrameBuilder},
|
||||
optflags::EncodePolicy,
|
||||
sys::PixelFormat,
|
||||
};
|
||||
use std::{fs, io};
|
||||
use std::fs;
|
||||
|
||||
pub(crate) struct SixelImage(DynamicImage);
|
||||
|
||||
impl SixelImage {
|
||||
pub(crate) fn as_rgba8(&self) -> RgbaImage {
|
||||
self.0.to_rgba8()
|
||||
}
|
||||
}
|
||||
|
||||
impl ImageProperties for SixelImage {
|
||||
fn dimensions(&self) -> (u32, u32) {
|
||||
self.0.dimensions()
|
||||
@ -29,22 +38,23 @@ impl SixelPrinter {
|
||||
impl PrintImage for SixelPrinter {
|
||||
type Image = SixelImage;
|
||||
|
||||
fn register(&self, image: image::DynamicImage) -> Result<Self::Image, RegisterImageError> {
|
||||
Ok(SixelImage(image))
|
||||
fn register(&self, spec: ImageSpec) -> Result<Self::Image, RegisterImageError> {
|
||||
match spec {
|
||||
ImageSpec::Generated(image) => Ok(SixelImage(image)),
|
||||
ImageSpec::Filesystem(path) => {
|
||||
let contents = fs::read(path)?;
|
||||
let image = image::load_from_memory(&contents)?;
|
||||
Ok(SixelImage(image))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn register_from_path<P: AsRef<std::path::Path>>(&self, path: P) -> Result<Self::Image, RegisterImageError> {
|
||||
let contents = fs::read(path)?;
|
||||
let image = image::load_from_memory(&contents)?;
|
||||
Ok(SixelImage(image))
|
||||
}
|
||||
|
||||
fn print<W>(&self, image: &Self::Image, options: &PrintOptions, writer: &mut W) -> Result<(), PrintImageError>
|
||||
fn print<T>(&self, image: &Self::Image, options: &PrintOptions, terminal: &mut T) -> Result<(), PrintImageError>
|
||||
where
|
||||
W: io::Write,
|
||||
T: TerminalIo,
|
||||
{
|
||||
// We're already positioned in the right place but we may not have flushed that yet.
|
||||
writer.flush()?;
|
||||
terminal.execute(&TerminalCommand::Flush)?;
|
||||
|
||||
let encoder = Encoder::new().map_err(|e| PrintImageError::other(format!("creating sixel encoder: {e:?}")))?;
|
||||
encoder
|
||||
|
@ -1,64 +1,125 @@
|
||||
use crate::render::properties::{CursorPosition, WindowSize};
|
||||
|
||||
/// Scale an image to a specific size.
|
||||
pub(crate) fn scale_image(
|
||||
scale_size: &WindowSize,
|
||||
window_dimensions: &WindowSize,
|
||||
image_width: u32,
|
||||
image_height: u32,
|
||||
position: &CursorPosition,
|
||||
) -> TerminalRect {
|
||||
let aspect_ratio = image_height as f64 / image_width as f64;
|
||||
let column_in_pixels = scale_size.pixels_per_column();
|
||||
let width_in_columns = scale_size.columns;
|
||||
let image_width = width_in_columns as f64 * column_in_pixels;
|
||||
let image_height = image_width * aspect_ratio;
|
||||
pub(crate) trait ScaleImage {
|
||||
/// Scale an image to a specific size.
|
||||
fn scale_image(
|
||||
&self,
|
||||
scale_size: &WindowSize,
|
||||
window_dimensions: &WindowSize,
|
||||
image_width: u32,
|
||||
image_height: u32,
|
||||
position: &CursorPosition,
|
||||
) -> TerminalRect;
|
||||
|
||||
fit_image_to_window(window_dimensions, image_width as u32, image_height as u32, position)
|
||||
/// Shrink an image so it fits the dimensions of the layout it's being displayed in.
|
||||
fn fit_image_to_rect(
|
||||
&self,
|
||||
dimensions: &WindowSize,
|
||||
image_width: u32,
|
||||
image_height: u32,
|
||||
position: &CursorPosition,
|
||||
) -> TerminalRect;
|
||||
}
|
||||
|
||||
/// Shrink an image so it fits the dimensions of the layout it's being displayed in.
|
||||
pub(crate) fn fit_image_to_window(
|
||||
dimensions: &WindowSize,
|
||||
image_width: u32,
|
||||
image_height: u32,
|
||||
position: &CursorPosition,
|
||||
) -> TerminalRect {
|
||||
let aspect_ratio = image_height as f64 / image_width as f64;
|
||||
pub(crate) struct ImageScaler {
|
||||
horizontal_margin: 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;
|
||||
impl ScaleImage for ImageScaler {
|
||||
fn scale_image(
|
||||
&self,
|
||||
scale_size: &WindowSize,
|
||||
window_dimensions: &WindowSize,
|
||||
image_width: u32,
|
||||
image_height: u32,
|
||||
position: &CursorPosition,
|
||||
) -> TerminalRect {
|
||||
let aspect_ratio = image_height as f64 / image_width as f64;
|
||||
let column_in_pixels = scale_size.pixels_per_column();
|
||||
let width_in_columns = scale_size.columns;
|
||||
let image_width = width_in_columns as f64 * column_in_pixels;
|
||||
let image_height = image_width * aspect_ratio;
|
||||
|
||||
// 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;
|
||||
self.fit_image_to_rect(window_dimensions, image_width as u32, image_height as u32, position)
|
||||
}
|
||||
// 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);
|
||||
fn fit_image_to_rect(
|
||||
&self,
|
||||
dimensions: &WindowSize,
|
||||
image_width: u32,
|
||||
image_height: u32,
|
||||
position: &CursorPosition,
|
||||
) -> TerminalRect {
|
||||
let aspect_ratio = image_height as f64 / image_width as f64;
|
||||
|
||||
// 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 }
|
||||
// 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 * (1.0 - self.horizontal_margin)) 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).round() as u32;
|
||||
}
|
||||
// Don't go too far wide.
|
||||
let width_in_columns = width_in_columns.min(column_margin);
|
||||
|
||||
// Now translate width -> height by using the original aspect ratio + translate based on
|
||||
// the window size's aspect ratio.
|
||||
let height_in_rows = (width_in_columns as f64 * aspect_ratio * dimensions.aspect_ratio()).round() as u16;
|
||||
|
||||
let width_in_columns = width_in_columns.max(1);
|
||||
let height_in_rows = height_in_rows.max(1);
|
||||
|
||||
TerminalRect { columns: width_in_columns as u16, rows: height_in_rows }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
impl Default for ImageScaler {
|
||||
fn default() -> Self {
|
||||
Self { horizontal_margin: 0.05 }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub(crate) struct TerminalRect {
|
||||
pub(crate) start_column: u16,
|
||||
pub(crate) columns: u16,
|
||||
pub(crate) rows: u16,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
|
||||
const WINDOW: WindowSize = WindowSize { rows: 50, columns: 100, height: 200, width: 200 };
|
||||
const SMALL_WINDOW: WindowSize = WindowSize { rows: 3, columns: 6, height: 10, width: 10 };
|
||||
const OTHER_RATIO: WindowSize = WindowSize { rows: 10, columns: 10, height: 10, width: 10 };
|
||||
|
||||
#[rstest]
|
||||
#[case::squares(WINDOW, 100, 100, TerminalRect { columns: 50, rows: 25 })]
|
||||
#[case::squares_smaller(WINDOW, 50, 50, TerminalRect { columns: 25, rows: 13 })]
|
||||
#[case::square_too_large(WINDOW, 400, 400, TerminalRect { columns: 100, rows: 50 })]
|
||||
#[case::too_tall(WINDOW, 200, 400, TerminalRect { columns: 50, rows: 50 })]
|
||||
#[case::too_wide(WINDOW, 400, 200, TerminalRect { columns: 100, rows: 25 })]
|
||||
#[case::small(SMALL_WINDOW, 899, 872, TerminalRect { columns: 6, rows: 3 })]
|
||||
#[case::other_ratio(OTHER_RATIO, 100, 100, TerminalRect { columns: 10, rows: 10 })]
|
||||
fn image_fitting(
|
||||
#[case] window: WindowSize,
|
||||
#[case] width: u32,
|
||||
#[case] height: u32,
|
||||
#[case] expected: TerminalRect,
|
||||
) {
|
||||
let cursor = CursorPosition::default();
|
||||
let rect = ImageScaler { horizontal_margin: 0.0 }.fit_image_to_rect(&window, width, height, &cursor);
|
||||
assert_eq!(rect, expected);
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,9 @@
|
||||
pub(crate) mod ansi;
|
||||
pub(crate) mod capabilities;
|
||||
pub(crate) mod emulator;
|
||||
pub(crate) mod image;
|
||||
pub(crate) mod printer;
|
||||
pub(crate) mod query;
|
||||
pub(crate) mod virt;
|
||||
|
||||
pub(crate) use printer::{Terminal, TerminalWrite, should_hide_cursor};
|
||||
|
||||
@ -14,6 +15,7 @@ pub enum GraphicsMode {
|
||||
inside_tmux: bool,
|
||||
},
|
||||
AsciiBlocks,
|
||||
Raw,
|
||||
#[cfg(feature = "sixel")]
|
||||
Sixel,
|
||||
}
|
||||
|
@ -1,13 +1,12 @@
|
||||
use crate::{
|
||||
markdown::text_style::{Color, Colors},
|
||||
markdown::text_style::{Color, Colors, TextStyle},
|
||||
terminal::image::{
|
||||
Image,
|
||||
printer::{ImagePrinter, PrintImage, PrintImageError, PrintOptions},
|
||||
},
|
||||
};
|
||||
use crossterm::{
|
||||
QueueableCommand, cursor,
|
||||
style::{self, StyledContent},
|
||||
QueueableCommand, cursor, style,
|
||||
terminal::{self},
|
||||
};
|
||||
use std::{
|
||||
@ -15,98 +14,139 @@ use std::{
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
/// A wrapper over the terminal write handle.
|
||||
pub(crate) struct Terminal<W>
|
||||
where
|
||||
W: TerminalWrite,
|
||||
{
|
||||
writer: W,
|
||||
image_printer: Arc<ImagePrinter>,
|
||||
pub(crate) cursor_row: u16,
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub(crate) enum TerminalCommand<'a> {
|
||||
BeginUpdate,
|
||||
EndUpdate,
|
||||
MoveTo { column: u16, row: u16 },
|
||||
MoveToRow(u16),
|
||||
MoveToColumn(u16),
|
||||
MoveDown(u16),
|
||||
MoveRight(u16),
|
||||
MoveLeft(u16),
|
||||
MoveToNextLine,
|
||||
PrintText { content: &'a str, style: TextStyle },
|
||||
ClearScreen,
|
||||
SetColors(Colors),
|
||||
SetBackgroundColor(Color),
|
||||
Flush,
|
||||
PrintImage { image: Image, options: PrintOptions },
|
||||
}
|
||||
|
||||
impl<W: TerminalWrite> Terminal<W> {
|
||||
pub(crate) fn new(mut writer: W, image_printer: Arc<ImagePrinter>) -> io::Result<Self> {
|
||||
pub(crate) trait TerminalIo {
|
||||
fn execute(&mut self, command: &TerminalCommand<'_>) -> Result<(), TerminalError>;
|
||||
fn cursor_row(&self) -> u16;
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(crate) enum TerminalError {
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] io::Error),
|
||||
|
||||
#[error("image: {0}")]
|
||||
Image(#[from] PrintImageError),
|
||||
}
|
||||
|
||||
/// A wrapper over the terminal write handle.
|
||||
pub(crate) struct Terminal<I: TerminalWrite> {
|
||||
writer: I,
|
||||
image_printer: Arc<ImagePrinter>,
|
||||
cursor_row: u16,
|
||||
current_row_height: u16,
|
||||
}
|
||||
|
||||
impl<I: TerminalWrite> Terminal<I> {
|
||||
pub(crate) fn new(mut writer: I, image_printer: Arc<ImagePrinter>) -> io::Result<Self> {
|
||||
writer.init()?;
|
||||
Ok(Self { writer, image_printer, cursor_row: 0 })
|
||||
Ok(Self { writer, image_printer, cursor_row: 0, current_row_height: 1 })
|
||||
}
|
||||
|
||||
pub(crate) fn begin_update(&mut self) -> io::Result<()> {
|
||||
fn begin_update(&mut self) -> io::Result<()> {
|
||||
self.writer.queue(terminal::BeginSynchronizedUpdate)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn end_update(&mut self) -> io::Result<()> {
|
||||
fn end_update(&mut self) -> io::Result<()> {
|
||||
self.writer.queue(terminal::EndSynchronizedUpdate)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn move_to(&mut self, column: u16, row: u16) -> io::Result<()> {
|
||||
fn move_to(&mut self, column: u16, row: u16) -> io::Result<()> {
|
||||
self.writer.queue(cursor::MoveTo(column, row))?;
|
||||
self.cursor_row = row;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn move_to_row(&mut self, row: u16) -> io::Result<()> {
|
||||
fn move_to_row(&mut self, row: u16) -> io::Result<()> {
|
||||
self.writer.queue(cursor::MoveToRow(row))?;
|
||||
self.cursor_row = row;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn move_to_column(&mut self, column: u16) -> io::Result<()> {
|
||||
fn move_to_column(&mut self, column: u16) -> io::Result<()> {
|
||||
self.writer.queue(cursor::MoveToColumn(column))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn move_down(&mut self, amount: u16) -> io::Result<()> {
|
||||
fn move_down(&mut self, amount: u16) -> io::Result<()> {
|
||||
self.writer.queue(cursor::MoveDown(amount))?;
|
||||
self.cursor_row += amount;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn move_to_next_line(&mut self, amount: u16) -> io::Result<()> {
|
||||
fn move_right(&mut self, amount: u16) -> io::Result<()> {
|
||||
self.writer.queue(cursor::MoveRight(amount))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn move_left(&mut self, amount: u16) -> io::Result<()> {
|
||||
self.writer.queue(cursor::MoveLeft(amount))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn move_to_next_line(&mut self) -> io::Result<()> {
|
||||
let amount = self.current_row_height;
|
||||
self.writer.queue(cursor::MoveToNextLine(amount))?;
|
||||
self.cursor_row += amount;
|
||||
self.current_row_height = 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn print_line(&mut self, text: &str) -> io::Result<()> {
|
||||
self.writer.queue(style::Print(text))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn print_styled_line(&mut self, content: StyledContent<String>) -> io::Result<()> {
|
||||
fn print_text(&mut self, content: &str, style: &TextStyle) -> io::Result<()> {
|
||||
let content = style.apply(content);
|
||||
self.writer.queue(style::PrintStyledContent(content))?;
|
||||
self.current_row_height = self.current_row_height.max(style.size as u16);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn clear_screen(&mut self) -> io::Result<()> {
|
||||
fn clear_screen(&mut self) -> io::Result<()> {
|
||||
self.writer.queue(terminal::Clear(terminal::ClearType::All))?;
|
||||
self.cursor_row = 0;
|
||||
self.current_row_height = 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn set_colors(&mut self, colors: Colors) -> io::Result<()> {
|
||||
let colors = colors.try_into().map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
|
||||
fn set_colors(&mut self, colors: Colors) -> io::Result<()> {
|
||||
let colors = colors.into();
|
||||
self.writer.queue(style::ResetColor)?;
|
||||
self.writer.queue(style::SetColors(colors))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn set_background_color(&mut self, color: Color) -> io::Result<()> {
|
||||
let color = color.try_into().map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
|
||||
fn set_background_color(&mut self, color: Color) -> io::Result<()> {
|
||||
let color = color.into();
|
||||
self.writer.queue(style::SetBackgroundColor(color))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn flush(&mut self) -> io::Result<()> {
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
self.writer.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn print_image(&mut self, image: &Image, options: &PrintOptions) -> Result<(), PrintImageError> {
|
||||
self.move_to_column(options.cursor_position.column)?;
|
||||
self.image_printer.print(&image.image, options, &mut self.writer)?;
|
||||
fn print_image(&mut self, image: &Image, options: &PrintOptions) -> Result<(), PrintImageError> {
|
||||
let image_printer = self.image_printer.clone();
|
||||
image_printer.print(image.image(), options, self)?;
|
||||
self.cursor_row += options.rows;
|
||||
Ok(())
|
||||
}
|
||||
@ -120,10 +160,35 @@ impl<W: TerminalWrite> Terminal<W> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<W> Drop for Terminal<W>
|
||||
where
|
||||
W: TerminalWrite,
|
||||
{
|
||||
impl<I: TerminalWrite> TerminalIo for Terminal<I> {
|
||||
fn execute(&mut self, command: &TerminalCommand<'_>) -> Result<(), TerminalError> {
|
||||
use TerminalCommand::*;
|
||||
match command {
|
||||
BeginUpdate => self.begin_update()?,
|
||||
EndUpdate => self.end_update()?,
|
||||
MoveTo { column, row } => self.move_to(*column, *row)?,
|
||||
MoveToRow(row) => self.move_to_row(*row)?,
|
||||
MoveToColumn(column) => self.move_to_column(*column)?,
|
||||
MoveDown(amount) => self.move_down(*amount)?,
|
||||
MoveRight(amount) => self.move_right(*amount)?,
|
||||
MoveLeft(amount) => self.move_left(*amount)?,
|
||||
MoveToNextLine => self.move_to_next_line()?,
|
||||
PrintText { content, style } => self.print_text(content, style)?,
|
||||
ClearScreen => self.clear_screen()?,
|
||||
SetColors(colors) => self.set_colors(*colors)?,
|
||||
SetBackgroundColor(color) => self.set_background_color(*color)?,
|
||||
Flush => self.flush()?,
|
||||
PrintImage { image, options } => self.print_image(image, options)?,
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cursor_row(&self) -> u16 {
|
||||
self.cursor_row
|
||||
}
|
||||
}
|
||||
|
||||
impl<I: TerminalWrite> Drop for Terminal<I> {
|
||||
fn drop(&mut self) {
|
||||
self.writer.deinit();
|
||||
}
|
||||
@ -143,7 +208,7 @@ fn is_windows_based_os() -> bool {
|
||||
is_windows || is_wsl
|
||||
}
|
||||
|
||||
pub trait TerminalWrite: io::Write {
|
||||
pub(crate) trait TerminalWrite: io::Write {
|
||||
fn init(&mut self) -> io::Result<()>;
|
||||
fn deinit(&mut self);
|
||||
}
|
||||
|
319
src/terminal/virt.rs
Normal file
319
src/terminal/virt.rs
Normal file
@ -0,0 +1,319 @@
|
||||
use super::{
|
||||
image::{
|
||||
Image,
|
||||
printer::{PrintImage, PrintImageError, PrintOptions},
|
||||
protocols::ascii::AsciiPrinter,
|
||||
},
|
||||
printer::{TerminalError, TerminalIo},
|
||||
};
|
||||
use crate::{
|
||||
WindowSize,
|
||||
markdown::{
|
||||
elements::Text,
|
||||
text_style::{Color, Colors, TextStyle},
|
||||
},
|
||||
terminal::printer::TerminalCommand,
|
||||
};
|
||||
use std::{collections::HashMap, io};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct PrintedImage {
|
||||
pub(crate) image: Image,
|
||||
pub(crate) width_columns: u16,
|
||||
}
|
||||
|
||||
pub(crate) struct TerminalRowIterator<'a> {
|
||||
row: &'a [StyledChar],
|
||||
}
|
||||
|
||||
impl<'a> TerminalRowIterator<'a> {
|
||||
pub(crate) fn new(row: &'a [StyledChar]) -> Self {
|
||||
Self { row }
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for TerminalRowIterator<'_> {
|
||||
type Item = Text;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
let style = self.row.first()?.style;
|
||||
let mut output = String::new();
|
||||
while let Some(c) = self.row.first() {
|
||||
if c.style != style {
|
||||
break;
|
||||
}
|
||||
output.push(c.character);
|
||||
self.row = &self.row[1..];
|
||||
}
|
||||
Some(Text::new(output, style))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct TerminalGrid {
|
||||
pub(crate) rows: Vec<Vec<StyledChar>>,
|
||||
pub(crate) background_color: Option<Color>,
|
||||
pub(crate) images: HashMap<(u16, u16), PrintedImage>,
|
||||
}
|
||||
|
||||
pub(crate) struct VirtualTerminal {
|
||||
row: u16,
|
||||
column: u16,
|
||||
colors: Colors,
|
||||
rows: Vec<Vec<StyledChar>>,
|
||||
background_color: Option<Color>,
|
||||
images: HashMap<(u16, u16), PrintedImage>,
|
||||
row_heights: Vec<u16>,
|
||||
image_behavior: ImageBehavior,
|
||||
}
|
||||
|
||||
impl VirtualTerminal {
|
||||
pub(crate) fn new(dimensions: WindowSize, image_behavior: ImageBehavior) -> Self {
|
||||
let rows = vec![vec![StyledChar::default(); dimensions.columns as usize]; dimensions.rows as usize];
|
||||
let row_heights = vec![1; dimensions.rows as usize];
|
||||
Self {
|
||||
row: 0,
|
||||
column: 0,
|
||||
colors: Default::default(),
|
||||
rows,
|
||||
background_color: None,
|
||||
images: Default::default(),
|
||||
row_heights,
|
||||
image_behavior,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn into_contents(self) -> TerminalGrid {
|
||||
TerminalGrid { rows: self.rows, background_color: self.background_color, images: self.images }
|
||||
}
|
||||
|
||||
fn current_cell_mut(&mut self) -> Option<&mut StyledChar> {
|
||||
self.rows.get_mut(self.row as usize).and_then(|row| row.get_mut(self.column as usize))
|
||||
}
|
||||
|
||||
fn set_current_row_height(&mut self, height: u16) {
|
||||
if let Some(current) = self.row_heights.get_mut(self.row as usize) {
|
||||
*current = height;
|
||||
}
|
||||
}
|
||||
|
||||
fn current_row_height(&self) -> u16 {
|
||||
*self.row_heights.get(self.row as usize).unwrap_or(&1)
|
||||
}
|
||||
|
||||
fn move_to(&mut self, column: u16, row: u16) -> io::Result<()> {
|
||||
self.column = column;
|
||||
self.row = row;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn move_to_row(&mut self, row: u16) -> io::Result<()> {
|
||||
self.row = row;
|
||||
self.set_current_row_height(1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn move_to_column(&mut self, column: u16) -> io::Result<()> {
|
||||
self.column = column;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn move_down(&mut self, amount: u16) -> io::Result<()> {
|
||||
self.row += amount;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn move_right(&mut self, amount: u16) -> io::Result<()> {
|
||||
self.column += amount;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn move_left(&mut self, amount: u16) -> io::Result<()> {
|
||||
self.column = self.column.saturating_sub(amount);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn move_to_next_line(&mut self) -> io::Result<()> {
|
||||
let amount = self.current_row_height();
|
||||
self.row += amount;
|
||||
self.column = 0;
|
||||
self.set_current_row_height(1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_text(&mut self, content: &str, style: &TextStyle) -> io::Result<()> {
|
||||
let style = style.merged(&TextStyle::default().colors(self.colors));
|
||||
for c in content.chars() {
|
||||
let Some(cell) = self.current_cell_mut() else {
|
||||
continue;
|
||||
};
|
||||
cell.character = c;
|
||||
cell.style = style;
|
||||
self.column += style.size as u16;
|
||||
}
|
||||
let height = self.current_row_height().max(style.size as u16);
|
||||
self.set_current_row_height(height);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn clear_screen(&mut self) -> io::Result<()> {
|
||||
for row in &mut self.rows {
|
||||
for cell in row {
|
||||
cell.character = ' ';
|
||||
}
|
||||
}
|
||||
self.background_color = self.colors.background;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_colors(&mut self, colors: crate::markdown::text_style::Colors) -> io::Result<()> {
|
||||
self.colors = colors;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_background_color(&mut self, color: Color) -> io::Result<()> {
|
||||
self.colors.background = Some(color);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_image(&mut self, image: &Image, options: &PrintOptions) -> Result<(), PrintImageError> {
|
||||
match &self.image_behavior {
|
||||
ImageBehavior::Store => {
|
||||
let key = (self.row, self.column);
|
||||
let image = PrintedImage { image: image.clone(), width_columns: options.columns };
|
||||
self.images.insert(key, image);
|
||||
}
|
||||
ImageBehavior::PrintAscii => {
|
||||
let image = image.to_ascii();
|
||||
let image_printer = AsciiPrinter;
|
||||
image_printer.print(&image, options, self)?
|
||||
}
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl TerminalIo for VirtualTerminal {
|
||||
fn execute(&mut self, command: &TerminalCommand<'_>) -> Result<(), TerminalError> {
|
||||
use TerminalCommand::*;
|
||||
match command {
|
||||
BeginUpdate | EndUpdate => (),
|
||||
MoveTo { column, row } => self.move_to(*column, *row)?,
|
||||
MoveToRow(row) => self.move_to_row(*row)?,
|
||||
MoveToColumn(column) => self.move_to_column(*column)?,
|
||||
MoveDown(amount) => self.move_down(*amount)?,
|
||||
MoveRight(amount) => self.move_right(*amount)?,
|
||||
MoveLeft(amount) => self.move_left(*amount)?,
|
||||
MoveToNextLine => self.move_to_next_line()?,
|
||||
PrintText { content, style } => self.print_text(content, style)?,
|
||||
ClearScreen => self.clear_screen()?,
|
||||
SetColors(colors) => self.set_colors(*colors)?,
|
||||
SetBackgroundColor(color) => self.set_background_color(*color)?,
|
||||
Flush => self.flush()?,
|
||||
PrintImage { image, options } => self.print_image(image, options)?,
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cursor_row(&self) -> u16 {
|
||||
self.row
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) enum ImageBehavior {
|
||||
#[default]
|
||||
Store,
|
||||
PrintAscii,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) struct StyledChar {
|
||||
pub(crate) character: char,
|
||||
pub(crate) style: TextStyle,
|
||||
}
|
||||
|
||||
impl StyledChar {
|
||||
#[cfg(test)]
|
||||
pub(crate) fn new(character: char, style: TextStyle) -> Self {
|
||||
Self { character, style }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<char> for StyledChar {
|
||||
fn from(character: char) -> Self {
|
||||
Self { character, style: Default::default() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for StyledChar {
|
||||
fn default() -> Self {
|
||||
Self { character: ' ', style: Default::default() }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
trait TerminalGridExt {
|
||||
fn assert_contents(&self, lines: &[&str]);
|
||||
}
|
||||
|
||||
impl TerminalGridExt for TerminalGrid {
|
||||
fn assert_contents(&self, lines: &[&str]) {
|
||||
assert_eq!(self.rows.len(), lines.len());
|
||||
for (line, expected) in self.rows.iter().zip(lines) {
|
||||
let line: String = line.iter().map(|c| c.character).collect();
|
||||
assert_eq!(line, *expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text() {
|
||||
let dimensions = WindowSize { rows: 2, columns: 3, height: 0, width: 0 };
|
||||
let mut term = VirtualTerminal::new(dimensions, Default::default());
|
||||
for c in "abc".chars() {
|
||||
term.print_text(&c.to_string(), &Default::default()).expect("print failed");
|
||||
}
|
||||
term.move_to_next_line().unwrap();
|
||||
term.print_text("A", &Default::default()).expect("print failed");
|
||||
let grid = term.into_contents();
|
||||
grid.assert_contents(&["abc", "A "]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn movement() {
|
||||
let dimensions = WindowSize { rows: 2, columns: 3, height: 0, width: 0 };
|
||||
let mut term = VirtualTerminal::new(dimensions, Default::default());
|
||||
term.print_text("A", &Default::default()).unwrap();
|
||||
term.move_down(1).unwrap();
|
||||
term.print_text("B", &Default::default()).unwrap();
|
||||
term.move_to(2, 0).unwrap();
|
||||
term.print_text("C", &Default::default()).unwrap();
|
||||
term.move_to_row(1).unwrap();
|
||||
term.move_to_column(2).unwrap();
|
||||
term.print_text("D", &Default::default()).unwrap();
|
||||
|
||||
let grid = term.into_contents();
|
||||
grid.assert_contents(&["A C", " BD"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iterator() {
|
||||
let row = &[
|
||||
StyledChar { character: ' ', style: TextStyle::default() },
|
||||
StyledChar { character: 'A', style: TextStyle::default() },
|
||||
StyledChar { character: 'B', style: TextStyle::default().bold() },
|
||||
StyledChar { character: 'C', style: TextStyle::default().bold() },
|
||||
StyledChar { character: 'D', style: TextStyle::default() },
|
||||
];
|
||||
let texts: Vec<_> = TerminalRowIterator::new(row).collect();
|
||||
assert_eq!(texts, &[Text::from(" A"), Text::new("BC", TextStyle::default().bold()), Text::from("D")]);
|
||||
}
|
||||
}
|
1194
src/theme.rs
1194
src/theme.rs
File diff suppressed because it is too large
Load Diff
729
src/theme/clean.rs
Normal file
729
src/theme/clean.rs
Normal file
@ -0,0 +1,729 @@
|
||||
use super::{
|
||||
AuthorPositioning, FooterTemplate, Margin,
|
||||
raw::{self, RawColor},
|
||||
};
|
||||
use crate::{
|
||||
markdown::text_style::{Color, Colors, TextStyle, UndefinedPaletteColorError},
|
||||
resource::Resources,
|
||||
terminal::image::{Image, printer::RegisterImageError},
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const DEFAULT_CODE_HIGHLIGHT_THEME: &str = "base16-eighties.dark";
|
||||
const DEFAULT_BLOCK_QUOTE_PREFIX: &str = "▍ ";
|
||||
const DEFAULT_PROGRESS_BAR_CHAR: char = '█';
|
||||
const DEFAULT_FOOTER_HEIGHT: u16 = 3;
|
||||
const DEFAULT_TYPST_HORIZONTAL_MARGIN: u16 = 5;
|
||||
const DEFAULT_TYPST_VERTICAL_MARGIN: u16 = 7;
|
||||
const DEFAULT_MERMAID_THEME: &str = "default";
|
||||
const DEFAULT_MERMAID_BACKGROUND: &str = "transparent";
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct ThemeOptions {
|
||||
pub(crate) font_size_supported: bool,
|
||||
}
|
||||
|
||||
impl ThemeOptions {
|
||||
fn adjust_font_size(&self, font_size: Option<u8>) -> u8 {
|
||||
if !self.font_size_supported { 1 } else { font_size.unwrap_or(1).clamp(1, 7) }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct PresentationTheme {
|
||||
pub(crate) slide_title: SlideTitleStyle,
|
||||
pub(crate) code: CodeBlockStyle,
|
||||
pub(crate) execution_output: ExecutionOutputBlockStyle,
|
||||
pub(crate) inline_code: InlineCodeStyle,
|
||||
pub(crate) table: Alignment,
|
||||
pub(crate) block_quote: BlockQuoteStyle,
|
||||
pub(crate) alert: AlertStyle,
|
||||
pub(crate) default_style: DefaultStyle,
|
||||
pub(crate) headings: HeadingStyles,
|
||||
pub(crate) intro_slide: IntroSlideStyle,
|
||||
pub(crate) footer: FooterStyle,
|
||||
pub(crate) typst: TypstStyle,
|
||||
pub(crate) mermaid: MermaidStyle,
|
||||
pub(crate) modals: ModalStyle,
|
||||
pub(crate) palette: ColorPalette,
|
||||
}
|
||||
|
||||
impl PresentationTheme {
|
||||
pub(crate) fn new(
|
||||
raw: &raw::PresentationTheme,
|
||||
resources: &Resources,
|
||||
options: &ThemeOptions,
|
||||
) -> Result<Self, ProcessingThemeError> {
|
||||
let raw::PresentationTheme {
|
||||
slide_title,
|
||||
code,
|
||||
execution_output,
|
||||
inline_code,
|
||||
table,
|
||||
block_quote,
|
||||
alert,
|
||||
default_style,
|
||||
headings,
|
||||
intro_slide,
|
||||
footer,
|
||||
typst,
|
||||
mermaid,
|
||||
modals,
|
||||
palette,
|
||||
extends: _,
|
||||
} = raw;
|
||||
|
||||
let palette = ColorPalette::try_from(palette)?;
|
||||
let default_style = DefaultStyle::new(default_style, &palette)?;
|
||||
Ok(Self {
|
||||
slide_title: SlideTitleStyle::new(slide_title, &palette, options)?,
|
||||
code: CodeBlockStyle::new(code),
|
||||
execution_output: ExecutionOutputBlockStyle::new(execution_output, &palette)?,
|
||||
inline_code: InlineCodeStyle::new(inline_code, &palette)?,
|
||||
table: table.clone().unwrap_or_default().into(),
|
||||
block_quote: BlockQuoteStyle::new(block_quote, &palette)?,
|
||||
alert: AlertStyle::new(alert, &palette)?,
|
||||
default_style: default_style.clone(),
|
||||
headings: HeadingStyles::new(headings, &palette, options)?,
|
||||
intro_slide: IntroSlideStyle::new(intro_slide, &palette, options)?,
|
||||
footer: FooterStyle::new(&footer.clone().unwrap_or_default(), &palette, resources)?,
|
||||
typst: TypstStyle::new(typst, &palette)?,
|
||||
mermaid: MermaidStyle::new(mermaid),
|
||||
modals: ModalStyle::new(modals, &default_style, &palette)?,
|
||||
palette,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn alignment(&self, element: &ElementType) -> Alignment {
|
||||
use ElementType::*;
|
||||
|
||||
match element {
|
||||
SlideTitle => self.slide_title.alignment,
|
||||
Heading1 => self.headings.h1.alignment,
|
||||
Heading2 => self.headings.h2.alignment,
|
||||
Heading3 => self.headings.h3.alignment,
|
||||
Heading4 => self.headings.h4.alignment,
|
||||
Heading5 => self.headings.h5.alignment,
|
||||
Heading6 => self.headings.h6.alignment,
|
||||
Paragraph => Default::default(),
|
||||
PresentationTitle => self.intro_slide.title.alignment,
|
||||
PresentationSubTitle => self.intro_slide.subtitle.alignment,
|
||||
PresentationEvent => self.intro_slide.event.alignment,
|
||||
PresentationLocation => self.intro_slide.location.alignment,
|
||||
PresentationDate => self.intro_slide.date.alignment,
|
||||
PresentationAuthor => self.intro_slide.author.alignment,
|
||||
Table => self.table,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(crate) enum ProcessingThemeError {
|
||||
#[error(transparent)]
|
||||
Palette(#[from] UndefinedPaletteColorError),
|
||||
|
||||
#[error("palette cannot contain other palette colors")]
|
||||
PaletteColorInPalette,
|
||||
|
||||
#[error("invalid footer image: {0}")]
|
||||
FooterImage(RegisterImageError),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct SlideTitleStyle {
|
||||
pub(crate) alignment: Alignment,
|
||||
pub(crate) separator: bool,
|
||||
pub(crate) padding_top: u8,
|
||||
pub(crate) padding_bottom: u8,
|
||||
pub(crate) style: TextStyle,
|
||||
}
|
||||
|
||||
impl SlideTitleStyle {
|
||||
fn new(
|
||||
raw: &raw::SlideTitleStyle,
|
||||
palette: &ColorPalette,
|
||||
options: &ThemeOptions,
|
||||
) -> Result<Self, ProcessingThemeError> {
|
||||
let raw::SlideTitleStyle {
|
||||
alignment,
|
||||
separator,
|
||||
padding_top,
|
||||
padding_bottom,
|
||||
colors,
|
||||
bold,
|
||||
italics,
|
||||
underlined,
|
||||
font_size,
|
||||
} = raw;
|
||||
let colors = colors.resolve(palette)?;
|
||||
let mut style = TextStyle::colored(colors).size(options.adjust_font_size(*font_size));
|
||||
if bold.unwrap_or_default() {
|
||||
style = style.bold();
|
||||
}
|
||||
if italics.unwrap_or_default() {
|
||||
style = style.italics();
|
||||
}
|
||||
if underlined.unwrap_or_default() {
|
||||
style = style.underlined();
|
||||
}
|
||||
Ok(Self {
|
||||
alignment: alignment.clone().unwrap_or_default().into(),
|
||||
separator: *separator,
|
||||
padding_top: padding_top.unwrap_or_default(),
|
||||
padding_bottom: padding_bottom.unwrap_or_default(),
|
||||
style,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct HeadingStyles {
|
||||
pub(crate) h1: HeadingStyle,
|
||||
pub(crate) h2: HeadingStyle,
|
||||
pub(crate) h3: HeadingStyle,
|
||||
pub(crate) h4: HeadingStyle,
|
||||
pub(crate) h5: HeadingStyle,
|
||||
pub(crate) h6: HeadingStyle,
|
||||
}
|
||||
|
||||
impl HeadingStyles {
|
||||
fn new(
|
||||
raw: &raw::HeadingStyles,
|
||||
palette: &ColorPalette,
|
||||
options: &ThemeOptions,
|
||||
) -> Result<Self, ProcessingThemeError> {
|
||||
let raw::HeadingStyles { h1, h2, h3, h4, h5, h6 } = raw;
|
||||
Ok(Self {
|
||||
h1: HeadingStyle::new(h1, palette, options)?,
|
||||
h2: HeadingStyle::new(h2, palette, options)?,
|
||||
h3: HeadingStyle::new(h3, palette, options)?,
|
||||
h4: HeadingStyle::new(h4, palette, options)?,
|
||||
h5: HeadingStyle::new(h5, palette, options)?,
|
||||
h6: HeadingStyle::new(h6, palette, options)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct HeadingStyle {
|
||||
pub(crate) alignment: Alignment,
|
||||
pub(crate) prefix: Option<String>,
|
||||
pub(crate) style: TextStyle,
|
||||
}
|
||||
|
||||
impl HeadingStyle {
|
||||
fn new(
|
||||
raw: &raw::HeadingStyle,
|
||||
palette: &ColorPalette,
|
||||
options: &ThemeOptions,
|
||||
) -> Result<Self, ProcessingThemeError> {
|
||||
let raw::HeadingStyle { alignment, prefix, colors, font_size } = raw;
|
||||
let alignment = alignment.clone().unwrap_or_default().into();
|
||||
let style = TextStyle::colored(colors.resolve(palette)?).size(options.adjust_font_size(*font_size));
|
||||
Ok(Self { alignment, prefix: prefix.clone(), style })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct BlockQuoteStyle {
|
||||
pub(crate) alignment: Alignment,
|
||||
pub(crate) prefix: String,
|
||||
pub(crate) base_style: TextStyle,
|
||||
pub(crate) prefix_style: TextStyle,
|
||||
}
|
||||
|
||||
impl BlockQuoteStyle {
|
||||
fn new(raw: &raw::BlockQuoteStyle, palette: &ColorPalette) -> Result<Self, ProcessingThemeError> {
|
||||
let raw::BlockQuoteStyle { alignment, prefix, colors } = raw;
|
||||
let alignment = alignment.clone().unwrap_or_default().into();
|
||||
let prefix = prefix.as_deref().unwrap_or(DEFAULT_BLOCK_QUOTE_PREFIX).to_string();
|
||||
let base_style = TextStyle::colored(colors.base.resolve(palette)?);
|
||||
let mut prefix_style = TextStyle::colored(colors.base.resolve(palette)?);
|
||||
if let Some(color) = &colors.prefix {
|
||||
prefix_style.colors.foreground = color.resolve(palette)?;
|
||||
}
|
||||
Ok(Self { alignment, prefix, base_style, prefix_style })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct AlertStyle {
|
||||
pub(crate) alignment: Alignment,
|
||||
pub(crate) base_style: TextStyle,
|
||||
pub(crate) prefix: String,
|
||||
pub(crate) styles: AlertTypeStyles,
|
||||
}
|
||||
|
||||
impl AlertStyle {
|
||||
fn new(raw: &raw::AlertStyle, palette: &ColorPalette) -> Result<Self, ProcessingThemeError> {
|
||||
let raw::AlertStyle { alignment, base_colors, prefix, styles } = raw;
|
||||
let alignment = alignment.clone().unwrap_or_default().into();
|
||||
let base_style = TextStyle::colored(base_colors.resolve(palette)?);
|
||||
let prefix = prefix.as_deref().unwrap_or(DEFAULT_BLOCK_QUOTE_PREFIX).to_string();
|
||||
let styles = AlertTypeStyles::new(styles, base_style, palette)?;
|
||||
Ok(Self { alignment, base_style, prefix, styles })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct AlertTypeStyles {
|
||||
pub(crate) note: AlertTypeStyle,
|
||||
pub(crate) tip: AlertTypeStyle,
|
||||
pub(crate) important: AlertTypeStyle,
|
||||
pub(crate) warning: AlertTypeStyle,
|
||||
pub(crate) caution: AlertTypeStyle,
|
||||
}
|
||||
|
||||
impl AlertTypeStyles {
|
||||
fn new(
|
||||
raw: &raw::AlertTypeStyles,
|
||||
base_style: TextStyle,
|
||||
palette: &ColorPalette,
|
||||
) -> Result<Self, ProcessingThemeError> {
|
||||
let raw::AlertTypeStyles { note, tip, important, warning, caution } = raw;
|
||||
Ok(Self {
|
||||
note: AlertTypeStyle::new(
|
||||
note,
|
||||
&AlertTypeDefaults { title: "Note", icon: "", color: Color::Blue },
|
||||
base_style,
|
||||
palette,
|
||||
)?,
|
||||
tip: AlertTypeStyle::new(
|
||||
tip,
|
||||
&AlertTypeDefaults { title: "Tip", icon: "", color: Color::Green },
|
||||
base_style,
|
||||
palette,
|
||||
)?,
|
||||
important: AlertTypeStyle::new(
|
||||
important,
|
||||
&AlertTypeDefaults { title: "Important", icon: "", color: Color::Cyan },
|
||||
base_style,
|
||||
palette,
|
||||
)?,
|
||||
warning: AlertTypeStyle::new(
|
||||
warning,
|
||||
&AlertTypeDefaults { title: "Warning", icon: "", color: Color::Yellow },
|
||||
base_style,
|
||||
palette,
|
||||
)?,
|
||||
caution: AlertTypeStyle::new(
|
||||
caution,
|
||||
&AlertTypeDefaults { title: "Caution", icon: "", color: Color::Red },
|
||||
base_style,
|
||||
palette,
|
||||
)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct AlertTypeStyle {
|
||||
pub(crate) style: TextStyle,
|
||||
pub(crate) title: String,
|
||||
pub(crate) icon: String,
|
||||
}
|
||||
|
||||
impl AlertTypeStyle {
|
||||
fn new(
|
||||
raw: &raw::AlertTypeStyle,
|
||||
defaults: &AlertTypeDefaults,
|
||||
base_style: TextStyle,
|
||||
palette: &ColorPalette,
|
||||
) -> Result<Self, ProcessingThemeError> {
|
||||
let raw::AlertTypeStyle { color, title, icon, .. } = raw;
|
||||
let color = color.as_ref().map(|c| c.resolve(palette)).transpose()?.flatten().unwrap_or(defaults.color);
|
||||
let style = base_style.fg_color(color);
|
||||
let title = title.as_deref().unwrap_or(defaults.title).to_string();
|
||||
let icon = icon.as_deref().unwrap_or(defaults.icon).to_string();
|
||||
Ok(Self { style, title, icon })
|
||||
}
|
||||
}
|
||||
|
||||
struct AlertTypeDefaults {
|
||||
title: &'static str,
|
||||
icon: &'static str,
|
||||
color: Color,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct IntroSlideStyle {
|
||||
pub(crate) title: IntroSlideTitleStyle,
|
||||
pub(crate) subtitle: IntroSlideLabelStyle,
|
||||
pub(crate) event: IntroSlideLabelStyle,
|
||||
pub(crate) location: IntroSlideLabelStyle,
|
||||
pub(crate) date: IntroSlideLabelStyle,
|
||||
pub(crate) author: AuthorStyle,
|
||||
pub(crate) footer: bool,
|
||||
}
|
||||
|
||||
impl IntroSlideStyle {
|
||||
fn new(
|
||||
raw: &raw::IntroSlideStyle,
|
||||
palette: &ColorPalette,
|
||||
options: &ThemeOptions,
|
||||
) -> Result<Self, ProcessingThemeError> {
|
||||
let raw::IntroSlideStyle { title, subtitle, event, location, date, author, footer } = raw;
|
||||
Ok(Self {
|
||||
title: IntroSlideTitleStyle::new(title, palette, options)?,
|
||||
subtitle: IntroSlideLabelStyle::new(subtitle, palette)?,
|
||||
event: IntroSlideLabelStyle::new(event, palette)?,
|
||||
location: IntroSlideLabelStyle::new(location, palette)?,
|
||||
date: IntroSlideLabelStyle::new(date, palette)?,
|
||||
author: AuthorStyle::new(author, palette)?,
|
||||
footer: footer.unwrap_or(false),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct IntroSlideLabelStyle {
|
||||
pub(crate) alignment: Alignment,
|
||||
pub(crate) style: TextStyle,
|
||||
}
|
||||
|
||||
impl IntroSlideLabelStyle {
|
||||
fn new(raw: &raw::BasicStyle, palette: &ColorPalette) -> Result<Self, ProcessingThemeError> {
|
||||
let raw::BasicStyle { alignment, colors } = raw;
|
||||
let style = TextStyle::colored(colors.resolve(palette)?);
|
||||
Ok(Self { alignment: alignment.clone().unwrap_or_default().into(), style })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct IntroSlideTitleStyle {
|
||||
pub(crate) alignment: Alignment,
|
||||
pub(crate) style: TextStyle,
|
||||
}
|
||||
|
||||
impl IntroSlideTitleStyle {
|
||||
fn new(
|
||||
raw: &raw::IntroSlideTitleStyle,
|
||||
palette: &ColorPalette,
|
||||
options: &ThemeOptions,
|
||||
) -> Result<Self, ProcessingThemeError> {
|
||||
let raw::IntroSlideTitleStyle { alignment, colors, font_size } = raw;
|
||||
let style = TextStyle::colored(colors.resolve(palette)?).size(options.adjust_font_size(*font_size));
|
||||
Ok(Self { alignment: alignment.clone().unwrap_or_default().into(), style })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct AuthorStyle {
|
||||
pub(crate) alignment: Alignment,
|
||||
pub(crate) style: TextStyle,
|
||||
pub(crate) positioning: AuthorPositioning,
|
||||
}
|
||||
|
||||
impl AuthorStyle {
|
||||
fn new(raw: &raw::AuthorStyle, palette: &ColorPalette) -> Result<Self, ProcessingThemeError> {
|
||||
let raw::AuthorStyle { alignment, colors, positioning } = raw;
|
||||
let style = TextStyle::colored(colors.resolve(palette)?);
|
||||
Ok(Self { alignment: alignment.clone().unwrap_or_default().into(), style, positioning: positioning.clone() })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct DefaultStyle {
|
||||
pub(crate) margin: Margin,
|
||||
pub(crate) style: TextStyle,
|
||||
}
|
||||
|
||||
impl DefaultStyle {
|
||||
fn new(raw: &raw::DefaultStyle, palette: &ColorPalette) -> Result<Self, ProcessingThemeError> {
|
||||
let raw::DefaultStyle { margin, colors } = raw;
|
||||
let margin = margin.unwrap_or_default();
|
||||
let style = TextStyle::colored(colors.resolve(palette)?);
|
||||
Ok(Self { margin, style })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq)]
|
||||
pub(crate) enum Alignment {
|
||||
Left { margin: Margin },
|
||||
Right { margin: Margin },
|
||||
Center { minimum_margin: Margin, minimum_size: u16 },
|
||||
}
|
||||
|
||||
impl Alignment {
|
||||
pub(crate) fn adjust_size(&self, size: u16) -> u16 {
|
||||
match self {
|
||||
Self::Left { .. } | Self::Right { .. } => size,
|
||||
Self::Center { minimum_size, .. } => size.max(*minimum_size),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<raw::Alignment> for Alignment {
|
||||
fn from(alignment: raw::Alignment) -> Self {
|
||||
match alignment {
|
||||
raw::Alignment::Left { margin } => Self::Left { margin },
|
||||
raw::Alignment::Right { margin } => Self::Right { margin },
|
||||
raw::Alignment::Center { minimum_margin, minimum_size } => Self::Center { minimum_margin, minimum_size },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Alignment {
|
||||
fn default() -> Self {
|
||||
Self::Left { margin: Margin::Fixed(0) }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) enum FooterStyle {
|
||||
Template {
|
||||
left: Option<FooterContent>,
|
||||
center: Option<FooterContent>,
|
||||
right: Option<FooterContent>,
|
||||
style: TextStyle,
|
||||
height: u16,
|
||||
},
|
||||
ProgressBar {
|
||||
character: char,
|
||||
style: TextStyle,
|
||||
},
|
||||
#[default]
|
||||
Empty,
|
||||
}
|
||||
|
||||
impl FooterStyle {
|
||||
fn new(
|
||||
raw: &raw::FooterStyle,
|
||||
palette: &ColorPalette,
|
||||
resources: &Resources,
|
||||
) -> Result<Self, ProcessingThemeError> {
|
||||
match raw {
|
||||
raw::FooterStyle::Template { left, center, right, colors, height } => {
|
||||
let left = left.as_ref().map(|t| FooterContent::new(t, resources)).transpose()?;
|
||||
let center = center.as_ref().map(|t| FooterContent::new(t, resources)).transpose()?;
|
||||
let right = right.as_ref().map(|t| FooterContent::new(t, resources)).transpose()?;
|
||||
let style = TextStyle::colored(colors.resolve(palette)?);
|
||||
let height = height.unwrap_or(DEFAULT_FOOTER_HEIGHT);
|
||||
Ok(Self::Template { left, center, right, style, height })
|
||||
}
|
||||
raw::FooterStyle::ProgressBar { character, colors } => {
|
||||
let character = character.unwrap_or(DEFAULT_PROGRESS_BAR_CHAR);
|
||||
let style = TextStyle::colored(colors.resolve(palette)?);
|
||||
Ok(Self::ProgressBar { character, style })
|
||||
}
|
||||
raw::FooterStyle::Empty => Ok(Self::Empty),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn height(&self) -> u16 {
|
||||
match self {
|
||||
Self::Template { height, .. } => *height,
|
||||
_ => DEFAULT_FOOTER_HEIGHT,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum FooterContent {
|
||||
Template(FooterTemplate),
|
||||
Image(Image),
|
||||
}
|
||||
|
||||
impl FooterContent {
|
||||
fn new(raw: &raw::FooterContent, resources: &Resources) -> Result<Self, ProcessingThemeError> {
|
||||
match raw {
|
||||
raw::FooterContent::Template(template) => Ok(Self::Template(template.clone())),
|
||||
raw::FooterContent::Image { path } => {
|
||||
let image = resources.theme_image(path).map_err(ProcessingThemeError::FooterImage)?;
|
||||
Ok(Self::Image(image))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct CodeBlockStyle {
|
||||
pub(crate) alignment: Alignment,
|
||||
pub(crate) padding: PaddingRect,
|
||||
pub(crate) theme_name: String,
|
||||
pub(crate) background: bool,
|
||||
}
|
||||
|
||||
impl CodeBlockStyle {
|
||||
fn new(raw: &raw::CodeBlockStyle) -> Self {
|
||||
let raw::CodeBlockStyle { alignment, padding, theme_name, background } = raw;
|
||||
let padding = PaddingRect {
|
||||
horizontal: padding.horizontal.unwrap_or_default(),
|
||||
vertical: padding.vertical.unwrap_or_default(),
|
||||
};
|
||||
Self {
|
||||
alignment: alignment.clone().unwrap_or_default().into(),
|
||||
padding,
|
||||
theme_name: theme_name.as_deref().unwrap_or(DEFAULT_CODE_HIGHLIGHT_THEME).to_string(),
|
||||
background: background.unwrap_or(true),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Vertical/horizontal padding.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct PaddingRect {
|
||||
/// The number of columns to use as horizontal padding.
|
||||
pub(crate) horizontal: u8,
|
||||
|
||||
/// The number of rows to use as vertical padding.
|
||||
pub(crate) vertical: u8,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct ExecutionOutputBlockStyle {
|
||||
pub(crate) style: TextStyle,
|
||||
pub(crate) status: ExecutionStatusBlockStyle,
|
||||
}
|
||||
|
||||
impl ExecutionOutputBlockStyle {
|
||||
fn new(raw: &raw::ExecutionOutputBlockStyle, palette: &ColorPalette) -> Result<Self, ProcessingThemeError> {
|
||||
let raw::ExecutionOutputBlockStyle { colors, status } = raw;
|
||||
let colors = colors.resolve(palette)?;
|
||||
let style = TextStyle::colored(colors);
|
||||
Ok(Self { style, status: ExecutionStatusBlockStyle::new(status, palette)? })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct ExecutionStatusBlockStyle {
|
||||
pub(crate) running_style: TextStyle,
|
||||
pub(crate) success_style: TextStyle,
|
||||
pub(crate) failure_style: TextStyle,
|
||||
pub(crate) not_started_style: TextStyle,
|
||||
}
|
||||
|
||||
impl ExecutionStatusBlockStyle {
|
||||
fn new(raw: &raw::ExecutionStatusBlockStyle, palette: &ColorPalette) -> Result<Self, ProcessingThemeError> {
|
||||
let raw::ExecutionStatusBlockStyle { running, success, failure, not_started } = raw;
|
||||
let running_style = TextStyle::colored(running.resolve(palette)?);
|
||||
let success_style = TextStyle::colored(success.resolve(palette)?);
|
||||
let failure_style = TextStyle::colored(failure.resolve(palette)?);
|
||||
let not_started_style = TextStyle::colored(not_started.resolve(palette)?);
|
||||
Ok(Self { running_style, success_style, failure_style, not_started_style })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct InlineCodeStyle {
|
||||
pub(crate) style: TextStyle,
|
||||
}
|
||||
|
||||
impl InlineCodeStyle {
|
||||
fn new(raw: &raw::InlineCodeStyle, palette: &ColorPalette) -> Result<Self, ProcessingThemeError> {
|
||||
let raw::InlineCodeStyle { colors } = raw;
|
||||
let style = TextStyle::colored(colors.resolve(palette)?);
|
||||
Ok(Self { style })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum ElementType {
|
||||
SlideTitle,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
Heading4,
|
||||
Heading5,
|
||||
Heading6,
|
||||
Paragraph,
|
||||
PresentationTitle,
|
||||
PresentationSubTitle,
|
||||
PresentationEvent,
|
||||
PresentationLocation,
|
||||
PresentationDate,
|
||||
PresentationAuthor,
|
||||
Table,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct TypstStyle {
|
||||
pub(crate) horizontal_margin: u16,
|
||||
pub(crate) vertical_margin: u16,
|
||||
pub(crate) style: TextStyle,
|
||||
}
|
||||
|
||||
impl TypstStyle {
|
||||
fn new(raw: &raw::TypstStyle, palette: &ColorPalette) -> Result<Self, ProcessingThemeError> {
|
||||
let raw::TypstStyle { horizontal_margin, vertical_margin, colors } = raw;
|
||||
let horizontal_margin = horizontal_margin.unwrap_or(DEFAULT_TYPST_HORIZONTAL_MARGIN);
|
||||
let vertical_margin = vertical_margin.unwrap_or(DEFAULT_TYPST_VERTICAL_MARGIN);
|
||||
let style = TextStyle::colored(colors.resolve(palette)?);
|
||||
Ok(Self { horizontal_margin, vertical_margin, style })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct MermaidStyle {
|
||||
pub(crate) theme: String,
|
||||
pub(crate) background: String,
|
||||
}
|
||||
|
||||
impl MermaidStyle {
|
||||
fn new(raw: &raw::MermaidStyle) -> Self {
|
||||
let raw::MermaidStyle { theme, background } = raw;
|
||||
let theme = theme.as_deref().unwrap_or(DEFAULT_MERMAID_THEME).to_string();
|
||||
let background = background.as_deref().unwrap_or(DEFAULT_MERMAID_BACKGROUND).to_string();
|
||||
Self { theme, background }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct ModalStyle {
|
||||
pub(crate) style: TextStyle,
|
||||
pub(crate) selection_style: TextStyle,
|
||||
}
|
||||
|
||||
impl ModalStyle {
|
||||
fn new(
|
||||
raw: &raw::ModalStyle,
|
||||
default_style: &DefaultStyle,
|
||||
palette: &ColorPalette,
|
||||
) -> Result<Self, ProcessingThemeError> {
|
||||
let raw::ModalStyle { colors, selection_colors } = raw;
|
||||
let mut style = default_style.style;
|
||||
style.merge(&TextStyle::colored(colors.resolve(palette)?));
|
||||
|
||||
let mut selection_style = style.bold();
|
||||
selection_style.merge(&TextStyle::colored(selection_colors.resolve(palette)?));
|
||||
Ok(Self { style, selection_style })
|
||||
}
|
||||
}
|
||||
|
||||
/// The color palette.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct ColorPalette {
|
||||
pub(crate) colors: BTreeMap<String, Color>,
|
||||
pub(crate) classes: BTreeMap<String, Colors>,
|
||||
}
|
||||
|
||||
impl TryFrom<&raw::ColorPalette> for ColorPalette {
|
||||
type Error = ProcessingThemeError;
|
||||
|
||||
fn try_from(palette: &raw::ColorPalette) -> Result<Self, Self::Error> {
|
||||
let mut colors = BTreeMap::new();
|
||||
let mut classes = BTreeMap::new();
|
||||
|
||||
for (name, color) in &palette.colors {
|
||||
let raw::RawColor::Color(color) = color else {
|
||||
return Err(ProcessingThemeError::PaletteColorInPalette);
|
||||
};
|
||||
colors.insert(name.clone(), *color);
|
||||
}
|
||||
|
||||
let resolve_local = |color: &RawColor| match color {
|
||||
raw::RawColor::Color(c) => Ok(*c),
|
||||
raw::RawColor::Palette(name) => colors
|
||||
.get(name)
|
||||
.copied()
|
||||
.ok_or_else(|| ProcessingThemeError::Palette(UndefinedPaletteColorError(name.clone()))),
|
||||
_ => Err(ProcessingThemeError::PaletteColorInPalette),
|
||||
};
|
||||
for (name, colors) in &palette.classes {
|
||||
let foreground = colors.foreground.as_ref().map(resolve_local).transpose()?;
|
||||
let background = colors.background.as_ref().map(resolve_local).transpose()?;
|
||||
classes.insert(name.clone(), Colors { foreground, background });
|
||||
}
|
||||
Ok(Self { colors, classes })
|
||||
}
|
||||
}
|
6
src/theme/mod.rs
Normal file
6
src/theme/mod.rs
Normal file
@ -0,0 +1,6 @@
|
||||
pub(crate) mod clean;
|
||||
pub(crate) mod raw;
|
||||
pub(crate) mod registry;
|
||||
|
||||
pub(crate) use clean::*;
|
||||
pub(crate) use raw::{AuthorPositioning, FooterTemplate, FooterTemplateChunk, Margin};
|
995
src/theme/raw.rs
Normal file
995
src/theme/raw.rs
Normal file
@ -0,0 +1,995 @@
|
||||
use super::registry::LoadThemeError;
|
||||
use crate::markdown::text_style::{Color, Colors, UndefinedPaletteColorError};
|
||||
use hex::{FromHex, FromHexError};
|
||||
use serde::{Deserialize, Serialize, de::Visitor};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fmt, fs,
|
||||
path::{Path, PathBuf},
|
||||
str::FromStr,
|
||||
};
|
||||
|
||||
pub(crate) type RawColors = Colors<RawColor>;
|
||||
|
||||
/// A presentation theme.
|
||||
#[derive(Default, Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PresentationTheme {
|
||||
/// The theme this theme extends from.
|
||||
#[serde(default)]
|
||||
pub(crate) extends: Option<String>,
|
||||
|
||||
/// The style for a slide's title.
|
||||
#[serde(default)]
|
||||
pub(super) slide_title: SlideTitleStyle,
|
||||
|
||||
/// The style for a block of code.
|
||||
#[serde(default)]
|
||||
pub(super) code: CodeBlockStyle,
|
||||
|
||||
/// The style for the execution output of a piece of code.
|
||||
#[serde(default)]
|
||||
pub(super) execution_output: ExecutionOutputBlockStyle,
|
||||
|
||||
/// The style for inline code.
|
||||
#[serde(default)]
|
||||
pub(super) inline_code: InlineCodeStyle,
|
||||
|
||||
/// The style for a table.
|
||||
#[serde(default)]
|
||||
pub(super) table: Option<Alignment>,
|
||||
|
||||
/// The style for a block quote.
|
||||
#[serde(default)]
|
||||
pub(super) block_quote: BlockQuoteStyle,
|
||||
|
||||
/// The style for an alert.
|
||||
#[serde(default)]
|
||||
pub(super) alert: AlertStyle,
|
||||
|
||||
/// The default style.
|
||||
#[serde(rename = "default", default)]
|
||||
pub(super) default_style: DefaultStyle,
|
||||
|
||||
//// The style of all headings.
|
||||
#[serde(default)]
|
||||
pub(super) headings: HeadingStyles,
|
||||
|
||||
/// The style of the introduction slide.
|
||||
#[serde(default)]
|
||||
pub(super) intro_slide: IntroSlideStyle,
|
||||
|
||||
/// The style of the presentation footer.
|
||||
#[serde(default)]
|
||||
pub(super) footer: Option<FooterStyle>,
|
||||
|
||||
/// The style for typst auto-rendered code blocks.
|
||||
#[serde(default)]
|
||||
pub(super) typst: TypstStyle,
|
||||
|
||||
/// The style for mermaid auto-rendered code blocks.
|
||||
#[serde(default)]
|
||||
pub(super) mermaid: MermaidStyle,
|
||||
|
||||
/// The style for modals.
|
||||
#[serde(default)]
|
||||
pub(super) modals: ModalStyle,
|
||||
|
||||
/// The color palette.
|
||||
#[serde(default)]
|
||||
pub(super) palette: ColorPalette,
|
||||
}
|
||||
|
||||
impl PresentationTheme {
|
||||
/// Construct a presentation from a path.
|
||||
pub(crate) fn from_path<P: AsRef<Path>>(path: P) -> Result<Self, LoadThemeError> {
|
||||
let contents = fs::read_to_string(&path)?;
|
||||
let theme = serde_yaml::from_str(&contents)
|
||||
.map_err(|e| LoadThemeError::Corrupted(path.as_ref().display().to_string(), e.into()))?;
|
||||
Ok(theme)
|
||||
}
|
||||
}
|
||||
|
||||
/// The style of a slide title.
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub(super) struct SlideTitleStyle {
|
||||
/// The alignment.
|
||||
#[serde(flatten, default)]
|
||||
pub(super) alignment: Option<Alignment>,
|
||||
|
||||
/// Whether to use a separator line.
|
||||
#[serde(default)]
|
||||
pub(super) separator: bool,
|
||||
|
||||
/// The padding that should be added before the text.
|
||||
#[serde(default)]
|
||||
pub(super) padding_top: Option<u8>,
|
||||
|
||||
/// The padding that should be added after the text.
|
||||
#[serde(default)]
|
||||
pub(super) padding_bottom: Option<u8>,
|
||||
|
||||
/// The colors to be used.
|
||||
#[serde(default)]
|
||||
pub(super) colors: RawColors,
|
||||
|
||||
/// Whether to use bold font for slide titles.
|
||||
#[serde(default)]
|
||||
pub(super) bold: Option<bool>,
|
||||
|
||||
/// Whether to use italics font for slide titles.
|
||||
#[serde(default)]
|
||||
pub(super) italics: Option<bool>,
|
||||
|
||||
/// Whether to use underlined font for slide titles.
|
||||
#[serde(default)]
|
||||
pub(super) underlined: Option<bool>,
|
||||
|
||||
/// The font size to be used if the terminal supports it.
|
||||
#[serde(default)]
|
||||
pub(super) font_size: Option<u8>,
|
||||
}
|
||||
|
||||
/// The style for all headings.
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub(super) struct HeadingStyles {
|
||||
/// H1 style.
|
||||
#[serde(default)]
|
||||
pub(super) h1: HeadingStyle,
|
||||
|
||||
/// H2 style.
|
||||
#[serde(default)]
|
||||
pub(super) h2: HeadingStyle,
|
||||
|
||||
/// H3 style.
|
||||
#[serde(default)]
|
||||
pub(super) h3: HeadingStyle,
|
||||
|
||||
/// H4 style.
|
||||
#[serde(default)]
|
||||
pub(super) h4: HeadingStyle,
|
||||
|
||||
/// H5 style.
|
||||
#[serde(default)]
|
||||
pub(super) h5: HeadingStyle,
|
||||
|
||||
/// H6 style.
|
||||
#[serde(default)]
|
||||
pub(super) h6: HeadingStyle,
|
||||
}
|
||||
|
||||
/// The style for a heading.
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub(super) struct HeadingStyle {
|
||||
/// The alignment.
|
||||
#[serde(flatten, default)]
|
||||
pub(super) alignment: Option<Alignment>,
|
||||
|
||||
/// The prefix to be added to this heading.
|
||||
///
|
||||
/// This allows adding text like "->" to every heading.
|
||||
#[serde(default)]
|
||||
pub(super) prefix: Option<String>,
|
||||
|
||||
/// The colors to be used.
|
||||
#[serde(default)]
|
||||
pub(super) colors: RawColors,
|
||||
|
||||
/// The font size to be used if the terminal supports it.
|
||||
#[serde(default)]
|
||||
pub(super) font_size: Option<u8>,
|
||||
}
|
||||
|
||||
/// The style of a block quote.
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub(super) struct BlockQuoteStyle {
|
||||
/// The alignment.
|
||||
#[serde(flatten, default)]
|
||||
pub(super) alignment: Option<Alignment>,
|
||||
|
||||
/// The prefix to be added to this block quote.
|
||||
///
|
||||
/// This allows adding something like a vertical bar before the text.
|
||||
#[serde(default)]
|
||||
pub(super) prefix: Option<String>,
|
||||
|
||||
/// The colors to be used.
|
||||
#[serde(default)]
|
||||
pub(super) colors: BlockQuoteColors,
|
||||
}
|
||||
|
||||
/// The colors of a block quote.
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub(super) struct BlockQuoteColors {
|
||||
/// The foreground/background colors.
|
||||
#[serde(flatten)]
|
||||
pub(super) base: RawColors,
|
||||
|
||||
/// The color of the vertical bar that prefixes each line in the quote.
|
||||
#[serde(default)]
|
||||
pub(super) prefix: Option<RawColor>,
|
||||
}
|
||||
|
||||
/// The style of an alert.
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub(super) struct AlertStyle {
|
||||
/// The alignment.
|
||||
#[serde(flatten, default)]
|
||||
pub(super) alignment: Option<Alignment>,
|
||||
|
||||
/// The base colors.
|
||||
#[serde(default)]
|
||||
pub(super) base_colors: RawColors,
|
||||
|
||||
/// The prefix to be added to this block quote.
|
||||
///
|
||||
/// This allows adding something like a vertical bar before the text.
|
||||
#[serde(default)]
|
||||
pub(super) prefix: Option<String>,
|
||||
|
||||
/// The style for each alert type.
|
||||
#[serde(default)]
|
||||
pub(super) styles: AlertTypeStyles,
|
||||
}
|
||||
|
||||
/// The style for each alert type.
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub(super) struct AlertTypeStyles {
|
||||
/// The style for note alert types.
|
||||
#[serde(default)]
|
||||
pub(super) note: AlertTypeStyle,
|
||||
|
||||
/// The style for tip alert types.
|
||||
#[serde(default)]
|
||||
pub(super) tip: AlertTypeStyle,
|
||||
|
||||
/// The style for important alert types.
|
||||
#[serde(default)]
|
||||
pub(super) important: AlertTypeStyle,
|
||||
|
||||
/// The style for warning alert types.
|
||||
#[serde(default)]
|
||||
pub(super) warning: AlertTypeStyle,
|
||||
|
||||
/// The style for caution alert types.
|
||||
#[serde(default)]
|
||||
pub(super) caution: AlertTypeStyle,
|
||||
}
|
||||
|
||||
/// The style for an alert type.
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub(super) struct AlertTypeStyle {
|
||||
/// The color to be used.
|
||||
#[serde(default)]
|
||||
pub(super) color: Option<RawColor>,
|
||||
|
||||
/// The title to be used.
|
||||
#[serde(default)]
|
||||
pub(super) title: Option<String>,
|
||||
|
||||
/// The icon to be used.
|
||||
#[serde(default)]
|
||||
pub(super) icon: Option<String>,
|
||||
}
|
||||
|
||||
/// The style for the presentation introduction slide.
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub(super) struct IntroSlideStyle {
|
||||
/// The style of the title line.
|
||||
#[serde(default)]
|
||||
pub(super) title: IntroSlideTitleStyle,
|
||||
|
||||
/// The style of the subtitle line.
|
||||
#[serde(default)]
|
||||
pub(super) subtitle: BasicStyle,
|
||||
|
||||
/// The style of the event line.
|
||||
#[serde(default)]
|
||||
pub(super) event: BasicStyle,
|
||||
|
||||
/// The style of the location line.
|
||||
#[serde(default)]
|
||||
pub(super) location: BasicStyle,
|
||||
|
||||
/// The style of the date line.
|
||||
#[serde(default)]
|
||||
pub(super) date: BasicStyle,
|
||||
|
||||
/// The style of the author line.
|
||||
#[serde(default)]
|
||||
pub(super) author: AuthorStyle,
|
||||
|
||||
/// Whether we want a footer in the intro slide.
|
||||
#[serde(default)]
|
||||
pub(super) footer: Option<bool>,
|
||||
}
|
||||
|
||||
/// A simple style.
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub(super) struct DefaultStyle {
|
||||
/// The margin on the left/right of the screen.
|
||||
#[serde(default, with = "serde_yaml::with::singleton_map")]
|
||||
pub(super) margin: Option<Margin>,
|
||||
|
||||
/// The colors to be used.
|
||||
#[serde(default)]
|
||||
pub(super) colors: RawColors,
|
||||
}
|
||||
|
||||
/// A simple style.
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub(super) struct BasicStyle {
|
||||
/// The alignment.
|
||||
#[serde(flatten, default)]
|
||||
pub(super) alignment: Option<Alignment>,
|
||||
|
||||
/// The colors to be used.
|
||||
#[serde(default)]
|
||||
pub(super) colors: RawColors,
|
||||
}
|
||||
|
||||
/// The intro slide title's style.
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub(super) struct IntroSlideTitleStyle {
|
||||
/// The alignment.
|
||||
#[serde(flatten, default)]
|
||||
pub(super) alignment: Option<Alignment>,
|
||||
|
||||
/// The colors to be used.
|
||||
#[serde(default)]
|
||||
pub(super) colors: RawColors,
|
||||
|
||||
/// The font size to be used if the terminal supports it.
|
||||
#[serde(default)]
|
||||
pub(super) font_size: Option<u8>,
|
||||
}
|
||||
|
||||
/// Text alignment.
|
||||
///
|
||||
/// This allows anchoring presentation elements to the left, center, or right of the screen.
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(tag = "alignment", rename_all = "snake_case")]
|
||||
pub(super) enum Alignment {
|
||||
/// Left alignment.
|
||||
Left {
|
||||
/// The margin before any text.
|
||||
#[serde(default)]
|
||||
margin: Margin,
|
||||
},
|
||||
|
||||
/// Right alignment.
|
||||
Right {
|
||||
/// The margin after any text.
|
||||
#[serde(default)]
|
||||
margin: Margin,
|
||||
},
|
||||
|
||||
/// Center alignment.
|
||||
Center {
|
||||
/// The minimum margin expected.
|
||||
#[serde(default)]
|
||||
minimum_margin: Margin,
|
||||
|
||||
/// The minimum size of this element, in columns.
|
||||
#[serde(default)]
|
||||
minimum_size: u16,
|
||||
},
|
||||
}
|
||||
|
||||
impl Default for Alignment {
|
||||
fn default() -> Self {
|
||||
Self::Left { margin: Margin::Fixed(0) }
|
||||
}
|
||||
}
|
||||
|
||||
/// The style for the author line in the presentation intro slide.
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub(super) struct AuthorStyle {
|
||||
/// The alignment.
|
||||
#[serde(flatten, default)]
|
||||
pub(super) alignment: Option<Alignment>,
|
||||
|
||||
/// The colors to be used.
|
||||
#[serde(default)]
|
||||
pub(super) colors: RawColors,
|
||||
|
||||
/// The positioning of the author's name.
|
||||
#[serde(default)]
|
||||
pub(super) positioning: AuthorPositioning,
|
||||
}
|
||||
|
||||
/// The style of the footer that's shown in every slide.
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(tag = "style", rename_all = "snake_case")]
|
||||
pub(super) enum FooterStyle {
|
||||
/// Use a template to generate the footer.
|
||||
Template {
|
||||
/// The content to be put on the left.
|
||||
left: Option<FooterContent>,
|
||||
|
||||
/// The content to be put on the center.
|
||||
center: Option<FooterContent>,
|
||||
|
||||
/// The content to be put on the right.
|
||||
right: Option<FooterContent>,
|
||||
|
||||
/// The colors to be used.
|
||||
#[serde(default)]
|
||||
colors: RawColors,
|
||||
|
||||
/// The height of the footer area.
|
||||
height: Option<u16>,
|
||||
},
|
||||
|
||||
/// Use a progress bar.
|
||||
ProgressBar {
|
||||
/// The character that will be used for the progress bar.
|
||||
character: Option<char>,
|
||||
|
||||
/// The colors to be used.
|
||||
#[serde(default)]
|
||||
colors: RawColors,
|
||||
},
|
||||
|
||||
/// No footer.
|
||||
Empty,
|
||||
}
|
||||
|
||||
impl Default for FooterStyle {
|
||||
fn default() -> Self {
|
||||
Self::Template { left: None, center: None, right: None, colors: RawColors::default(), height: None }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
|
||||
pub(crate) enum FooterTemplateChunk {
|
||||
Literal(String),
|
||||
OpenBrace,
|
||||
ClosedBrace,
|
||||
CurrentSlide,
|
||||
TotalSlides,
|
||||
Author,
|
||||
Title,
|
||||
SubTitle,
|
||||
Event,
|
||||
Location,
|
||||
Date,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(untagged)]
|
||||
pub(super) enum FooterContent {
|
||||
Template(FooterTemplate),
|
||||
Image {
|
||||
#[serde(rename = "image")]
|
||||
path: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
struct FooterContentVisitor;
|
||||
|
||||
impl<'de> Visitor<'de> for FooterContentVisitor {
|
||||
type Value = FooterContent;
|
||||
|
||||
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||
formatter.write_str("a valid footer")
|
||||
}
|
||||
|
||||
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
let template = FooterTemplate::from_str(v).map_err(|e| E::custom(e.to_string()))?;
|
||||
Ok(FooterContent::Template(template))
|
||||
}
|
||||
|
||||
fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
|
||||
where
|
||||
A: serde::de::MapAccess<'de>,
|
||||
{
|
||||
let Some((key, value)): Option<(String, PathBuf)> = map.next_entry()? else {
|
||||
return Err(serde::de::Error::custom("invalid footer"));
|
||||
};
|
||||
|
||||
match key.as_str() {
|
||||
"image" => Ok(FooterContent::Image { path: value }),
|
||||
_ => Err(serde::de::Error::invalid_value(serde::de::Unexpected::Str(&key), &self)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for FooterContent {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
deserializer.deserialize_any(FooterContentVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct FooterTemplate(pub(crate) Vec<FooterTemplateChunk>);
|
||||
|
||||
crate::utils::impl_deserialize_from_str!(FooterTemplate);
|
||||
crate::utils::impl_serialize_from_display!(FooterTemplate);
|
||||
|
||||
impl FromStr for FooterTemplate {
|
||||
type Err = ParseFooterTemplateError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
let mut chunks = Vec::new();
|
||||
let mut chunk_start = 0;
|
||||
let mut in_variable = false;
|
||||
let mut iter = s.char_indices().peekable();
|
||||
while let Some((index, c)) = iter.next() {
|
||||
if c == '{' {
|
||||
if in_variable {
|
||||
return Err(ParseFooterTemplateError::NestedOpenBrace);
|
||||
}
|
||||
let double_brace = iter.peek() == Some(&(index + 1, '{'));
|
||||
if double_brace {
|
||||
iter.next();
|
||||
if chunk_start != index {
|
||||
chunks.push(FooterTemplateChunk::Literal(s[chunk_start..index].to_string()));
|
||||
}
|
||||
chunks.push(FooterTemplateChunk::OpenBrace);
|
||||
chunk_start = index + 2;
|
||||
} else {
|
||||
in_variable = true;
|
||||
if chunk_start != index {
|
||||
chunks.push(FooterTemplateChunk::Literal(s[chunk_start..index].to_string()));
|
||||
}
|
||||
chunk_start = index + 1;
|
||||
}
|
||||
} else if c == '}' {
|
||||
if !in_variable {
|
||||
let double_brace = iter.peek() == Some(&(index + 1, '}'));
|
||||
if double_brace {
|
||||
iter.next();
|
||||
chunks.push(FooterTemplateChunk::Literal(s[chunk_start..index].to_string()));
|
||||
chunks.push(FooterTemplateChunk::ClosedBrace);
|
||||
in_variable = false;
|
||||
chunk_start = index + 2;
|
||||
continue;
|
||||
}
|
||||
return Err(ParseFooterTemplateError::ClosedBraceWithoutOpen);
|
||||
}
|
||||
let variable = &s[chunk_start..index];
|
||||
let chunk = match variable {
|
||||
"current_slide" => FooterTemplateChunk::CurrentSlide,
|
||||
"total_slides" => FooterTemplateChunk::TotalSlides,
|
||||
"author" => FooterTemplateChunk::Author,
|
||||
"title" => FooterTemplateChunk::Title,
|
||||
"sub_title" => FooterTemplateChunk::SubTitle,
|
||||
"event" => FooterTemplateChunk::Event,
|
||||
"location" => FooterTemplateChunk::Location,
|
||||
"date" => FooterTemplateChunk::Date,
|
||||
_ => return Err(ParseFooterTemplateError::UnsupportedVariable(variable.to_string())),
|
||||
};
|
||||
chunks.push(chunk);
|
||||
in_variable = false;
|
||||
chunk_start = index + 1;
|
||||
}
|
||||
}
|
||||
if in_variable {
|
||||
return Err(ParseFooterTemplateError::TrailingBrace);
|
||||
} else if chunk_start != s.len() {
|
||||
chunks.push(FooterTemplateChunk::Literal(s[chunk_start..].to_string()));
|
||||
}
|
||||
Ok(Self(chunks))
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for FooterTemplate {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
use FooterTemplateChunk::*;
|
||||
for c in &self.0 {
|
||||
match c {
|
||||
Literal(l) => write!(f, "{l}"),
|
||||
OpenBrace => write!(f, "{{{{"),
|
||||
ClosedBrace => write!(f, "}}}}"),
|
||||
CurrentSlide => write!(f, "{{current_slide}}"),
|
||||
TotalSlides => write!(f, "{{total_slides}}"),
|
||||
Author => write!(f, "{{author}}"),
|
||||
Title => write!(f, "{{title}}"),
|
||||
SubTitle => write!(f, "{{sub_title}}"),
|
||||
Event => write!(f, "{{event}}"),
|
||||
Location => write!(f, "{{location}}"),
|
||||
Date => write!(f, "{{date}}"),
|
||||
}?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(crate) enum ParseFooterTemplateError {
|
||||
#[error("found '{{' while already inside '{{' scope")]
|
||||
NestedOpenBrace,
|
||||
|
||||
#[error("open '{{' was not closed")]
|
||||
TrailingBrace,
|
||||
|
||||
#[error("found '}}' but no '{{' was found")]
|
||||
ClosedBraceWithoutOpen,
|
||||
|
||||
#[error("unsupported variable: '{0}'")]
|
||||
UnsupportedVariable(String),
|
||||
}
|
||||
|
||||
/// The style for a piece of code.
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub(super) struct CodeBlockStyle {
|
||||
/// The alignment.
|
||||
#[serde(flatten)]
|
||||
pub(super) alignment: Option<Alignment>,
|
||||
|
||||
/// The padding.
|
||||
#[serde(default)]
|
||||
pub(super) padding: PaddingRect,
|
||||
|
||||
/// The syntect theme name to use.
|
||||
#[serde(default)]
|
||||
pub(super) theme_name: Option<String>,
|
||||
|
||||
/// Whether to use the theme's background color.
|
||||
pub(super) background: Option<bool>,
|
||||
}
|
||||
|
||||
/// The style for the output of a code execution block.
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub(super) struct ExecutionOutputBlockStyle {
|
||||
/// The colors to be used for the output pane.
|
||||
#[serde(default)]
|
||||
pub(super) colors: RawColors,
|
||||
|
||||
/// The colors to be used for the text that represents the status of the execution block.
|
||||
#[serde(default)]
|
||||
pub(super) status: ExecutionStatusBlockStyle,
|
||||
}
|
||||
|
||||
/// The style for the status of a code execution block.
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub(super) struct ExecutionStatusBlockStyle {
|
||||
/// The colors for the "running" status.
|
||||
#[serde(default)]
|
||||
pub(super) running: RawColors,
|
||||
|
||||
/// The colors for the "finished" status.
|
||||
#[serde(default)]
|
||||
pub(super) success: RawColors,
|
||||
|
||||
/// The colors for the "finished with error" status.
|
||||
#[serde(default)]
|
||||
pub(super) failure: RawColors,
|
||||
|
||||
/// The colors for the "not started" status.
|
||||
#[serde(default)]
|
||||
pub(super) not_started: RawColors,
|
||||
}
|
||||
|
||||
/// The style for inline code.
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub(super) struct InlineCodeStyle {
|
||||
/// The colors to be used.
|
||||
#[serde(default)]
|
||||
pub(super) colors: RawColors,
|
||||
}
|
||||
|
||||
/// Vertical/horizontal padding.
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub(super) struct PaddingRect {
|
||||
/// The number of columns to use as horizontal padding.
|
||||
#[serde(default)]
|
||||
pub(super) horizontal: Option<u8>,
|
||||
|
||||
/// The number of rows to use as vertical padding.
|
||||
#[serde(default)]
|
||||
pub(super) vertical: Option<u8>,
|
||||
}
|
||||
|
||||
/// A margin.
|
||||
#[derive(Copy, Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum Margin {
|
||||
/// A fixed number of characters.
|
||||
Fixed(u16),
|
||||
|
||||
/// A percent of the screen size.
|
||||
Percent(u16),
|
||||
}
|
||||
|
||||
impl Margin {
|
||||
pub(crate) fn as_characters(&self, screen_size: u16) -> u16 {
|
||||
match *self {
|
||||
Self::Fixed(value) => value,
|
||||
Self::Percent(percent) => {
|
||||
let ratio = percent as f64 / 100.0;
|
||||
(screen_size as f64 * ratio).ceil() as u16
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_empty(&self) -> bool {
|
||||
matches!(self, Self::Fixed(0) | Self::Percent(0))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Margin {
|
||||
fn default() -> Self {
|
||||
Self::Fixed(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// An element type.
|
||||
#[derive(Clone, Deserialize, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(super) enum ElementType {
|
||||
SlideTitle,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
Heading4,
|
||||
Heading5,
|
||||
Heading6,
|
||||
Paragraph,
|
||||
List,
|
||||
Code,
|
||||
PresentationTitle,
|
||||
PresentationSubTitle,
|
||||
PresentationEvent,
|
||||
PresentationLocation,
|
||||
PresentationDate,
|
||||
PresentationAuthor,
|
||||
Table,
|
||||
BlockQuote,
|
||||
}
|
||||
|
||||
/// Where to position the author's name in the intro slide.
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum AuthorPositioning {
|
||||
/// Right below the title.
|
||||
BelowTitle,
|
||||
|
||||
/// At the bottom of the page.
|
||||
#[default]
|
||||
PageBottom,
|
||||
}
|
||||
|
||||
/// Typst styles.
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub(super) struct TypstStyle {
|
||||
/// The horizontal margin on the generated images.
|
||||
pub(super) horizontal_margin: Option<u16>,
|
||||
|
||||
/// The vertical margin on the generated images.
|
||||
pub(super) vertical_margin: Option<u16>,
|
||||
|
||||
/// The colors to be used.
|
||||
#[serde(default)]
|
||||
pub(super) colors: RawColors,
|
||||
}
|
||||
|
||||
/// Mermaid styles.
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub(super) struct MermaidStyle {
|
||||
/// The mermaidjs theme to use.
|
||||
pub(super) theme: Option<String>,
|
||||
|
||||
/// The background color to use.
|
||||
pub(super) background: Option<String>,
|
||||
}
|
||||
|
||||
/// Modals style.
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub(super) struct ModalStyle {
|
||||
/// The default colors to use for everything in the modal.
|
||||
#[serde(default)]
|
||||
pub(super) colors: RawColors,
|
||||
|
||||
/// The colors to use for selected lines.
|
||||
#[serde(default)]
|
||||
pub(super) selection_colors: RawColors,
|
||||
}
|
||||
|
||||
/// The color palette.
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub(super) struct ColorPalette {
|
||||
#[serde(default)]
|
||||
pub(super) colors: BTreeMap<String, RawColor>,
|
||||
|
||||
#[serde(default)]
|
||||
pub(super) classes: BTreeMap<String, RawColors>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum RawColor {
|
||||
Color(Color),
|
||||
Palette(String),
|
||||
ForegroundClass(String),
|
||||
BackgroundClass(String),
|
||||
}
|
||||
|
||||
crate::utils::impl_deserialize_from_str!(RawColor);
|
||||
crate::utils::impl_serialize_from_display!(RawColor);
|
||||
|
||||
impl RawColor {
|
||||
fn new_palette(name: &str) -> Result<Self, ParseColorError> {
|
||||
if name.is_empty() { Err(ParseColorError::PaletteColorEmpty) } else { Ok(Self::Palette(name.into())) }
|
||||
}
|
||||
|
||||
pub(crate) fn resolve(
|
||||
&self,
|
||||
palette: &crate::theme::clean::ColorPalette,
|
||||
) -> Result<Option<Color>, UndefinedPaletteColorError> {
|
||||
let color = match self {
|
||||
Self::Color(c) => Some(*c),
|
||||
Self::Palette(name) => {
|
||||
Some(palette.colors.get(name).copied().ok_or(UndefinedPaletteColorError(name.clone()))?)
|
||||
}
|
||||
Self::ForegroundClass(name) => {
|
||||
palette.classes.get(name).ok_or(UndefinedPaletteColorError(name.clone()))?.foreground
|
||||
}
|
||||
Self::BackgroundClass(name) => {
|
||||
palette.classes.get(name).ok_or(UndefinedPaletteColorError(name.clone()))?.background
|
||||
}
|
||||
};
|
||||
Ok(color)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Color> for RawColor {
|
||||
fn from(color: Color) -> Self {
|
||||
Self::Color(color)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for RawColor {
|
||||
type Err = ParseColorError;
|
||||
|
||||
fn from_str(input: &str) -> Result<Self, Self::Err> {
|
||||
let output = match input {
|
||||
"black" => Color::Black.into(),
|
||||
"white" => Color::White.into(),
|
||||
"grey" => Color::Grey.into(),
|
||||
"dark_grey" => Color::DarkGrey.into(),
|
||||
"red" => Color::Red.into(),
|
||||
"dark_red" => Color::DarkRed.into(),
|
||||
"green" => Color::Green.into(),
|
||||
"dark_green" => Color::DarkGreen.into(),
|
||||
"blue" => Color::Blue.into(),
|
||||
"dark_blue" => Color::DarkBlue.into(),
|
||||
"yellow" => Color::Yellow.into(),
|
||||
"dark_yellow" => Color::DarkYellow.into(),
|
||||
"magenta" => Color::Magenta.into(),
|
||||
"dark_magenta" => Color::DarkMagenta.into(),
|
||||
"cyan" => Color::Cyan.into(),
|
||||
"dark_cyan" => Color::DarkCyan.into(),
|
||||
other if other.starts_with("palette:") => Self::new_palette(other.trim_start_matches("palette:"))?,
|
||||
other if other.starts_with("p:") => Self::new_palette(other.trim_start_matches("p:"))?,
|
||||
// Fallback to hex-encoded rgb
|
||||
_ => {
|
||||
let values = <[u8; 3]>::from_hex(input)?;
|
||||
Color::Rgb { r: values[0], g: values[1], b: values[2] }.into()
|
||||
}
|
||||
};
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for RawColor {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
use Color::*;
|
||||
match self {
|
||||
Self::Color(Rgb { r, g, b }) => write!(f, "{}", hex::encode([*r, *g, *b])),
|
||||
Self::Color(Black) => write!(f, "black"),
|
||||
Self::Color(White) => write!(f, "white"),
|
||||
Self::Color(Grey) => write!(f, "grey"),
|
||||
Self::Color(DarkGrey) => write!(f, "dark_grey"),
|
||||
Self::Color(Red) => write!(f, "red"),
|
||||
Self::Color(DarkRed) => write!(f, "dark_red"),
|
||||
Self::Color(Green) => write!(f, "green"),
|
||||
Self::Color(DarkGreen) => write!(f, "dark_green"),
|
||||
Self::Color(Blue) => write!(f, "blue"),
|
||||
Self::Color(DarkBlue) => write!(f, "dark_blue"),
|
||||
Self::Color(Yellow) => write!(f, "yellow"),
|
||||
Self::Color(DarkYellow) => write!(f, "dark_yellow"),
|
||||
Self::Color(Magenta) => write!(f, "magenta"),
|
||||
Self::Color(DarkMagenta) => write!(f, "dark_magenta"),
|
||||
Self::Color(Cyan) => write!(f, "cyan"),
|
||||
Self::Color(DarkCyan) => write!(f, "dark_cyan"),
|
||||
Self::Palette(name) => write!(f, "palette:{name}"),
|
||||
Self::ForegroundClass(_) => Err(fmt::Error),
|
||||
Self::BackgroundClass(_) => Err(fmt::Error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub(crate) enum ParseColorError {
|
||||
#[error("invalid hex color: {0}")]
|
||||
Hex(#[from] FromHexError),
|
||||
|
||||
#[error("palette color name is empty")]
|
||||
PaletteColorEmpty,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
|
||||
#[test]
|
||||
fn parse_all_footer_template_variables() {
|
||||
use FooterTemplateChunk::*;
|
||||
let raw = "hi {current_slide} {total_slides} {author} {title} {sub_title} {event} {location} {event}";
|
||||
let t: FooterTemplate = raw.parse().expect("invalid input");
|
||||
let expected = vec![
|
||||
Literal("hi ".into()),
|
||||
CurrentSlide,
|
||||
Literal(" ".into()),
|
||||
TotalSlides,
|
||||
Literal(" ".into()),
|
||||
Author,
|
||||
Literal(" ".into()),
|
||||
Title,
|
||||
Literal(" ".into()),
|
||||
SubTitle,
|
||||
Literal(" ".into()),
|
||||
Event,
|
||||
Literal(" ".into()),
|
||||
Location,
|
||||
Literal(" ".into()),
|
||||
Event,
|
||||
];
|
||||
assert_eq!(t.0, expected);
|
||||
assert_eq!(t.to_string(), raw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_double_braces() {
|
||||
use FooterTemplateChunk::*;
|
||||
let raw = "hi {{beep}} {{author}} {{{{}}}}";
|
||||
let t: FooterTemplate = raw.parse().expect("invalid input");
|
||||
let merged: String =
|
||||
t.0.into_iter()
|
||||
.map(|l| match l {
|
||||
Literal(s) => s,
|
||||
OpenBrace => "{".to_string(),
|
||||
ClosedBrace => "}".to_string(),
|
||||
_ => panic!("not a literal"),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(merged, "hi {beep} {author} {{}}");
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::trailing("{author")]
|
||||
#[case::close_without_open2("author}")]
|
||||
fn invalid_footer_templates(#[case] input: &str) {
|
||||
FooterTemplate::from_str(input).expect_err("parse succeeded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_serde() {
|
||||
let color: RawColor = "beef42".parse().unwrap();
|
||||
assert_eq!(color.to_string(), "beef42");
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::empty1("p:")]
|
||||
#[case::empty2("palette:")]
|
||||
fn invalid_palette_color_names(#[case] input: &str) {
|
||||
RawColor::from_str(input).expect_err("not an error");
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::short("p:hi", "hi")]
|
||||
#[case::long("palette:bye", "bye")]
|
||||
fn valid_palette_color_names(#[case] input: &str, #[case] expected: &str) {
|
||||
let color = RawColor::from_str(input).expect("failed to parse");
|
||||
let RawColor::Palette(name) = color else { panic!("not a palette color") };
|
||||
assert_eq!(name, expected);
|
||||
}
|
||||
}
|
239
src/theme/registry.rs
Normal file
239
src/theme/registry.rs
Normal file
@ -0,0 +1,239 @@
|
||||
use super::raw::PresentationTheme;
|
||||
use std::{collections::BTreeMap, fs, io, path::Path};
|
||||
|
||||
include!(concat!(env!("OUT_DIR"), "/themes.rs"));
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct PresentationThemeRegistry {
|
||||
custom_themes: BTreeMap<String, PresentationTheme>,
|
||||
}
|
||||
|
||||
impl PresentationThemeRegistry {
|
||||
/// Loads a theme from its name.
|
||||
pub fn load_by_name(&self, name: &str) -> Option<PresentationTheme> {
|
||||
match THEMES.get(name) {
|
||||
Some(contents) => {
|
||||
// This is going to be caught by the test down here.
|
||||
let theme = serde_yaml::from_slice(contents).expect("corrupted theme");
|
||||
Some(theme)
|
||||
}
|
||||
None => self.custom_themes.get(name).cloned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register all the themes in the given directory.
|
||||
pub fn register_from_directory<P: AsRef<Path>>(&mut self, path: P) -> Result<(), LoadThemeError> {
|
||||
let handle = match fs::read_dir(&path) {
|
||||
Ok(handle) => handle,
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let mut dependencies = BTreeMap::new();
|
||||
for entry in handle {
|
||||
let entry = entry?;
|
||||
let metadata = entry.metadata()?;
|
||||
let Some(file_name) = entry.file_name().to_str().map(ToOwned::to_owned) else {
|
||||
continue;
|
||||
};
|
||||
if metadata.is_file() && file_name.ends_with(".yaml") {
|
||||
let theme_name = file_name.trim_end_matches(".yaml");
|
||||
if THEMES.contains_key(theme_name) {
|
||||
return Err(LoadThemeError::Duplicate(theme_name.into()));
|
||||
}
|
||||
let theme = PresentationTheme::from_path(entry.path())?;
|
||||
let base = theme.extends.clone();
|
||||
self.custom_themes.insert(theme_name.into(), theme);
|
||||
dependencies.insert(theme_name.to_string(), base);
|
||||
}
|
||||
}
|
||||
let mut graph = ThemeGraph::new(dependencies);
|
||||
for theme_name in graph.dependents.keys() {
|
||||
let theme_name = theme_name.as_str();
|
||||
if !THEMES.contains_key(theme_name) && !self.custom_themes.contains_key(theme_name) {
|
||||
return Err(LoadThemeError::ExtendedThemeNotFound(theme_name.into()));
|
||||
}
|
||||
}
|
||||
|
||||
while let Some(theme_name) = graph.pop() {
|
||||
self.extend_theme(&theme_name)?;
|
||||
}
|
||||
if !graph.dependents.is_empty() {
|
||||
return Err(LoadThemeError::ExtensionLoop(graph.dependents.into_keys().collect()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extend_theme(&mut self, theme_name: &str) -> Result<(), LoadThemeError> {
|
||||
let Some(base_name) = self.custom_themes.get(theme_name).expect("theme not found").extends.clone() else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(base_theme) = self.load_by_name(&base_name) else {
|
||||
return Err(LoadThemeError::ExtendedThemeNotFound(base_name.clone()));
|
||||
};
|
||||
let theme = self.custom_themes.get_mut(theme_name).expect("theme not found");
|
||||
*theme = merge_struct::merge(&base_theme, theme)
|
||||
.map_err(|e| LoadThemeError::Corrupted(base_name.to_string(), e.into()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get all the registered theme names.
|
||||
pub fn theme_names(&self) -> Vec<String> {
|
||||
let builtin_themes = THEMES.keys().map(|name| name.to_string());
|
||||
let themes = self.custom_themes.keys().cloned().chain(builtin_themes).collect();
|
||||
themes
|
||||
}
|
||||
}
|
||||
|
||||
struct ThemeGraph {
|
||||
dependents: BTreeMap<String, Vec<String>>,
|
||||
ready: Vec<String>,
|
||||
}
|
||||
|
||||
impl ThemeGraph {
|
||||
fn new<I>(dependencies: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = (String, Option<String>)>,
|
||||
{
|
||||
let mut dependents: BTreeMap<_, Vec<_>> = BTreeMap::new();
|
||||
let mut ready = Vec::new();
|
||||
for (name, extends) in dependencies {
|
||||
dependents.entry(name.clone()).or_default();
|
||||
match extends {
|
||||
// If we extend from a non built in theme, make ourselves their dependent
|
||||
Some(base) if !THEMES.contains_key(base.as_str()) => {
|
||||
dependents.entry(base).or_default().push(name);
|
||||
}
|
||||
// Otherwise this theme is ready to be processed
|
||||
_ => ready.push(name),
|
||||
}
|
||||
}
|
||||
Self { dependents, ready }
|
||||
}
|
||||
|
||||
fn pop(&mut self) -> Option<String> {
|
||||
let theme = self.ready.pop()?;
|
||||
if let Some(dependents) = self.dependents.remove(&theme) {
|
||||
self.ready.extend(dependents);
|
||||
}
|
||||
Some(theme)
|
||||
}
|
||||
}
|
||||
|
||||
/// An error loading a presentation theme.
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum LoadThemeError {
|
||||
#[error(transparent)]
|
||||
Io(#[from] io::Error),
|
||||
|
||||
#[error("theme '{0}' is corrupted: {1}")]
|
||||
Corrupted(String, Box<dyn std::error::Error>),
|
||||
|
||||
#[error("duplicate custom theme '{0}'")]
|
||||
Duplicate(String),
|
||||
|
||||
#[error("extended theme does not exist: {0}")]
|
||||
ExtendedThemeNotFound(String),
|
||||
|
||||
#[error("theme has an extension loop involving: {0:?}")]
|
||||
ExtensionLoop(Vec<String>),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use crate::resource::Resources;
|
||||
|
||||
use super::*;
|
||||
use tempfile::{TempDir, tempdir};
|
||||
|
||||
fn write_theme(name: &str, theme: PresentationTheme, directory: &TempDir) {
|
||||
let theme = serde_yaml::to_string(&theme).unwrap();
|
||||
let file_name = format!("{name}.yaml");
|
||||
fs::write(directory.path().join(file_name), theme).expect("writing theme");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_themes() {
|
||||
let themes = PresentationThemeRegistry::default();
|
||||
for theme_name in THEMES.keys() {
|
||||
let Some(theme) = themes.load_by_name(theme_name).clone() else {
|
||||
panic!("theme '{theme_name}' is corrupted");
|
||||
};
|
||||
|
||||
// Built-in themes can't use this because... I don't feel like supporting this now.
|
||||
assert!(theme.extends.is_none(), "theme '{theme_name}' uses extends");
|
||||
|
||||
let merged = merge_struct::merge(&PresentationTheme::default(), &theme);
|
||||
assert!(merged.is_ok(), "theme '{theme_name}' can't be merged: {}", merged.unwrap_err());
|
||||
|
||||
let resources = Resources::new("/tmp/foo", "/tmp/foo", Default::default());
|
||||
crate::theme::PresentationTheme::new(&theme, &resources, &Default::default()).expect("malformed theme");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_custom() {
|
||||
let directory = tempdir().expect("creating tempdir");
|
||||
write_theme(
|
||||
"potato",
|
||||
PresentationTheme { extends: Some("dark".to_string()), ..Default::default() },
|
||||
&directory,
|
||||
);
|
||||
|
||||
let mut themes = PresentationThemeRegistry::default();
|
||||
themes.register_from_directory(directory.path()).expect("loading themes");
|
||||
let mut theme = themes.load_by_name("potato").expect("theme not found");
|
||||
|
||||
// Since we extend the dark theme they must match after we remove the "extends" field.
|
||||
let dark = themes.load_by_name("dark");
|
||||
theme.extends.take().expect("no extends");
|
||||
assert_eq!(serde_yaml::to_string(&theme).unwrap(), serde_yaml::to_string(&dark).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_derive_chain() {
|
||||
let directory = tempdir().expect("creating tempdir");
|
||||
write_theme("A", PresentationTheme { extends: Some("dark".to_string()), ..Default::default() }, &directory);
|
||||
write_theme("B", PresentationTheme { extends: Some("C".to_string()), ..Default::default() }, &directory);
|
||||
write_theme("C", PresentationTheme { extends: Some("A".to_string()), ..Default::default() }, &directory);
|
||||
write_theme("D", PresentationTheme::default(), &directory);
|
||||
|
||||
let mut themes = PresentationThemeRegistry::default();
|
||||
themes.register_from_directory(directory.path()).expect("loading themes");
|
||||
themes.load_by_name("A").expect("A not found");
|
||||
themes.load_by_name("B").expect("B not found");
|
||||
themes.load_by_name("C").expect("C not found");
|
||||
themes.load_by_name("D").expect("D not found");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_derives() {
|
||||
let directory = tempdir().expect("creating tempdir");
|
||||
write_theme(
|
||||
"A",
|
||||
PresentationTheme { extends: Some("non-existent-theme".to_string()), ..Default::default() },
|
||||
&directory,
|
||||
);
|
||||
|
||||
let mut themes = PresentationThemeRegistry::default();
|
||||
themes.register_from_directory(directory.path()).expect_err("loading themes succeeded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_derive_chain_loop() {
|
||||
let directory = tempdir().expect("creating tempdir");
|
||||
write_theme("A", PresentationTheme { extends: Some("B".to_string()), ..Default::default() }, &directory);
|
||||
write_theme("B", PresentationTheme { extends: Some("A".to_string()), ..Default::default() }, &directory);
|
||||
|
||||
let mut themes = PresentationThemeRegistry::default();
|
||||
let err = themes.register_from_directory(directory.path()).expect_err("loading themes succeeded");
|
||||
let LoadThemeError::ExtensionLoop(names) = err else { panic!("not an extension loop error") };
|
||||
assert_eq!(names, &["A", "B"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_from_missing_directory() {
|
||||
let mut themes = PresentationThemeRegistry::default();
|
||||
let result = themes.register_from_directory("/tmp/presenterm/8ee2027983915ec78acc45027d874316");
|
||||
result.expect("loading failed");
|
||||
}
|
||||
}
|
@ -1,19 +1,22 @@
|
||||
use crate::{
|
||||
ImageRegistry, PresentationTheme,
|
||||
ImageRegistry,
|
||||
config::{default_mermaid_scale, default_snippet_render_threads, default_typst_ppi},
|
||||
markdown::{
|
||||
elements::{Line, Percent, Text},
|
||||
text_style::{Color, Colors, TextStyle},
|
||||
text_style::{Color, TextStyle},
|
||||
},
|
||||
presentation::{AsyncPresentationError, AsyncPresentationErrorHolder, builder::DEFAULT_IMAGE_Z_INDEX},
|
||||
render::{
|
||||
operation::{
|
||||
AsRenderOperations, ImageRenderProperties, ImageSize, RenderAsync, RenderAsyncState, RenderOperation,
|
||||
AsRenderOperations, ImageRenderProperties, ImageSize, Pollable, PollableState, RenderAsync,
|
||||
RenderAsyncStartPolicy, RenderOperation,
|
||||
},
|
||||
properties::WindowSize,
|
||||
},
|
||||
terminal::image::{Image, printer::RegisterImageError},
|
||||
theme::{Alignment, MermaidStyle, TypstStyle},
|
||||
terminal::image::{
|
||||
Image,
|
||||
printer::{ImageSpec, RegisterImageError},
|
||||
},
|
||||
theme::{Alignment, MermaidStyle, PresentationTheme, TypstStyle, raw::RawColor},
|
||||
tools::{ExecutionError, ThirdPartyTools},
|
||||
};
|
||||
use std::{
|
||||
@ -25,9 +28,6 @@ use std::{
|
||||
thread,
|
||||
};
|
||||
|
||||
const DEFAULT_HORIZONTAL_MARGIN: u16 = 5;
|
||||
const DEFAULT_VERTICAL_MARGIN: u16 = 7;
|
||||
|
||||
pub struct ThirdPartyConfigs {
|
||||
pub typst_ppi: String,
|
||||
pub mermaid_scale: String,
|
||||
@ -53,12 +53,10 @@ impl ThirdPartyRender {
|
||||
&self,
|
||||
request: ThirdPartyRenderRequest,
|
||||
theme: &PresentationTheme,
|
||||
error_holder: AsyncPresentationErrorHolder,
|
||||
slide: usize,
|
||||
width: Option<Percent>,
|
||||
) -> Result<RenderOperation, ThirdPartyRenderError> {
|
||||
let result = self.render_pool.render(request);
|
||||
let operation = Rc::new(RenderThirdParty::new(result, theme.default_style.colors, error_holder, slide, width));
|
||||
let operation = Rc::new(RenderThirdParty::new(result, theme.default_style.style, width));
|
||||
Ok(RenderOperation::RenderAsync(operation))
|
||||
}
|
||||
}
|
||||
@ -203,9 +201,9 @@ impl Worker {
|
||||
"-s",
|
||||
&self.shared.config.mermaid_scale,
|
||||
"-t",
|
||||
style.theme.as_deref().unwrap_or("default"),
|
||||
&style.theme,
|
||||
"-b",
|
||||
style.background.as_deref().unwrap_or("transparent"),
|
||||
&style.background,
|
||||
])
|
||||
.run()?;
|
||||
|
||||
@ -243,14 +241,19 @@ impl Worker {
|
||||
}
|
||||
|
||||
fn generate_page_header(style: &TypstStyle) -> Result<String, ThirdPartyRenderError> {
|
||||
let x_margin = style.horizontal_margin.unwrap_or(DEFAULT_HORIZONTAL_MARGIN);
|
||||
let y_margin = style.vertical_margin.unwrap_or(DEFAULT_VERTICAL_MARGIN);
|
||||
let background =
|
||||
style.colors.background.as_ref().map(Self::as_typst_color).unwrap_or_else(|| Ok(String::from("none")))?;
|
||||
let x_margin = style.horizontal_margin;
|
||||
let y_margin = style.vertical_margin;
|
||||
let background = style
|
||||
.style
|
||||
.colors
|
||||
.background
|
||||
.as_ref()
|
||||
.map(Self::as_typst_color)
|
||||
.unwrap_or_else(|| Ok(String::from("none")))?;
|
||||
let mut header = format!(
|
||||
"#set page(width: auto, height: auto, margin: (x: {x_margin}pt, y: {y_margin}pt), fill: {background})\n"
|
||||
);
|
||||
if let Some(color) = &style.colors.foreground {
|
||||
if let Some(color) = &style.style.colors.foreground {
|
||||
let color = Self::as_typst_color(color)?;
|
||||
header.push_str(&format!("#set text(fill: {color})\n"));
|
||||
}
|
||||
@ -260,14 +263,14 @@ impl Worker {
|
||||
fn as_typst_color(color: &Color) -> Result<String, ThirdPartyRenderError> {
|
||||
match color.as_rgb() {
|
||||
Some((r, g, b)) => Ok(format!("rgb(\"#{r:02x}{g:02x}{b:02x}\")")),
|
||||
None => Err(ThirdPartyRenderError::UnsupportedColor(color.to_string())),
|
||||
None => Err(ThirdPartyRenderError::UnsupportedColor(RawColor::from(*color).to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
fn load_image(&self, snippet: ImageSnippet, path: &Path) -> Result<Image, ThirdPartyRenderError> {
|
||||
let contents = fs::read(path)?;
|
||||
let image = image::load_from_memory(&contents)?;
|
||||
let image = self.state.lock().unwrap().image_registry.register_image(image)?;
|
||||
let image = self.state.lock().unwrap().image_registry.register(ImageSpec::Generated(image))?;
|
||||
self.state.lock().unwrap().cache.insert(snippet, image.clone());
|
||||
Ok(image)
|
||||
}
|
||||
@ -306,70 +309,45 @@ struct ImageSnippet {
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct RenderThirdParty {
|
||||
contents: Arc<Mutex<Option<Image>>>,
|
||||
contents: Arc<Mutex<Option<Output>>>,
|
||||
pending_result: Arc<Mutex<RenderResult>>,
|
||||
default_colors: Colors,
|
||||
error_holder: AsyncPresentationErrorHolder,
|
||||
slide: usize,
|
||||
default_style: TextStyle,
|
||||
width: Option<Percent>,
|
||||
}
|
||||
|
||||
impl RenderThirdParty {
|
||||
fn new(
|
||||
pending_result: Arc<Mutex<RenderResult>>,
|
||||
default_colors: Colors,
|
||||
error_holder: AsyncPresentationErrorHolder,
|
||||
slide: usize,
|
||||
width: Option<Percent>,
|
||||
) -> Self {
|
||||
Self { contents: Default::default(), pending_result, default_colors, error_holder, slide, width }
|
||||
fn new(pending_result: Arc<Mutex<RenderResult>>, default_style: TextStyle, width: Option<Percent>) -> Self {
|
||||
Self { contents: Default::default(), pending_result, default_style, width }
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderAsync for RenderThirdParty {
|
||||
fn start_render(&self) -> bool {
|
||||
false
|
||||
fn pollable(&self) -> Box<dyn Pollable> {
|
||||
Box::new(OperationPollable { contents: self.contents.clone(), pending_result: self.pending_result.clone() })
|
||||
}
|
||||
|
||||
fn poll_state(&self) -> RenderAsyncState {
|
||||
let mut contents = self.contents.lock().unwrap();
|
||||
if contents.is_some() {
|
||||
return RenderAsyncState::Rendered;
|
||||
}
|
||||
match mem::take(&mut *self.pending_result.lock().unwrap()) {
|
||||
RenderResult::Success(image) => {
|
||||
*contents = Some(image);
|
||||
RenderAsyncState::JustFinishedRendering
|
||||
}
|
||||
RenderResult::Failure(error) => {
|
||||
*self.error_holder.lock().unwrap() = Some(AsyncPresentationError { slide: self.slide, error });
|
||||
RenderAsyncState::JustFinishedRendering
|
||||
}
|
||||
RenderResult::Pending => RenderAsyncState::Rendering { modified: false },
|
||||
}
|
||||
fn start_policy(&self) -> RenderAsyncStartPolicy {
|
||||
RenderAsyncStartPolicy::Automatic
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRenderOperations for RenderThirdParty {
|
||||
fn as_render_operations(&self, _: &WindowSize) -> Vec<RenderOperation> {
|
||||
match &*self.contents.lock().unwrap() {
|
||||
Some(image) => {
|
||||
Some(Output::Image(image)) => {
|
||||
let size = match &self.width {
|
||||
Some(percent) => ImageSize::WidthScaled { ratio: percent.as_ratio() },
|
||||
None => Default::default(),
|
||||
};
|
||||
let properties = ImageRenderProperties {
|
||||
z_index: DEFAULT_IMAGE_Z_INDEX,
|
||||
size,
|
||||
restore_cursor: false,
|
||||
background_color: self.default_colors.background,
|
||||
background_color: self.default_style.colors.background,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
vec![
|
||||
RenderOperation::RenderImage(image.clone(), properties),
|
||||
RenderOperation::SetColors(self.default_colors),
|
||||
]
|
||||
vec![RenderOperation::RenderImage(image.clone(), properties)]
|
||||
}
|
||||
Some(Output::Error) => Vec::new(),
|
||||
None => {
|
||||
let text = Line::from(Text::new("Loading...", TextStyle::default().bold()));
|
||||
vec![RenderOperation::RenderText {
|
||||
@ -380,3 +358,35 @@ impl AsRenderOperations for RenderThirdParty {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum Output {
|
||||
Image(Image),
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct OperationPollable {
|
||||
contents: Arc<Mutex<Option<Output>>>,
|
||||
pending_result: Arc<Mutex<RenderResult>>,
|
||||
}
|
||||
|
||||
impl Pollable for OperationPollable {
|
||||
fn poll(&mut self) -> PollableState {
|
||||
let mut contents = self.contents.lock().unwrap();
|
||||
if contents.is_some() {
|
||||
return PollableState::Done;
|
||||
}
|
||||
match mem::take(&mut *self.pending_result.lock().unwrap()) {
|
||||
RenderResult::Success(image) => {
|
||||
*contents = Some(Output::Image(image));
|
||||
PollableState::Done
|
||||
}
|
||||
RenderResult::Failure(error) => {
|
||||
*contents = Some(Output::Error);
|
||||
PollableState::Failed { error }
|
||||
}
|
||||
RenderResult::Pending => PollableState::Unmodified,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -22,8 +22,8 @@ impl ThirdPartyTools {
|
||||
Tool::new(mmdc, args)
|
||||
}
|
||||
|
||||
pub(crate) fn presenterm_export(args: &[&str]) -> Tool {
|
||||
Tool::new("presenterm-export", args).inherit_stdout().max_error_lines(100)
|
||||
pub(crate) fn weasyprint(args: &[&str]) -> Tool {
|
||||
Tool::new("weasyprint", args).inherit_stdout().max_error_lines(100)
|
||||
}
|
||||
}
|
||||
|
||||
|
65
src/transitions/collapse_horizontal.rs
Normal file
65
src/transitions/collapse_horizontal.rs
Normal file
@ -0,0 +1,65 @@
|
||||
use super::{AnimateTransition, LinesFrame, TransitionDirection};
|
||||
use crate::terminal::virt::TerminalGrid;
|
||||
|
||||
pub(crate) struct CollapseHorizontalAnimation {
|
||||
from: TerminalGrid,
|
||||
to: TerminalGrid,
|
||||
}
|
||||
|
||||
impl CollapseHorizontalAnimation {
|
||||
pub(crate) fn new(left: TerminalGrid, right: TerminalGrid, direction: TransitionDirection) -> Self {
|
||||
let (from, to) = match direction {
|
||||
TransitionDirection::Next => (left, right),
|
||||
TransitionDirection::Previous => (right, left),
|
||||
};
|
||||
Self { from, to }
|
||||
}
|
||||
}
|
||||
|
||||
impl AnimateTransition for CollapseHorizontalAnimation {
|
||||
type Frame = LinesFrame;
|
||||
|
||||
fn build_frame(&self, frame: usize, _previous_frame: usize) -> Self::Frame {
|
||||
let mut rows = Vec::new();
|
||||
for (from, to) in self.from.rows.iter().zip(&self.to.rows) {
|
||||
// Take the first and last `frame` cells
|
||||
let to_prefix = to.iter().take(frame);
|
||||
let to_suffix = to.iter().rev().take(frame).rev();
|
||||
|
||||
let total_rows_from = from.len() - frame * 2;
|
||||
let from = from.iter().skip(frame).take(total_rows_from);
|
||||
let row = to_prefix.chain(from).chain(to_suffix).copied().collect();
|
||||
rows.push(row)
|
||||
}
|
||||
let grid = TerminalGrid { rows, background_color: self.from.background_color, images: Default::default() };
|
||||
LinesFrame::from(&grid)
|
||||
}
|
||||
|
||||
fn total_frames(&self) -> usize {
|
||||
self.from.rows[0].len() / 2
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{markdown::elements::Line, transitions::utils::build_grid};
|
||||
use rstest::rstest;
|
||||
|
||||
fn as_text(line: Line) -> String {
|
||||
line.0.into_iter().map(|l| l.content).collect()
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case(0, &["ABCDEF"])]
|
||||
#[case(1, &["1BCDE6"])]
|
||||
#[case(2, &["12CD56"])]
|
||||
#[case(3, &["123456"])]
|
||||
fn transition(#[case] frame: usize, #[case] expected: &[&str]) {
|
||||
let left = build_grid(&["ABCDEF"]);
|
||||
let right = build_grid(&["123456"]);
|
||||
let transition = CollapseHorizontalAnimation::new(left, right, TransitionDirection::Next);
|
||||
let lines: Vec<_> = transition.build_frame(frame, 0).lines.into_iter().map(as_text).collect();
|
||||
assert_eq!(lines, expected);
|
||||
}
|
||||
}
|
134
src/transitions/fade.rs
Normal file
134
src/transitions/fade.rs
Normal file
@ -0,0 +1,134 @@
|
||||
use super::{AnimateTransition, AnimationFrame, TransitionDirection};
|
||||
use crate::{
|
||||
markdown::text_style::TextStyle,
|
||||
terminal::{
|
||||
printer::TerminalCommand,
|
||||
virt::{StyledChar, TerminalGrid},
|
||||
},
|
||||
};
|
||||
use std::str;
|
||||
|
||||
pub(crate) struct FadeAnimation {
|
||||
changes: Vec<Change>,
|
||||
}
|
||||
|
||||
impl FadeAnimation {
|
||||
pub(crate) fn new(left: TerminalGrid, right: TerminalGrid, direction: TransitionDirection) -> Self {
|
||||
let mut changes = Vec::new();
|
||||
let background = left.background_color;
|
||||
for (row, (left, right)) in left.rows.into_iter().zip(right.rows).enumerate() {
|
||||
for (column, (left, right)) in left.into_iter().zip(right).enumerate() {
|
||||
let character = match &direction {
|
||||
TransitionDirection::Next => right,
|
||||
TransitionDirection::Previous => left,
|
||||
};
|
||||
if left != right {
|
||||
let StyledChar { character, mut style } = character;
|
||||
// If we don't have an explicit background color fall back to the default
|
||||
style.colors.background = style.colors.background.or(background);
|
||||
|
||||
let mut char_buffer = [0; 4];
|
||||
let char_buffer_len = character.encode_utf8(&mut char_buffer).len() as u8;
|
||||
changes.push(Change {
|
||||
row: row as u16,
|
||||
column: column as u16,
|
||||
char_buffer,
|
||||
char_buffer_len,
|
||||
style,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
fastrand::shuffle(&mut changes);
|
||||
Self { changes }
|
||||
}
|
||||
}
|
||||
|
||||
impl AnimateTransition for FadeAnimation {
|
||||
type Frame = FadeCellsFrame;
|
||||
|
||||
fn build_frame(&self, frame: usize, previous_frame: usize) -> Self::Frame {
|
||||
let last_frame = self.changes.len().saturating_sub(1);
|
||||
let previous_frame = previous_frame.min(last_frame);
|
||||
let frame_index = frame.min(self.changes.len());
|
||||
let changes = self.changes[previous_frame..frame_index].to_vec();
|
||||
FadeCellsFrame { changes }
|
||||
}
|
||||
|
||||
fn total_frames(&self) -> usize {
|
||||
self.changes.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct FadeCellsFrame {
|
||||
changes: Vec<Change>,
|
||||
}
|
||||
|
||||
impl AnimationFrame for FadeCellsFrame {
|
||||
fn build_commands(&self) -> Vec<TerminalCommand> {
|
||||
let mut commands = Vec::new();
|
||||
for change in &self.changes {
|
||||
let Change { row, column, char_buffer, char_buffer_len, style } = change;
|
||||
let char_buffer_len = *char_buffer_len as usize;
|
||||
// SAFETY: this is an utf8 encoded char so it must be valid
|
||||
let content = str::from_utf8(&char_buffer[..char_buffer_len]).expect("invalid utf8");
|
||||
commands.push(TerminalCommand::MoveTo { row: *row, column: *column });
|
||||
commands.push(TerminalCommand::PrintText { content, style: *style });
|
||||
}
|
||||
commands
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Change {
|
||||
row: u16,
|
||||
column: u16,
|
||||
char_buffer: [u8; 4],
|
||||
char_buffer_len: u8,
|
||||
style: TextStyle,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
WindowSize,
|
||||
terminal::{printer::TerminalIo, virt::VirtualTerminal},
|
||||
};
|
||||
use rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
#[case::next(TransitionDirection::Next)]
|
||||
#[case::previous(TransitionDirection::Previous)]
|
||||
fn transition(#[case] direction: TransitionDirection) {
|
||||
let left = TerminalGrid {
|
||||
rows: vec![
|
||||
vec!['X'.into(), ' '.into(), 'B'.into()],
|
||||
vec!['C'.into(), StyledChar::new('X', TextStyle::default().size(2)), 'D'.into()],
|
||||
],
|
||||
background_color: None,
|
||||
images: Default::default(),
|
||||
};
|
||||
let right = TerminalGrid {
|
||||
rows: vec![
|
||||
vec![' '.into(), 'A'.into(), StyledChar::new('B', TextStyle::default().bold())],
|
||||
vec![StyledChar::new('C', TextStyle::default().size(2)), ' '.into(), '🚀'.into()],
|
||||
],
|
||||
background_color: None,
|
||||
images: Default::default(),
|
||||
};
|
||||
let expected = match direction {
|
||||
TransitionDirection::Next => right.clone(),
|
||||
TransitionDirection::Previous => left.clone(),
|
||||
};
|
||||
let dimensions = WindowSize { rows: 2, columns: 3, height: 0, width: 0 };
|
||||
let mut virt = VirtualTerminal::new(dimensions, Default::default());
|
||||
let animation = FadeAnimation::new(left, right, direction);
|
||||
for command in animation.build_frame(animation.total_frames(), 0).build_commands() {
|
||||
virt.execute(&command).expect("failed to run")
|
||||
}
|
||||
let output = virt.into_contents();
|
||||
assert_eq!(output, expected);
|
||||
}
|
||||
}
|
160
src/transitions/mod.rs
Normal file
160
src/transitions/mod.rs
Normal file
@ -0,0 +1,160 @@
|
||||
use crate::{
|
||||
markdown::{elements::Line, text_style::Color},
|
||||
terminal::{
|
||||
printer::TerminalCommand,
|
||||
virt::{TerminalGrid, TerminalRowIterator},
|
||||
},
|
||||
};
|
||||
use std::fmt::Debug;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
pub(crate) mod collapse_horizontal;
|
||||
pub(crate) mod fade;
|
||||
pub(crate) mod slide_horizontal;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum TransitionDirection {
|
||||
Next,
|
||||
Previous,
|
||||
}
|
||||
|
||||
pub(crate) trait AnimateTransition {
|
||||
type Frame: AnimationFrame + Debug;
|
||||
|
||||
fn build_frame(&self, frame: usize, previous_frame: usize) -> Self::Frame;
|
||||
fn total_frames(&self) -> usize;
|
||||
}
|
||||
|
||||
pub(crate) trait AnimationFrame {
|
||||
fn build_commands(&self) -> Vec<TerminalCommand>;
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct LinesFrame {
|
||||
pub(crate) lines: Vec<Line>,
|
||||
pub(crate) background_color: Option<Color>,
|
||||
}
|
||||
|
||||
impl LinesFrame {
|
||||
fn skip_whitespace(mut text: &str) -> (&str, usize, usize) {
|
||||
let mut trimmed_before = 0;
|
||||
while let Some(' ') = text.chars().next() {
|
||||
text = &text[1..];
|
||||
trimmed_before += 1;
|
||||
}
|
||||
let mut trimmed_after = 0;
|
||||
let mut rev = text.chars().rev();
|
||||
while let Some(' ') = rev.next() {
|
||||
text = &text[..text.len() - 1];
|
||||
trimmed_after += 1;
|
||||
}
|
||||
(text, trimmed_before, trimmed_after)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&TerminalGrid> for LinesFrame {
|
||||
fn from(grid: &TerminalGrid) -> Self {
|
||||
let mut lines = Vec::new();
|
||||
for row in &grid.rows {
|
||||
let line = TerminalRowIterator::new(row).collect();
|
||||
lines.push(Line(line));
|
||||
}
|
||||
Self { lines, background_color: grid.background_color }
|
||||
}
|
||||
}
|
||||
|
||||
impl AnimationFrame for LinesFrame {
|
||||
fn build_commands(&self) -> Vec<TerminalCommand> {
|
||||
use TerminalCommand::*;
|
||||
let mut commands = vec![];
|
||||
if let Some(color) = self.background_color {
|
||||
commands.push(SetBackgroundColor(color));
|
||||
}
|
||||
commands.push(ClearScreen);
|
||||
for (row, line) in self.lines.iter().enumerate() {
|
||||
let mut column = 0;
|
||||
let mut is_in_column = false;
|
||||
let mut is_in_row = false;
|
||||
for chunk in &line.0 {
|
||||
let (text, white_before, white_after) = match chunk.style.colors.background {
|
||||
Some(_) => (chunk.content.as_str(), 0, 0),
|
||||
None => Self::skip_whitespace(&chunk.content),
|
||||
};
|
||||
// If this is an empty line just skip it
|
||||
if text.is_empty() {
|
||||
column += chunk.content.width();
|
||||
is_in_column = false;
|
||||
continue;
|
||||
}
|
||||
if !is_in_row {
|
||||
commands.push(MoveToRow(row as u16));
|
||||
is_in_row = true;
|
||||
}
|
||||
if white_before > 0 {
|
||||
column += white_before;
|
||||
is_in_column = false;
|
||||
}
|
||||
if !is_in_column {
|
||||
commands.push(MoveToColumn(column as u16));
|
||||
is_in_column = true;
|
||||
}
|
||||
commands.push(PrintText { content: text, style: chunk.style });
|
||||
column += text.width();
|
||||
if white_after > 0 {
|
||||
column += white_after;
|
||||
is_in_column = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
commands
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod utils {
|
||||
use crate::terminal::virt::{StyledChar, TerminalGrid};
|
||||
|
||||
pub(crate) fn build_grid(rows: &[&str]) -> TerminalGrid {
|
||||
let rows = rows
|
||||
.iter()
|
||||
.map(|r| r.chars().map(|c| StyledChar { character: c, style: Default::default() }).collect())
|
||||
.collect();
|
||||
TerminalGrid { rows, background_color: None, images: Default::default() }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::markdown::elements::Text;
|
||||
|
||||
#[test]
|
||||
fn commands() {
|
||||
let animation = LinesFrame {
|
||||
lines: vec![
|
||||
Line(vec![Text::from(" hi "), Text::from("bye"), Text::from("s")]),
|
||||
Line(vec![Text::from("hello"), Text::from(" wor"), Text::from("s")]),
|
||||
],
|
||||
background_color: Some(Color::Red),
|
||||
};
|
||||
let commands = animation.build_commands();
|
||||
use TerminalCommand::*;
|
||||
let expected = &[
|
||||
SetBackgroundColor(Color::Red),
|
||||
ClearScreen,
|
||||
MoveToRow(0),
|
||||
MoveToColumn(2),
|
||||
PrintText { content: "hi", style: Default::default() },
|
||||
MoveToColumn(6),
|
||||
PrintText { content: "bye", style: Default::default() },
|
||||
PrintText { content: "s", style: Default::default() },
|
||||
MoveToRow(1),
|
||||
MoveToColumn(0),
|
||||
PrintText { content: "hello", style: Default::default() },
|
||||
MoveToColumn(6),
|
||||
PrintText { content: "wor", style: Default::default() },
|
||||
PrintText { content: "s", style: Default::default() },
|
||||
];
|
||||
assert_eq!(commands, expected);
|
||||
}
|
||||
}
|
97
src/transitions/slide_horizontal.rs
Normal file
97
src/transitions/slide_horizontal.rs
Normal file
@ -0,0 +1,97 @@
|
||||
use super::{AnimateTransition, LinesFrame, TransitionDirection};
|
||||
use crate::{
|
||||
WindowSize,
|
||||
markdown::elements::Line,
|
||||
terminal::virt::{TerminalGrid, TerminalRowIterator},
|
||||
};
|
||||
|
||||
pub(crate) struct SlideHorizontalAnimation {
|
||||
grid: TerminalGrid,
|
||||
dimensions: WindowSize,
|
||||
direction: TransitionDirection,
|
||||
}
|
||||
|
||||
impl SlideHorizontalAnimation {
|
||||
pub(crate) fn new(
|
||||
left: TerminalGrid,
|
||||
right: TerminalGrid,
|
||||
dimensions: WindowSize,
|
||||
direction: TransitionDirection,
|
||||
) -> Self {
|
||||
let mut rows = Vec::new();
|
||||
for (mut row, right) in left.rows.into_iter().zip(right.rows) {
|
||||
row.extend(right);
|
||||
rows.push(row);
|
||||
}
|
||||
let grid = TerminalGrid { rows, background_color: left.background_color, images: Default::default() };
|
||||
Self { grid, dimensions, direction }
|
||||
}
|
||||
}
|
||||
|
||||
impl AnimateTransition for SlideHorizontalAnimation {
|
||||
type Frame = LinesFrame;
|
||||
|
||||
fn build_frame(&self, frame: usize, _previous_frame: usize) -> Self::Frame {
|
||||
let total = self.total_frames();
|
||||
let frame = frame.min(total);
|
||||
let index = match &self.direction {
|
||||
TransitionDirection::Next => frame,
|
||||
TransitionDirection::Previous => total.saturating_sub(frame),
|
||||
};
|
||||
let mut lines = Vec::new();
|
||||
for row in &self.grid.rows {
|
||||
let row = &row[index..index + self.dimensions.columns as usize];
|
||||
let mut line = Vec::new();
|
||||
let max_width = self.dimensions.columns as usize;
|
||||
let mut width = 0;
|
||||
for mut text in TerminalRowIterator::new(row) {
|
||||
let text_width = text.width() * text.style.size as usize;
|
||||
if width + text_width > max_width {
|
||||
let capped_width = max_width.saturating_sub(width) / text.style.size as usize;
|
||||
if capped_width == 0 {
|
||||
continue;
|
||||
}
|
||||
text.content = text.content.chars().take(capped_width).collect();
|
||||
}
|
||||
width += text_width;
|
||||
line.push(text);
|
||||
}
|
||||
lines.push(Line(line));
|
||||
}
|
||||
LinesFrame { lines, background_color: self.grid.background_color }
|
||||
}
|
||||
|
||||
fn total_frames(&self) -> usize {
|
||||
self.grid.rows[0].len().saturating_sub(self.dimensions.columns as usize)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
|
||||
fn as_text(line: Line) -> String {
|
||||
line.0.into_iter().map(|l| l.content).collect()
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::next_frame0(0, TransitionDirection::Next, &["AB", "CD"])]
|
||||
#[case::next_frame1(1, TransitionDirection::Next, &["BE", "DG"])]
|
||||
#[case::next_frame2(2, TransitionDirection::Next, &["EF", "GH"])]
|
||||
#[case::next_way_past(100, TransitionDirection::Next, &["EF", "GH"])]
|
||||
#[case::previous_frame0(0, TransitionDirection::Previous, &["EF", "GH"])]
|
||||
#[case::previous_frame1(1, TransitionDirection::Previous, &["BE", "DG"])]
|
||||
#[case::previous_frame2(2, TransitionDirection::Previous, &["AB", "CD"])]
|
||||
#[case::previous_way_past(100, TransitionDirection::Previous, &["AB", "CD"])]
|
||||
fn build_frame(#[case] frame: usize, #[case] direction: TransitionDirection, #[case] expected: &[&str]) {
|
||||
use crate::transitions::utils::build_grid;
|
||||
|
||||
let left = build_grid(&["AB", "CD"]);
|
||||
let right = build_grid(&["EF", "GH"]);
|
||||
let dimensions = WindowSize { rows: 2, columns: 2, height: 0, width: 0 };
|
||||
let transition = SlideHorizontalAnimation::new(left, right, dimensions, direction);
|
||||
let lines: Vec<_> = transition.build_frame(frame, 0).lines.into_iter().map(as_text).collect();
|
||||
assert_eq!(lines, expected);
|
||||
}
|
||||
}
|
@ -1,507 +0,0 @@
|
||||
use super::separator::{RenderSeparator, SeparatorWidth};
|
||||
use crate::{
|
||||
code::{
|
||||
execute::{ExecutionHandle, ExecutionState, ProcessStatus, SnippetExecutor},
|
||||
snippet::Snippet,
|
||||
},
|
||||
markdown::{
|
||||
elements::{Line, Text},
|
||||
text::WeightedLine,
|
||||
text_style::{Colors, TextStyle},
|
||||
},
|
||||
render::{
|
||||
operation::{
|
||||
AsRenderOperations, BlockLine, ImageRenderProperties, ImageSize, RenderAsync, RenderAsyncState,
|
||||
RenderOperation,
|
||||
},
|
||||
properties::WindowSize,
|
||||
},
|
||||
terminal::{
|
||||
ansi::AnsiSplitter,
|
||||
image::{Image, printer::ImageRegistry},
|
||||
should_hide_cursor,
|
||||
},
|
||||
theme::{Alignment, ExecutionOutputBlockStyle, ExecutionStatusBlockStyle, Margin},
|
||||
};
|
||||
use crossterm::{
|
||||
ExecutableCommand, cursor,
|
||||
terminal::{self, disable_raw_mode, enable_raw_mode},
|
||||
};
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
io::{self, BufRead},
|
||||
mem,
|
||||
ops::{Deref, DerefMut},
|
||||
rc::Rc,
|
||||
};
|
||||
|
||||
const MINIMUM_SEPARATOR_WIDTH: u16 = 32;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct RunSnippetOperationInner {
|
||||
handle: Option<ExecutionHandle>,
|
||||
output_lines: Vec<WeightedLine>,
|
||||
state: RenderAsyncState,
|
||||
max_line_length: u16,
|
||||
starting_style: TextStyle,
|
||||
last_length: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct RunSnippetOperation {
|
||||
code: Snippet,
|
||||
executor: Rc<SnippetExecutor>,
|
||||
default_colors: Colors,
|
||||
block_colors: Colors,
|
||||
status_colors: ExecutionStatusBlockStyle,
|
||||
block_length: u16,
|
||||
alignment: Alignment,
|
||||
inner: Rc<RefCell<RunSnippetOperationInner>>,
|
||||
state_description: RefCell<Text>,
|
||||
separator: DisplaySeparator,
|
||||
}
|
||||
|
||||
impl RunSnippetOperation {
|
||||
pub(crate) fn new(
|
||||
code: Snippet,
|
||||
executor: Rc<SnippetExecutor>,
|
||||
default_colors: Colors,
|
||||
execution_output_style: ExecutionOutputBlockStyle,
|
||||
block_length: u16,
|
||||
separator: DisplaySeparator,
|
||||
alignment: Alignment,
|
||||
) -> Self {
|
||||
let block_colors = execution_output_style.colors;
|
||||
let status_colors = execution_output_style.status.clone();
|
||||
let not_started_colors = status_colors.not_started;
|
||||
let block_length = match &alignment {
|
||||
Alignment::Left { .. } | Alignment::Right { .. } => block_length,
|
||||
Alignment::Center { minimum_size, .. } => block_length.max(*minimum_size),
|
||||
};
|
||||
let inner = RunSnippetOperationInner {
|
||||
handle: None,
|
||||
output_lines: Vec::new(),
|
||||
state: RenderAsyncState::default(),
|
||||
max_line_length: 0,
|
||||
starting_style: TextStyle::default(),
|
||||
last_length: 0,
|
||||
};
|
||||
Self {
|
||||
code,
|
||||
executor,
|
||||
default_colors,
|
||||
block_colors,
|
||||
status_colors,
|
||||
block_length,
|
||||
alignment,
|
||||
inner: Rc::new(RefCell::new(inner)),
|
||||
state_description: Text::new("not started", TextStyle::default().colors(not_started_colors)).into(),
|
||||
separator,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum DisplaySeparator {
|
||||
On,
|
||||
Off,
|
||||
}
|
||||
|
||||
impl AsRenderOperations for RunSnippetOperation {
|
||||
fn as_render_operations(&self, _dimensions: &WindowSize) -> Vec<RenderOperation> {
|
||||
let inner = self.inner.borrow();
|
||||
let description = self.state_description.borrow();
|
||||
let mut operations = match self.separator {
|
||||
DisplaySeparator::On => {
|
||||
let heading = Line(vec![" [".into(), description.clone(), "] ".into()]);
|
||||
let separator_width = match &self.alignment {
|
||||
Alignment::Left { .. } | Alignment::Right { .. } => SeparatorWidth::FitToWindow,
|
||||
// We need a minimum here otherwise if the code/block length is too narrow, the separator is
|
||||
// word-wrapped and looks bad.
|
||||
Alignment::Center { .. } => SeparatorWidth::Fixed(self.block_length.max(MINIMUM_SEPARATOR_WIDTH)),
|
||||
};
|
||||
let separator = RenderSeparator::new(heading, separator_width);
|
||||
vec![
|
||||
RenderOperation::RenderLineBreak,
|
||||
RenderOperation::RenderDynamic(Rc::new(separator)),
|
||||
RenderOperation::RenderLineBreak,
|
||||
]
|
||||
}
|
||||
DisplaySeparator::Off => vec![],
|
||||
};
|
||||
if matches!(inner.state, RenderAsyncState::NotStarted) {
|
||||
return operations;
|
||||
}
|
||||
operations.push(RenderOperation::RenderLineBreak);
|
||||
|
||||
if self.block_colors.background.is_some() {
|
||||
operations.push(RenderOperation::SetColors(self.block_colors));
|
||||
}
|
||||
|
||||
let has_margin = match &self.alignment {
|
||||
Alignment::Left { margin } => !margin.is_empty(),
|
||||
Alignment::Right { margin } => !margin.is_empty(),
|
||||
Alignment::Center { minimum_margin, minimum_size } => !minimum_margin.is_empty() || minimum_size != &0,
|
||||
};
|
||||
let block_length =
|
||||
if has_margin { self.block_length.max(inner.max_line_length) } else { inner.max_line_length };
|
||||
for line in &inner.output_lines {
|
||||
operations.push(RenderOperation::RenderBlockLine(BlockLine {
|
||||
prefix: "".into(),
|
||||
right_padding_length: 0,
|
||||
repeat_prefix_on_wrap: false,
|
||||
text: line.clone(),
|
||||
block_length,
|
||||
alignment: self.alignment.clone(),
|
||||
block_color: self.block_colors.background,
|
||||
}));
|
||||
operations.push(RenderOperation::RenderLineBreak);
|
||||
}
|
||||
operations.push(RenderOperation::SetColors(self.default_colors));
|
||||
operations
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderAsync for RunSnippetOperation {
|
||||
fn poll_state(&self) -> RenderAsyncState {
|
||||
let mut inner = self.inner.borrow_mut();
|
||||
let last_length = inner.last_length;
|
||||
if let Some(handle) = inner.handle.as_mut() {
|
||||
let mut state = handle.state.lock().unwrap();
|
||||
let ExecutionState { output, status } = &mut *state;
|
||||
*self.state_description.borrow_mut() = match status {
|
||||
ProcessStatus::Running => Text::new("running", TextStyle::default().colors(self.status_colors.running)),
|
||||
ProcessStatus::Success => {
|
||||
Text::new("finished", TextStyle::default().colors(self.status_colors.success))
|
||||
}
|
||||
ProcessStatus::Failure => {
|
||||
Text::new("finished with error", TextStyle::default().colors(self.status_colors.failure))
|
||||
}
|
||||
};
|
||||
let modified = output.len() != last_length;
|
||||
let is_finished = status.is_finished();
|
||||
let mut lines = Vec::new();
|
||||
for line in output.lines() {
|
||||
let mut line = line.expect("invalid utf8");
|
||||
if line.contains('\t') {
|
||||
line = line.replace('\t', " ");
|
||||
}
|
||||
lines.push(line);
|
||||
}
|
||||
drop(state);
|
||||
|
||||
let mut max_line_length = 0;
|
||||
let (lines, style) = AnsiSplitter::new(inner.starting_style).split_lines(&lines);
|
||||
for line in &lines {
|
||||
let width = u16::try_from(line.width()).unwrap_or(u16::MAX);
|
||||
max_line_length = max_line_length.max(width);
|
||||
}
|
||||
inner.starting_style = style;
|
||||
if is_finished {
|
||||
inner.handle.take();
|
||||
inner.state = RenderAsyncState::JustFinishedRendering;
|
||||
} else {
|
||||
inner.state = RenderAsyncState::Rendering { modified };
|
||||
}
|
||||
inner.output_lines = lines;
|
||||
inner.max_line_length = inner.max_line_length.max(max_line_length);
|
||||
}
|
||||
inner.state.clone()
|
||||
}
|
||||
|
||||
fn start_render(&self) -> bool {
|
||||
let mut inner = self.inner.borrow_mut();
|
||||
if !matches!(inner.state, RenderAsyncState::NotStarted) {
|
||||
return false;
|
||||
}
|
||||
match self.executor.execute_async(&self.code) {
|
||||
Ok(handle) => {
|
||||
inner.handle = Some(handle);
|
||||
inner.state = RenderAsyncState::Rendering { modified: false };
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
inner.output_lines = vec![WeightedLine::from(e.to_string())];
|
||||
inner.state = RenderAsyncState::Rendered;
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct SnippetExecutionDisabledOperation {
|
||||
colors: Colors,
|
||||
alignment: Alignment,
|
||||
started: RefCell<bool>,
|
||||
}
|
||||
|
||||
impl SnippetExecutionDisabledOperation {
|
||||
pub(crate) fn new(colors: Colors, alignment: Alignment) -> Self {
|
||||
Self { colors, alignment, started: Default::default() }
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRenderOperations for SnippetExecutionDisabledOperation {
|
||||
fn as_render_operations(&self, _: &WindowSize) -> Vec<RenderOperation> {
|
||||
if !*self.started.borrow() {
|
||||
return Vec::new();
|
||||
}
|
||||
vec![
|
||||
RenderOperation::RenderLineBreak,
|
||||
RenderOperation::RenderText {
|
||||
line: vec![Text::new("snippet execution is disabled", TextStyle::default().colors(self.colors))].into(),
|
||||
alignment: self.alignment.clone(),
|
||||
},
|
||||
RenderOperation::RenderLineBreak,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderAsync for SnippetExecutionDisabledOperation {
|
||||
fn start_render(&self) -> bool {
|
||||
let was_started = mem::replace(&mut *self.started.borrow_mut(), true);
|
||||
!was_started
|
||||
}
|
||||
|
||||
fn poll_state(&self) -> RenderAsyncState {
|
||||
RenderAsyncState::Rendered
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
enum AcquireTerminalSnippetState {
|
||||
#[default]
|
||||
NotStarted,
|
||||
Success,
|
||||
Failure(Vec<String>),
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for AcquireTerminalSnippetState {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::NotStarted => write!(f, "NotStarted"),
|
||||
Self::Success => write!(f, "Success"),
|
||||
Self::Failure(_) => write!(f, "Failure"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct RunAcquireTerminalSnippet {
|
||||
snippet: Snippet,
|
||||
block_length: u16,
|
||||
executor: Rc<SnippetExecutor>,
|
||||
colors: ExecutionStatusBlockStyle,
|
||||
state: RefCell<AcquireTerminalSnippetState>,
|
||||
}
|
||||
|
||||
impl RunAcquireTerminalSnippet {
|
||||
pub(crate) fn new(
|
||||
snippet: Snippet,
|
||||
executor: Rc<SnippetExecutor>,
|
||||
colors: ExecutionStatusBlockStyle,
|
||||
block_length: u16,
|
||||
) -> Self {
|
||||
Self { snippet, block_length, executor, colors, state: Default::default() }
|
||||
}
|
||||
}
|
||||
|
||||
impl RunAcquireTerminalSnippet {
|
||||
fn invoke(&self) -> Result<(), String> {
|
||||
let mut stdout = io::stdout();
|
||||
stdout
|
||||
.execute(terminal::LeaveAlternateScreen)
|
||||
.and_then(|_| disable_raw_mode())
|
||||
.map_err(|e| format!("failed to deinit terminal: {e}"))?;
|
||||
|
||||
// save result for later, but first reinit the terminal
|
||||
let result = self.executor.execute_sync(&self.snippet).map_err(|e| format!("failed to run snippet: {e}"));
|
||||
|
||||
stdout
|
||||
.execute(terminal::EnterAlternateScreen)
|
||||
.and_then(|_| enable_raw_mode())
|
||||
.map_err(|e| format!("failed to reinit terminal: {e}"))?;
|
||||
if should_hide_cursor() {
|
||||
stdout.execute(cursor::Hide).map_err(|e| e.to_string())?;
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRenderOperations for RunAcquireTerminalSnippet {
|
||||
fn as_render_operations(&self, _dimensions: &WindowSize) -> Vec<RenderOperation> {
|
||||
let state = self.state.borrow();
|
||||
let separator_text = match state.deref() {
|
||||
AcquireTerminalSnippetState::NotStarted => {
|
||||
Text::new("not started", TextStyle::colored(self.colors.not_started))
|
||||
}
|
||||
AcquireTerminalSnippetState::Success => Text::new("finished", TextStyle::colored(self.colors.success)),
|
||||
AcquireTerminalSnippetState::Failure(_) => {
|
||||
Text::new("finished with error", TextStyle::colored(self.colors.failure))
|
||||
}
|
||||
};
|
||||
|
||||
let heading = Line(vec![" [".into(), separator_text, "] ".into()]);
|
||||
let separator_width = SeparatorWidth::Fixed(self.block_length.max(MINIMUM_SEPARATOR_WIDTH));
|
||||
let separator = RenderSeparator::new(heading, separator_width);
|
||||
let mut ops = vec![
|
||||
RenderOperation::RenderLineBreak,
|
||||
RenderOperation::RenderDynamic(Rc::new(separator)),
|
||||
RenderOperation::RenderLineBreak,
|
||||
];
|
||||
if let AcquireTerminalSnippetState::Failure(lines) = state.deref() {
|
||||
ops.push(RenderOperation::RenderLineBreak);
|
||||
for line in lines {
|
||||
ops.extend([
|
||||
RenderOperation::RenderText {
|
||||
line: vec![Text::new(line, TextStyle::default().colors(self.colors.failure))].into(),
|
||||
alignment: Alignment::Left { margin: Margin::Percent(25) },
|
||||
},
|
||||
RenderOperation::RenderLineBreak,
|
||||
]);
|
||||
}
|
||||
}
|
||||
ops
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderAsync for RunAcquireTerminalSnippet {
|
||||
fn start_render(&self) -> bool {
|
||||
if !matches!(*self.state.borrow(), AcquireTerminalSnippetState::NotStarted) {
|
||||
return false;
|
||||
}
|
||||
if let Err(e) = self.invoke() {
|
||||
let lines = e.lines().map(ToString::to_string).collect();
|
||||
*self.state.borrow_mut() = AcquireTerminalSnippetState::Failure(lines);
|
||||
} else {
|
||||
*self.state.borrow_mut() = AcquireTerminalSnippetState::Success;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn poll_state(&self) -> RenderAsyncState {
|
||||
RenderAsyncState::Rendered
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct RunImageSnippet {
|
||||
snippet: Snippet,
|
||||
executor: Rc<SnippetExecutor>,
|
||||
state: RefCell<RunImageSnippetState>,
|
||||
image_registry: ImageRegistry,
|
||||
colors: ExecutionStatusBlockStyle,
|
||||
}
|
||||
|
||||
impl RunImageSnippet {
|
||||
pub(crate) fn new(
|
||||
snippet: Snippet,
|
||||
executor: Rc<SnippetExecutor>,
|
||||
image_registry: ImageRegistry,
|
||||
colors: ExecutionStatusBlockStyle,
|
||||
) -> Self {
|
||||
Self { snippet, executor, image_registry, colors, state: Default::default() }
|
||||
}
|
||||
|
||||
fn load_image(&self, data: &[u8]) -> Result<Image, String> {
|
||||
let image = match image::load_from_memory(data) {
|
||||
Ok(image) => image,
|
||||
Err(e) => {
|
||||
return Err(e.to_string());
|
||||
}
|
||||
};
|
||||
self.image_registry.register_image(image).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderAsync for RunImageSnippet {
|
||||
fn start_render(&self) -> bool {
|
||||
if !matches!(*self.state.borrow(), RunImageSnippetState::NotStarted) {
|
||||
return false;
|
||||
}
|
||||
let state = match self.executor.execute_async(&self.snippet) {
|
||||
Ok(handle) => RunImageSnippetState::Running(handle),
|
||||
Err(e) => RunImageSnippetState::Failure(e.to_string().lines().map(ToString::to_string).collect()),
|
||||
};
|
||||
*self.state.borrow_mut() = state;
|
||||
true
|
||||
}
|
||||
|
||||
fn poll_state(&self) -> RenderAsyncState {
|
||||
let mut state = self.state.borrow_mut();
|
||||
match state.deref_mut() {
|
||||
RunImageSnippetState::NotStarted => RenderAsyncState::NotStarted,
|
||||
RunImageSnippetState::Running(handle) => {
|
||||
let mut inner = handle.state.lock().unwrap();
|
||||
match inner.status {
|
||||
ProcessStatus::Running => RenderAsyncState::Rendering { modified: false },
|
||||
ProcessStatus::Success => {
|
||||
let data = mem::take(&mut inner.output);
|
||||
drop(inner);
|
||||
|
||||
let image = match self.load_image(&data) {
|
||||
Ok(image) => image,
|
||||
Err(e) => {
|
||||
*state = RunImageSnippetState::Failure(vec![e.to_string()]);
|
||||
return RenderAsyncState::JustFinishedRendering;
|
||||
}
|
||||
};
|
||||
*state = RunImageSnippetState::Success(image);
|
||||
RenderAsyncState::JustFinishedRendering
|
||||
}
|
||||
ProcessStatus::Failure => {
|
||||
let mut lines = Vec::new();
|
||||
for line in inner.output.lines() {
|
||||
lines.push(line.unwrap_or_else(|_| String::new()));
|
||||
}
|
||||
drop(inner);
|
||||
|
||||
*state = RunImageSnippetState::Failure(lines);
|
||||
RenderAsyncState::JustFinishedRendering
|
||||
}
|
||||
}
|
||||
}
|
||||
RunImageSnippetState::Success(_) | RunImageSnippetState::Failure(_) => RenderAsyncState::Rendered,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRenderOperations for RunImageSnippet {
|
||||
fn as_render_operations(&self, _dimensions: &WindowSize) -> Vec<RenderOperation> {
|
||||
let state = self.state.borrow();
|
||||
match state.deref() {
|
||||
RunImageSnippetState::NotStarted | RunImageSnippetState::Running(_) => vec![],
|
||||
RunImageSnippetState::Success(image) => {
|
||||
vec![RenderOperation::RenderImage(
|
||||
image.clone(),
|
||||
ImageRenderProperties {
|
||||
z_index: 0,
|
||||
size: ImageSize::ShrinkIfNeeded,
|
||||
restore_cursor: false,
|
||||
background_color: None,
|
||||
},
|
||||
)]
|
||||
}
|
||||
RunImageSnippetState::Failure(lines) => {
|
||||
let mut output = Vec::new();
|
||||
for line in lines {
|
||||
output.extend([RenderOperation::RenderText {
|
||||
line: vec![Text::new(line, TextStyle::default().colors(self.colors.failure))].into(),
|
||||
alignment: Alignment::Left { margin: Margin::Percent(25) },
|
||||
}]);
|
||||
}
|
||||
output
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
enum RunImageSnippetState {
|
||||
#[default]
|
||||
NotStarted,
|
||||
Running(ExecutionHandle),
|
||||
Success(Image),
|
||||
Failure(Vec<String>),
|
||||
}
|
141
src/ui/execution/acquire_terminal.rs
Normal file
141
src/ui/execution/acquire_terminal.rs
Normal file
@ -0,0 +1,141 @@
|
||||
use crate::{
|
||||
code::{execute::SnippetExecutor, snippet::Snippet},
|
||||
markdown::elements::{Line, Text},
|
||||
render::{
|
||||
operation::{AsRenderOperations, Pollable, PollableState, RenderAsync, RenderOperation},
|
||||
properties::WindowSize,
|
||||
},
|
||||
terminal::should_hide_cursor,
|
||||
theme::{Alignment, ExecutionStatusBlockStyle, Margin},
|
||||
ui::separator::{RenderSeparator, SeparatorWidth},
|
||||
};
|
||||
use crossterm::{
|
||||
ExecutableCommand, cursor,
|
||||
terminal::{self, disable_raw_mode, enable_raw_mode},
|
||||
};
|
||||
use std::{
|
||||
io::{self},
|
||||
ops::Deref,
|
||||
rc::Rc,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
const MINIMUM_SEPARATOR_WIDTH: u16 = 32;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct RunAcquireTerminalSnippet {
|
||||
snippet: Snippet,
|
||||
block_length: u16,
|
||||
executor: Arc<SnippetExecutor>,
|
||||
colors: ExecutionStatusBlockStyle,
|
||||
state: Arc<Mutex<State>>,
|
||||
font_size: u8,
|
||||
}
|
||||
|
||||
impl RunAcquireTerminalSnippet {
|
||||
pub(crate) fn new(
|
||||
snippet: Snippet,
|
||||
executor: Arc<SnippetExecutor>,
|
||||
colors: ExecutionStatusBlockStyle,
|
||||
block_length: u16,
|
||||
font_size: u8,
|
||||
) -> Self {
|
||||
Self { snippet, block_length, executor, colors, state: Default::default(), font_size }
|
||||
}
|
||||
|
||||
fn invoke(&self) -> Result<(), String> {
|
||||
let mut stdout = io::stdout();
|
||||
stdout
|
||||
.execute(terminal::LeaveAlternateScreen)
|
||||
.and_then(|_| disable_raw_mode())
|
||||
.map_err(|e| format!("failed to deinit terminal: {e}"))?;
|
||||
|
||||
// save result for later, but first reinit the terminal
|
||||
let result = self.executor.execute_sync(&self.snippet).map_err(|e| format!("failed to run snippet: {e}"));
|
||||
|
||||
stdout
|
||||
.execute(terminal::EnterAlternateScreen)
|
||||
.and_then(|_| enable_raw_mode())
|
||||
.map_err(|e| format!("failed to reinit terminal: {e}"))?;
|
||||
if should_hide_cursor() {
|
||||
stdout.execute(cursor::Hide).map_err(|e| e.to_string())?;
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRenderOperations for RunAcquireTerminalSnippet {
|
||||
fn as_render_operations(&self, _dimensions: &WindowSize) -> Vec<RenderOperation> {
|
||||
let state = self.state.lock().unwrap();
|
||||
let separator_text = match state.deref() {
|
||||
State::NotStarted => Text::new("not started", self.colors.not_started_style),
|
||||
State::Success => Text::new("finished", self.colors.success_style),
|
||||
State::Failure(_) => Text::new("finished with error", self.colors.failure_style),
|
||||
};
|
||||
|
||||
let heading = Line(vec![" [".into(), separator_text, "] ".into()]);
|
||||
let separator_width = SeparatorWidth::Fixed(self.block_length.max(MINIMUM_SEPARATOR_WIDTH));
|
||||
let separator = RenderSeparator::new(heading, separator_width, self.font_size);
|
||||
let mut ops = vec![
|
||||
RenderOperation::RenderLineBreak,
|
||||
RenderOperation::RenderDynamic(Rc::new(separator)),
|
||||
RenderOperation::RenderLineBreak,
|
||||
];
|
||||
if let State::Failure(lines) = state.deref() {
|
||||
ops.push(RenderOperation::RenderLineBreak);
|
||||
for line in lines {
|
||||
ops.extend([
|
||||
RenderOperation::RenderText {
|
||||
line: vec![Text::new(line, self.colors.failure_style)].into(),
|
||||
alignment: Alignment::Left { margin: Margin::Percent(25) },
|
||||
},
|
||||
RenderOperation::RenderLineBreak,
|
||||
]);
|
||||
}
|
||||
}
|
||||
ops
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderAsync for RunAcquireTerminalSnippet {
|
||||
fn pollable(&self) -> Box<dyn Pollable> {
|
||||
// Run within this method because we need to release/acquire the raw terminal in the main
|
||||
// thread.
|
||||
let mut state = self.state.lock().unwrap();
|
||||
if matches!(*state, State::NotStarted) {
|
||||
if let Err(e) = self.invoke() {
|
||||
let lines = e.lines().map(ToString::to_string).collect();
|
||||
*state = State::Failure(lines);
|
||||
} else {
|
||||
*state = State::Success;
|
||||
}
|
||||
}
|
||||
Box::new(OperationPollable)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
enum State {
|
||||
#[default]
|
||||
NotStarted,
|
||||
Success,
|
||||
Failure(Vec<String>),
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for State {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::NotStarted => write!(f, "NotStarted"),
|
||||
Self::Success => write!(f, "Success"),
|
||||
Self::Failure(_) => write!(f, "Failure"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct OperationPollable;
|
||||
|
||||
impl Pollable for OperationPollable {
|
||||
fn poll(&mut self) -> PollableState {
|
||||
PollableState::Done
|
||||
}
|
||||
}
|
64
src/ui/execution/disabled.rs
Normal file
64
src/ui/execution/disabled.rs
Normal file
@ -0,0 +1,64 @@
|
||||
use crate::{
|
||||
markdown::{elements::Text, text_style::TextStyle},
|
||||
render::{
|
||||
operation::{AsRenderOperations, Pollable, RenderAsync, RenderAsyncStartPolicy, RenderOperation, ToggleState},
|
||||
properties::WindowSize,
|
||||
},
|
||||
theme::Alignment,
|
||||
};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct SnippetExecutionDisabledOperation {
|
||||
text: Text,
|
||||
alignment: Alignment,
|
||||
policy: RenderAsyncStartPolicy,
|
||||
toggled: Arc<Mutex<bool>>,
|
||||
}
|
||||
|
||||
impl SnippetExecutionDisabledOperation {
|
||||
pub(crate) fn new(
|
||||
style: TextStyle,
|
||||
alignment: Alignment,
|
||||
policy: RenderAsyncStartPolicy,
|
||||
exec_type: ExecutionType,
|
||||
) -> Self {
|
||||
let (attribute, cli_parameter) = match exec_type {
|
||||
ExecutionType::Execute => ("+exec", "-x"),
|
||||
ExecutionType::ExecReplace => ("+exec_replace", "-X"),
|
||||
ExecutionType::Image => ("+image", "-X"),
|
||||
};
|
||||
let text = Text::new(format!("snippet {attribute} is disabled, run with {cli_parameter} to enable"), style);
|
||||
Self { text, alignment, policy, toggled: Default::default() }
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRenderOperations for SnippetExecutionDisabledOperation {
|
||||
fn as_render_operations(&self, _: &WindowSize) -> Vec<RenderOperation> {
|
||||
if !*self.toggled.lock().unwrap() {
|
||||
return Vec::new();
|
||||
}
|
||||
vec![
|
||||
RenderOperation::RenderLineBreak,
|
||||
RenderOperation::RenderText { line: vec![self.text.clone()].into(), alignment: self.alignment },
|
||||
RenderOperation::RenderLineBreak,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderAsync for SnippetExecutionDisabledOperation {
|
||||
fn pollable(&self) -> Box<dyn Pollable> {
|
||||
Box::new(ToggleState::new(self.toggled.clone()))
|
||||
}
|
||||
|
||||
fn start_policy(&self) -> RenderAsyncStartPolicy {
|
||||
self.policy
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum ExecutionType {
|
||||
Execute,
|
||||
ExecReplace,
|
||||
Image,
|
||||
}
|
159
src/ui/execution/image.rs
Normal file
159
src/ui/execution/image.rs
Normal file
@ -0,0 +1,159 @@
|
||||
use crate::{
|
||||
code::{
|
||||
execute::{ExecutionHandle, ProcessStatus, SnippetExecutor},
|
||||
snippet::Snippet,
|
||||
},
|
||||
markdown::elements::Text,
|
||||
render::{
|
||||
operation::{
|
||||
AsRenderOperations, ImageRenderProperties, Pollable, PollableState, RenderAsync, RenderAsyncStartPolicy,
|
||||
RenderOperation,
|
||||
},
|
||||
properties::WindowSize,
|
||||
},
|
||||
terminal::image::{
|
||||
Image,
|
||||
printer::{ImageRegistry, ImageSpec},
|
||||
},
|
||||
theme::{Alignment, ExecutionStatusBlockStyle, Margin},
|
||||
};
|
||||
use std::{
|
||||
io::BufRead,
|
||||
mem,
|
||||
ops::Deref,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct RunImageSnippet {
|
||||
snippet: Snippet,
|
||||
executor: Arc<SnippetExecutor>,
|
||||
state: Arc<Mutex<State>>,
|
||||
image_registry: ImageRegistry,
|
||||
colors: ExecutionStatusBlockStyle,
|
||||
}
|
||||
|
||||
impl RunImageSnippet {
|
||||
pub(crate) fn new(
|
||||
snippet: Snippet,
|
||||
executor: Arc<SnippetExecutor>,
|
||||
image_registry: ImageRegistry,
|
||||
colors: ExecutionStatusBlockStyle,
|
||||
) -> Self {
|
||||
Self { snippet, executor, image_registry, colors, state: Default::default() }
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderAsync for RunImageSnippet {
|
||||
fn pollable(&self) -> Box<dyn Pollable> {
|
||||
Box::new(OperationPollable {
|
||||
state: self.state.clone(),
|
||||
executor: self.executor.clone(),
|
||||
snippet: self.snippet.clone(),
|
||||
image_registry: self.image_registry.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn start_policy(&self) -> RenderAsyncStartPolicy {
|
||||
RenderAsyncStartPolicy::Automatic
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRenderOperations for RunImageSnippet {
|
||||
fn as_render_operations(&self, _dimensions: &WindowSize) -> Vec<RenderOperation> {
|
||||
let state = self.state.lock().unwrap();
|
||||
match state.deref() {
|
||||
State::NotStarted | State::Running(_) => vec![],
|
||||
State::Success(image) => {
|
||||
vec![RenderOperation::RenderImage(image.clone(), ImageRenderProperties::default())]
|
||||
}
|
||||
State::Failure(lines) => {
|
||||
let mut output = Vec::new();
|
||||
for line in lines {
|
||||
output.extend([RenderOperation::RenderText {
|
||||
line: vec![Text::new(line, self.colors.failure_style)].into(),
|
||||
alignment: Alignment::Left { margin: Margin::Percent(25) },
|
||||
}]);
|
||||
}
|
||||
output
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct OperationPollable {
|
||||
state: Arc<Mutex<State>>,
|
||||
executor: Arc<SnippetExecutor>,
|
||||
snippet: Snippet,
|
||||
image_registry: ImageRegistry,
|
||||
}
|
||||
|
||||
impl OperationPollable {
|
||||
fn load_image(&self, data: &[u8]) -> Result<Image, String> {
|
||||
let image = match image::load_from_memory(data) {
|
||||
Ok(image) => image,
|
||||
Err(e) => {
|
||||
return Err(e.to_string());
|
||||
}
|
||||
};
|
||||
self.image_registry.register(ImageSpec::Generated(image)).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl Pollable for OperationPollable {
|
||||
fn poll(&mut self) -> PollableState {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
match state.deref() {
|
||||
State::NotStarted => match self.executor.execute_async(&self.snippet) {
|
||||
Ok(handle) => {
|
||||
*state = State::Running(handle);
|
||||
PollableState::Unmodified
|
||||
}
|
||||
Err(e) => {
|
||||
*state = State::Failure(e.to_string().lines().map(ToString::to_string).collect());
|
||||
PollableState::Done
|
||||
}
|
||||
},
|
||||
State::Running(handle) => {
|
||||
let mut inner = handle.state.lock().unwrap();
|
||||
match inner.status {
|
||||
ProcessStatus::Running => PollableState::Unmodified,
|
||||
ProcessStatus::Success => {
|
||||
let data = mem::take(&mut inner.output);
|
||||
drop(inner);
|
||||
|
||||
match self.load_image(&data) {
|
||||
Ok(image) => {
|
||||
*state = State::Success(image);
|
||||
}
|
||||
Err(e) => {
|
||||
*state = State::Failure(vec![e.to_string()]);
|
||||
}
|
||||
};
|
||||
PollableState::Done
|
||||
}
|
||||
ProcessStatus::Failure => {
|
||||
let mut lines = Vec::new();
|
||||
for line in inner.output.lines() {
|
||||
lines.push(line.unwrap_or_else(|_| String::new()));
|
||||
}
|
||||
drop(inner);
|
||||
|
||||
*state = State::Failure(lines);
|
||||
PollableState::Done
|
||||
}
|
||||
}
|
||||
}
|
||||
State::Success(_) | State::Failure(_) => PollableState::Done,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
enum State {
|
||||
#[default]
|
||||
NotStarted,
|
||||
Running(ExecutionHandle),
|
||||
Success(Image),
|
||||
Failure(Vec<String>),
|
||||
}
|
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