mirror of
https://github.com/mfontanini/presenterm.git
synced 2025-05-05 15:32:58 +00:00
Compare commits
139 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 |
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 }}`
|
95
CHANGELOG.md
95
CHANGELOG.md
@ -1,3 +1,96 @@
|
||||
# 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
|
||||
@ -7,7 +100,7 @@
|
||||
## 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-sizes), 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)).
|
||||
* [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)).
|
||||
|
257
Cargo.lock
generated
257
Cargo.lock
generated
@ -17,31 +17,6 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "android-tzdata"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ansi-parser"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c43e7fd8284f025d0bd143c2855618ecdf697db55bde39211e5c9faec7669173"
|
||||
dependencies = [
|
||||
"heapless",
|
||||
"nom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "0.6.18"
|
||||
@ -98,6 +73,12 @@ version = "1.0.96"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b964d184e89d9b6b67dd2715bc8e74cf3107fb2b529990c90cf517326150bf4"
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.4.0"
|
||||
@ -146,12 +127,6 @@ version = "1.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef657dfab802224e671f5818e9a4935f9b1957ed18e58292690cc39e7a4092a3"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder-lite"
|
||||
version = "0.1.0"
|
||||
@ -182,19 +157,6 @@ version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.40"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c"
|
||||
dependencies = [
|
||||
"android-tzdata",
|
||||
"iana-time-zone",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.5.31"
|
||||
@ -261,12 +223,6 @@ dependencies = [
|
||||
"unicode_categories",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.4.2"
|
||||
@ -302,41 +258,6 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.20.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.20.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.20.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.3.11"
|
||||
@ -344,7 +265,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4"
|
||||
dependencies = [
|
||||
"powerfmt",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -466,37 +386,12 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2"
|
||||
|
||||
[[package]]
|
||||
name = "hash32"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289"
|
||||
|
||||
[[package]]
|
||||
name = "heapless"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad"
|
||||
dependencies = [
|
||||
"hash32",
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
@ -509,35 +404,6 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.61"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"core-foundation-sys",
|
||||
"iana-time-zone-haiku",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone-haiku"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ident_case"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.5"
|
||||
@ -554,17 +420,6 @@ dependencies = [
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "1.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"hashbrown 0.12.3",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.7.1"
|
||||
@ -572,8 +427,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c9c992b02b5b4c94ea26e32fe5bccb7aa7d9f390ab5c1221ff895bc7ea8b652"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.15.2",
|
||||
"serde",
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -597,16 +451,6 @@ version = "1.0.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.77"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.170"
|
||||
@ -667,12 +511,6 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.5"
|
||||
@ -695,16 +533,6 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.1.0"
|
||||
@ -800,7 +628,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42cf17e9a1800f5f396bc67d193dc9411b59012a5876445ef450d449881e1016"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"indexmap 2.7.1",
|
||||
"indexmap",
|
||||
"quick-xml",
|
||||
"serde",
|
||||
"time",
|
||||
@ -827,9 +655,8 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "presenterm"
|
||||
version = "0.11.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"ansi-parser",
|
||||
"anyhow",
|
||||
"base64",
|
||||
"bincode",
|
||||
@ -848,10 +675,8 @@ dependencies = [
|
||||
"os_pipe",
|
||||
"rstest",
|
||||
"schemars",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_with",
|
||||
"serde_yaml",
|
||||
"sixel-rs",
|
||||
"socket2",
|
||||
@ -861,6 +686,7 @@ dependencies = [
|
||||
"thiserror 2.0.11",
|
||||
"tl",
|
||||
"unicode-width",
|
||||
"vte",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1094,43 +920,13 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_with"
|
||||
version = "3.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6b6f7f2fcb69f747921f79f3926bd1e203fce4fef62c268dd3abfb6d86029aa"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"chrono",
|
||||
"hex",
|
||||
"indexmap 1.9.3",
|
||||
"indexmap 2.7.1",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"serde_with_macros",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_with_macros"
|
||||
version = "3.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8d00caa5193a3c8362ac2b73be6b9e768aa5a4b2f721d8f4b339600c3cb51f8e"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_yaml"
|
||||
version = "0.9.34+deprecated"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
||||
dependencies = [
|
||||
"indexmap 2.7.1",
|
||||
"indexmap",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
@ -1223,12 +1019,6 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
@ -1439,6 +1229,16 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "vte"
|
||||
version = "0.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
@ -1550,21 +1350,6 @@ version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dccfd733ce2b1753b03b6d3c65edf020262ea35e20ccdf3e288043e6dd620e3"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
|
@ -4,12 +4,11 @@ authors = ["Matias Fontanini"]
|
||||
description = "A terminal slideshow presentation tool"
|
||||
repository = "https://github.com/mfontanini/presenterm"
|
||||
license = "BSD-2-Clause"
|
||||
version = "0.11.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"] }
|
||||
@ -24,12 +23,10 @@ sixel-rs = { version = "0.4.1", optional = true }
|
||||
merge-struct = "0.1.0"
|
||||
itertools = "0.14"
|
||||
once_cell = "1.19"
|
||||
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.27", features = ["derive"] }
|
||||
@ -39,6 +36,7 @@ thiserror = "2"
|
||||
unicode-width = "0.2"
|
||||
os_pipe = "1.1.5"
|
||||
libc = "0.2"
|
||||
vte = "0.15"
|
||||
|
||||
[dev-dependencies]
|
||||
rstest = { version = "0.25", default-features = false }
|
||||
@ -46,6 +44,7 @@ rstest = { version = "0.25", default-features = false }
|
||||
[features]
|
||||
default = []
|
||||
sixel = ["sixel-rs"]
|
||||
json-schema = ["dep:schemars"]
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 0
|
||||
|
20
README.md
20
README.md
@ -32,7 +32,7 @@ Visit the [documentation][docs-introduction] to get started.
|
||||
|
||||
* Define your presentation in a single markdown file.
|
||||
* [Images and animated gifs][docs-images] on terminals like _kitty_, _iterm2_, and _wezterm_.
|
||||
* [Customizeable themes][docs-themes] including colors, margins, layout (left/center aligned content), footer for every
|
||||
* [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][docs-code-highlight].
|
||||
@ -45,6 +45,7 @@ Visit the [documentation][docs-introduction] to get started.
|
||||
* [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.
|
||||
@ -52,6 +53,16 @@ Visit the [documentation][docs-introduction] to get started.
|
||||
|
||||
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 -->
|
||||
|
||||
[docs-introduction]: https://mfontanini.github.io/presenterm/
|
||||
@ -66,6 +77,7 @@ See the [introduction page][docs-introduction] to learn more.
|
||||
[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
|
||||
@ -75,5 +87,7 @@ See the [introduction page][docs-introduction] to learn more.
|
||||
[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,
|
||||
@ -58,6 +79,21 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"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,
|
||||
@ -83,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": [
|
||||
{
|
||||
@ -129,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"
|
||||
},
|
||||
@ -301,6 +409,32 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"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": {
|
||||
@ -369,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": {
|
||||
@ -466,6 +702,7 @@
|
||||
"Java",
|
||||
"JavaScript",
|
||||
"Json",
|
||||
"Julia",
|
||||
"Kotlin",
|
||||
"Latex",
|
||||
"Lua",
|
||||
|
@ -16,6 +16,7 @@
|
||||
- [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)
|
||||
|
@ -10,7 +10,7 @@ custom themes, in the following directories:
|
||||
|
||||
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.
|
||||
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.
|
||||
|
@ -61,13 +61,59 @@ defaults:
|
||||
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
|
||||
@ -81,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"]
|
||||
|
||||
@ -117,6 +173,8 @@ default won't apply anymore and only what you've defined will be used.
|
||||
|
||||
# Snippet configurations
|
||||
|
||||
The configurations that affect code snippets in presentations.
|
||||
|
||||
## Snippet execution
|
||||
|
||||
[Snippet execution](../features/code/execution.md#executing-code-blocks) is disabled by default for security reasons.
|
||||
@ -216,7 +274,32 @@ 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
|
||||
```
|
||||
|
@ -30,6 +30,7 @@ Code highlighting is supported for the following languages:
|
||||
| java | ✓ |
|
||||
| javascript | ✓ |
|
||||
| json | |
|
||||
| julia | ✓ |
|
||||
| kotlin | ✓ |
|
||||
| latex | |
|
||||
| lua | ✓ |
|
||||
@ -140,6 +141,18 @@ 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
|
||||
|
@ -94,3 +94,30 @@ If you don't want the footer to show up in some particular slide for some reason
|
||||
<!-- 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
|
||||
```
|
||||
|
||||
|
@ -93,7 +93,7 @@ Because we just reset the layout, this text is now below both of the columns.
|
||||
|
||||
This would render the following way:
|
||||
|
||||

|
||||

|
||||
|
||||
## Other uses
|
||||
|
||||
|
@ -1,45 +1,35 @@
|
||||
# Exporting presentations in PDF format
|
||||
|
||||
Presentations can be converted into PDF by using a [helper tool](https://github.com/mfontanini/presenterm-export). You
|
||||
can install it by running:
|
||||
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.
|
||||
|
||||
```bash
|
||||
pip install presenterm-export
|
||||
```
|
||||
> [!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.
|
||||
|
||||
> [!important]
|
||||
> 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.
|
||||
|
||||
_presenterm-export_ uses [tmux](https://github.com/tmux/tmux/) to run _presenterm_ inside it and capture its output.
|
||||
|
||||
After you've installed both _presenterm-export_ and _tmux_, run _presenterm_ with the `--export-pdf` parameter to
|
||||
generate the output PDF:
|
||||
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`.
|
||||
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 _presenterm-export_ just make sure you activate it before running
|
||||
> 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.
|
||||
|
||||
## Page sizes
|
||||
## PDF page size
|
||||
|
||||
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.
|
||||
By default, the size of each page in the generated PDF will depend on the size of your terminal.
|
||||
|
||||
## tmux <= 3.5a active sessions bug
|
||||
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).
|
||||
|
||||
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.
|
||||
## Pause behavior
|
||||
|
||||
## 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.
|
||||
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)
|
@ -56,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
|
||||
|
@ -163,7 +163,7 @@ using another brace. e.g. `{{potato}} farms` will be displayed as `{potato} farm
|
||||
|
||||
#### Footer images
|
||||
|
||||
Besides text, images can also be used in the left and center positions. This can be done by specifying an `image` key
|
||||
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
|
||||
@ -173,7 +173,8 @@ footer:
|
||||
image: potato.png
|
||||
center:
|
||||
image: banana.png
|
||||
right: "{current_slide} / {total_slides}"
|
||||
right:
|
||||
image: apple.png
|
||||
# The height of the footer to adjust image sizes
|
||||
height: 5
|
||||
```
|
||||
|
@ -69,17 +69,18 @@ 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`
|
||||
* `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.
|
||||
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
|
||||
|
||||
|
@ -42,6 +42,11 @@ 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:
|
||||
|
@ -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
|
||||
|
@ -131,6 +131,7 @@ impl SnippetHighlighter {
|
||||
Java => "java",
|
||||
JavaScript => "js",
|
||||
Json => "json",
|
||||
Julia => "jl",
|
||||
Kotlin => "kt",
|
||||
Latex => "tex",
|
||||
Lua => "lua",
|
||||
|
@ -15,9 +15,7 @@ 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;
|
||||
@ -110,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,
|
||||
}
|
||||
|
||||
@ -141,7 +139,7 @@ impl AsRenderOperations for HighlightedLine {
|
||||
right_padding_length: self.right_padding_length,
|
||||
repeat_prefix_on_wrap: false,
|
||||
text,
|
||||
block_length: context.block_length as u16,
|
||||
block_length: context.block_length,
|
||||
alignment: context.alignment,
|
||||
block_color: self.block_color,
|
||||
}),
|
||||
@ -440,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,
|
||||
@ -470,6 +469,7 @@ pub enum SnippetLanguage {
|
||||
Java,
|
||||
JavaScript,
|
||||
Json,
|
||||
Julia,
|
||||
Kotlin,
|
||||
Latex,
|
||||
Lua,
|
||||
@ -508,6 +508,8 @@ pub enum SnippetLanguage {
|
||||
Zsh,
|
||||
}
|
||||
|
||||
crate::utils::impl_deserialize_from_str!(SnippetLanguage);
|
||||
|
||||
impl FromStr for SnippetLanguage {
|
||||
type Err = Infallible;
|
||||
|
||||
@ -541,6 +543,7 @@ impl FromStr for SnippetLanguage {
|
||||
"java" => Java,
|
||||
"javascript" | "js" => JavaScript,
|
||||
"json" => Json,
|
||||
"julia" => Julia,
|
||||
"kotlin" => Kotlin,
|
||||
"latex" => Latex,
|
||||
"lua" => Lua,
|
||||
@ -656,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)]
|
||||
|
@ -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),
|
||||
}
|
||||
|
184
src/config.rs
184
src/config.rs
@ -7,7 +7,6 @@ use crate::{
|
||||
},
|
||||
};
|
||||
use clap::ValueEnum;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
@ -16,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.
|
||||
@ -40,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 {
|
||||
@ -67,7 +73,8 @@ pub enum ConfigLoadError {
|
||||
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.
|
||||
@ -75,7 +82,7 @@ pub struct DefaultsConfig {
|
||||
|
||||
/// Override the terminal font size when in windows or when using sixel.
|
||||
#[serde(default = "default_terminal_font_size")]
|
||||
#[validate(range(min = 1))]
|
||||
#[cfg_attr(feature = "json-schema", validate(range(min = 1)))]
|
||||
pub terminal_font_size: u8,
|
||||
|
||||
/// The image protocol to use.
|
||||
@ -87,13 +94,26 @@ 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 {
|
||||
@ -103,18 +123,36 @@ impl Default for DefaultsConfig {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The configuration for lists when incremental lists are enabled.
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct IncrementalListsConfig {
|
||||
/// Whether to pause before a list begins.
|
||||
#[serde(default)]
|
||||
pub pause_before: Option<bool>,
|
||||
|
||||
/// Whether to pause after a list ends.
|
||||
#[serde(default)]
|
||||
pub pause_after: Option<bool>,
|
||||
}
|
||||
|
||||
fn default_terminal_font_size() -> u8 {
|
||||
16
|
||||
}
|
||||
|
||||
/// The alignment to use when `defaults.max_columns` is set.
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, JsonSchema)]
|
||||
#[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.
|
||||
@ -128,7 +166,24 @@ pub enum MaxColumnsAlignment {
|
||||
Right,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
|
||||
/// 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]
|
||||
@ -138,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.
|
||||
@ -164,7 +220,8 @@ pub struct OptionsConfig {
|
||||
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.
|
||||
@ -180,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.
|
||||
@ -191,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
|
||||
@ -199,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.
|
||||
@ -217,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.
|
||||
@ -235,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.
|
||||
@ -253,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,
|
||||
@ -274,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.
|
||||
@ -328,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.
|
||||
@ -415,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.
|
||||
@ -441,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 {
|
||||
@ -505,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)
|
||||
|
@ -14,7 +14,7 @@ use crate::{
|
||||
terminal::emulator::TerminalEmulator,
|
||||
theme::raw::PresentationTheme,
|
||||
};
|
||||
use std::{io, rc::Rc};
|
||||
use std::{io, sync::Arc};
|
||||
|
||||
const PRESENTATION: &str = r#"
|
||||
# Header 1
|
||||
@ -109,7 +109,7 @@ impl ThemesDemo {
|
||||
theme_options: ThemeOptions { font_size_supported: TerminalEmulator::capabilities().font_size },
|
||||
..Default::default()
|
||||
};
|
||||
let executer = Rc::new(SnippetExecutor::default());
|
||||
let executer = Arc::new(SnippetExecutor::default());
|
||||
let bindings_config = Default::default();
|
||||
let builder = PresentationBuilder::new(
|
||||
theme,
|
||||
|
425
src/export.rs
425
src/export.rs
@ -1,425 +0,0 @@
|
||||
use crate::{
|
||||
MarkdownParser, 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},
|
||||
},
|
||||
theme::{ProcessingThemeError, raw::PresentationTheme},
|
||||
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,
|
||||
self.resources.clone(),
|
||||
&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("processing theme: {0}")]
|
||||
ProcessingTheme(#[from] ProcessingThemeError),
|
||||
|
||||
#[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::registry::PresentationThemeRegistry;
|
||||
use comrak::Arena;
|
||||
|
||||
fn extract_metadata(content: &str, path: &str) -> ExportMetadata {
|
||||
let arena = Arena::new();
|
||||
let parser = MarkdownParser::new(&arena);
|
||||
let theme = PresentationThemeRegistry::default().load_by_name("dark").unwrap();
|
||||
let resources = Resources::new("examples", "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();
|
||||
});
|
||||
|
140
src/main.rs
140
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},
|
||||
@ -20,12 +20,17 @@ 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;
|
||||
@ -45,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)]
|
||||
@ -59,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,
|
||||
@ -83,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,
|
||||
@ -179,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,
|
||||
@ -203,14 +221,12 @@ impl CoreComponents {
|
||||
|
||||
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;
|
||||
}
|
||||
@ -219,7 +235,7 @@ 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 registry = ImageRegistry::new(printer.clone());
|
||||
let resources = Resources::new(
|
||||
resources_path.clone(),
|
||||
themes_path.unwrap_or_else(|| resources_path.clone()),
|
||||
@ -231,7 +247,7 @@ impl CoreComponents {
|
||||
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,
|
||||
@ -248,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
|
||||
@ -271,12 +286,15 @@ impl CoreComponents {
|
||||
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) {
|
||||
@ -335,29 +353,14 @@ 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(());
|
||||
@ -370,9 +373,16 @@ fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
|
||||
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 || cli.generate_pdf_metadata || cli.enable_export_mode {
|
||||
if cli.export_pdf {
|
||||
TerminalEmulator::disable_capability_detection();
|
||||
}
|
||||
|
||||
@ -395,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 } =
|
||||
@ -417,8 +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_columns_alignment: config.defaults.max_columns_alignment,
|
||||
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,
|
||||
@ -440,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);
|
||||
}
|
||||
}
|
||||
|
@ -151,11 +151,22 @@ pub(crate) struct Text<C = Color> {
|
||||
pub(crate) style: TextStyle<C>,
|
||||
}
|
||||
|
||||
impl<C> Default for Text<C> {
|
||||
fn default() -> Self {
|
||||
Self { content: Default::default(), style: TextStyle::default() }
|
||||
}
|
||||
}
|
||||
|
||||
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> {
|
||||
|
@ -83,14 +83,10 @@ where
|
||||
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.
|
||||
@ -98,16 +94,6 @@ where
|
||||
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<C>) {
|
||||
self.flags |= other.flags;
|
||||
@ -116,6 +102,12 @@ where
|
||||
self.colors.foreground = self.colors.foreground.clone().or(other.colors.foreground.clone());
|
||||
}
|
||||
|
||||
/// Return a new style merged with the one passed in.
|
||||
pub(crate) fn merged(mut self, other: &TextStyle<C>) -> Self {
|
||||
self.merge(other);
|
||||
self
|
||||
}
|
||||
|
||||
fn add_flag(mut self, flag: TextFormatFlags) -> Self {
|
||||
self.flags |= flag as u8;
|
||||
self
|
||||
@ -131,23 +123,15 @@ impl TextStyle<Color> {
|
||||
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);
|
||||
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.into());
|
||||
}
|
||||
if let Some(color) = self.colors.foreground {
|
||||
styled = styled.with(color.into());
|
||||
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
|
||||
}
|
||||
@ -159,6 +143,16 @@ impl TextStyle<Color> {
|
||||
};
|
||||
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> {
|
||||
@ -168,6 +162,57 @@ impl TextStyle<RawColor> {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@ -184,7 +229,7 @@ impl fmt::Display for FontSizedStr<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum TextFormatFlags {
|
||||
Bold = 1,
|
||||
Italics = 2,
|
||||
@ -312,3 +357,34 @@ pub(crate) enum ParseColorError {
|
||||
#[error("invalid hex color: {0}")]
|
||||
Hex(#[from] FromHexError),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
#[case::default(TextStyle::default(), &[])]
|
||||
#[case::code(TextStyle::default().code(), &[])]
|
||||
#[case::bold(TextStyle::default().bold(), &[TextAttribute::Bold])]
|
||||
#[case::italics(TextStyle::default().italics(), &[TextAttribute::Italics])]
|
||||
#[case::strikethrough(TextStyle::default().strikethrough(), &[TextAttribute::Strikethrough])]
|
||||
#[case::underlined(TextStyle::default().underlined(), &[TextAttribute::Underlined])]
|
||||
#[case::bg_color(TextStyle::default().bg_color(Color::Red), &[TextAttribute::BackgroundColor(Color::Red)])]
|
||||
#[case::bg_color(TextStyle::default().fg_color(Color::Red), &[TextAttribute::ForegroundColor(Color::Red)])]
|
||||
#[case::all(
|
||||
TextStyle::default().bold().code().italics().strikethrough().underlined().bg_color(Color::Black).fg_color(Color::Red),
|
||||
&[
|
||||
TextAttribute::BackgroundColor(Color::Black),
|
||||
TextAttribute::ForegroundColor(Color::Red),
|
||||
TextAttribute::Bold,
|
||||
TextAttribute::Italics,
|
||||
TextAttribute::Strikethrough,
|
||||
TextAttribute::Underlined,
|
||||
]
|
||||
)]
|
||||
fn iterate_attributes(#[case] style: TextStyle, #[case] expected: &[TextAttribute]) {
|
||||
let attrs: Vec<_> = style.iter_attributes().collect();
|
||||
assert_eq!(attrs, expected);
|
||||
}
|
||||
}
|
||||
|
@ -2,10 +2,7 @@ use crate::{
|
||||
code::{
|
||||
execute::SnippetExecutor,
|
||||
highlighting::{HighlightThemeSet, SnippetHighlighter},
|
||||
snippet::{
|
||||
ExternalFile, Highlight, HighlightContext, HighlightGroup, HighlightMutator, HighlightedLine, Snippet,
|
||||
SnippetExec, SnippetLanguage, SnippetLine, SnippetParser, SnippetRepr, SnippetSplitter,
|
||||
},
|
||||
snippet::SnippetLanguage,
|
||||
},
|
||||
config::{KeyBindingsConfig, OptionsConfig},
|
||||
markdown::{
|
||||
@ -21,27 +18,19 @@ use crate::{
|
||||
ChunkMutator, Modals, Presentation, PresentationMetadata, PresentationState, PresentationThemeMetadata,
|
||||
RenderOperation, SlideBuilder, SlideChunk,
|
||||
},
|
||||
render::{
|
||||
operation::{AsRenderOperations, BlockLine, ImageRenderProperties, ImageSize, MarginProperties, RenderAsync},
|
||||
properties::WindowSize,
|
||||
},
|
||||
render::operation::{BlockLine, ImageRenderProperties, ImageSize, MarginProperties},
|
||||
resource::Resources,
|
||||
terminal::image::{
|
||||
Image,
|
||||
printer::{ImageRegistry, RegisterImageError},
|
||||
printer::{ImageRegistry, ImageSpec, RegisterImageError},
|
||||
},
|
||||
theme::{
|
||||
Alignment, AuthorPositioning, CodeBlockStyle, ElementType, Margin, PresentationTheme, ProcessingThemeError,
|
||||
ThemeOptions,
|
||||
Alignment, AuthorPositioning, ElementType, PresentationTheme, ProcessingThemeError, ThemeOptions,
|
||||
raw::{self, RawColor},
|
||||
registry::{LoadThemeError, PresentationThemeRegistry},
|
||||
},
|
||||
third_party::{ThirdPartyRender, ThirdPartyRenderError, ThirdPartyRenderRequest},
|
||||
third_party::{ThirdPartyRender, ThirdPartyRenderError},
|
||||
ui::{
|
||||
execution::{
|
||||
DisplaySeparator, RunAcquireTerminalSnippet, RunImageSnippet, RunSnippetOperation,
|
||||
SnippetExecutionDisabledOperation,
|
||||
},
|
||||
footer::{FooterGenerator, FooterVariables, InvalidFooterTemplateError},
|
||||
modals::{IndexBuilder, KeyBindingsModalBuilder},
|
||||
separator::RenderSeparator,
|
||||
@ -50,9 +39,12 @@ use crate::{
|
||||
use comrak::{Arena, nodes::AlertType};
|
||||
use image::DynamicImage;
|
||||
use serde::Deserialize;
|
||||
use std::{cell::RefCell, collections::HashSet, fmt::Display, iter, mem, path::PathBuf, rc::Rc, str::FromStr};
|
||||
use snippet::{SnippetOperations, SnippetProcessor, SnippetProcessorState};
|
||||
use std::{collections::HashSet, fmt::Display, iter, mem, path::PathBuf, rc::Rc, str::FromStr, sync::Arc};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
mod snippet;
|
||||
|
||||
pub(crate) type BuildResult = Result<(), BuildError>;
|
||||
|
||||
#[derive(Default)]
|
||||
@ -77,6 +69,9 @@ pub struct PresentationBuilderOptions {
|
||||
pub render_speaker_notes_only: bool,
|
||||
pub auto_render_languages: Vec<SnippetLanguage>,
|
||||
pub theme_options: ThemeOptions,
|
||||
pub pause_before_incremental_lists: bool,
|
||||
pub pause_after_incremental_lists: bool,
|
||||
pub pause_create_new_slide: bool,
|
||||
}
|
||||
|
||||
impl PresentationBuilderOptions {
|
||||
@ -115,6 +110,9 @@ impl Default for PresentationBuilderOptions {
|
||||
render_speaker_notes_only: false,
|
||||
auto_render_languages: Default::default(),
|
||||
theme_options: ThemeOptions { font_size_supported: false },
|
||||
pause_before_incremental_lists: true,
|
||||
pause_after_incremental_lists: true,
|
||||
pause_create_new_slide: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -129,7 +127,7 @@ pub(crate) struct PresentationBuilder<'a> {
|
||||
chunk_mutators: Vec<Box<dyn ChunkMutator>>,
|
||||
slide_builders: Vec<SlideBuilder>,
|
||||
highlighter: SnippetHighlighter,
|
||||
code_executor: Rc<SnippetExecutor>,
|
||||
code_executor: Arc<SnippetExecutor>,
|
||||
theme: PresentationTheme,
|
||||
default_raw_theme: &'a raw::PresentationTheme,
|
||||
resources: Resources,
|
||||
@ -152,7 +150,7 @@ impl<'a> PresentationBuilder<'a> {
|
||||
default_raw_theme: &'a raw::PresentationTheme,
|
||||
resources: Resources,
|
||||
third_party: &'a mut ThirdPartyRender,
|
||||
code_executor: Rc<SnippetExecutor>,
|
||||
code_executor: Arc<SnippetExecutor>,
|
||||
themes: &'a Themes,
|
||||
image_registry: ImageRegistry,
|
||||
bindings_config: KeyBindingsConfig,
|
||||
@ -250,7 +248,7 @@ impl<'a> PresentationBuilder<'a> {
|
||||
};
|
||||
let mut image = DynamicImage::new_rgba8(1, 1);
|
||||
image.as_mut_rgba8().unwrap().get_pixel_mut(0, 0).0 = rgba;
|
||||
let image = self.image_registry.register_image(image)?;
|
||||
let image = self.image_registry.register(ImageSpec::Generated(image))?;
|
||||
Ok(image)
|
||||
}
|
||||
|
||||
@ -319,6 +317,12 @@ impl<'a> PresentationBuilder<'a> {
|
||||
match element {
|
||||
MarkdownElement::Comment { comment, source_position } => self.process_comment(comment, source_position)?,
|
||||
MarkdownElement::SetexHeading { text } => self.push_slide_title(text)?,
|
||||
MarkdownElement::ThematicBreak => {
|
||||
if self.options.end_slide_shorthand {
|
||||
self.terminate_slide();
|
||||
self.slide_state.ignore_element_line_break = true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// Allows us to start the next speaker slide when a title is pushed and implicit_slide_ends is enabled.
|
||||
@ -375,7 +379,16 @@ impl<'a> PresentationBuilder<'a> {
|
||||
new_theme = Some(theme);
|
||||
}
|
||||
if let Some(theme_path) = &metadata.path {
|
||||
let theme = self.resources.theme(theme_path)?;
|
||||
let mut theme = self.resources.theme(theme_path)?;
|
||||
if let Some(name) = &theme.extends {
|
||||
let base = self
|
||||
.themes
|
||||
.presentation
|
||||
.load_by_name(name)
|
||||
.ok_or_else(|| BuildError::InvalidMetadata(format!("extended theme {name} not found")))?;
|
||||
theme = merge_struct::merge(&theme, &base)
|
||||
.map_err(|e| BuildError::InvalidMetadata(format!("invalid theme: {e}")))?;
|
||||
}
|
||||
new_theme = Some(theme);
|
||||
}
|
||||
}
|
||||
@ -383,8 +396,9 @@ impl<'a> PresentationBuilder<'a> {
|
||||
if overrides.extends.is_some() {
|
||||
return Err(BuildError::InvalidMetadata("theme overrides can't use 'extends'".into()));
|
||||
}
|
||||
let base = new_theme.as_ref().unwrap_or(self.default_raw_theme);
|
||||
// This shouldn't fail as the models are already correct.
|
||||
let theme = merge_struct::merge(self.default_raw_theme, overrides)
|
||||
let theme = merge_struct::merge(base, overrides)
|
||||
.map_err(|e| BuildError::InvalidMetadata(format!("invalid theme: {e}")))?;
|
||||
new_theme = Some(theme);
|
||||
}
|
||||
@ -492,9 +506,9 @@ impl<'a> PresentationBuilder<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
fn process_comment_command_presentation_mode(&mut self, comment_command: CommentCommand) -> BuildResult {
|
||||
match comment_command {
|
||||
CommentCommand::Pause => self.process_pause(),
|
||||
fn process_comment_command_presentation_mode(&mut self, command: CommentCommand) -> BuildResult {
|
||||
match command {
|
||||
CommentCommand::Pause => self.push_pause(),
|
||||
CommentCommand::EndSlide => self.terminate_slide(),
|
||||
CommentCommand::NewLine => self.push_line_breaks(self.slide_font_size() as usize),
|
||||
CommentCommand::NewLines(count) => {
|
||||
@ -538,6 +552,19 @@ impl<'a> PresentationBuilder<'a> {
|
||||
}
|
||||
self.slide_state.font_size = Some(size)
|
||||
}
|
||||
CommentCommand::Alignment(alignment) => {
|
||||
let alignment = match alignment {
|
||||
CommentCommandAlignment::Left => Alignment::Left { margin: Default::default() },
|
||||
CommentCommandAlignment::Center => {
|
||||
Alignment::Center { minimum_margin: Default::default(), minimum_size: Default::default() }
|
||||
}
|
||||
CommentCommandAlignment::Right => Alignment::Right { margin: Default::default() },
|
||||
};
|
||||
self.slide_state.alignment = Some(alignment);
|
||||
}
|
||||
CommentCommand::SkipSlide => {
|
||||
self.slide_state.skip_slide = true;
|
||||
}
|
||||
};
|
||||
// Don't push line breaks for any comments.
|
||||
self.slide_state.ignore_element_line_break = true;
|
||||
@ -582,7 +609,13 @@ impl<'a> PresentationBuilder<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
fn process_pause(&mut self) {
|
||||
fn push_pause(&mut self) {
|
||||
if self.options.pause_create_new_slide {
|
||||
let operations = self.chunk_operations.clone();
|
||||
self.terminate_slide();
|
||||
self.chunk_operations = operations;
|
||||
return;
|
||||
}
|
||||
self.slide_state.last_chunk_ended_in_list = matches!(self.slide_state.last_element, LastElement::List { .. });
|
||||
|
||||
let chunk_operations = mem::take(&mut self.chunk_operations);
|
||||
@ -634,9 +667,11 @@ impl<'a> PresentationBuilder<'a> {
|
||||
other => panic!("unexpected heading level {other}"),
|
||||
};
|
||||
if let Some(prefix) = &style.prefix {
|
||||
let mut prefix = prefix.clone();
|
||||
prefix.push(' ');
|
||||
text.0.insert(0, Text::from(prefix));
|
||||
if !prefix.is_empty() {
|
||||
let mut prefix = prefix.clone();
|
||||
prefix.push(' ');
|
||||
text.0.insert(0, Text::from(prefix));
|
||||
}
|
||||
}
|
||||
text.apply_style(&style.style);
|
||||
|
||||
@ -706,19 +741,66 @@ impl<'a> PresentationBuilder<'a> {
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
let block_length =
|
||||
list.iter().map(|l| self.list_item_prefix(l).width() + l.contents.width()).max().unwrap_or_default() as u16;
|
||||
let block_length = block_length * self.slide_font_size() as u16;
|
||||
let incremental_lists = self.slide_state.incremental_lists.unwrap_or(self.options.incremental_lists);
|
||||
let iter = ListIterator::new(list, start_index);
|
||||
if incremental_lists && self.options.pause_before_incremental_lists {
|
||||
self.push_pause();
|
||||
}
|
||||
for (index, item) in iter.enumerate() {
|
||||
if index > 0 && incremental_lists {
|
||||
self.process_pause();
|
||||
self.push_pause();
|
||||
}
|
||||
self.push_list_item(item.index, item.item)?;
|
||||
self.push_list_item(item.index, item.item, block_length)?;
|
||||
}
|
||||
if incremental_lists && self.options.pause_after_incremental_lists {
|
||||
self.push_pause();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn push_list_item(&mut self, index: usize, item: ListItem) -> BuildResult {
|
||||
let padding_length = (item.depth as usize + 1) * 3;
|
||||
fn push_list_item(&mut self, index: usize, item: ListItem, block_length: u16) -> BuildResult {
|
||||
let prefix = self.list_item_prefix(&item);
|
||||
let mut text = item.contents.resolve(&self.theme.palette)?;
|
||||
let font_size = self.slide_font_size();
|
||||
for piece in &mut text.0 {
|
||||
if piece.style.is_code() {
|
||||
piece.style.colors = self.theme.inline_code.style.colors;
|
||||
}
|
||||
piece.style = piece.style.size(font_size);
|
||||
}
|
||||
let alignment = self.slide_state.alignment.unwrap_or_default();
|
||||
self.chunk_operations.push(RenderOperation::RenderBlockLine(BlockLine {
|
||||
prefix: prefix.into(),
|
||||
right_padding_length: 0,
|
||||
repeat_prefix_on_wrap: false,
|
||||
text: text.into(),
|
||||
block_length,
|
||||
alignment,
|
||||
block_color: None,
|
||||
}));
|
||||
self.push_line_break();
|
||||
if item.depth == 0 {
|
||||
self.slide_state.last_element = LastElement::List { last_index: index };
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn list_item_prefix(&self, item: &ListItem) -> Text {
|
||||
let font_size = self.slide_font_size();
|
||||
let spaces_per_indent = match item.depth {
|
||||
0 => 3_u8.div_ceil(font_size),
|
||||
_ => {
|
||||
if font_size == 1 {
|
||||
3
|
||||
} else {
|
||||
2
|
||||
}
|
||||
}
|
||||
};
|
||||
let padding_length = (item.depth as usize + 1) * spaces_per_indent as usize;
|
||||
let mut prefix: String = " ".repeat(padding_length);
|
||||
match item.item_type {
|
||||
ListItemType::Unordered => {
|
||||
@ -728,6 +810,7 @@ impl<'a> PresentationBuilder<'a> {
|
||||
_ => '▪',
|
||||
};
|
||||
prefix.push(delimiter);
|
||||
prefix.push_str(" ");
|
||||
}
|
||||
ListItemType::OrderedParens(value) => {
|
||||
prefix.push_str(&value.to_string());
|
||||
@ -738,17 +821,7 @@ impl<'a> PresentationBuilder<'a> {
|
||||
prefix.push_str(". ");
|
||||
}
|
||||
};
|
||||
|
||||
let prefix_length = prefix.len() as u16 * self.slide_font_size() as u16;
|
||||
self.push_text(prefix.into(), ElementType::List);
|
||||
|
||||
let text = item.contents.resolve(&self.theme.palette)?;
|
||||
self.push_aligned_text(text, Alignment::Left { margin: Margin::Fixed(prefix_length) });
|
||||
self.push_line_break();
|
||||
if item.depth == 0 {
|
||||
self.slide_state.last_element = LastElement::List { last_index: index };
|
||||
}
|
||||
Ok(())
|
||||
Text::new(prefix, TextStyle::default().size(font_size))
|
||||
}
|
||||
|
||||
fn push_block_quote(&mut self, lines: Vec<Line<RawColor>>) -> BuildResult {
|
||||
@ -836,7 +909,7 @@ impl<'a> PresentationBuilder<'a> {
|
||||
}
|
||||
|
||||
fn push_text(&mut self, line: Line, element_type: ElementType) {
|
||||
let alignment = self.theme.alignment(&element_type);
|
||||
let alignment = self.slide_state.alignment.unwrap_or_else(|| self.theme.alignment(&element_type));
|
||||
self.push_aligned_text(line, alignment);
|
||||
}
|
||||
|
||||
@ -863,272 +936,41 @@ impl<'a> PresentationBuilder<'a> {
|
||||
self.chunk_operations.extend(iter::repeat(RenderOperation::RenderLineBreak).take(count));
|
||||
}
|
||||
|
||||
fn push_differ(&mut self, text: String) {
|
||||
self.chunk_operations.push(RenderOperation::RenderDynamic(Rc::new(Differ(text))));
|
||||
}
|
||||
|
||||
fn push_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 state = SnippetProcessorState {
|
||||
resources: &self.resources,
|
||||
image_registry: &self.image_registry,
|
||||
snippet_executor: self.code_executor.clone(),
|
||||
theme: &self.theme,
|
||||
third_party: self.third_party,
|
||||
highlighter: &self.highlighter,
|
||||
options: &self.options,
|
||||
font_size: self.slide_font_size(),
|
||||
};
|
||||
|
||||
let block_length = self.push_code_lines(&snippet);
|
||||
match snippet.attributes.execution {
|
||||
SnippetExec::None => Ok(()),
|
||||
SnippetExec::Exec | SnippetExec::AcquireTerminal if !execution_allowed => {
|
||||
let auto_start = match snippet.attributes.representation {
|
||||
SnippetRepr::Image | SnippetRepr::ExecReplace => true,
|
||||
SnippetRepr::Render | SnippetRepr::Snippet => false,
|
||||
};
|
||||
self.push_execution_disabled_operation(auto_start);
|
||||
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) -> usize {
|
||||
let lines = SnippetSplitter::new(&self.theme.code, self.code_executor.hidden_line_prefix(&snippet.language))
|
||||
.split(snippet);
|
||||
let block_length = lines.iter().map(|line| line.width()).max().unwrap_or(0) * self.slide_font_size() as usize;
|
||||
let (lines, context) = self.highlight_lines(snippet, lines, block_length);
|
||||
for line in lines {
|
||||
self.chunk_operations.push(RenderOperation::RenderDynamic(Rc::new(line)));
|
||||
}
|
||||
self.set_colors(self.theme.default_style.style.colors);
|
||||
if self.options.allow_mutations && context.borrow().groups.len() > 1 {
|
||||
self.chunk_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 = contents;
|
||||
Ok(code)
|
||||
}
|
||||
|
||||
fn push_rendered_code(&mut self, code: Snippet, source_position: SourcePosition) -> BuildResult {
|
||||
let Snippet { contents, language, attributes } = code;
|
||||
let error_holder = self.presentation_state.async_error_holder();
|
||||
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,
|
||||
error_holder,
|
||||
self.slide_builders.len() + 1,
|
||||
attributes.width,
|
||||
)?;
|
||||
self.chunk_operations.push(operation);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn highlight_lines(
|
||||
&self,
|
||||
code: &Snippet,
|
||||
lines: Vec<SnippetLine>,
|
||||
block_length: usize,
|
||||
) -> (Vec<HighlightedLine>, Rc<RefCell<HighlightContext>>) {
|
||||
let mut code_highlighter = self.highlighter.language_highlighter(&code.language);
|
||||
let style = self.code_style(code);
|
||||
let block_length = match &self.theme.code.alignment {
|
||||
Alignment::Left { .. } | Alignment::Right { .. } => block_length,
|
||||
Alignment::Center { minimum_size, .. } => block_length.max(*minimum_size as usize),
|
||||
};
|
||||
let font_size = self.slide_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.slide_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.alignment = match style.alignment {
|
||||
Alignment::Center { .. } => Alignment::Center { minimum_size: 0, minimum_margin: Margin::default() },
|
||||
Alignment::Left { .. } => Alignment::Left { margin: Margin::default() },
|
||||
Alignment::Right { .. } => Alignment::Right { margin: Margin::default() },
|
||||
};
|
||||
style.background = false;
|
||||
}
|
||||
style
|
||||
}
|
||||
|
||||
fn push_execution_disabled_operation(&mut self, auto_start: bool) {
|
||||
let operation = SnippetExecutionDisabledOperation::new(
|
||||
self.theme.execution_output.status.failure_style,
|
||||
self.theme.code.alignment,
|
||||
);
|
||||
if auto_start {
|
||||
operation.start_render();
|
||||
}
|
||||
self.chunk_operations.push(RenderOperation::RenderAsync(Rc::new(operation)));
|
||||
}
|
||||
|
||||
fn push_code_as_image(&mut self, snippet: Snippet) -> BuildResult {
|
||||
if !self.code_executor.is_execution_supported(&snippet.language) {
|
||||
return Err(BuildError::UnsupportedExecution(snippet.language));
|
||||
}
|
||||
let operation = RunImageSnippet::new(
|
||||
snippet,
|
||||
self.code_executor.clone(),
|
||||
self.image_registry.clone(),
|
||||
self.theme.execution_output.status.clone(),
|
||||
);
|
||||
operation.start_render();
|
||||
|
||||
let operation = RenderOperation::RenderAsync(Rc::new(operation));
|
||||
self.chunk_operations.push(operation);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn push_acquire_terminal_execution(&mut self, snippet: Snippet, block_length: usize) -> BuildResult {
|
||||
if !self.code_executor.is_execution_supported(&snippet.language) {
|
||||
return Err(BuildError::UnsupportedExecution(snippet.language));
|
||||
}
|
||||
let block_length = block_length as u16;
|
||||
let block_length = match &self.theme.code.alignment {
|
||||
Alignment::Left { .. } | Alignment::Right { .. } => block_length,
|
||||
Alignment::Center { minimum_size, .. } => block_length.max(*minimum_size),
|
||||
};
|
||||
let operation = RunAcquireTerminalSnippet::new(
|
||||
snippet,
|
||||
self.code_executor.clone(),
|
||||
self.theme.execution_output.status.clone(),
|
||||
block_length,
|
||||
self.slide_font_size(),
|
||||
);
|
||||
let operation = RenderOperation::RenderAsync(Rc::new(operation));
|
||||
self.chunk_operations.push(operation);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn push_code_execution(&mut self, snippet: Snippet, block_length: usize, mode: ExecutionMode) -> BuildResult {
|
||||
if !self.code_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 alignment = self.code_style(&snippet).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 operation = RunSnippetOperation::new(
|
||||
snippet,
|
||||
self.code_executor.clone(),
|
||||
default_colors,
|
||||
execution_output_style,
|
||||
block_length as u16,
|
||||
separator,
|
||||
alignment,
|
||||
self.slide_font_size(),
|
||||
);
|
||||
if matches!(mode, ExecutionMode::ReplaceSnippet) {
|
||||
operation.start_render();
|
||||
}
|
||||
let operation = RenderOperation::RenderAsync(Rc::new(operation));
|
||||
self.chunk_operations.push(operation);
|
||||
let processor = SnippetProcessor::new(state);
|
||||
let SnippetOperations { operations, mutators } = processor.process_code(info, code, source_position)?;
|
||||
self.chunk_operations.extend(operations);
|
||||
self.chunk_mutators.extend(mutators);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn terminate_slide(&mut self) {
|
||||
let operations = mem::take(&mut self.chunk_operations);
|
||||
let mutators = mem::take(&mut self.chunk_mutators);
|
||||
self.slide_chunks.push(SlideChunk::new(operations, mutators));
|
||||
|
||||
let chunks = mem::take(&mut self.slide_chunks);
|
||||
let builder = SlideBuilder::default().chunks(chunks);
|
||||
self.index_builder.add_title(self.slide_state.title.take().unwrap_or_else(|| Text::from("<no title>").into()));
|
||||
if !self.slide_state.skip_slide {
|
||||
self.slide_chunks.push(SlideChunk::new(operations, mutators));
|
||||
|
||||
if self.slide_state.ignore_footer {
|
||||
self.slides_without_footer.insert(self.slide_builders.len());
|
||||
let chunks = mem::take(&mut self.slide_chunks);
|
||||
let builder = SlideBuilder::default().chunks(chunks);
|
||||
self.index_builder
|
||||
.add_title(self.slide_state.title.take().unwrap_or_else(|| Text::from("<no title>").into()));
|
||||
|
||||
if self.slide_state.ignore_footer {
|
||||
self.slides_without_footer.insert(self.slide_builders.len());
|
||||
}
|
||||
self.slide_builders.push(builder);
|
||||
}
|
||||
self.slide_builders.push(builder);
|
||||
|
||||
self.push_slide_prelude();
|
||||
self.slide_state = Default::default();
|
||||
@ -1247,6 +1089,8 @@ struct SlideState {
|
||||
layout: LayoutState,
|
||||
title: Option<Line>,
|
||||
font_size: Option<u8>,
|
||||
alignment: Option<Alignment>,
|
||||
skip_slide: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
@ -1336,6 +1180,7 @@ pub enum BuildError {
|
||||
InvalidFooter(#[from] InvalidFooterTemplateError),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum ExecutionMode {
|
||||
AlongSnippet,
|
||||
ReplaceSnippet,
|
||||
@ -1359,6 +1204,8 @@ enum CommentCommand {
|
||||
NoFooter,
|
||||
SpeakerNote(String),
|
||||
FontSize(u8),
|
||||
Alignment(CommentCommandAlignment),
|
||||
SkipSlide,
|
||||
}
|
||||
|
||||
impl FromStr for CommentCommand {
|
||||
@ -1373,6 +1220,14 @@ impl FromStr for CommentCommand {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum CommentCommandAlignment {
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub struct CommandParseError(#[from] serde_yaml::Error);
|
||||
|
||||
@ -1493,19 +1348,6 @@ struct ImageAttributes {
|
||||
width: Option<Percent>,
|
||||
}
|
||||
|
||||
#[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 test {
|
||||
use crate::presentation::Slide;
|
||||
@ -1533,9 +1375,10 @@ mod test {
|
||||
options: PresentationBuilderOptions,
|
||||
) -> Result<Presentation, BuildError> {
|
||||
let theme = raw::PresentationTheme::default();
|
||||
let resources = Resources::new("/tmp", "/tmp", Default::default());
|
||||
let tmp_dir = std::env::temp_dir();
|
||||
let resources = Resources::new(&tmp_dir, &tmp_dir, Default::default());
|
||||
let mut third_party = ThirdPartyRender::default();
|
||||
let code_executor = Rc::new(SnippetExecutor::default());
|
||||
let code_executor = Arc::new(SnippetExecutor::default());
|
||||
let themes = Themes::default();
|
||||
let bindings = KeyBindingsConfig::default();
|
||||
let builder = PresentationBuilder::new(
|
||||
@ -1596,8 +1439,13 @@ mod test {
|
||||
for operation in operations {
|
||||
match operation {
|
||||
RenderOperation::RenderText { line, .. } => {
|
||||
let texts: Vec<_> = line.iter_texts().map(|text| text.text().content.clone()).collect();
|
||||
current_line.push_str(&texts.join(""));
|
||||
let text: String = line.iter_texts().map(|text| text.text().content.clone()).collect();
|
||||
current_line.push_str(&text);
|
||||
}
|
||||
RenderOperation::RenderBlockLine(line) => {
|
||||
current_line.push_str(&line.prefix.text().content);
|
||||
current_line
|
||||
.push_str(&line.text.iter_texts().map(|text| text.text().content.clone()).collect::<String>());
|
||||
}
|
||||
RenderOperation::RenderLineBreak if !current_line.is_empty() => {
|
||||
output.push(mem::take(&mut current_line));
|
||||
@ -1616,6 +1464,25 @@ mod test {
|
||||
extract_text_lines(&operations)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_heading_prefix() {
|
||||
let frontmatter = r#"
|
||||
theme:
|
||||
override:
|
||||
headings:
|
||||
h1:
|
||||
prefix: ""
|
||||
"#;
|
||||
let elements = vec![
|
||||
MarkdownElement::FrontMatter(frontmatter.into()),
|
||||
MarkdownElement::Heading { text: "hi".into(), level: 1 },
|
||||
];
|
||||
let slides = build_presentation(elements).into_slides();
|
||||
let lines = extract_slide_text_lines(slides.into_iter().next().unwrap());
|
||||
let expected_lines = &["hi"];
|
||||
assert_eq!(lines, expected_lines);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prelude_appears_once() {
|
||||
let elements = vec![
|
||||
@ -1810,8 +1677,43 @@ mod test {
|
||||
assert_eq!(lines, expected_lines);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_pauses() {
|
||||
#[rstest]
|
||||
#[case::two(2, &[" • 0", " ◦ 00"])]
|
||||
#[case::three(3, &[" • 0", " ◦ 00"])]
|
||||
#[case::four(4, &[" • 0", " ◦ 00"])]
|
||||
fn list_font_size(#[case] font_size: u8, #[case] expected: &[&str]) {
|
||||
let elements = vec![
|
||||
MarkdownElement::Comment {
|
||||
comment: format!("font_size: {font_size}"),
|
||||
source_position: Default::default(),
|
||||
},
|
||||
MarkdownElement::List(vec![
|
||||
ListItem { depth: 0, contents: "0".into(), item_type: ListItemType::Unordered },
|
||||
ListItem { depth: 1, contents: "00".into(), item_type: ListItemType::Unordered },
|
||||
]),
|
||||
];
|
||||
let options = PresentationBuilderOptions {
|
||||
theme_options: ThemeOptions { font_size_supported: true },
|
||||
..Default::default()
|
||||
};
|
||||
let slides = build_presentation_with_options(elements, options).into_slides();
|
||||
let lines = extract_slide_text_lines(slides.into_iter().next().unwrap());
|
||||
assert_eq!(lines, expected);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::default(Default::default(), 5)]
|
||||
#[case::no_pause_before(PresentationBuilderOptions{pause_before_incremental_lists: false, ..Default::default()}, 4)]
|
||||
#[case::no_pause_after(PresentationBuilderOptions{pause_after_incremental_lists: false, ..Default::default()}, 4)]
|
||||
#[case::no_pauses(
|
||||
PresentationBuilderOptions{
|
||||
pause_before_incremental_lists: false,
|
||||
pause_after_incremental_lists: false,
|
||||
..Default::default()
|
||||
},
|
||||
3
|
||||
)]
|
||||
fn automatic_pauses(#[case] options: PresentationBuilderOptions, #[case] expected_chunks: usize) {
|
||||
let elements = vec![
|
||||
MarkdownElement::Comment { comment: "incremental_lists: true".into(), source_position: Default::default() },
|
||||
MarkdownElement::List(vec![
|
||||
@ -1820,8 +1722,49 @@ mod test {
|
||||
ListItem { depth: 0, contents: "three".into(), item_type: ListItemType::Unordered },
|
||||
]),
|
||||
];
|
||||
let slides = build_presentation(elements).into_slides();
|
||||
assert_eq!(slides[0].iter_chunks().count(), 3);
|
||||
let slides = build_presentation_with_options(elements, options).into_slides();
|
||||
assert_eq!(slides[0].iter_chunks().count(), expected_chunks);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_pauses_no_incremental_lists() {
|
||||
let elements = vec![
|
||||
MarkdownElement::Comment {
|
||||
comment: "incremental_lists: false".into(),
|
||||
source_position: Default::default(),
|
||||
},
|
||||
MarkdownElement::List(vec![
|
||||
ListItem { depth: 0, contents: "one".into(), item_type: ListItemType::Unordered },
|
||||
ListItem { depth: 1, contents: "two".into(), item_type: ListItemType::Unordered },
|
||||
ListItem { depth: 0, contents: "three".into(), item_type: ListItemType::Unordered },
|
||||
]),
|
||||
];
|
||||
let options = PresentationBuilderOptions { pause_after_incremental_lists: false, ..Default::default() };
|
||||
let slides = build_presentation_with_options(elements, options).into_slides();
|
||||
assert_eq!(slides[0].iter_chunks().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pause_new_slide() {
|
||||
let elements = vec![
|
||||
MarkdownElement::Paragraph(vec![Line::from("hi")]),
|
||||
MarkdownElement::Comment { comment: "pause".into(), source_position: Default::default() },
|
||||
MarkdownElement::Paragraph(vec![Line::from("bye")]),
|
||||
];
|
||||
let options = PresentationBuilderOptions { pause_create_new_slide: true, ..Default::default() };
|
||||
let slides = build_presentation_with_options(elements, options).into_slides();
|
||||
assert_eq!(slides.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_slide() {
|
||||
let elements = vec![
|
||||
MarkdownElement::Paragraph(vec![Line::from("hi")]),
|
||||
MarkdownElement::Comment { comment: "skip_slide".into(), source_position: Default::default() },
|
||||
];
|
||||
let options = PresentationBuilderOptions { pause_after_incremental_lists: false, ..Default::default() };
|
||||
let slides = build_presentation_with_options(elements, options).into_slides();
|
||||
assert_eq!(slides.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -1848,6 +1791,35 @@ mod test {
|
||||
assert!(matches!(last_operation, RenderOperation::RenderLineBreak), "last operation is {last_operation:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alignment() {
|
||||
let elements = vec![
|
||||
MarkdownElement::Paragraph(vec!["hi".into()]),
|
||||
MarkdownElement::Comment { comment: "alignment: center".into(), source_position: Default::default() },
|
||||
MarkdownElement::Paragraph(vec!["hello".into()]),
|
||||
MarkdownElement::Comment { comment: "alignment: right".into(), source_position: Default::default() },
|
||||
MarkdownElement::Paragraph(vec!["hola".into()]),
|
||||
];
|
||||
|
||||
let slides = build_presentation(elements).into_slides();
|
||||
let operations = slides.into_iter().next().unwrap().into_operations();
|
||||
let alignments: Vec<_> = operations
|
||||
.into_iter()
|
||||
.filter_map(|op| match op {
|
||||
RenderOperation::RenderText { alignment, .. } => Some(alignment),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
alignments,
|
||||
&[
|
||||
Alignment::Left { margin: Default::default() },
|
||||
Alignment::Center { minimum_margin: Default::default(), minimum_size: Default::default() },
|
||||
Alignment::Right { margin: Default::default() },
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn implicit_slide_ends() {
|
||||
let elements = vec![
|
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,11 +1,7 @@
|
||||
use crate::{
|
||||
config::OptionsConfig,
|
||||
render::operation::{RenderAsyncState, RenderOperation},
|
||||
};
|
||||
use crate::{config::OptionsConfig, render::operation::RenderOperation};
|
||||
use serde::Deserialize;
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::HashSet,
|
||||
fmt::Debug,
|
||||
ops::Deref,
|
||||
rc::Rc,
|
||||
@ -14,6 +10,7 @@ use std::{
|
||||
|
||||
pub(crate) mod builder;
|
||||
pub(crate) mod diff;
|
||||
pub(crate) mod poller;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Modals {
|
||||
@ -40,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()
|
||||
@ -51,7 +53,6 @@ impl Presentation {
|
||||
}
|
||||
|
||||
/// Consume this presentation and return its slides.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn into_slides(self) -> Vec<Slide> {
|
||||
self.slides
|
||||
}
|
||||
@ -139,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]
|
||||
}
|
||||
@ -382,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();
|
||||
|
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);
|
||||
}
|
||||
}
|
||||
}
|
334
src/presenter.rs
334
src/presenter.rs
@ -4,36 +4,45 @@ use crate::{
|
||||
listener::{Command, CommandListener},
|
||||
speaker_notes::{SpeakerNotesEvent, SpeakerNotesEventPublisher},
|
||||
},
|
||||
config::{KeyBindingsConfig, MaxColumnsAlignment},
|
||||
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},
|
||||
printer::TerminalIo,
|
||||
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},
|
||||
mem,
|
||||
ops::Deref,
|
||||
path::Path,
|
||||
rc::Rc,
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
pub struct PresenterOptions {
|
||||
@ -42,8 +51,8 @@ pub struct PresenterOptions {
|
||||
pub font_size_fallback: u8,
|
||||
pub bindings: KeyBindingsConfig,
|
||||
pub validate_overflows: bool,
|
||||
pub max_columns: u16,
|
||||
pub max_columns_alignment: MaxColumnsAlignment,
|
||||
pub max_size: MaxSize,
|
||||
pub transition: Option<SlideTransitionConfig>,
|
||||
}
|
||||
|
||||
/// A slideshow presenter.
|
||||
@ -55,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> {
|
||||
@ -73,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,
|
||||
@ -87,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(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -101,26 +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_columns_alignment: self.options.max_columns_alignment,
|
||||
max_size: self.options.max_size.clone(),
|
||||
};
|
||||
let mut drawer = TerminalDrawer::new(self.image_printer.clone(), drawer_options)?;
|
||||
loop {
|
||||
if matches!(self.options.mode, PresentMode::Export) {
|
||||
if let PresenterState::Failure { error, .. } = &self.state {
|
||||
return Err(PresentationError::Fatal(format!("failed to run presentation: {error}")));
|
||||
}
|
||||
}
|
||||
// Poll async renders once before we draw just in case.
|
||||
self.poll_async_renders()?;
|
||||
self.render(&mut drawer)?;
|
||||
|
||||
loop {
|
||||
if self.poll_async_renders()? {
|
||||
if self.process_poller_effects()? {
|
||||
self.render(&mut drawer)?;
|
||||
}
|
||||
|
||||
@ -146,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 => (),
|
||||
@ -161,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)?;
|
||||
@ -186,27 +227,6 @@ 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) -> RenderResult {
|
||||
let result = match &self.state {
|
||||
PresenterState::Presenting(presentation) => {
|
||||
@ -262,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;
|
||||
@ -298,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) => {
|
||||
@ -314,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 {
|
||||
@ -356,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,
|
||||
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)
|
||||
}
|
||||
|
||||
@ -405,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 {
|
||||
@ -412,6 +600,8 @@ enum CommandSideEffect {
|
||||
Suspend,
|
||||
Redraw,
|
||||
Reload,
|
||||
AnimateNextSlide,
|
||||
AnimatePreviousSlide,
|
||||
None,
|
||||
}
|
||||
|
||||
@ -483,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.
|
||||
@ -512,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,6 +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,
|
||||
config::{MaxColumnsAlignment, MaxRowsAlignment},
|
||||
markdown::{text::WeightedLine, text_style::Colors},
|
||||
render::{
|
||||
layout::Positioning,
|
||||
@ -14,9 +16,9 @@ use crate::{
|
||||
image::{
|
||||
Image,
|
||||
printer::{ImageProperties, PrintOptions},
|
||||
scale::{ImageScaler, TerminalRect},
|
||||
scale::{ImageScaler, ScaleImage},
|
||||
},
|
||||
printer::TerminalIo,
|
||||
printer::{TerminalCommand, TerminalIo},
|
||||
},
|
||||
theme::Alignment,
|
||||
};
|
||||
@ -24,22 +26,35 @@ use std::mem;
|
||||
|
||||
const MINIMUM_LINE_LENGTH: u16 = 10;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct RenderEngineOptions {
|
||||
pub(crate) validate_overflows: bool,
|
||||
#[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_size: MaxSize,
|
||||
pub(crate) column_layout_margin: u16,
|
||||
}
|
||||
|
||||
impl Default for RenderEngineOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
validate_overflows: false,
|
||||
max_columns: u16::MAX,
|
||||
max_columns_alignment: Default::default(),
|
||||
column_layout_margin: 4,
|
||||
}
|
||||
Self { validate_overflows: false, max_size: Default::default(), column_layout_margin: 4 }
|
||||
}
|
||||
}
|
||||
|
||||
@ -53,6 +68,7 @@ where
|
||||
max_modified_row: u16,
|
||||
layout: LayoutState,
|
||||
options: RenderEngineOptions,
|
||||
image_scaler: Box<dyn ScaleImage>,
|
||||
}
|
||||
|
||||
impl<'a, T> RenderEngine<'a, T>
|
||||
@ -70,32 +86,46 @@ where
|
||||
max_modified_row,
|
||||
layout: Default::default(),
|
||||
options,
|
||||
image_scaler: Box::<ImageScaler>::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn starting_rect(window_dimensions: WindowSize, options: &RenderEngineOptions) -> WindowRect {
|
||||
let start_row = 0;
|
||||
if window_dimensions.columns > options.max_columns {
|
||||
let extra_width = window_dimensions.columns - options.max_columns;
|
||||
let dimensions = window_dimensions.shrink_columns(extra_width);
|
||||
let start_column = match options.max_columns_alignment {
|
||||
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,
|
||||
};
|
||||
WindowRect { dimensions, start_column, start_row }
|
||||
} else {
|
||||
WindowRect { dimensions: window_dimensions, start_column: 0, start_row }
|
||||
}
|
||||
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);
|
||||
}
|
||||
@ -139,8 +169,9 @@ 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(())
|
||||
}
|
||||
@ -151,7 +182,7 @@ where
|
||||
let margin = horizontal_margin.as_characters(current.dimensions.columns);
|
||||
let new_rect = current.shrink_horizontal(margin).shrink_bottom(*bottom).shrink_top(*top);
|
||||
if new_rect.start_row != self.terminal.cursor_row() {
|
||||
self.jump_to_row(new_rect.start_row)?;
|
||||
self.terminal.execute(&TerminalCommand::MoveToRow(new_rect.start_row))?;
|
||||
}
|
||||
self.window_rects.push(new_rect);
|
||||
Ok(())
|
||||
@ -171,33 +202,37 @@ 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, 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.move_to_row(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 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.move_to_column(column)?;
|
||||
self.terminal.execute(&TerminalCommand::MoveToColumn(column))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -207,69 +242,75 @@ where
|
||||
let positioning = layout.compute(dimensions, text.width() as u16);
|
||||
let prefix = "".into();
|
||||
let text_drawer = TextDrawer::new(&prefix, 0, text, positioning, &self.colors, MINIMUM_LINE_LENGTH)?;
|
||||
let center_newlines = matches!(alignment, Alignment::Center { .. });
|
||||
let text_drawer = text_drawer.center_newlines(center_newlines);
|
||||
text_drawer.draw(self.terminal)?;
|
||||
// Restore colors
|
||||
self.apply_colors()
|
||||
}
|
||||
|
||||
fn render_line_break(&mut self) -> RenderResult {
|
||||
self.terminal.move_to_next_line()?;
|
||||
self.terminal.execute(&TerminalCommand::MoveToNextLine)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_image(&mut self, image: &Image, properties: &ImageRenderProperties) -> RenderResult {
|
||||
let rect = self.current_rect();
|
||||
let starting_cursor = 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, columns, rows) = match properties.size {
|
||||
let (width, height) = image.image().dimensions();
|
||||
let (columns, rows) = match properties.size {
|
||||
ImageSize::ShrinkIfNeeded => {
|
||||
let image_scale =
|
||||
ImageScaler::default().fit_image_to_rect(&rect.dimensions, width, height, &starting_cursor);
|
||||
let cursor = match properties.center {
|
||||
true => Self::center_cursor(&image_scale, &rect.dimensions, &starting_cursor),
|
||||
false => starting_cursor.clone(),
|
||||
};
|
||||
(cursor, image_scale.columns, image_scale.rows)
|
||||
self.image_scaler.fit_image_to_rect(&rect.dimensions, width, height, &starting_cursor);
|
||||
(image_scale.columns, image_scale.rows)
|
||||
}
|
||||
ImageSize::Specific(columns, rows) => (starting_cursor.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 image_scale =
|
||||
ImageScaler::default().scale_image(&dimensions, &rect.dimensions, width, height, &starting_cursor);
|
||||
let cursor = match properties.center {
|
||||
true => Self::center_cursor(&image_scale, &rect.dimensions, &starting_cursor),
|
||||
false => starting_cursor.clone(),
|
||||
};
|
||||
(cursor, image_scale.columns, image_scale.rows)
|
||||
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: cursor,
|
||||
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_cursor.column, starting_cursor.row)?;
|
||||
self.terminal.execute(&TerminalCommand::MoveTo { column: starting_cursor.column, row: starting_row })?;
|
||||
} else {
|
||||
self.terminal.move_to_row(starting_cursor.row + rows)?;
|
||||
self.terminal.execute(&TerminalCommand::MoveToRow(starting_row + rows))?;
|
||||
}
|
||||
self.apply_colors()
|
||||
}
|
||||
|
||||
fn center_cursor(rect: &TerminalRect, window: &WindowSize, cursor: &CursorPosition) -> CursorPosition {
|
||||
let start_column = window.columns / 2 - (rect.columns / 2);
|
||||
fn center_cursor(columns: u16, window: &WindowSize, cursor: &CursorPosition) -> CursorPosition {
|
||||
let start_column = window.columns / 2 - (columns / 2);
|
||||
let start_column = start_column + cursor.column;
|
||||
CursorPosition { row: cursor.row, column: start_column }
|
||||
}
|
||||
|
||||
fn align_cursor_right(columns: u16, window: &WindowSize, cursor: &CursorPosition) -> CursorPosition {
|
||||
let start_column = window.columns.saturating_sub(columns).saturating_add(cursor.column);
|
||||
CursorPosition { row: cursor.row, column: start_column }
|
||||
}
|
||||
|
||||
fn render_block_line(&mut self, operation: &BlockLine) -> RenderResult {
|
||||
let BlockLine {
|
||||
text,
|
||||
@ -288,7 +329,7 @@ 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 =
|
||||
@ -352,7 +393,10 @@ where
|
||||
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 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 {
|
||||
@ -364,7 +408,7 @@ where
|
||||
}
|
||||
|
||||
self.window_rects.push(dimensions);
|
||||
self.terminal.move_to_row(start_row)?;
|
||||
self.terminal.execute(&TerminalCommand::MoveToRow(start_row))?;
|
||||
self.layout = LayoutState::EnteredColumn { column: column_index, columns };
|
||||
Ok(())
|
||||
}
|
||||
@ -373,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(())
|
||||
@ -430,8 +474,9 @@ impl WindowRect {
|
||||
}
|
||||
|
||||
fn shrink_top(&self, rows: u16) -> Self {
|
||||
let dimensions = self.dimensions.shrink_rows(rows);
|
||||
let start_row = self.start_row.saturating_add(rows);
|
||||
Self { dimensions: self.dimensions.clone(), start_column: self.start_column, start_row }
|
||||
Self { dimensions, start_column: self.start_column, start_row }
|
||||
}
|
||||
|
||||
fn shrink_bottom(&self, rows: u16) -> Self {
|
||||
@ -445,9 +490,18 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
markdown::text_style::{Color, TextStyle},
|
||||
terminal::printer::TextProperties,
|
||||
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;
|
||||
|
||||
@ -457,13 +511,13 @@ mod tests {
|
||||
MoveToRow(u16),
|
||||
MoveToColumn(u16),
|
||||
MoveDown(u16),
|
||||
MoveRight(u16),
|
||||
MoveLeft(u16),
|
||||
MoveToNextLine,
|
||||
PrintText(String),
|
||||
ClearScreen,
|
||||
SetBackgroundColor(Color),
|
||||
PrintImage(Image),
|
||||
Suspend,
|
||||
Resume,
|
||||
PrintImage(PrintOptions),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@ -477,44 +531,38 @@ mod tests {
|
||||
self.instructions.push(instruction);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl TerminalIo for TerminalBuf {
|
||||
fn begin_update(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn end_update(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cursor_row(&self) -> u16 {
|
||||
self.cursor_row
|
||||
}
|
||||
|
||||
fn move_to(&mut self, column: u16, row: u16) -> std::io::Result<()> {
|
||||
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) -> std::io::Result<()> {
|
||||
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) -> std::io::Result<()> {
|
||||
fn move_to_column(&mut self, column: u16) -> io::Result<()> {
|
||||
self.push(Instruction::MoveToColumn(column))
|
||||
}
|
||||
|
||||
fn move_down(&mut self, amount: u16) -> std::io::Result<()> {
|
||||
fn move_down(&mut self, amount: u16) -> io::Result<()> {
|
||||
self.push(Instruction::MoveDown(amount))
|
||||
}
|
||||
|
||||
fn move_to_next_line(&mut self) -> std::io::Result<()> {
|
||||
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, _properties: &TextProperties) -> io::Result<()> {
|
||||
fn print_text(&mut self, content: &str, _style: &TextStyle) -> io::Result<()> {
|
||||
let content = content.to_string();
|
||||
if content.is_empty() {
|
||||
return Ok(());
|
||||
@ -523,55 +571,106 @@ mod tests {
|
||||
self.push(Instruction::PrintText(content))
|
||||
}
|
||||
|
||||
fn clear_screen(&mut self) -> std::io::Result<()> {
|
||||
fn clear_screen(&mut self) -> io::Result<()> {
|
||||
self.cursor_row = 0;
|
||||
self.push(Instruction::ClearScreen)
|
||||
}
|
||||
|
||||
fn set_colors(&mut self, _colors: Colors) -> std::io::Result<()> {
|
||||
fn set_colors(&mut self, _colors: Colors) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_background_color(&mut self, color: Color) -> std::io::Result<()> {
|
||||
fn set_background_color(&mut self, color: Color) -> io::Result<()> {
|
||||
self.push(Instruction::SetBackgroundColor(color))
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_image(
|
||||
&mut self,
|
||||
image: &Image,
|
||||
_options: &PrintOptions,
|
||||
) -> Result<(), crate::terminal::image::printer::PrintImageError> {
|
||||
let _ = self.push(Instruction::PrintImage(image.clone()));
|
||||
fn print_image(&mut self, _image: &Image, options: &PrintOptions) -> Result<(), PrintImageError> {
|
||||
let _ = self.push(Instruction::PrintImage(options.clone()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn suspend(&mut self) {
|
||||
let _ = self.push(Instruction::Suspend);
|
||||
}
|
||||
|
||||
fn resume(&mut self) {
|
||||
let _ = self.push(Instruction::Resume);
|
||||
}
|
||||
}
|
||||
|
||||
fn render(operations: &[RenderOperation]) -> Vec<Instruction> {
|
||||
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_columns: u16::MAX,
|
||||
max_columns_alignment: Default::default(),
|
||||
column_layout_margin: 0,
|
||||
};
|
||||
let engine = RenderEngine::new(&mut buf, dimensions, options);
|
||||
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(&[
|
||||
@ -679,4 +778,164 @@ mod tests {
|
||||
];
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
pub(crate) mod ascii_scaler;
|
||||
pub(crate) mod engine;
|
||||
pub(crate) mod layout;
|
||||
pub(crate) mod operation;
|
||||
@ -6,7 +7,6 @@ pub(crate) mod text;
|
||||
pub(crate) mod validate;
|
||||
|
||||
use crate::{
|
||||
config::MaxColumnsAlignment,
|
||||
markdown::{
|
||||
elements::Text,
|
||||
text::WeightedLine,
|
||||
@ -16,12 +16,16 @@ use crate::{
|
||||
terminal::{
|
||||
Terminal,
|
||||
image::printer::{ImagePrinter, PrintImageError},
|
||||
printer::TerminalError,
|
||||
},
|
||||
theme::{Alignment, Margin},
|
||||
};
|
||||
use engine::{RenderEngine, RenderEngineOptions};
|
||||
use engine::{MaxSize, RenderEngine, RenderEngineOptions};
|
||||
use operation::AsRenderOperations;
|
||||
use std::{
|
||||
io::{self, Stdout},
|
||||
iter,
|
||||
rc::Rc,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
@ -30,13 +34,12 @@ pub(crate) type RenderResult = Result<(), RenderError>;
|
||||
|
||||
pub(crate) struct TerminalDrawerOptions {
|
||||
pub(crate) font_size_fallback: u8,
|
||||
pub(crate) max_columns: u16,
|
||||
pub(crate) max_columns_alignment: MaxColumnsAlignment,
|
||||
pub(crate) max_size: MaxSize,
|
||||
}
|
||||
|
||||
impl Default for TerminalDrawerOptions {
|
||||
fn default() -> Self {
|
||||
Self { font_size_fallback: 1, max_columns: u16::MAX, max_columns_alignment: Default::default() }
|
||||
Self { font_size_fallback: 1, max_size: Default::default() }
|
||||
}
|
||||
}
|
||||
|
||||
@ -63,45 +66,20 @@ impl TerminalDrawer {
|
||||
}
|
||||
|
||||
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 },
|
||||
RenderOperation::RenderLineBreak,
|
||||
RenderOperation::RenderLineBreak,
|
||||
];
|
||||
for line in message.lines() {
|
||||
let error = vec![Text::from(line)];
|
||||
let op = RenderOperation::RenderText { line: WeightedLine::from(error), alignment };
|
||||
operations.extend([op, RenderOperation::RenderLineBreak]);
|
||||
}
|
||||
let engine = self.create_engine(dimensions);
|
||||
engine.render(operations.iter())?;
|
||||
engine.render(iter::once(&operation))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn render_engine_options(&self) -> RenderEngineOptions {
|
||||
RenderEngineOptions { max_size: self.options.max_size.clone(), ..Default::default() }
|
||||
}
|
||||
|
||||
fn create_engine(&mut self, dimensions: WindowSize) -> RenderEngine<Terminal<Stdout>> {
|
||||
let options = RenderEngineOptions {
|
||||
max_columns: self.options.max_columns,
|
||||
max_columns_alignment: self.options.max_columns_alignment,
|
||||
..Default::default()
|
||||
};
|
||||
let options = self.render_engine_options();
|
||||
RenderEngine::new(&mut self.terminal, dimensions, options)
|
||||
}
|
||||
}
|
||||
@ -112,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,
|
||||
|
||||
@ -134,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,11 @@ 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;
|
||||
|
||||
@ -101,7 +105,7 @@ pub(crate) struct ImageRenderProperties {
|
||||
pub(crate) size: ImageSize,
|
||||
pub(crate) restore_cursor: bool,
|
||||
pub(crate) background_color: Option<Color>,
|
||||
pub(crate) center: bool,
|
||||
pub(crate) position: ImagePosition,
|
||||
}
|
||||
|
||||
impl Default for ImageRenderProperties {
|
||||
@ -111,11 +115,18 @@ impl Default for ImageRenderProperties {
|
||||
size: Default::default(),
|
||||
restore_cursor: false,
|
||||
background_color: None,
|
||||
center: true,
|
||||
position: ImagePosition::Center,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) enum ImagePosition {
|
||||
Cursor,
|
||||
Center,
|
||||
Right,
|
||||
}
|
||||
|
||||
/// The size used when printing an image.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub(crate) enum ImageSize {
|
||||
@ -153,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
|
||||
}
|
||||
}
|
||||
|
@ -92,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,
|
||||
|
@ -5,7 +5,7 @@ use crate::{
|
||||
text_style::{Color, Colors, TextStyle},
|
||||
},
|
||||
render::{RenderError, RenderResult, layout::Positioning},
|
||||
terminal::printer::{TerminalIo, TextProperties},
|
||||
terminal::printer::{TerminalCommand, TerminalIo},
|
||||
};
|
||||
|
||||
/// Draws text on the screen.
|
||||
@ -21,7 +21,7 @@ pub(crate) struct TextDrawer<'a> {
|
||||
draw_block: bool,
|
||||
block_color: Option<Color>,
|
||||
repeat_prefix: bool,
|
||||
properties: TextProperties,
|
||||
center_newlines: bool,
|
||||
}
|
||||
|
||||
impl<'a> TextDrawer<'a> {
|
||||
@ -56,7 +56,7 @@ impl<'a> TextDrawer<'a> {
|
||||
draw_block: false,
|
||||
block_color: None,
|
||||
repeat_prefix: false,
|
||||
properties: TextProperties { height: line.font_size() },
|
||||
center_newlines: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -72,6 +72,11 @@ 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.
|
||||
@ -80,33 +85,42 @@ impl<'a> TextDrawer<'a> {
|
||||
T: TerminalIo,
|
||||
{
|
||||
let mut line_length: u16 = 0;
|
||||
terminal.move_to_column(self.positioning.start_column)?;
|
||||
terminal.execute(&TerminalCommand::MoveToColumn(self.positioning.start_column))?;
|
||||
let font_size = self.line.font_size();
|
||||
|
||||
// Print the prefix at the beginning of the line.
|
||||
if self.prefix_width > 0 {
|
||||
let Text { content, style } = self.prefix.text();
|
||||
terminal.print_text(content, style, &self.properties)?;
|
||||
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(self.properties.height as u16)?;
|
||||
terminal.move_to_column(self.positioning.start_column)?;
|
||||
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_width > 0 {
|
||||
if self.repeat_prefix {
|
||||
let Text { content, style } = self.prefix.text();
|
||||
terminal.print_text(content, style, &self.properties)?;
|
||||
terminal.execute(&TerminalCommand::PrintText { content, style: *style })?;
|
||||
} else {
|
||||
if let Some(color) = self.block_color {
|
||||
terminal.set_background_color(color)?;
|
||||
terminal.execute(&TerminalCommand::SetBackgroundColor(color))?;
|
||||
}
|
||||
let text = " ".repeat(self.prefix_width as usize / self.properties.height as usize);
|
||||
let style = TextStyle::default().size(self.properties.height);
|
||||
terminal.print_text(&text, &style, &self.properties)?;
|
||||
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 })?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -114,12 +128,12 @@ impl<'a> TextDrawer<'a> {
|
||||
line_length = line_length.saturating_add(chunk.width() as u16);
|
||||
|
||||
let (text, style) = chunk.into_parts();
|
||||
terminal.print_text(text, &style, &self.properties)?;
|
||||
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))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -135,12 +149,13 @@ impl<'a> TextDrawer<'a> {
|
||||
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 / self.properties.height as usize);
|
||||
let style = TextStyle::default().size(self.properties.height);
|
||||
terminal.print_text(&text, &style, &self.properties)?;
|
||||
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(())
|
||||
@ -150,7 +165,7 @@ impl<'a> TextDrawer<'a> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::terminal::image::{Image, printer::PrintOptions};
|
||||
use crate::terminal::printer::TerminalError;
|
||||
use std::io;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
@ -172,28 +187,6 @@ mod tests {
|
||||
self.instructions.push(instruction);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl TerminalIo for TerminalBuf {
|
||||
fn begin_update(&mut self) -> std::io::Result<()> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
fn end_update(&mut self) -> std::io::Result<()> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
fn cursor_row(&self) -> u16 {
|
||||
self.cursor_row
|
||||
}
|
||||
|
||||
fn move_to(&mut self, _column: u16, _row: u16) -> std::io::Result<()> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
fn move_to_row(&mut self, _row: u16) -> std::io::Result<()> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
fn move_to_column(&mut self, column: u16) -> std::io::Result<()> {
|
||||
self.push(Instruction::MoveToColumn(column))
|
||||
@ -203,11 +196,7 @@ mod tests {
|
||||
self.push(Instruction::MoveDown(amount))
|
||||
}
|
||||
|
||||
fn move_to_next_line(&mut self) -> std::io::Result<()> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
fn print_text(&mut self, content: &str, style: &TextStyle, _properties: &TextProperties) -> io::Result<()> {
|
||||
fn print_text(&mut self, content: &str, style: &TextStyle) -> io::Result<()> {
|
||||
let content = content.to_string();
|
||||
if content.is_empty() {
|
||||
return Ok(());
|
||||
@ -232,21 +221,35 @@ mod tests {
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn print_image(
|
||||
&mut self,
|
||||
_image: &Image,
|
||||
_options: &PrintOptions,
|
||||
) -> Result<(), crate::terminal::image::printer::PrintImageError> {
|
||||
unimplemented!()
|
||||
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 suspend(&mut self) {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
fn resume(&mut self) {
|
||||
unimplemented!()
|
||||
fn cursor_row(&self) -> u16 {
|
||||
self.cursor_row
|
||||
}
|
||||
}
|
||||
|
||||
@ -255,6 +258,7 @@ mod tests {
|
||||
positioning: Positioning,
|
||||
right_padding_length: u16,
|
||||
repeat_prefix_on_wrap: bool,
|
||||
center_newlines: bool,
|
||||
}
|
||||
|
||||
impl TestDrawer {
|
||||
@ -278,12 +282,18 @@ mod tests {
|
||||
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);
|
||||
.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
|
||||
@ -297,6 +307,7 @@ mod tests {
|
||||
positioning: Positioning { max_line_length: 100, start_column: 0 },
|
||||
right_padding_length: 0,
|
||||
repeat_prefix_on_wrap: false,
|
||||
center_newlines: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -350,4 +361,18 @@ mod tests {
|
||||
];
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
use crate::{
|
||||
terminal::image::{
|
||||
Image,
|
||||
printer::{ImageRegistry, RegisterImageError},
|
||||
printer::{ImageRegistry, ImageSpec, RegisterImageError},
|
||||
},
|
||||
theme::{raw::PresentationTheme, registry::LoadThemeError},
|
||||
};
|
||||
@ -24,8 +24,6 @@ const LOOP_INTERVAL: Duration = Duration::from_millis(250);
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ResourcesInner {
|
||||
images: HashMap<PathBuf, Image>,
|
||||
theme_images: HashMap<PathBuf, Image>,
|
||||
themes: HashMap<PathBuf, PresentationTheme>,
|
||||
external_snippets: HashMap<PathBuf, String>,
|
||||
base_path: PathBuf,
|
||||
@ -56,8 +54,6 @@ impl Resources {
|
||||
let inner = ResourcesInner {
|
||||
base_path: base_path.into(),
|
||||
themes_path: themes_path.into(),
|
||||
images: Default::default(),
|
||||
theme_images: Default::default(),
|
||||
themes: Default::default(),
|
||||
external_snippets: Default::default(),
|
||||
image_registry,
|
||||
@ -73,14 +69,9 @@ impl Resources {
|
||||
|
||||
/// Get the image at the given path.
|
||||
pub(crate) fn image<P: AsRef<Path>>(&self, path: P) -> Result<Image, RegisterImageError> {
|
||||
let mut inner = self.inner.borrow_mut();
|
||||
let inner = self.inner.borrow();
|
||||
let path = inner.base_path.join(path);
|
||||
if let Some(image) = inner.images.get(&path) {
|
||||
return Ok(image.clone());
|
||||
}
|
||||
|
||||
let image = inner.image_registry.register_resource(path.clone())?;
|
||||
inner.images.insert(path, image.clone());
|
||||
let image = inner.image_registry.register(ImageSpec::Filesystem(path.clone()))?;
|
||||
Ok(image)
|
||||
}
|
||||
|
||||
@ -91,14 +82,9 @@ impl Resources {
|
||||
_ => (),
|
||||
};
|
||||
|
||||
let mut inner = self.inner.borrow_mut();
|
||||
let inner = self.inner.borrow();
|
||||
let path = inner.themes_path.join(path);
|
||||
if let Some(image) = inner.theme_images.get(&path) {
|
||||
return Ok(image.clone());
|
||||
}
|
||||
|
||||
let image = inner.image_registry.register_resource(path.clone())?;
|
||||
inner.theme_images.insert(path, image.clone());
|
||||
let image = inner.image_registry.register(ImageSpec::Filesystem(path.clone()))?;
|
||||
Ok(image)
|
||||
}
|
||||
|
||||
@ -144,7 +130,7 @@ impl Resources {
|
||||
/// Clears all resources.
|
||||
pub(crate) fn clear(&self) {
|
||||
let mut inner = self.inner.borrow_mut();
|
||||
inner.images.clear();
|
||||
inner.image_registry.clear();
|
||||
inner.themes.clear();
|
||||
}
|
||||
}
|
||||
|
@ -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()]);
|
||||
}
|
||||
}
|
||||
|
@ -85,6 +85,7 @@ impl TerminalCapabilities {
|
||||
response.font_size = true;
|
||||
}
|
||||
stdout.queue(terminal::LeaveAlternateScreen)?;
|
||||
stdout.flush()?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
|
@ -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()?,
|
||||
};
|
||||
@ -110,6 +114,10 @@ impl ImagePrinter {
|
||||
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 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::{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 {
|
||||
@ -29,32 +38,35 @@ 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,
|
||||
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,14 +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 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},
|
||||
};
|
||||
@ -71,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
|
||||
@ -147,15 +168,15 @@ impl KittyPrinter {
|
||||
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),
|
||||
@ -174,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() {
|
||||
@ -222,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 {
|
||||
@ -233,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),
|
||||
@ -242,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),
|
||||
@ -257,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(())
|
||||
}
|
||||
|
||||
@ -266,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"));
|
||||
@ -281,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));
|
||||
|
||||
@ -310,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 {
|
||||
@ -321,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.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"));
|
||||
}
|
||||
@ -338,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(())
|
||||
}
|
||||
@ -370,33 +395,25 @@ 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)?,
|
||||
};
|
||||
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,12 +1,32 @@
|
||||
use crate::render::properties::{CursorPosition, WindowSize};
|
||||
|
||||
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;
|
||||
|
||||
/// 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;
|
||||
}
|
||||
|
||||
pub(crate) struct ImageScaler {
|
||||
horizontal_margin: f64,
|
||||
}
|
||||
|
||||
impl ImageScaler {
|
||||
/// Scale an image to a specific size.
|
||||
pub(crate) fn scale_image(
|
||||
impl ScaleImage for ImageScaler {
|
||||
fn scale_image(
|
||||
&self,
|
||||
scale_size: &WindowSize,
|
||||
window_dimensions: &WindowSize,
|
||||
@ -23,8 +43,7 @@ impl ImageScaler {
|
||||
self.fit_image_to_rect(window_dimensions, image_width as u32, image_height as u32, position)
|
||||
}
|
||||
|
||||
/// Shrink an image so it fits the dimensions of the layout it's being displayed in.
|
||||
pub(crate) fn fit_image_to_rect(
|
||||
fn fit_image_to_rect(
|
||||
&self,
|
||||
dimensions: &WindowSize,
|
||||
image_width: u32,
|
||||
|
@ -3,6 +3,7 @@ pub(crate) mod capabilities;
|
||||
pub(crate) mod emulator;
|
||||
pub(crate) mod image;
|
||||
pub(crate) mod printer;
|
||||
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,
|
||||
}
|
||||
|
@ -14,23 +14,37 @@ use std::{
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
#[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 },
|
||||
}
|
||||
|
||||
pub(crate) trait TerminalIo {
|
||||
fn begin_update(&mut self) -> io::Result<()>;
|
||||
fn end_update(&mut self) -> io::Result<()>;
|
||||
fn execute(&mut self, command: &TerminalCommand<'_>) -> Result<(), TerminalError>;
|
||||
fn cursor_row(&self) -> u16;
|
||||
fn move_to(&mut self, column: u16, row: u16) -> io::Result<()>;
|
||||
fn move_to_row(&mut self, row: u16) -> io::Result<()>;
|
||||
fn move_to_column(&mut self, column: u16) -> io::Result<()>;
|
||||
fn move_down(&mut self, amount: u16) -> io::Result<()>;
|
||||
fn move_to_next_line(&mut self) -> io::Result<()>;
|
||||
fn print_text(&mut self, content: &str, style: &TextStyle, properties: &TextProperties) -> io::Result<()>;
|
||||
fn clear_screen(&mut self) -> io::Result<()>;
|
||||
fn set_colors(&mut self, colors: Colors) -> io::Result<()>;
|
||||
fn set_background_color(&mut self, color: Color) -> io::Result<()>;
|
||||
fn flush(&mut self) -> io::Result<()>;
|
||||
fn print_image(&mut self, image: &Image, options: &PrintOptions) -> Result<(), PrintImageError>;
|
||||
fn suspend(&mut self);
|
||||
fn resume(&mut self);
|
||||
}
|
||||
|
||||
#[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.
|
||||
@ -46,9 +60,7 @@ impl<I: TerminalWrite> Terminal<I> {
|
||||
writer.init()?;
|
||||
Ok(Self { writer, image_printer, cursor_row: 0, current_row_height: 1 })
|
||||
}
|
||||
}
|
||||
|
||||
impl<I: TerminalWrite> TerminalIo for Terminal<I> {
|
||||
fn begin_update(&mut self) -> io::Result<()> {
|
||||
self.writer.queue(terminal::BeginSynchronizedUpdate)?;
|
||||
Ok(())
|
||||
@ -59,10 +71,6 @@ impl<I: TerminalWrite> TerminalIo for Terminal<I> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cursor_row(&self) -> u16 {
|
||||
self.cursor_row
|
||||
}
|
||||
|
||||
fn move_to(&mut self, column: u16, row: u16) -> io::Result<()> {
|
||||
self.writer.queue(cursor::MoveTo(column, row))?;
|
||||
self.cursor_row = row;
|
||||
@ -86,6 +94,16 @@ impl<I: TerminalWrite> TerminalIo for Terminal<I> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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))?;
|
||||
@ -94,16 +112,17 @@ impl<I: TerminalWrite> TerminalIo for Terminal<I> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_text(&mut self, content: &str, style: &TextStyle, properties: &TextProperties) -> 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(properties.height as u16);
|
||||
self.current_row_height = self.current_row_height.max(style.size as u16);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
@ -126,38 +145,55 @@ impl<I: TerminalWrite> TerminalIo for Terminal<I> {
|
||||
}
|
||||
|
||||
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)?;
|
||||
let image_printer = self.image_printer.clone();
|
||||
image_printer.print(image.image(), options, self)?;
|
||||
self.cursor_row += options.rows;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn suspend(&mut self) {
|
||||
pub(crate) fn suspend(&mut self) {
|
||||
self.writer.deinit();
|
||||
}
|
||||
|
||||
fn resume(&mut self) {
|
||||
pub(crate) fn resume(&mut self) {
|
||||
let _ = self.writer.init();
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct TextProperties {
|
||||
pub(crate) height: u8,
|
||||
}
|
||||
|
||||
impl Default for TextProperties {
|
||||
fn default() -> Self {
|
||||
Self { height: 1 }
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn should_hide_cursor() -> bool {
|
||||
// WezTerm on Windows fails to display images if we've hidden the cursor so we **always** hide it
|
||||
// unless we're on WezTerm on Windows.
|
||||
|
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")]);
|
||||
}
|
||||
}
|
@ -105,7 +105,7 @@ impl PresentationTheme {
|
||||
Heading4 => self.headings.h4.alignment,
|
||||
Heading5 => self.headings.h5.alignment,
|
||||
Heading6 => self.headings.h6.alignment,
|
||||
Paragraph | List => Default::default(),
|
||||
Paragraph => Default::default(),
|
||||
PresentationTitle => self.intro_slide.title.alignment,
|
||||
PresentationSubTitle => self.intro_slide.subtitle.alignment,
|
||||
PresentationEvent => self.intro_slide.event.alignment,
|
||||
@ -444,6 +444,15 @@ pub(crate) enum Alignment {
|
||||
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 {
|
||||
@ -465,7 +474,7 @@ pub(crate) enum FooterStyle {
|
||||
Template {
|
||||
left: Option<FooterContent>,
|
||||
center: Option<FooterContent>,
|
||||
right: Option<FooterTemplate>,
|
||||
right: Option<FooterContent>,
|
||||
style: TextStyle,
|
||||
height: u16,
|
||||
},
|
||||
@ -487,7 +496,7 @@ impl FooterStyle {
|
||||
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.clone();
|
||||
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 })
|
||||
@ -618,7 +627,6 @@ pub(crate) enum ElementType {
|
||||
Heading5,
|
||||
Heading6,
|
||||
Paragraph,
|
||||
List,
|
||||
PresentationTitle,
|
||||
PresentationSubTitle,
|
||||
PresentationEvent,
|
||||
|
@ -2,7 +2,6 @@ use super::registry::LoadThemeError;
|
||||
use crate::markdown::text_style::{Color, Colors, UndefinedPaletteColorError};
|
||||
use hex::{FromHex, FromHexError};
|
||||
use serde::{Deserialize, Serialize, de::Visitor};
|
||||
use serde_with::{DeserializeFromStr, SerializeDisplay};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fmt, fs,
|
||||
@ -412,7 +411,7 @@ pub(super) enum FooterStyle {
|
||||
center: Option<FooterContent>,
|
||||
|
||||
/// The content to be put on the right.
|
||||
right: Option<FooterTemplate>,
|
||||
right: Option<FooterContent>,
|
||||
|
||||
/// The colors to be used.
|
||||
#[serde(default)]
|
||||
@ -508,9 +507,12 @@ impl<'de> Deserialize<'de> for FooterContent {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, SerializeDisplay, DeserializeFromStr)]
|
||||
#[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;
|
||||
|
||||
@ -801,7 +803,7 @@ pub(super) struct ColorPalette {
|
||||
pub(super) classes: BTreeMap<String, RawColors>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, SerializeDisplay, DeserializeFromStr)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum RawColor {
|
||||
Color(Color),
|
||||
Palette(String),
|
||||
@ -809,6 +811,9 @@ pub(crate) enum RawColor {
|
||||
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())) }
|
||||
@ -823,7 +828,6 @@ impl RawColor {
|
||||
Self::Palette(name) => {
|
||||
Some(palette.colors.get(name).copied().ok_or(UndefinedPaletteColorError(name.clone()))?)
|
||||
}
|
||||
// TODO better error
|
||||
Self::ForegroundClass(name) => {
|
||||
palette.classes.get(name).ok_or(UndefinedPaletteColorError(name.clone()))?.foreground
|
||||
}
|
||||
|
@ -5,14 +5,17 @@ use crate::{
|
||||
elements::{Line, Percent, Text},
|
||||
text_style::{Color, TextStyle},
|
||||
},
|
||||
presentation::{AsyncPresentationError, AsyncPresentationErrorHolder},
|
||||
render::{
|
||||
operation::{
|
||||
AsRenderOperations, ImageRenderProperties, ImageSize, RenderAsync, RenderAsyncState, RenderOperation,
|
||||
AsRenderOperations, ImageRenderProperties, ImageSize, Pollable, PollableState, RenderAsync,
|
||||
RenderAsyncStartPolicy, RenderOperation,
|
||||
},
|
||||
properties::WindowSize,
|
||||
},
|
||||
terminal::image::{Image, printer::RegisterImageError},
|
||||
terminal::image::{
|
||||
Image,
|
||||
printer::{ImageSpec, RegisterImageError},
|
||||
},
|
||||
theme::{Alignment, MermaidStyle, PresentationTheme, TypstStyle, raw::RawColor},
|
||||
tools::{ExecutionError, ThirdPartyTools},
|
||||
};
|
||||
@ -50,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.style, error_holder, slide, width));
|
||||
let operation = Rc::new(RenderThirdParty::new(result, theme.default_style.style, width));
|
||||
Ok(RenderOperation::RenderAsync(operation))
|
||||
}
|
||||
}
|
||||
@ -269,7 +270,7 @@ impl Worker {
|
||||
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)
|
||||
}
|
||||
@ -308,54 +309,32 @@ struct ImageSnippet {
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct RenderThirdParty {
|
||||
contents: Arc<Mutex<Option<Image>>>,
|
||||
contents: Arc<Mutex<Option<Output>>>,
|
||||
pending_result: Arc<Mutex<RenderResult>>,
|
||||
default_style: TextStyle,
|
||||
error_holder: AsyncPresentationErrorHolder,
|
||||
slide: usize,
|
||||
width: Option<Percent>,
|
||||
}
|
||||
|
||||
impl RenderThirdParty {
|
||||
fn new(
|
||||
pending_result: Arc<Mutex<RenderResult>>,
|
||||
default_style: TextStyle,
|
||||
error_holder: AsyncPresentationErrorHolder,
|
||||
slide: usize,
|
||||
width: Option<Percent>,
|
||||
) -> Self {
|
||||
Self { contents: Default::default(), pending_result, default_style, 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(),
|
||||
@ -368,6 +347,7 @@ impl AsRenderOperations for RenderThirdParty {
|
||||
|
||||
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 {
|
||||
@ -378,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,495 +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, 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,
|
||||
style: ExecutionStatusBlockStyle,
|
||||
block_length: u16,
|
||||
alignment: Alignment,
|
||||
inner: Rc<RefCell<RunSnippetOperationInner>>,
|
||||
state_description: RefCell<Text>,
|
||||
separator: DisplaySeparator,
|
||||
font_size: u8,
|
||||
}
|
||||
|
||||
impl RunSnippetOperation {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn new(
|
||||
code: Snippet,
|
||||
executor: Rc<SnippetExecutor>,
|
||||
default_colors: Colors,
|
||||
style: ExecutionOutputBlockStyle,
|
||||
block_length: u16,
|
||||
separator: DisplaySeparator,
|
||||
alignment: Alignment,
|
||||
font_size: u8,
|
||||
) -> Self {
|
||||
let block_colors = style.style.colors;
|
||||
let status_colors = style.status.clone();
|
||||
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().size(font_size),
|
||||
last_length: 0,
|
||||
};
|
||||
Self {
|
||||
code,
|
||||
executor,
|
||||
default_colors,
|
||||
block_colors,
|
||||
style: status_colors,
|
||||
block_length,
|
||||
alignment,
|
||||
inner: Rc::new(RefCell::new(inner)),
|
||||
state_description: Text::new("not started", style.status.not_started_style).into(),
|
||||
separator,
|
||||
font_size,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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, self.font_size);
|
||||
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,
|
||||
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", self.style.running_style),
|
||||
ProcessStatus::Success => Text::new("finished", self.style.success_style),
|
||||
ProcessStatus::Failure => Text::new("finished with error", self.style.failure_style),
|
||||
};
|
||||
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 {
|
||||
style: TextStyle,
|
||||
alignment: Alignment,
|
||||
started: RefCell<bool>,
|
||||
}
|
||||
|
||||
impl SnippetExecutionDisabledOperation {
|
||||
pub(crate) fn new(style: TextStyle, alignment: Alignment) -> Self {
|
||||
Self { style, 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", self.style)].into(),
|
||||
alignment: self.alignment,
|
||||
},
|
||||
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>,
|
||||
font_size: u8,
|
||||
}
|
||||
|
||||
impl RunAcquireTerminalSnippet {
|
||||
pub(crate) fn new(
|
||||
snippet: Snippet,
|
||||
executor: Rc<SnippetExecutor>,
|
||||
colors: ExecutionStatusBlockStyle,
|
||||
block_length: u16,
|
||||
font_size: u8,
|
||||
) -> Self {
|
||||
Self { snippet, block_length, executor, colors, state: Default::default(), font_size }
|
||||
}
|
||||
}
|
||||
|
||||
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", self.colors.not_started_style),
|
||||
AcquireTerminalSnippetState::Success => Text::new("finished", self.colors.success_style),
|
||||
AcquireTerminalSnippetState::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 AcquireTerminalSnippetState::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 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::default())]
|
||||
}
|
||||
RunImageSnippetState::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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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>),
|
||||
}
|
9
src/ui/execution/mod.rs
Normal file
9
src/ui/execution/mod.rs
Normal file
@ -0,0 +1,9 @@
|
||||
pub(crate) mod acquire_terminal;
|
||||
pub(crate) mod disabled;
|
||||
pub(crate) mod image;
|
||||
pub(crate) mod snippet;
|
||||
|
||||
pub(crate) use acquire_terminal::RunAcquireTerminalSnippet;
|
||||
pub(crate) use disabled::SnippetExecutionDisabledOperation;
|
||||
pub(crate) use image::RunImageSnippet;
|
||||
pub(crate) use snippet::RunSnippetOperation;
|
243
src/ui/execution/snippet.rs
Normal file
243
src/ui/execution/snippet.rs
Normal file
@ -0,0 +1,243 @@
|
||||
use crate::{
|
||||
code::{
|
||||
execute::{ExecutionHandle, ExecutionState, ProcessStatus, SnippetExecutor},
|
||||
snippet::Snippet,
|
||||
},
|
||||
markdown::{
|
||||
elements::{Line, Text},
|
||||
text::WeightedLine,
|
||||
text_style::{Colors, TextStyle},
|
||||
},
|
||||
render::{
|
||||
operation::{
|
||||
AsRenderOperations, BlockLine, Pollable, PollableState, RenderAsync, RenderAsyncStartPolicy,
|
||||
RenderOperation,
|
||||
},
|
||||
properties::WindowSize,
|
||||
},
|
||||
terminal::ansi::AnsiSplitter,
|
||||
theme::{Alignment, ExecutionOutputBlockStyle, ExecutionStatusBlockStyle},
|
||||
ui::separator::{RenderSeparator, SeparatorWidth},
|
||||
};
|
||||
use std::{
|
||||
io::BufRead,
|
||||
rc::Rc,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
const MINIMUM_SEPARATOR_WIDTH: u16 = 32;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Inner {
|
||||
output_lines: Vec<WeightedLine>,
|
||||
max_line_length: u16,
|
||||
process_status: Option<ProcessStatus>,
|
||||
started: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct RunSnippetOperation {
|
||||
code: Snippet,
|
||||
executor: Arc<SnippetExecutor>,
|
||||
default_colors: Colors,
|
||||
block_colors: Colors,
|
||||
style: ExecutionStatusBlockStyle,
|
||||
block_length: u16,
|
||||
alignment: Alignment,
|
||||
inner: Arc<Mutex<Inner>>,
|
||||
separator: DisplaySeparator,
|
||||
font_size: u8,
|
||||
policy: RenderAsyncStartPolicy,
|
||||
}
|
||||
|
||||
impl RunSnippetOperation {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn new(
|
||||
code: Snippet,
|
||||
executor: Arc<SnippetExecutor>,
|
||||
default_colors: Colors,
|
||||
style: ExecutionOutputBlockStyle,
|
||||
block_length: u16,
|
||||
separator: DisplaySeparator,
|
||||
alignment: Alignment,
|
||||
font_size: u8,
|
||||
policy: RenderAsyncStartPolicy,
|
||||
) -> Self {
|
||||
let block_colors = style.style.colors;
|
||||
let status_colors = style.status.clone();
|
||||
let block_length = alignment.adjust_size(block_length);
|
||||
let inner = Inner { output_lines: Vec::new(), max_line_length: 0, process_status: None, started: false };
|
||||
Self {
|
||||
code,
|
||||
executor,
|
||||
default_colors,
|
||||
block_colors,
|
||||
style: status_colors,
|
||||
block_length,
|
||||
alignment,
|
||||
inner: Arc::new(Mutex::new(inner)),
|
||||
separator,
|
||||
font_size,
|
||||
policy,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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.lock().unwrap();
|
||||
let description = match &inner.process_status {
|
||||
Some(ProcessStatus::Running) => Text::new("running", self.style.running_style),
|
||||
Some(ProcessStatus::Success) => Text::new("finished", self.style.success_style),
|
||||
Some(ProcessStatus::Failure) => Text::new("finished with error", self.style.failure_style),
|
||||
None => Text::new("not started", self.style.not_started_style),
|
||||
};
|
||||
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, self.font_size);
|
||||
vec![
|
||||
RenderOperation::RenderLineBreak,
|
||||
RenderOperation::RenderDynamic(Rc::new(separator)),
|
||||
RenderOperation::RenderLineBreak,
|
||||
]
|
||||
}
|
||||
DisplaySeparator::Off => vec![],
|
||||
};
|
||||
if !inner.started {
|
||||
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,
|
||||
block_color: self.block_colors.background,
|
||||
}));
|
||||
operations.push(RenderOperation::RenderLineBreak);
|
||||
}
|
||||
operations.push(RenderOperation::SetColors(self.default_colors));
|
||||
operations
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderAsync for RunSnippetOperation {
|
||||
fn pollable(&self) -> Box<dyn Pollable> {
|
||||
Box::new(OperationPollable {
|
||||
inner: self.inner.clone(),
|
||||
executor: self.executor.clone(),
|
||||
code: self.code.clone(),
|
||||
handle: None,
|
||||
last_length: 0,
|
||||
starting_style: TextStyle::default().size(self.font_size),
|
||||
})
|
||||
}
|
||||
|
||||
fn start_policy(&self) -> RenderAsyncStartPolicy {
|
||||
self.policy
|
||||
}
|
||||
}
|
||||
|
||||
struct OperationPollable {
|
||||
inner: Arc<Mutex<Inner>>,
|
||||
executor: Arc<SnippetExecutor>,
|
||||
code: Snippet,
|
||||
handle: Option<ExecutionHandle>,
|
||||
last_length: usize,
|
||||
starting_style: TextStyle,
|
||||
}
|
||||
|
||||
impl OperationPollable {
|
||||
fn try_start(&mut self) {
|
||||
let mut inner = self.inner.lock().unwrap();
|
||||
if inner.started {
|
||||
return;
|
||||
}
|
||||
inner.started = true;
|
||||
match self.executor.execute_async(&self.code) {
|
||||
Ok(handle) => {
|
||||
self.handle = Some(handle);
|
||||
}
|
||||
Err(e) => {
|
||||
inner.output_lines = vec![WeightedLine::from(e.to_string())];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Pollable for OperationPollable {
|
||||
fn poll(&mut self) -> PollableState {
|
||||
self.try_start();
|
||||
|
||||
// At this point if we don't have a handle it's because we're done.
|
||||
let Some(handle) = self.handle.as_mut() else { return PollableState::Done };
|
||||
|
||||
// Pull data out of the process' output and drop the handle state.
|
||||
let mut state = handle.state.lock().unwrap();
|
||||
let ExecutionState { output, status } = &mut *state;
|
||||
let status = status.clone();
|
||||
|
||||
let modified = output.len() != self.last_length;
|
||||
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(self.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);
|
||||
}
|
||||
|
||||
let mut inner = self.inner.lock().unwrap();
|
||||
let is_finished = status.is_finished();
|
||||
inner.process_status = Some(status);
|
||||
inner.output_lines = lines;
|
||||
inner.max_line_length = inner.max_line_length.max(max_line_length);
|
||||
if is_finished {
|
||||
self.handle.take();
|
||||
PollableState::Done
|
||||
} else {
|
||||
// Save the style so we continue with it next time
|
||||
self.starting_style = style;
|
||||
match modified {
|
||||
true => PollableState::Modified,
|
||||
false => PollableState::Unmodified,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -5,7 +5,7 @@ use crate::{
|
||||
text_style::{TextStyle, UndefinedPaletteColorError},
|
||||
},
|
||||
render::{
|
||||
operation::{AsRenderOperations, ImageRenderProperties, MarginProperties, RenderOperation},
|
||||
operation::{AsRenderOperations, ImagePosition, ImageRenderProperties, MarginProperties, RenderOperation},
|
||||
properties::WindowSize,
|
||||
},
|
||||
terminal::image::Image,
|
||||
@ -53,14 +53,8 @@ impl FooterGenerator {
|
||||
]);
|
||||
}
|
||||
|
||||
fn push_image(
|
||||
&self,
|
||||
image: &Image,
|
||||
alignment: Alignment,
|
||||
dimensions: &WindowSize,
|
||||
operations: &mut Vec<RenderOperation>,
|
||||
) {
|
||||
let mut properties = ImageRenderProperties { center: false, ..Default::default() };
|
||||
fn push_image(&self, image: &Image, alignment: Alignment, operations: &mut Vec<RenderOperation>) {
|
||||
let mut properties = ImageRenderProperties::default();
|
||||
|
||||
operations.push(RenderOperation::ApplyMargin(MarginProperties {
|
||||
horizontal: Margin::Fixed(0),
|
||||
@ -70,11 +64,12 @@ impl FooterGenerator {
|
||||
match alignment {
|
||||
Alignment::Left { .. } => {
|
||||
operations.push(RenderOperation::JumpToColumn { index: 0 });
|
||||
properties.position = ImagePosition::Cursor;
|
||||
}
|
||||
Alignment::Right { .. } => {
|
||||
operations.push(RenderOperation::JumpToColumn { index: dimensions.columns.saturating_sub(1) });
|
||||
properties.position = ImagePosition::Right;
|
||||
}
|
||||
Alignment::Center { .. } => properties.center = true,
|
||||
Alignment::Center { .. } => properties.position = ImagePosition::Center,
|
||||
};
|
||||
operations.extend([
|
||||
// Start printing the image at the top of the footer rect
|
||||
@ -101,23 +96,20 @@ impl AsRenderOperations for FooterGenerator {
|
||||
let alignments = [
|
||||
Alignment::Left { margin: Default::default() },
|
||||
Alignment::Center { minimum_size: 0, minimum_margin: Default::default() },
|
||||
Alignment::Right { margin: Default::default() },
|
||||
];
|
||||
for (content, alignment) in [left, center].iter().zip(alignments) {
|
||||
for (content, alignment) in [left, center, right].iter().zip(alignments) {
|
||||
if let Some(content) = content {
|
||||
match content {
|
||||
RenderedFooterContent::Line(line) => {
|
||||
Self::render_line(line, alignment, *height, &mut operations);
|
||||
}
|
||||
RenderedFooterContent::Image(image) => {
|
||||
self.push_image(image, alignment, dimensions, &mut operations);
|
||||
self.push_image(image, alignment, &mut operations);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
// We don't support images on the right so treat this differently
|
||||
if let Some(line) = right {
|
||||
Self::render_line(line, Alignment::Right { margin: Default::default() }, *height, &mut operations);
|
||||
}
|
||||
operations.push(RenderOperation::PopMargin);
|
||||
operations
|
||||
}
|
||||
@ -146,7 +138,7 @@ enum RenderedFooterStyle {
|
||||
Template {
|
||||
left: Option<RenderedFooterContent>,
|
||||
center: Option<RenderedFooterContent>,
|
||||
right: Option<FooterLine>,
|
||||
right: Option<RenderedFooterContent>,
|
||||
height: u16,
|
||||
},
|
||||
ProgressBar {
|
||||
@ -166,7 +158,7 @@ impl RenderedFooterStyle {
|
||||
FooterStyle::Template { left, center, right, style, height } => {
|
||||
let left = left.map(|c| RenderedFooterContent::new(c, &style, vars, palette)).transpose()?;
|
||||
let center = center.map(|c| RenderedFooterContent::new(c, &style, vars, palette)).transpose()?;
|
||||
let right = right.map(|c| FooterLine::new(c, &style, vars, palette)).transpose()?;
|
||||
let right = right.map(|c| RenderedFooterContent::new(c, &style, vars, palette)).transpose()?;
|
||||
Ok(Self::Template { left, center, right, height })
|
||||
}
|
||||
FooterStyle::ProgressBar { character, style } => Ok(Self::ProgressBar { character, style }),
|
||||
@ -186,10 +178,9 @@ impl FooterLine {
|
||||
palette: &ColorPalette,
|
||||
) -> Result<Self, InvalidFooterTemplateError> {
|
||||
use FooterTemplateChunk::*;
|
||||
let mut line = Line::default();
|
||||
let FooterVariables { current_slide, total_slides, author, title, sub_title, event, location, date } = vars;
|
||||
let arena = Arena::default();
|
||||
let parser = MarkdownParser::new(&arena);
|
||||
let mut reassembled = String::new();
|
||||
for chunk in template.0 {
|
||||
let raw_text = match chunk {
|
||||
CurrentSlide => Cow::Owned(current_slide.to_string()),
|
||||
@ -207,20 +198,22 @@ impl FooterLine {
|
||||
if raw_text.lines().count() != 1 {
|
||||
return Err(InvalidFooterTemplateError::NoNewlines);
|
||||
}
|
||||
let starting_length = raw_text.len();
|
||||
let raw_text = raw_text.trim_start();
|
||||
let left_whitespace = starting_length - raw_text.len();
|
||||
let raw_text = raw_text.trim_end();
|
||||
let right_whitespace = starting_length - raw_text.len() - left_whitespace;
|
||||
let inlines = parser.parse_inlines(raw_text)?;
|
||||
let mut contents = inlines.resolve(palette)?;
|
||||
if left_whitespace != 0 {
|
||||
contents.0.insert(0, " ".repeat(left_whitespace).into());
|
||||
}
|
||||
if right_whitespace != 0 {
|
||||
contents.0.push(" ".repeat(right_whitespace).into());
|
||||
}
|
||||
line.0.extend(contents.0);
|
||||
reassembled.push_str(&raw_text);
|
||||
}
|
||||
// Inline parsing loses leading/trailing whitespaces so re-add them ourselves
|
||||
let starting_length = reassembled.len();
|
||||
let raw_text = reassembled.trim_start();
|
||||
let left_whitespace = starting_length - raw_text.len();
|
||||
let raw_text = raw_text.trim_end();
|
||||
let right_whitespace = starting_length - raw_text.len() - left_whitespace;
|
||||
let parser = MarkdownParser::new(&arena);
|
||||
let inlines = parser.parse_inlines(&reassembled)?;
|
||||
let mut line = inlines.resolve(palette)?;
|
||||
if left_whitespace != 0 {
|
||||
line.0.insert(0, " ".repeat(left_whitespace).into());
|
||||
}
|
||||
if right_whitespace != 0 {
|
||||
line.0.push(" ".repeat(right_whitespace).into());
|
||||
}
|
||||
line.apply_style(style);
|
||||
Ok(Self(line))
|
||||
@ -328,4 +321,25 @@ mod tests {
|
||||
let template = FooterTemplate(vec![chunk]);
|
||||
FooterLine::new(template, &Default::default(), &VARIABLES, &PALETTE).expect_err("render succeeded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interleaved_spans() {
|
||||
let chunks = vec![
|
||||
FooterTemplateChunk::Literal("<span style=\"color: palette:red\">".into()),
|
||||
FooterTemplateChunk::CurrentSlide,
|
||||
FooterTemplateChunk::Literal(" / ".into()),
|
||||
FooterTemplateChunk::TotalSlides,
|
||||
FooterTemplateChunk::Literal("</span>".into()),
|
||||
FooterTemplateChunk::Literal("<span style=\"color: green\">".into()),
|
||||
FooterTemplateChunk::Title,
|
||||
FooterTemplateChunk::Literal("</span>".into()),
|
||||
];
|
||||
let template = FooterTemplate(chunks);
|
||||
let line = FooterLine::new(template, &Default::default(), &VARIABLES, &PALETTE).expect("render failed");
|
||||
let expected = &[
|
||||
Text::new("1 / 5", TextStyle::default().fg_color(Color::new(255, 0, 0))),
|
||||
Text::new("hi", TextStyle::default().fg_color(Color::Green)),
|
||||
];
|
||||
assert_eq!(line.0.0, expected);
|
||||
}
|
||||
}
|
||||
|
@ -9,7 +9,9 @@ use crate::{
|
||||
},
|
||||
presentation::PresentationState,
|
||||
render::{
|
||||
operation::{AsRenderOperations, ImageRenderProperties, ImageSize, MarginProperties, RenderOperation},
|
||||
operation::{
|
||||
AsRenderOperations, ImagePosition, ImageRenderProperties, ImageSize, MarginProperties, RenderOperation,
|
||||
},
|
||||
properties::WindowSize,
|
||||
},
|
||||
terminal::image::Image,
|
||||
@ -307,7 +309,7 @@ impl AsRenderOperations for CenterModalContent {
|
||||
size: ImageSize::Specific(self.content_width, content_height),
|
||||
restore_cursor: true,
|
||||
background_color: None,
|
||||
center: true,
|
||||
position: ImagePosition::Center,
|
||||
};
|
||||
operations.push(RenderOperation::RenderImage(image.clone(), properties));
|
||||
}
|
||||
|
75
src/utils.rs
Normal file
75
src/utils.rs
Normal file
@ -0,0 +1,75 @@
|
||||
use serde::{Deserializer, Serializer};
|
||||
use std::{
|
||||
fmt::{self, Display},
|
||||
marker::PhantomData,
|
||||
str::FromStr,
|
||||
};
|
||||
|
||||
macro_rules! impl_deserialize_from_str {
|
||||
($ty:ty) => {
|
||||
impl<'de> serde::de::Deserialize<'de> for $ty {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::de::Deserializer<'de>,
|
||||
{
|
||||
$crate::utils::deserialize_from_str(deserializer)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! impl_serialize_from_display {
|
||||
($ty:ty) => {
|
||||
impl serde::Serialize for $ty {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
$crate::utils::serialize_display(self, serializer)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) use impl_deserialize_from_str;
|
||||
pub(crate) use impl_serialize_from_display;
|
||||
|
||||
// Same behavior as serde_with::DeserializeFromStr
|
||||
pub(crate) fn deserialize_from_str<'de, D, T>(deserializer: D) -> Result<T, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
T: FromStr,
|
||||
T::Err: Display,
|
||||
{
|
||||
struct Visitor<S>(PhantomData<S>);
|
||||
|
||||
impl<S> serde::de::Visitor<'_> for Visitor<S>
|
||||
where
|
||||
S: FromStr,
|
||||
<S as FromStr>::Err: Display,
|
||||
{
|
||||
type Value = S;
|
||||
|
||||
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||
write!(formatter, "a string")
|
||||
}
|
||||
|
||||
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
value.parse::<S>().map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_str(Visitor(PhantomData))
|
||||
}
|
||||
|
||||
// Same behavior as serde_with::SerializeDisplay
|
||||
pub(crate) fn serialize_display<T, S>(value: &T, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
T: Display,
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(&value.to_string())
|
||||
}
|
140
themes/gruvbox-dark.yaml
Normal file
140
themes/gruvbox-dark.yaml
Normal file
@ -0,0 +1,140 @@
|
||||
---
|
||||
default:
|
||||
margin:
|
||||
percent: 8
|
||||
colors:
|
||||
foreground: "ebdbb2"
|
||||
background: "282828"
|
||||
|
||||
slide_title:
|
||||
alignment: center
|
||||
padding_bottom: 1
|
||||
padding_top: 1
|
||||
colors:
|
||||
foreground: "fabd2f"
|
||||
bold: true
|
||||
font_size: 2
|
||||
|
||||
code:
|
||||
alignment: center
|
||||
minimum_size: 50
|
||||
minimum_margin:
|
||||
percent: 8
|
||||
theme_name: base16-eighties.dark
|
||||
padding:
|
||||
horizontal: 2
|
||||
vertical: 1
|
||||
|
||||
execution_output:
|
||||
colors:
|
||||
foreground: "ebdbb2"
|
||||
background: "3c3836"
|
||||
status:
|
||||
running:
|
||||
foreground: "83a598"
|
||||
success:
|
||||
foreground: "b8bb26"
|
||||
failure:
|
||||
foreground: "fb4934"
|
||||
not_started:
|
||||
foreground: "fabd2f"
|
||||
|
||||
inline_code:
|
||||
colors:
|
||||
foreground: "b8bb26"
|
||||
|
||||
intro_slide:
|
||||
title:
|
||||
alignment: center
|
||||
colors:
|
||||
foreground: "b8bb26"
|
||||
font_size: 2
|
||||
subtitle:
|
||||
alignment: center
|
||||
colors:
|
||||
foreground: "83a598"
|
||||
event:
|
||||
alignment: center
|
||||
colors:
|
||||
foreground: "b8bb26"
|
||||
location:
|
||||
alignment: center
|
||||
colors:
|
||||
foreground: "83a598"
|
||||
date:
|
||||
alignment: center
|
||||
colors:
|
||||
foreground: "fabd2f"
|
||||
author:
|
||||
alignment: center
|
||||
colors:
|
||||
foreground: "d5c4a1"
|
||||
positioning: page_bottom
|
||||
footer: false
|
||||
|
||||
headings:
|
||||
h1:
|
||||
prefix: "██"
|
||||
colors:
|
||||
foreground: "8ec07c"
|
||||
h2:
|
||||
prefix: "▓▓▓"
|
||||
colors:
|
||||
foreground: "d3869b"
|
||||
h3:
|
||||
prefix: "▒▒▒▒"
|
||||
colors:
|
||||
foreground: "83a598"
|
||||
h4:
|
||||
prefix: "░░░░░"
|
||||
colors:
|
||||
foreground: "fb4934"
|
||||
h5:
|
||||
prefix: "░░░░░░"
|
||||
colors:
|
||||
foreground: "b8bb26"
|
||||
h6:
|
||||
prefix: "░░░░░░░"
|
||||
colors:
|
||||
foreground: "fe8019"
|
||||
|
||||
block_quote:
|
||||
prefix: "▍ "
|
||||
colors:
|
||||
foreground: "ebdbb2"
|
||||
background: "3c3836"
|
||||
prefix: "fabd2f"
|
||||
|
||||
alert:
|
||||
prefix: "▍ "
|
||||
base_colors:
|
||||
foreground: "ebdbb2"
|
||||
background: "3c3836"
|
||||
styles:
|
||||
note:
|
||||
color: "83a598"
|
||||
tip:
|
||||
color: "b8bb26"
|
||||
important:
|
||||
color: "d3869b"
|
||||
warning:
|
||||
color: "fe8019"
|
||||
caution:
|
||||
color: "fb4934"
|
||||
|
||||
typst:
|
||||
colors:
|
||||
foreground: "ebdbb2"
|
||||
background: "3c3836"
|
||||
|
||||
footer:
|
||||
style: template
|
||||
right: "{current_slide} / {total_slides}"
|
||||
|
||||
modals:
|
||||
selection_colors:
|
||||
foreground: "83a598"
|
||||
|
||||
mermaid:
|
||||
background: transparent
|
||||
theme: dark
|
@ -24,19 +24,20 @@ code:
|
||||
padding:
|
||||
horizontal: 2
|
||||
vertical: 1
|
||||
background: false
|
||||
|
||||
execution_output:
|
||||
colors:
|
||||
background: grey
|
||||
foreground: white
|
||||
status:
|
||||
running:
|
||||
foreground: "blue"
|
||||
foreground: blue
|
||||
success:
|
||||
foreground: "green"
|
||||
foreground: green
|
||||
failure:
|
||||
foreground: "red"
|
||||
foreground: red
|
||||
not_started:
|
||||
foreground: "yellow"
|
||||
foreground: yellow
|
||||
|
||||
inline_code:
|
||||
colors:
|
||||
@ -124,7 +125,6 @@ alert:
|
||||
typst:
|
||||
colors:
|
||||
foreground: "f0f0f0"
|
||||
background: "292e42"
|
||||
|
||||
footer:
|
||||
style: template
|
||||
|
@ -24,10 +24,11 @@ code:
|
||||
padding:
|
||||
horizontal: 2
|
||||
vertical: 1
|
||||
background: false
|
||||
|
||||
execution_output:
|
||||
colors:
|
||||
background: grey
|
||||
foreground: black
|
||||
status:
|
||||
running:
|
||||
foreground: dark_blue
|
||||
@ -124,7 +125,6 @@ alert:
|
||||
typst:
|
||||
colors:
|
||||
foreground: "212529"
|
||||
background: "e9ecef"
|
||||
|
||||
footer:
|
||||
style: template
|
||||
|
Loading…
x
Reference in New Issue
Block a user