Compare commits

..

No commits in common. "master" and "v0.10.0" have entirely different histories.

116 changed files with 5882 additions and 11090 deletions

1
.github/FUNDING.yml vendored
View File

@ -1 +0,0 @@
github: mfontanini

View File

@ -36,27 +36,6 @@ 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

View File

@ -1,102 +0,0 @@
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 }}`

View File

@ -1,137 +1,3 @@
# v0.13.0 - 2025-04-25
## Breaking changes
* The CLI parameter to generate the JSON schema for the config file (`--generate-config-file-schema`) is now hidden behind a `json-schema` feature flag. The JSON schema file for the latest version is already publicly available at `https://github.com/mfontanini/presenterm/blob/${VERSION}/config-file-schema.json`, so anyone can use it without having to generate it by hand. This allows cutting down the number of dependencies in this project quite a bit ([#563](https://github.com/mfontanini/presenterm/issues/563)).
## New features
* Support for [slide transitions](https://mfontanini.github.io/presenterm/features/slide-transitions.html) is now available ([#530](https://github.com/mfontanini/presenterm/issues/530)):
* Add fade slide transition ([#534](https://github.com/mfontanini/presenterm/issues/534)).
* Add slide horizontally slide transition animation ([#528](https://github.com/mfontanini/presenterm/issues/528)).
* Add `collapse_horizontal` slide transition ([#560](https://github.com/mfontanini/presenterm/issues/560)).
* Add `--output` option to specify the path where the output file is written to during an export ([#526](https://github.com/mfontanini/presenterm/issues/526)) - thanks @marianozunino.
* Allow specifying [start/end lines](https://mfontanini.github.io/presenterm/features/code/highlighting.html#including-external-code-snippets) in file snippet type ([#565](https://github.com/mfontanini/presenterm/issues/565)).
* Allow letting [pauses become new slides](https://mfontanini.github.io/presenterm/configuration/settings.html#pause-behavior) when exporting ([#557](https://github.com/mfontanini/presenterm/issues/557)).
* Allow [using images on right in footer](https://mfontanini.github.io/presenterm/features/themes/definition.html#footer-images) ([#554](https://github.com/mfontanini/presenterm/issues/554)).
* Add [`max_rows` configuration](https://mfontanini.github.io/presenterm/configuration/settings.html#maximum-presentation-height) to cap vertical size ([#531](https://github.com/mfontanini/presenterm/issues/531)).
* Add julia language highlighting and execution support ([#561](https://github.com/mfontanini/presenterm/issues/561)).
## Fixes
* Center overflow lines when using centered text ([#546](https://github.com/mfontanini/presenterm/issues/546)).
* Don't add extra space before heading if prefix in theme is empty ([#542](https://github.com/mfontanini/presenterm/issues/542)).
* Use no typst background in terminal-* built in themes ([#535](https://github.com/mfontanini/presenterm/issues/535)).
* Use `std::env::temp_dir` in the `external_snippet` test ([#533](https://github.com/mfontanini/presenterm/issues/533)) - thanks @Medovi.
* Respect `extends` in a theme set via `path` in front matter ([#532](https://github.com/mfontanini/presenterm/issues/532)).
## Misc
* Refactor async renders (e.g. mermaid/typst/latex `+render` blocks, `+exec` blocks, etc) to work truly asynchronously. This causes the output to be polled faster, and causes jumping to a slide that contains an async render to take a likely negligible (but maybe noticeable) amount of time to be jumped to. This was needed for slide transitions to work seemlessly ([#556](https://github.com/mfontanini/presenterm/issues/556)).
* Get rid of `textproperties` ([#529](https://github.com/mfontanini/presenterm/issues/529)).
* Add links to presentations using presenterm ([#544](https://github.com/mfontanini/presenterm/issues/544)) - thanks @orhun.
## Performance improvements
* A few performance improvements had to be done for slide transitions to work seemlessly:
* Pre-scale ASCII images when transitions are enabled ([#550](https://github.com/mfontanini/presenterm/issues/550)).
* Pre-scale generated images ([#553](https://github.com/mfontanini/presenterm/issues/553)).
* Cache resized ASCII images ([#547](https://github.com/mfontanini/presenterm/issues/547)).
## ❤️ Sponsors
Thanks to the following users who supported _presenterm_ via a [github sponsorship](https://github.com/sponsors/mfontanini) in this release:
* [@0atman](https://github.com/0atman)
* [@orhun](https://github.com/orhun)
* [@fipoac](https://github.com/fipoac)
# v0.12.0 - 2025-03-24
## Breaking changes
* Using incremental lists now adds an extra pause before and after a list. Use the `defaults.incremental_lists` [configuration parameter](https://mfontanini.github.io/presenterm/features/commands.html#incremental-lists-behavior) to go back to the previous behavior ([#487](https://github.com/mfontanini/presenterm/issues/487)) ([#498](https://github.com/mfontanini/presenterm/issues/498)).
## New features
* [PDF exports](https://mfontanini.github.io/presenterm/features/pdf-export.html) are now generated by invoking [weasyprint](https://pypi.org/project/weasyprint/) rather than by using the now deprecated _presenterm-export_. This gets rid of the need for _tmux_ and opens up the door for other export formats ([#509](https://github.com/mfontanini/presenterm/issues/509)) ([#517](https://github.com/mfontanini/presenterm/issues/517)).
* PDF export dimensions can now also be [specified in the config file](https://mfontanini.github.io/presenterm/configuration/settings.html#pdf-export-size) rather than always having them inferred by the terminal size ([#511](https://github.com/mfontanini/presenterm/issues/511)).
* Allow specifying path for temporary files generated during presentation export ([#518](https://github.com/mfontanini/presenterm/issues/518)).
* Respect font sizes in generated PDF ([#510](https://github.com/mfontanini/presenterm/issues/510)).
* Add [`skip_slide` comment command](https://mfontanini.github.io/presenterm/features/commands.html#skip-slide) to avoid including a slide in the final presentation ([#505](https://github.com/mfontanini/presenterm/issues/505)).
* Add [`alignment` comment](https://mfontanini.github.io/presenterm/features/commands.html#text-alignment) command to specify text alignment for the remainder of a slide ([#493](https://github.com/mfontanini/presenterm/issues/493)) ([#522](https://github.com/mfontanini/presenterm/issues/522)).
* Add `--current-theme` CLI parameter to display the theme being used ([#489](https://github.com/mfontanini/presenterm/issues/489)).
* Add gruvbox dark theme ([#483](https://github.com/mfontanini/presenterm/issues/483)) - thanks @ret2src.
## Fixes
* Fix broken ANSI escape code parsing which would cause command output to sometimes be incorrectly parsed and therefore led to its colors/attributes not being respected ([#500](https://github.com/mfontanini/presenterm/issues/500)).
* Center lists correctly ([#512](https://github.com/mfontanini/presenterm/issues/512)) ([#520](https://github.com/mfontanini/presenterm/issues/520)).
* Respect end slide shorthand in speaker notes mode ([#494](https://github.com/mfontanini/presenterm/issues/494)).
* Use more visible colors in snippet execution output in terminal-light/dark themes ([#485](https://github.com/mfontanini/presenterm/issues/485)).
* Show error if sixel mode is selected but disabled ([#525](https://github.com/mfontanini/presenterm/issues/525)).
## CI
* Add nightly build job ([#496](https://github.com/mfontanini/presenterm/issues/496)).
## Docs
* Fix typo in README.md ([#490](https://github.com/mfontanini/presenterm/issues/490)) - thanks @eltociear.
* Correctly include layout pic ([#495](https://github.com/mfontanini/presenterm/issues/495)) - thanks @Tuxified.
## Misc
* Cleanup text attributes ([#519](https://github.com/mfontanini/presenterm/issues/519)).
* Refactor snippet processing ([#484](https://github.com/mfontanini/presenterm/issues/484)).
## Sponsors
It is now possible to sponsor this project via [github sponsors](https://github.com/sponsors/mfontanini).
Thanks to [@0atman](https://github.com/0atman) for being the first project sponsor!
# v0.11.0 - 2025-03-08
## Breaking changes
* Footer templates are now sanitized, and any variables surrounded in braces that aren't supported (e.g. `{potato}`) will now cause _presenterm_ to display an error. If you'd like to use braces in contexts where you're not trying to reference a variable you can use double braces, e.g. `live at {{PotatoConf}}` ([#442](https://github.com/mfontanini/presenterm/issues/442)) ([#467](https://github.com/mfontanini/presenterm/issues/467)) ([#469](https://github.com/mfontanini/presenterm/issues/469)) ([#471](https://github.com/mfontanini/presenterm/issues/471)).
## New features
* [Add support for kitty's font size protocol](https://mfontanini.github.io/presenterm/features/introduction.html#font-sizes). This is now used by default in built in themes in a few components such as the intro slide's title and slide titles. See the [example presentation gif](https://github.com/mfontanini/presenterm/blob/master/docs/src/assets/demo.gif) to check out how this looks like. Terminal suport for this feature is detected on startup and will be ignored if unsupported. This requires _kitty_ >= 0.40.0 ([#438](https://github.com/mfontanini/presenterm/issues/438)) ([#460](https://github.com/mfontanini/presenterm/issues/460)) ([#470](https://github.com/mfontanini/presenterm/issues/470)).
* [Allow specifying font size in a comment command](https://mfontanini.github.io/presenterm/features/commands.html#font-size), which causes any subsequent text in a slide to use the specified font size. Just like the above, only supported in _kitty_ >= 0.40.0 for now ([#458](https://github.com/mfontanini/presenterm/issues/458)).
* [Footers can now contain images](https://mfontanini.github.io/presenterm/features/themes/definition.html#footer-images) in the left and center components. This allows including some form of branding/company logo to your presentations ([#450](https://github.com/mfontanini/presenterm/issues/450)) ([#476](https://github.com/mfontanini/presenterm/issues/476)).
* [Footers can now contain inline markdown](https://mfontanini.github.io/presenterm/features/themes/definition.html#template-footers), which allows using bold, italics, `<span>` tags for colors, etc ([#466](https://github.com/mfontanini/presenterm/issues/466)).
* [Presentation titles can now contain inline markdown](https://mfontanini.github.io/presenterm/features/introduction.html#introduction-slide) ([#464](https://github.com/mfontanini/presenterm/issues/464)).
* [Introduce palette.classes in themes](https://mfontanini.github.io/presenterm/features/themes/definition.html#color-palette) to allow specifying combinations of foreground/background colors that can be referenced via the `class` attribute in `<span>` tags ([#468](https://github.com/mfontanini/presenterm/issues/468)).
* It's now possible to [configure the alignment](https://mfontanini.github.io/presenterm/configuration/settings.html#maximum-presentation-width) to use when `max_columns` is configured and the terminal width is larger than it ([#475](https://github.com/mfontanini/presenterm/issues/475)).
* Add support for wikilinks ([#448](https://github.com/mfontanini/presenterm/issues/448)).
## Fixes
* Don't get stuck if tmux doesn't passthrough ([#456](https://github.com/mfontanini/presenterm/issues/456)).
* Don't squash image if terminal's font aspect ratio is not 2:1 ([#446](https://github.com/mfontanini/presenterm/issues/446)).
* Fail if `--config-file` points to non existent file ([#474](https://github.com/mfontanini/presenterm/issues/474)).
* Use right script name for kotlin files when executing ([#462](https://github.com/mfontanini/presenterm/issues/462)).
* Respect lists that start at non 1 indexes ([#459](https://github.com/mfontanini/presenterm/issues/459)).
* Jump to right slide on code attribute change ([#478](https://github.com/mfontanini/presenterm/issues/478)).
## Improvements
* Remove `result` return type from builder fns that don't need it ([#465](https://github.com/mfontanini/presenterm/issues/465)).
* Refactor theme code ([#463](https://github.com/mfontanini/presenterm/issues/463)).
* Restructure `terminal` code and add test for margins/layouts ([#443](https://github.com/mfontanini/presenterm/issues/443)).
* Use `fastrand` instead of `rand` ([#441](https://github.com/mfontanini/presenterm/issues/441)).
* Avoid cloning strings when styling them ([#440](https://github.com/mfontanini/presenterm/issues/440)).
# v0.10.1 - 2025-02-14
## Fixes
* Don't error out if `options` in front matter doesn't include `auto_render_languages` ([#454](https://github.com/mfontanini/presenterm/pull/454)).
* Bump sixel-rs to 0.4.1 to fix build in aarch64 and riscv64 ([#452](https://github.com/mfontanini/presenterm/pull/452)) - thanks @Xeonacid.
# v0.10.0 - 2025-02-02
## New features

872
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -4,47 +4,52 @@ authors = ["Matias Fontanini"]
description = "A terminal slideshow presentation tool"
repository = "https://github.com/mfontanini/presenterm"
license = "BSD-2-Clause"
version = "0.13.0"
version = "0.10.0"
edition = "2021"
[dependencies]
anyhow = "1"
ansi-parser = "0.9"
base64 = "0.22"
bincode = "1.3"
clap = { version = "4.4", features = ["derive", "string"] }
comrak = { version = "0.36", default-features = false }
comrak = { version = "0.34", default-features = false }
crossterm = { version = "0.28", features = ["serde"] }
directories = "6.0"
hex = "0.4"
fastrand = "2.3"
flate2 = "1.0"
image = { version = "0.25", features = ["gif", "jpeg", "png"], default-features = false }
sixel-rs = { version = "0.4.1", optional = true }
image = { version = "0.25", features = ["gif", "jpeg", "png", "rayon"], default-features = false }
sixel-rs = { version = "0.4", optional = true }
merge-struct = "0.1.0"
itertools = "0.14"
once_cell = "1.19"
schemars = { version = "0.8", optional = true }
rand = "0.8.5"
schemars = "0.8"
semver = "1.0"
serde = { version = "1.0", features = ["derive"] }
serde_yaml = "0.9"
serde_json = "1.0"
syntect = { version = "5.2", features = ["parsing", "default-themes", "regex-onig", "plist-load"], default-features = false }
serde_with = "3.6"
socket2 = "0.5.8"
strum = { version = "0.27", features = ["derive"] }
tempfile = { version = "3.10", default-features = false }
strum = { version = "0.26", features = ["derive"] }
tempfile = "3.10"
tl = "0.7"
thiserror = "2"
unicode-width = "0.2"
os_pipe = "1.1.5"
libc = "0.2"
vte = "0.15"
[dependencies.syntect]
version = "5.2"
default-features = false
features = ["parsing", "default-themes", "regex-onig", "plist-load"]
[dev-dependencies]
rstest = { version = "0.25", default-features = false }
rstest = { version = "0.24", default-features = false }
[features]
default = []
sixel = ["sixel-rs"]
json-schema = ["dep:schemars"]
[profile.dev]
opt-level = 0

View File

@ -16,9 +16,8 @@ presenterm
[scoop-package]: https://scoop.sh/#/apps?q=presenterm&id=a462290f824b50f180afbaa6d8c7c1e6e0952e3a
_presenterm_ lets you create presentations in markdown format and run them from your terminal, with support for image
and animated gifs, highly customizable themes, code highlighting, exporting presentations into PDF format, and plenty of
other features. This is how the [demo presentation](/examples/demo.md) looks like when running in the [kitty
terminal](https://sw.kovidgoyal.net/kitty/):
and animated gifs, highly customizable themes, code highlighting, exporting presentations into PDF format, and
plenty of other features. This is how the [demo presentation](/examples/demo.md) looks like:
![](/docs/src/assets/demo.gif)
@ -26,68 +25,53 @@ Check the rest of the example presentations in the [examples directory](/example
# Documentation
Visit the [documentation][docs-introduction] to get started.
Visit the [documentation][guide-introduction] to get started.
# Features
* Define your presentation in a single markdown file.
* [Images and animated gifs][docs-images] on terminals like _kitty_, _iterm2_, and _wezterm_.
* [Customizable themes][docs-themes] including colors, margins, layout (left/center aligned content), footer for every
slide, etc. Several [built-in themes][docs-builtin-themes] can give your presentation the look you want without
* [Images and animated gifs][guide-images] on terminals like _kitty_, _iterm2_, and _wezterm_.
* [Customizeable themes][guide-themes] including colors, margins, layout (left/center aligned content), footer for every
slide, etc. Several [built-in themes][guide-builtin-themes] can give your presentation the look you want without
having to define your own.
* Code highlighting for a [wide list of programming languages][docs-code-highlight].
* [Font sizes][docs-font-sizes] for terminals that support them.
* [Selective/dynamic][docs-selective-highlight] code highlighting that only highlights portions of code at a time.
* [Column layouts][docs-layout].
* [mermaid graph rendering][docs-mermaid].
* [_LaTeX_ and _typst_ formula rendering][docs-latex].
* [Introduction slide][docs-intro-slide] that displays the presentation title and your name.
* [Slide titles][docs-slide-titles].
* [Snippet execution][docs-code-execute] for various programming languages.
* [Export presentations to PDF][docs-pdf-export].
* [Slide transitions][docs-slide-transitions].
* [Pause][docs-pauses] portions of your slides.
* [Custom key bindings][docs-key-bindings].
* [Automatically reload your presentation][docs-hot-reload] every time it changes for a fast development loop.
* [Define speaker notes][docs-speaker-notes] to aid you during presentations.
* Code highlighting for a [wide list of programming languages][guide-code-highlight].
* [Selective/dynamic][guide-selective-highlight] code highlighting that only highlights portions of code at a time.
* [Column layouts][guide-layout].
* [mermaid graph rendering][guide-mermaid].
* [_LaTeX_ and _typst_ formula rendering][guide-latex].
* [Introduction slide][guide-intro-slide] that displays the presentation title and your name.
* [Slide titles][guide-slide-titles].
* [Snippet execution][guide-code-execute] for various programming languages.
* [Export presentations to PDF][guide-pdf-export].
* [Pause][guide-pauses] portions of your slides.
* [Custom key bindings][guide-key-bindings].
* [Automatically reload your presentation][guide-hot-reload] every time it changes for a fast development loop.
* [Define speaker notes][guide-speaker-notes] to aid you during presentations.
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.
See the [introduction page][guide-introduction] to learn more.
<!-- links -->
[docs-introduction]: https://mfontanini.github.io/presenterm/
[docs-basics]: https://mfontanini.github.io/presenterm/features/introduction.html
[docs-intro-slide]: https://mfontanini.github.io/presenterm/features/introduction.html#introduction-slide
[docs-slide-titles]: https://mfontanini.github.io/presenterm/features/introduction.html#slide-titles
[docs-font-sizes]: https://mfontanini.github.io/presenterm/features/introduction.html#font-sizes
[docs-pauses]: https://mfontanini.github.io/presenterm/features/commands.html#pauses
[docs-images]: https://mfontanini.github.io/presenterm/features/images.html
[docs-themes]: https://mfontanini.github.io/presenterm/features/themes/introduction.html
[docs-builtin-themes]: https://mfontanini.github.io/presenterm/features/themes/introduction.html#built-in-themes
[docs-code-highlight]: https://mfontanini.github.io/presenterm/features/code/highlighting.html
[docs-code-execute]: https://mfontanini.github.io/presenterm/features/code/execution.html
[docs-selective-highlight]: https://mfontanini.github.io/presenterm/features/code/highlighting.html#selective-highlighting
[docs-slide-transitions]: https://mfontanini.github.io/presenterm/features/slide-transitons.html
[docs-layout]: https://mfontanini.github.io/presenterm/features/layout.html
[docs-mermaid]: https://mfontanini.github.io/presenterm/features/code/mermaid.html
[docs-latex]: https://mfontanini.github.io/presenterm/features/code/latex.html
[docs-pdf-export]: https://mfontanini.github.io/presenterm/features/pdf-export.html
[docs-key-bindings]: https://mfontanini.github.io/presenterm/configuration/settings.html#key-bindings
[docs-hot-reload]: https://mfontanini.github.io/presenterm/features/introduction.html#hot-reload
[docs-speaker-notes]: https://mfontanini.github.io/presenterm/features/speaker-notes.html
[guide-introduction]: https://mfontanini.github.io/presenterm/
[guide-installation]: https://mfontanini.github.io/presenterm/guides/installation.html
[guide-basics]: https://mfontanini.github.io/presenterm/guides/basics.html
[guide-intro-slide]: https://mfontanini.github.io/presenterm/guides/basics.html#introduction-slide
[guide-slide-titles]: https://mfontanini.github.io/presenterm/guides/basics.html#slide-titles
[guide-pauses]: https://mfontanini.github.io/presenterm/guides/basics.html#pauses
[guide-images]: https://mfontanini.github.io/presenterm/guides/basics.html#images
[guide-themes]: https://mfontanini.github.io/presenterm/guides/themes.html
[guide-builtin-themes]: https://mfontanini.github.io/presenterm/guides/themes.html#built-in-themes
[guide-code-highlight]: https://mfontanini.github.io/presenterm/guides/code-highlight.html
[guide-code-execute]: https://mfontanini.github.io/presenterm/guides/code-highlight.html#executing-code
[guide-selective-highlight]: https://mfontanini.github.io/presenterm/guides/code-highlight.html#selective-highlighting
[guide-layout]: https://mfontanini.github.io/presenterm/guides/layout.html
[guide-mermaid]: https://mfontanini.github.io/presenterm/guides/mermaid.html
[guide-latex]: https://mfontanini.github.io/presenterm/guides/latex.html
[guide-pdf-export]: https://mfontanini.github.io/presenterm/guides/pdf-export.html
[guide-key-bindings]: https://mfontanini.github.io/presenterm/guides/configuration.html#key-bindings
[guide-hot-reload]: https://mfontanini.github.io/presenterm/guides/basics.html#hot-reload
[guide-speaker-notes]: https://mfontanini.github.io/presenterm/guides/speaker-notes.html
[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

View File

@ -14,9 +14,6 @@
}
]
},
"export": {
"$ref": "#/definitions/ExportConfig"
},
"mermaid": {
"$ref": "#/definitions/MermaidConfig"
},
@ -29,16 +26,6 @@
"speaker_notes": {
"$ref": "#/definitions/SpeakerNotesConfig"
},
"transition": {
"anyOf": [
{
"$ref": "#/definitions/SlideTransitionConfig"
},
{
"type": "null"
}
]
},
"typst": {
"$ref": "#/definitions/TypstConfig"
}
@ -56,14 +43,6 @@
}
]
},
"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,
@ -71,29 +50,6 @@
"format": "uint16",
"minimum": 0.0
},
"max_columns_alignment": {
"description": "The alignment the presentation should have if `max_columns` is set and the terminal is larger than that.",
"allOf": [
{
"$ref": "#/definitions/MaxColumnsAlignment"
}
]
},
"max_rows": {
"description": "A max height in rows that the presentation must always be capped to.",
"default": 65535,
"type": "integer",
"format": "uint16",
"minimum": 0.0
},
"max_rows_alignment": {
"description": "The alignment the presentation should have if `max_rows` is set and the terminal is larger than that.",
"allOf": [
{
"$ref": "#/definitions/MaxRowsAlignment"
}
]
},
"terminal_font_size": {
"description": "Override the terminal font size when in windows or when using sixel.",
"default": 16,
@ -119,55 +75,6 @@
},
"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": [
{
@ -214,29 +121,6 @@
}
]
},
"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"
},
@ -383,58 +267,6 @@
}
}
},
"MaxColumnsAlignment": {
"description": "The alignment to use when `defaults.max_columns` is set.",
"oneOf": [
{
"description": "Align the presentation to the left.",
"type": "string",
"enum": [
"left"
]
},
{
"description": "Align the presentation on the center.",
"type": "string",
"enum": [
"center"
]
},
{
"description": "Align the presentation to the right.",
"type": "string",
"enum": [
"right"
]
}
]
},
"MaxRowsAlignment": {
"description": "The alignment to use when `defaults.max_rows` is set.",
"oneOf": [
{
"description": "Align the presentation to the top.",
"type": "string",
"enum": [
"top"
]
},
{
"description": "Align the presentation on the center.",
"type": "string",
"enum": [
"center"
]
},
{
"description": "Align the presentation to the bottom.",
"type": "string",
"enum": [
"bottom"
]
}
]
},
"MermaidConfig": {
"type": "object",
"properties": {
@ -450,6 +282,9 @@
},
"OptionsConfig": {
"type": "object",
"required": [
"auto_render_languages"
],
"properties": {
"auto_render_languages": {
"description": "Assume snippets for these languages contain `+render` and render them automatically.",
@ -503,108 +338,6 @@
},
"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": {
@ -702,7 +435,6 @@
"Java",
"JavaScript",
"Json",
"Julia",
"Kotlin",
"Latex",
"Lua",

View File

@ -14,11 +14,3 @@ title = "presenterm documentation"
[output.html]
git-repository-url = "https://github.com/mfontanini/presenterm"
default-theme = "navy"
# Redirects for broken links after 02/02/2025 restructuring.
[output.html.redirect]
"/guides/basics.html" = "../features/introduction.html"
"/guides/installation.html" = "../install.html"
"/guides/code-highlight.html" = "../features/code/highlighting.html"
"/guides/mermaid.html" = "../features/code/mermaid.html"

View File

@ -2,25 +2,18 @@
[Introduction](./introduction.md)
# Docs
# Guides
- [Install](./install.md)
- [Features](./features/introduction.md)
- [Images](./features/images.md).
- [Commands](./features/commands.md).
- [Layout](./features/layout.md).
- [Code](./features/code/highlighting.md)
- [Execution](./features/code/execution.md)
- [Mermaid diagrams](./features/code/mermaid.md)
- [LaTeX and typst](./features/code/latex.md)
- [Themes](./features/themes/introduction.md)
- [Definition](./features/themes/definition.md)
- [PDF export](./features/pdf-export.md)
- [Slide transitions](./features/slide-transitions.md)
- [Speaker notes](./features/speaker-notes.md)
- [Configuration](./configuration/introduction.md)
- [Options](./configuration/options.md)
- [Settings](./configuration/settings.md)
- [Installation](./guides/installation.md)
- [Basics](./guides/basics.md)
- [Themes](./guides/themes.md)
- [Layout](./guides/layout.md)
- [Configuration](./guides/configuration.md)
- [Code highlighting](./guides/code-highlight.md)
- [PDF export](./guides/pdf-export.md)
- [Speaker notes](./guides/speaker-notes.md)
- [Mermaid](./guides/mermaid.md)
- [LaTeX and typst](./guides/latex.md)
# Internals

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 655 KiB

After

Width:  |  Height:  |  Size: 477 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

View File

@ -1,28 +0,0 @@
# Configuration
_presenterm_ allows you to customize its behavior via a configuration file. This file is stored, along with all of your
custom themes, in the following directories:
* `$XDG_CONFIG_HOME/presenterm/` if that environment variable is defined, otherwise:
* `~/.config/presenterm/` in Linux.
* `~/Library/Application Support/presenterm/` in macOS.
* `~/AppData/Roaming/presenterm/config/` in Windows.
The configuration file will be looked up automatically in the directories above under the name `config.yaml`. e.g. on
Linux you should create it under `~/.config/presenterm/config.yaml`. You can also specify a custom path to this file
when running _presenterm_ via the `--config-file` parameter.
A [sample configuration file](https://github.com/mfontanini/presenterm/blob/master/config.sample.yaml) is provided in
the repository that you can use as a base.
# Configuration schema
A JSON schema that defines the configuration file's schema is available to be used with YAML language servers such as
[yaml-language-server](https://github.com/redhat-developer/yaml-language-server).
Include the following line at the beginning of your configuration file to have your editor pull in autocompletion
suggestions and docs automatically:
```yaml
# yaml-language-server: $schema=https://raw.githubusercontent.com/mfontanini/presenterm/master/config-file-schema.json
```

View File

@ -1,165 +0,0 @@
# Options
Options are special configuration parameters that can be set either in the configuration file under the `options` key,
or in a presentation's front matter under the same key. This last one allows you to customize a single presentation so
that it acts in a particular way. This can also be useful if you'd like to share the source files for your presentation
with other people.
The supported configuration options are currently the following:
## implicit_slide_ends
This option removes the need to use `<!-- end_slide -->` in between slides and instead assumes that if you use a slide
title, then you're implying that the previous slide ended. For example, the following presentation:
```markdown
---
options:
implicit_slide_ends: true
---
Tasty vegetables
================
* Potato
Awful vegetables
================
* Lettuce
```
Is equivalent to this "vanilla" one that doesn't use implicit slide ends.
```markdown
Tasty vegetables
================
* Potato
<!-- end_slide -->
Awful vegetables
================
* Lettuce
```
## end_slide_shorthand
This option allows using thematic breaks (`---`) as a delimiter between slides. When enabling this option, you can still
use `<!-- end_slide -->` but any thematic break will also be considered a slide terminator.
```
---
options:
end_slide_shorthand: true
---
this is a slide
---------------------
this is another slide
```
## command_prefix
Because _presenterm_ uses HTML comments to represent commands, it is necessary to make some assumptions on _what_ is a
command and what isn't. The current heuristic is:
* If an HTML comment is laid out on a single line, it is assumed to be a command. This means if you want to use a real
HTML comment like `<!-- remember to say "potato" here -->`, this will raise an error.
* If an HTML comment is multi-line, then it is assumed to be a comment and it can have anything inside it. This means
you can't have a multi-line comment that contains a command like `pause` inside.
Depending on how you use HTML comments personally, this may be limiting to you: you cannot use any single line comments
that are not commands. To get around this, the `command_prefix` option lets you configure a prefix that must be set in
all commands for them to be configured as such. Any single line comment that doesn't start with this prefix will not be
considered a command.
For example:
```
---
options:
command_prefix: "cmd:"
---
<!-- remember to say "potato here" -->
Tasty vegetables
================
* Potato
<!-- cmd:pause -->
**That's it!**
```
In the example above, the first comment is ignored because it doesn't start with "cmd:" and the second one is processed
because it does.
## incremental_lists
If you'd like all bullet points in all lists to show up with pauses in between you can enable the `incremental_lists`
option:
```
---
options:
incremental_lists: true
---
* pauses
* in
* between
```
Keep in mind if you only want specific bullet points to show up with pauses in between, you can use the
[`incremental_lists` comment command](../features/commands.md#incremental-lists).
## strict_front_matter_parsing
This option tells _presenterm_ you don't care about extra parameters in presentation's front matter. This can be useful
if you're trying to load a presentation made for another tool. The following presentation would only be successfully
loaded if you set `strict_front_matter_parsing` to `false` in your configuration file:
```markdown
---
potato: 42
---
# Hi
```
## image_attributes_prefix
The [image size](../features/images.md#image-size) prefix (by default `image:`) can be configured to be anything you
would want in case you don't like the default one. For example, if you'd like to set the image size by simply doing
`![width:50%](path.png)` you would need to set:
```yaml
---
options:
image_attributes_prefix: ""
---
![width:50%](path.png)
```
## auto_render_languages
This option allows indicating a list of languages for which the `+render` attribute can be omitted in their code
snippets and will be implicitly considered to be set. This can be used for languages like `mermaid` so that graphs are
always automatically rendered without the need to specify `+render` everywhere.
```yaml
---
options:
auto_render_languages:
- mermaid
---
```

View File

@ -1,180 +0,0 @@
# Code highlighting
Code highlighting is supported for the following languages:
| Language | Execution support |
| -----------|-------------------|
| ada | |
| asp | |
| awk | |
| bash | ✓ |
| batchfile | |
| C | ✓ |
| cmake | |
| crontab | |
| C# | ✓ |
| clojure | |
| C++ | ✓ |
| CSS | |
| D | |
| diff | |
| docker | |
| dotenv | |
| elixir | |
| elm | |
| erlang | |
| fish | ✓ |
| go | ✓ |
| haskell | ✓ |
| HTML | |
| java | ✓ |
| javascript | ✓ |
| json | |
| julia | ✓ |
| kotlin | ✓ |
| latex | |
| lua | ✓ |
| makefile | |
| markdown | |
| nix | |
| ocaml | |
| perl | ✓ |
| php | ✓ |
| protobuf | |
| puppet | |
| python | ✓ |
| R | ✓ |
| ruby | ✓ |
| rust | ✓ |
| scala | |
| shell | ✓ |
| sql | |
| swift | |
| svelte | |
| tcl | |
| toml | |
| terraform | |
| typescript | |
| xml | |
| yaml | |
| vue | |
| zig | |
| zsh | ✓ |
Other languages that are supported are:
* nushell, for which highlighting isn't supported but execution is.
* rust-script, which is highlighted as rust but is executed via the [rust-script](https://rust-script.org/) tool,
which lets you specify dependencies in your snippet.
If there's a language that is not in this list and you would like it to be supported, please [create an
issue](https://github.com/mfontanini/presenterm/issues/new). If you'd also like code execution support, provide details
on how to compile (if necessary) and run snippets for that language. You can also configure how to run code snippet for
a language locally in your [config file](../../configuration/settings.md#custom-snippet-executors).
## Enabling line numbers
If you would like line numbers to be shown on the left of a code block use the `+line_numbers` switch after specifying
the language in a code block:
~~~markdown
```rust +line_numbers
fn hello_world() {
println!("Hello world");
}
```
~~~
## Selective highlighting
By default, the entire code block will be syntax-highlighted. If instead you only wanted a subset of it to be
highlighted, you can use braces and a list of either individual lines, or line ranges that you'd want to highlight.
~~~markdown
```rust {1,3,5-7}
fn potato() -> u32 { // 1: highlighted
// 2: not highlighted
println!("Hello world"); // 3: highlighted
let mut q = 42; // 4: not highlighted
q = q * 1337; // 5: highlighted
q // 6: highlighted
} // 7: highlighted
```
~~~
## Dynamic highlighting
Similar to the syntax used for selective highlighting, dynamic highlighting will change which lines of the code in a
code block are highlighted every time you move to the next/previous slide.
This is achieved by using the separator `|` to indicate what sections of the code will be highlighted at a given time.
You can also use `all` to highlight all lines for a particular frame.
~~~markdown
```rust {1,3|5-7}
fn potato() -> u32 {
println!("Hello world");
let mut q = 42;
q = q * 1337;
q
}
```
~~~
In this example, lines 1 and 3 will be highlighted initially. Then once you press a key to move to the next slide, lines
1 and 3 will no longer be highlighted and instead lines 5 through 7 will. This allows you to create more dynamic
presentations where you can display sections of the code to explain something specific about each of them.
See this real example of how this looks like.
[![asciicast](https://asciinema.org/a/iCf4f6how1Ux3H8GNzksFUczI.svg)](https://asciinema.org/a/iCf4f6how1Ux3H8GNzksFUczI)
## Including external code snippets
The `file` snippet type can be used to specify an external code snippet that will be included and highlighted as usual.
~~~markdown
```file +exec +line_numbers
path: snippet.rs
language: rust
```
~~~
If you'd like to include only a subset of the file, you can use the optional fields `start_line` and `end_line`:
~~~markdown
```file +exec +line_numbers
path: snippet.rs
language: rust
# Only shot lines 5-10
start_line: 5
end_line: 10
```
~~~
## Showing a snippet without a background
Using the `+no_background` flag will cause the snippet to have no background. This is useful when combining it with the
`+exec_replace` flag described further down.
## Adding highlighting syntaxes for new languages
_presenterm_ uses the syntaxes supported by [bat](https://github.com/sharkdp/bat) to highlight code snippets, so any
languages supported by _bat_ natively can be added to _presenterm_ easily. Please create a ticket or use
[this](https://github.com/mfontanini/presenterm/pull/385) as a reference to submit a pull request to make a syntax
officially supported by _presenterm_ as well.
If a language isn't natively supported by _bat_ but you'd like to use it, you can follow
[this guide in the bat docs](https://github.com/sharkdp/bat#adding-new-syntaxes--language-definitions) and
invoke _bat_ directly in a presentation:
~~~markdown
```bash +exec_replace
bat --color always script.py
```
~~~
> [!note]
> Check the [code execution docs](execution.md#executing-and-replacing) for more details on how to allow the tool to run
> `exec_replace` blocks.

View File

@ -1,123 +0,0 @@
# Comment commands
_presenterm_ uses "comment commands" in the form of HTML comments to let the user specify certain behaviors that can't
be specified by vanilla markdown.
## Pauses
Pauses allow the sections of the content in your slide to only show up when you advance in your presentation. That is,
only after you press, say, the right arrow will a section of the slide show up. This can be done by the `pause` comment
command:
```html
<!-- pause -->
```
## Font size
The font size can be changed by using the `font_size` command:
```html
<!-- font_size: 2 -->
```
This causes the remainder of the slide to use the font size specified. The font size can range from 1 to 7, 1 being the
default.
> ![note]
> This is currently only supported in the [_kitty_](https://sw.kovidgoyal.net/kitty/) terminal and only as of version
> 0.40.0. See the notes on font sizes on the [introduction page](introduction.md#font-sizes) for more information on
> this.
## Jumping to the vertical center
The command `jump_to_middle` lets you jump to the middle of the page vertically. This is useful in combination
with slide titles to create separator slides:
```markdown
blablabla
<!-- end_slide -->
<!-- jump_to_middle -->
Farming potatoes
===
<!-- end_slide -->
```
This will create a slide with the text "Farming potatoes" in the center, rendered using the slide title style.
## Explicit new lines
The `newline`/`new_line` and `newlines`/`new_lines` commands allow you to explicitly create new lines. Because markdown
ignores multiple line breaks in a row, this is useful to create some spacing where necessary:
```markdown
hi
<!-- new_lines: 10 -->
mom
<!-- new_line -->
bye
```
## Incremental lists
Using `<!-- pause -->` in between each bullet point a list is a bit tedious so instead you can use the
`incremental_lists` command to tell _presenterm_ that **until the end of the current slide** you want each individual
bullet point to appear only after you move to the next slide:
```markdown
<!-- incremental_lists: true -->
* this
* appears
* one after
* the other
<!-- incremental_lists: false -->
* this appears
* all at once
```
## No footer
If you don't want the footer to show up in some particular slide for some reason, you can use the `no_footer` command:
```html
<!-- no_footer -->
```
## Skip slide
If you don't want a specific slide to be included in the presentation use the `skip_slide` command:
```html
<!-- skip_slide -->
```
## Text alignment
The text alignment for the remainder of the slide can be configured via the `alignment` command, which can use values:
`left`, `center`, and `right`:
```markdown
<!-- alignment: left -->
left alignment, the default
<!-- alignment: center -->
centered
<!-- alignment: right -->
right aligned
```

View File

@ -1,58 +0,0 @@
# Images
Images are supported and will render in your terminal as long as it supports either the [iterm2 image
protocol](https://iterm2.com/documentation-images.html), the [kitty graphics
protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/), or [sixel](https://saitoha.github.io/libsixel/). Some of
the terminals where at least one of these is supported are:
* [kitty](https://sw.kovidgoyal.net/kitty/)
* [iterm2](https://iterm2.com/)
* [WezTerm](https://wezfurlong.org/wezterm/index.html)
* [foot](https://codeberg.org/dnkl/foot)
Sixel support is experimental so it needs to be explicitly enabled via the `sixel` configuration flag:
```bash
cargo build --release --features sixel
```
> [!note]
> This feature flag is only needed if your terminal emulator _only_ supports sixel. Many terminals support the kitty or
> iterm2 protocols so using this flag is often not required to get images to render successfully.
---
Things you should know when using image tags in your presentation's markdown are:
* Image paths are relative to your presentation path. That is a tag like `![](food/potato.png)` will be looked up at
`$PRESENTATION_DIRECTORY/food/potato.png`.
* Images will be rendered by default in their original size. That is, if your terminal is 300x200px and your image is
200x100px, it will take up 66% of your horizontal space and 50% of your vertical space.
* The exception to the point above is if the image does not fit in your terminal, it will be resized accordingly while
preserving the aspect ratio.
* If your terminal does not support any of the graphics protocol above, images will be rendered using ascii blocks. It
ain't great but it's something!
## tmux
If you're using tmux, you will need to enable the [allow-passthrough
option](https://github.com/tmux/tmux/wiki/FAQ#what-is-the-passthrough-escape-sequence-and-how-do-i-use-it) for images to
work correctly.
## Image size
The size of each image can be set by using the `image:width` or `image:w` attributes in the image tag. For example, the
following will cause the image to take up 50% of the terminal width:
```markdown
![image:width:50%](image.png)
```
The image will always be scaled to preserve its aspect ratio and it will not be allowed to overflow vertically nor
horizontally.
## Protocol detection
By default the image protocol to be used will be automatically detected. In cases where this detection fails, you can
set it manually via the `--image-protocol` parameter or by setting it in the [config
file](../configuration/settings.md#preferred-image-protocol).

View File

@ -1,182 +0,0 @@
# Introduction
This guide teaches you how to use _presenterm_. At this point you should have already installed _presenterm_, otherwise
visit the [installation](../install.md) guide to get started.
## Quick start
Download the demo presentation and run it using:
```bash
git clone https://github.com/mfontanini/presenterm.git
cd presenterm
presenterm examples/demo.md
```
# Presentations
A presentation in _presenterm_ is a single markdown file. Every slide in the presentation file is delimited by a line
that contains a single HTML comment:
```html
<!-- end_slide -->
```
Presentations can contain most commonly used markdown elements such as ordered and unordered lists, headings, formatted
text (**bold**, _italics_, ~strikethrough~, `inline code`, etc), code blocks, block quotes, tables, etc.
## Introduction slide
By setting a front matter at the beginning of your presentation you can configure the title, sub title, author and other
metadata about your presentation. Doing so will cause _presenterm_ to create an introduction slide:
```yaml
---
title: "My _first_ **presentation**"
sub_title: (in presenterm!)
author: Myself
---
```
All of these attributes are optional and should be avoided if an introduction slide is not needed. Note that the `title`
key can contain arbitrary markdown so you can use bold, italics, `<span>` tags, etc.
### Multiple authors
If you're creating a presentation in which there's multiple authors, you can use the `authors` key instead of `author`
and list them all this way:
```yaml
---
title: Our first presentation
authors:
- Me
- You
---
```
## Slide titles
Any [setext header](https://spec.commonmark.org/0.30/#setext-headings) will be considered to be a slide title and will
be rendered in a more slide-title-looking way. By default this means it will be centered, some vertical padding will be
added and the text color will be different.
~~~markdown
Hello
===
~~~
> [!note]
> See the [themes](themes/introduction.md) section on how to customize the looks of slide titles and any other element
> in a presentation.
## Ending slides
While other applications use a thematic break (`---`) to mark the end of a slide, _presenterm_ uses a special
`end_slide` HTML comment:
```html
<!-- end_slide -->
```
This makes the end of a slide more explicit and easy to spot while you're editing your presentation. See the
[configuration](../configuration/options.md#implicit_slide_ends) if you want to customize this behavior.
If you really would prefer to use thematic breaks (`---`) to delimit slides, you can do that by enabling the
[`end_slide_shorthand`](../configuration/options.md#end_slide_shorthand) options.
## Colored text
`span` HTML tags can be used to provide foreground and/or background colors to text. There's currently two ways to
specify colors:
* Via the `style` attribute, in which only the CSS attributes `color` and `background-color` can be used to set the
foreground and background colors respectively. Colors used in both CSS attributes can refer to
[theme palette colors](themes/definition.md#color-palette) by using the `palette:<name>` or `p:<name` syntaxes.
* Via the `class` attribute, which must point to a class defined in the [theme
palette](themes/definition.md#color-palette). Classes allow configuring foreground/background color combinations to be
used across your presentation.
For example, the following will use `ff0000` as the foreground color and whatever the active theme's palette defines as
`foo`:
```markdown
<span style="color: #ff0000; background-color: palette:foo">colored text!</span>
```
Alternatively, can you can define a class that contains a foreground/background color combination in your theme's
palette and use it:
```markdown
<span class="my_class">colored text!</span>
```
> [!note]
> Keep in mind **only `span` tags are supported**.
## Font sizes
The [_kitty_](https://sw.kovidgoyal.net/kitty/) terminal added in version 0.40.0 support for a new protocol that allows
TUIs to specify the font size to be used when printing text. _presenterm_ is one of the first applications supports this
protocol in various places:
* Themes can specify it in the presentation title in the introduction slide, in slide titles, and in headers by using
the `font_size` property. All built in themes currently set font size to 2 (1 is the default) for these elements.
* Explicitly by using the `font_size` comment command:
```markdown
# Normal text
<!-- font_size: 2 -->
# Larger text
```
Terminal support for this feature is verified when _presenterm_ starts and any attempt to change the font size, be it
via the theme or via the comment command, will be ignored if it's not supported.
# Key bindings
Navigation within a presentation should be intuitive: jumping to the next/previous slide can be done by using the arrow
keys, _hjkl_, and page up/down keys.
Besides this:
* Jumping to the first slide: `gg`.
* Jumping to the last slide: `G`.
* Jumping to a specific slide: `<slide-number>G`.
* Exit the presentation: `<ctrl>c`.
You can check all the configured keybindings by pressing `?` while running _presenterm_.
## Configuring key bindings
If you don't like the default key bindings, you can override them in the [configuration
file](../configuration/settings.md#key-bindings).
# Modals
_presenterm_ currently has 2 modals that can provide some information while running the application. Modals can be
toggled using some key combination and can be hidden using the escape key by default, but these can be configured via
the [configuration file key bindings](../configuration/settings.md#key-bindings).
## Slide index modal
This modal can be toggled by default using `control+p` and lets you see an index that contains a row for every slide in
the presentation, including its title and slide index. This allows you to find a slide you're trying to jump to
quicklier rather than scanning through each of them.
[![asciicast](https://asciinema.org/a/1VgRxVIEyLrMmq6OZ3oKx4PGi.svg)](https://asciinema.org/a/1VgRxVIEyLrMmq6OZ3oKx4PGi)
## Key bindings modal
The key bindings modal displays the key bindings for each of the supported actions and can be opened by pressing `?`.
# Hot reload
Unless you run in presentation mode by passing in the `--present` parameter, _presenterm_ will automatically reload your
presentation file every time you save it. _presenterm_ will also automatically detect which specific slide was modified
and jump to it so you don't have to be jumping back and forth between the source markdown and the presentation to see
how the changes look like.
[![asciicast](https://asciinema.org/a/bu9ITs8KhaQK5OdDWnPwUYKu3.svg)](https://asciinema.org/a/bu9ITs8KhaQK5OdDWnPwUYKu3)

View File

@ -1,35 +0,0 @@
# Exporting presentations in PDF format
Presentations can be converted into PDF by using [weasyprint](https://pypi.org/project/weasyprint/). Follow their
[installation instructions](https://doc.courtbouillon.org/weasyprint/stable/first_steps.html) since it may require you
to install extra dependencies for the tool to work.
> [!note]
> If you were using _presenterm-export_ before it was deprecated, that tool already required _weasyprint_ so it is
> already installed in whatever virtual env you were using and there's nothing to be done.
After you've installed _weasyprint_, run _presenterm_ with the `--export-pdf` parameter to generate the output PDF:
```bash
presenterm --export-pdf examples/demo.md
```
The output PDF will be placed in `examples/demo.pdf`. Alternatively you can use the `--output` flag to specify where you
want the output file to be written to.
> [!note]
> If you're using a separate virtual env to install _weasyprint_ just make sure you activate it before running
> _presenterm_ with the `--export-pdf` parameter.
## PDF page size
By default, the size of each page in the generated PDF will depend on the size of your terminal.
If you would like to instead configure the dimensions by hand, set the `export.dimensions` key in the configuration file
as described in the [settings page](../configuration/settings.md#pdf-export-size).
## Pause behavior
See the [settings page](../configuration/settings.md#pause-behavior) to learn how to configure the behavior of pauses in
generated PDFs.

View File

@ -1,24 +0,0 @@
# 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#slide-transitions) to learn how to configure transitions.
The following animations are supported:
## `fade`
Fade the current slide into the next one.
[![asciicast](https://asciinema.org/a/RvxLw0FHOopjdF4ixWbCkWuSw.svg)](https://asciinema.org/a/RvxLw0FHOopjdF4ixWbCkWuSw)
## `slide_horizontal`
Slide horizontally to the next/previous slide.
[![asciicast](https://asciinema.org/a/T43ttxPWZ8TsM2auTqNZSWrmZ.svg)](https://asciinema.org/a/T43ttxPWZ8TsM2auTqNZSWrmZ)
## `collapse_horizontal`
Collapse the current slide into the center of the screen horizontally.
[![asciicast](https://asciinema.org/a/VB8i3kGMvbkbiYYPpaZJUl2dW.svg)](https://asciinema.org/a/VB8i3kGMvbkbiYYPpaZJUl2dW)

View File

@ -1,102 +0,0 @@
# Themes
_presenterm_ tries to be as configurable as possible, allowing users to create presentations that look exactly how they
want them to look like. The tool ships with a set of [built-in
themes](https://github.com/mfontanini/presenterm/tree/master/themes) but users can be created by users in their local
setup and imported in their presentations.
## Setting themes
There's various ways of setting the theme you want in your presentation:
### CLI
Passing in the `--theme` parameter when running _presenterm_ to select one of the built-in themes.
### Within the presentation
The presentation's markdown file can contain a front matter that specifies the theme to use. This comes in 3 flavors:
#### By name
Using a built-in theme name makes your presentation use that one regardless of what the default or what the `--theme`
option specifies:
```yaml
---
theme:
name: dark
---
```
#### By path
You can define a theme file in yaml format somewhere in your filesystem and reference it within the presentation:
```yaml
---
theme:
path: /home/me/Documents/epic-theme.yaml
---
```
#### Overrides
You can partially/completely override the theme in use from within the presentation:
```yaml
---
theme:
override:
default:
colors:
foreground: "beeeff"
---
```
This lets you:
1. Create a unique style for your presentation without having to go through the process of taking an existing theme,
copying somewhere, and changing it when you only expect to use it for that one presentation.
2. Iterate quickly on styles given overrides are reloaded whenever you save your presentation file.
# Built-in themes
A few built-in themes are bundled with the application binary, meaning you don't need to have any external files
available to use them. These are packed as part of the [build
process](https://github.com/mfontanini/presenterm/blob/master/build.rs) as a binary blob and are decoded on demand only
when used.
Currently, the following themes are supported:
* A set of themes based on the [catppuccin](https://github.com/catppuccin/catppuccin) color palette:
* `catppuccin-latte`
* `catppuccin-frappe`
* `catppuccin-macchiato`
* `catppuccin-mocha`
* `dark`: A dark theme.
* `gruvbox`: A theme inspired by the colors used in [gruvbox](https://github.com/morhetz/gruvbox).
* `light`: A light theme.
* `terminal-dark`: A theme that uses your terminals color and looks best if your terminal uses a dark color scheme. This
means if your terminal background is e.g. transparent, or uses an image, the presentation will inherit that.
* `terminal-light`: The same as `terminal-dark` but works best if your terminal uses a light color scheme.
* `tokyonight-storm`: A theme inspired by the colors used in [toyonight](https://github.com/folke/tokyonight.nvim).
## Trying out built-in themes
All built-in themes can be tested by using the `--list-themes` parameter:
```bash
presenterm --list-themes
```
This will run a presentation where the same content is rendered using a different theme in each slide:
[![asciicast](https://asciinema.org/a/zeV1QloyrLkfBp6rNltvX7Lle.svg)](https://asciinema.org/a/zeV1QloyrLkfBp6rNltvX7Lle)
# Loading custom themes
On startup, _presenterm_ will look into the `themes` directory under the [configuration
directory](../../configuration/introduction.md) (e.g. `~/.config/presenterm/themes` in Linux) and will load any `.yaml`
file as a theme and make it available as if it was a built-in theme. This means you can use it as an argument to the
`--theme` parameter, use it in the `theme.name` property in a presentation's front matter, etc.

273
docs/src/guides/basics.md Normal file
View File

@ -0,0 +1,273 @@
# Introduction
This guide teaches you how to use _presenterm_. At this point you should have already installed _presenterm_, otherwise
visit the [installation](installation.html) guide to get started.
## Quick start
Download the demo presentation and run it using:
```bash
git clone https://github.com/mfontanini/presenterm.git
cd presenterm
presenterm examples/demo.md
```
# Presentations
A presentation in _presenterm_ is a single markdown file. Every slide in the presentation file is delimited by a line
that contains a single HTML comment:
```html
<!-- end_slide -->
```
Presentations can contain most commonly used markdown elements such as ordered and unordered lists, headings, formatted
text (**bold**, _italics_, ~strikethrough~, `inline code`, etc), code blocks, block quotes, tables, etc.
## Colored text
`span` HTML tags can be used to provide foreground and/or background colors to text. Currently only the `style`
attribute is supported, and only the CSS attributes `color` and `background-color` can be used to set the foreground and
background colors respectively. Colors used in both CSS attributes can refer to [theme palette
colors](themes.html#color-palette) by using the `palette:<name>` or `p:<name` syntaxes.
For example, the following will use `ff0000` as the foreground color and whatever the active theme's palette defines as
`foo`:
```markdown
<span style="color: #ff0000; background-color: palette:foo">colored text!</span>
```
> [!note]
> Keep in mind **only `span` tags are supported**.
## Images
![](../assets/demo-image.png)
Images are supported and will render in your terminal as long as it supports either the [iterm2 image
protocol](https://iterm2.com/documentation-images.html), the [kitty graphics
protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/), or [sixel](https://saitoha.github.io/libsixel/). Some of
the terminals where at least one of these is supported are:
* [kitty](https://sw.kovidgoyal.net/kitty/)
* [iterm2](https://iterm2.com/)
* [WezTerm](https://wezfurlong.org/wezterm/index.html)
* [foot](https://codeberg.org/dnkl/foot)
Sixel support is experimental so it needs to be explicitly enabled via the `sixel` configuration flag:
```bash
cargo build --release --features sixel
```
> [!note]
> This feature flag is only needed if your terminal emulator _only_ supports sixel. Many terminals support the kitty or
> iterm2 protocols so using this flag is often not required to get images to render successfully.
---
Things you should know when using image tags in your presentation's markdown are:
* Image paths are relative to your presentation path. That is a tag like `![](food/potato.png)` will be looked up at
`$PRESENTATION_DIRECTORY/food/potato.png`.
* Images will be rendered by default in their original size. That is, if your terminal is 300x200px and your image is
200x100px, it will take up 66% of your horizontal space and 50% of your vertical space.
* The exception to the point above is if the image does not fit in your terminal, it will be resized accordingly while
preserving the aspect ratio.
* If your terminal does not support any of the graphics protocol above, images will be rendered using ascii blocks. It
ain't great but it's something!
### Image size
The size of each image can be set by using the `image:width` or `image:w` attributes in the image tag. For example, the
following will cause the image to take up 50% of the terminal width:
```markdown
![image:width:50%](image.png)
```
The image will always be scaled to preserve its aspect ratio and it will not be allowed to overflow vertically nor
horizontally.
### Protocol detection
By default the image protocol to be used will be automatically detected. In cases where this detection fails, you can
set it manually via the `--image-protocol` parameter or by setting it in the [config
file](configuration.html#preferred-image-protocol).
# Extensions
Besides the standard markdown elements, _presenterm_ supports a few extensions.
## Introduction slide
By setting a front matter at the beginning of your presentation, you can configure the title, sub title, and author of
your presentation and implicitly create an introduction slide:
```yaml
---
title: My first presentation
sub_title: (in presenterm!)
author: Myself
---
```
All of these attributes are optional so you're not forced to set them all.
### Multiple authors
If you're creating a presentation in which there's multiple authors, you can use the `authors` key instead of `author`
and list them all this way:
```yaml
---
title: Our first presentation
authors:
- Me
- You
---
```
## Slide titles
Any [setext header](https://spec.commonmark.org/0.30/#setext-headings) will be considered to be a slide title and will
be rendered in a more slide-title-looking way. By default this means it will be centered, some vertical padding will be
added and the text color will be different.
~~~markdown
Hello
===
~~~
> [!note]
> See the [themes](themes.html) section on how to customize the looks of slide titles and any other element in a
> presentation.
## Pauses
Pauses allow the sections of the content in your slide to only show up when you advance in your presentation. That is,
only after you press, say, the right arrow will a section of the slide show up. This can be done by the `pause` comment
command:
```html
<!-- pause -->
```
## Ending slides
While other applications use a thematic break (`---`) to mark the end of a slide, _presenterm_ uses a special
`end_slide` HTML comment:
```html
<!-- end_slide -->
```
This makes the end of a slide more explicit and easy to spot while you're editing your presentation. See the
[configuration](/docs/config.md#implicit_slide_ends) if you want to customize this behavior.
If you really would prefer to use thematic breaks (`---`) to delimit slides, you can do that by enabling the
[`end_slide_shorthand`](configuration.html#end_slide_shorthand) options.
## Jumping to the vertical center
The command `jump_to_middle` lets you jump to the middle of the page vertically. This is useful in combination
with slide titles to create separator slides:
```markdown
blablabla
<!-- end_slide -->
<!-- jump_to_middle -->
Farming potatoes
===
<!-- end_slide -->
```
This will create a slide with the text "Farming potatoes" in the center, rendered using the slide title style.
## Explicit new lines
The `newline`/`new_line` and `newlines`/`new_lines` commands allow you to explicitly create new lines. Because markdown
ignores multiple line breaks in a row, this is useful to create some spacing where necessary:
```markdown
hi
<!-- new_lines: 10 -->
mom
<!-- new_line -->
bye
```
## Incremental lists
Using `<!-- pause -->` in between each bullet point a list is a bit tedious so instead you can use the
`incremental_lists` command to tell _presenterm_ that **until the end of the current slide** you want each individual
bullet point to appear only after you move to the next slide:
```markdown
<!-- incremental_lists: true -->
* this
* appears
* one after
* the other
<!-- incremental_lists: false -->
* this appears
* all at once
```
# Key bindings
Navigation within a presentation should be intuitive: jumping to the next/previous slide can be done by using the arrow
keys, _hjkl_, and page up/down keys.
Besides this:
* Jumping to the first slide: `gg`.
* Jumping to the last slide: `G`.
* Jumping to a specific slide: `<slide-number>G`.
* Exit the presentation: `<ctrl>c`.
You can check all the configured keybindings by pressing `?` while running _presenterm_.
## Configuring key bindings
If you don't like the default key bindings, you can override them in the [configuration
file](configuration.html#key-bindings).
# Modals
_presenterm_ currently has 2 modals that can provide some information while running the application. Modals can be
toggled using some key combination and can be hidden using the escape key by default, but these can be configured via
the [configuration file key bindings](configuration.html#key-bindings).
## Slide index modal
This modal can be toggled by default using `control+p` and lets you see an index that contains a row for every slide in
the presentation, including its title and slide index. This allows you to find a slide you're trying to jump to
quicklier rather than scanning through each of them.
[![asciicast](https://asciinema.org/a/1VgRxVIEyLrMmq6OZ3oKx4PGi.svg)](https://asciinema.org/a/1VgRxVIEyLrMmq6OZ3oKx4PGi)
## Key bindings modal
The key bindings modal displays the key bindings for each of the supported actions and can be opened by pressing `?`.
# Hot reload
Unless you run in presentation mode by passing in the `--present` parameter, _presenterm_ will automatically reload your
presentation file every time you save it. _presenterm_ will also automatically detect which specific slide was modified
and jump to it so you don't have to be jumping back and forth between the source markdown and the presentation to see
how the changes look like.
[![asciicast](https://asciinema.org/a/bu9ITs8KhaQK5OdDWnPwUYKu3.svg)](https://asciinema.org/a/bu9ITs8KhaQK5OdDWnPwUYKu3)

View File

@ -1,3 +1,135 @@
# Code highlighting
Code highlighting is supported for the following languages:
* ada
* asp
* awk
* bash
* batchfile
* C
* cmake
* crontab
* C#
* clojure
* C++
* CSS
* D
* diff
* docker
* dotenv
* elixir
* elm
* erlang
* go
* haskell
* HTML
* java
* javascript
* json
* kotlin
* latex
* lua
* makefile
* markdown
* nix
* ocaml
* perl
* php
* protobuf
* puppet
* python
* R
* ruby
* rust
* scala
* shell
* sql
* swift
* svelte
* tcl
* toml
* terraform
* typescript
* xml
* yaml
* vue
* zig
## Enabling line numbers
If you would like line numbers to be shown on the left of a code block use the `+line_numbers` switch after specifying
the language in a code block:
~~~markdown
```rust +line_numbers
fn hello_world() {
println!("Hello world");
}
```
~~~
## Selective highlighting
By default, the entire code block will be syntax-highlighted. If instead you only wanted a subset of it to be
highlighted, you can use braces and a list of either individual lines, or line ranges that you'd want to highlight.
~~~markdown
```rust {1,3,5-7}
fn potato() -> u32 { // 1: highlighted
// 2: not highlighted
println!("Hello world"); // 3: highlighted
let mut q = 42; // 4: not highlighted
q = q * 1337; // 5: highlighted
q // 6: highlighted
} // 7: highlighted
```
~~~
## Dynamic highlighting
Similar to the syntax used for selective highlighting, dynamic highlighting will change which lines of the code in a
code block are highlighted every time you move to the next/previous slide.
This is achieved by using the separator `|` to indicate what sections of the code will be highlighted at a given time.
You can also use `all` to highlight all lines for a particular frame.
~~~markdown
```rust {1,3|5-7}
fn potato() -> u32 {
println!("Hello world");
let mut q = 42;
q = q * 1337;
q
}
```
~~~
In this example, lines 1 and 3 will be highlighted initially. Then once you press a key to move to the next slide, lines
1 and 3 will no longer be highlighted and instead lines 5 through 7 will. This allows you to create more dynamic
presentations where you can display sections of the code to explain something specific about each of them.
See this real example of how this looks like.
[![asciicast](https://asciinema.org/a/iCf4f6how1Ux3H8GNzksFUczI.svg)](https://asciinema.org/a/iCf4f6how1Ux3H8GNzksFUczI)
## Including external code snippets
The `file` snippet type can be used to specify an external code snippet that will be included and highlighted as usual.
~~~markdown
```file +exec +line_numbers
path: snippet.rs
language: rust
```
~~~
## Showing a snippet without a background
Using the `+no_background` flag will cause the snippet to have no background. This is useful when combining it with the
`+exec_replace` flag described further down.
# Snippet execution
## Executing code blocks
@ -17,13 +149,40 @@ Code execution **must be explicitly enabled** by using either:
* The `-x` command line parameter when running _presenterm_.
* Setting the `snippet.exec.enable` property to `true` in your [_presenterm_ config
file](../../configuration/settings.md#snippet-execution).
Refer to [the table in the highlighting page](highlighting.md#code-highlighting) for the list of languages for which
code execution is supported.
file](configuration.html#snippet-execution).
---
The list of languages that support execution are:
* bash
* c++
* c
* fish
* go
* haskell
* java
* js
* kotlin
* lua
* nushell
* perl
* php
* python
* r
* ruby
* rust
* rust-script: this highlights as normal Rust but uses [rust-script](https://rust-script.org/) to execute the snippet so
it lets you use dependencies.
* sh
* zsh
* c#
If there's a language that is not in this list and you would like it to be supported, please [create an
issue](https://github.com/mfontanini/presenterm/issues/new) providing details on how to compile (if necessary) and run
snippets for that language. You can also configure how to run code snippet for a language locally in your [config
file](configuration.html#custom-snippet-executors).
[![asciicast](https://asciinema.org/a/BbAY817esxagCgPtnKUwgYnHr.svg)](https://asciinema.org/a/BbAY817esxagCgPtnKUwgYnHr)
> [!warning]
@ -53,11 +212,9 @@ nothing else.
For example, this would render the demo presentation's image:
~~~markdown
```bash +image
cat examples/doge.png
```
~~~
This attribute carries the same risks as `+exec_replace` and therefore needs to be enabled via the same flags.
@ -121,5 +278,21 @@ is loaded. The languages that currently support this are _mermaid_, _LaTeX_, and
block is transformed into an image, allowing you to define formulas as text in your presentation. This can be done by
using the `+render` attribute on a code block.
See the [LaTeX and typst](latex.md) and [mermaid](mermaid.md) docs for more information.
See the [LaTeX and typst](latex.html) and [mermaid](mermaid.html) docs for more information.
## Adding highlighting syntaxes for new languages
_presenterm_ uses the syntaxes supported by [bat](https://github.com/sharkdp/bat) to highlight code snippets, so any
languages supported by _bat_ natively can be added to _presenterm_ easily. Please create a ticket or use
[this](https://github.com/mfontanini/presenterm/pull/385) as a reference to submit a pull request to make a syntax
officially supported by _presenterm_ as well.
If a language isn't natively supported by _bat_ but you'd like to use it, you can follow
[this guide in the bat docs](https://github.com/sharkdp/bat#adding-new-syntaxes--language-definitions) and
invoke _bat_ directly in a presentation:
~~~markdown
```bash +exec_replace
bat --color always script.py
```
~~~

View File

@ -1,8 +1,190 @@
# Settings
# Configuration
As opposed to options, the rest of these settings **can only be configured via the configuration file**.
_presenterm_ allows you to customize its behavior via a configuration file. This file is stored, along with all of your
custom themes, in the following directories:
## Default theme
* `$XDG_CONFIG_HOME/presenterm/` if that environment variable is defined, otherwise:
* `~/.config/presenterm/` in Linux.
* `~/Library/Application Support/presenterm/` in macOS.
* `~/AppData/Roaming/presenterm/config/` in Windows.
The configuration file will be looked up automatically in the directories above under the name `config.yaml`. e.g. on
Linux you should create it under `~/.config/presenterm/config.yaml`. You can also specify a custom path to this file
when running _presenterm_ via the `--config-path` parameter.
A [sample configuration file](https://github.com/mfontanini/presenterm/blob/master/config.sample.yaml) is provided in
the repository that you can use as a base.
## Options
Options are special configuration parameters that can be set either in the configuration file under the `options` key,
or in a presentation's front matter under the same key. This last one allows you to customize a single presentation so
that it acts in a particular way. This can also be useful if you'd like to share the source files for your presentation
with other people.
The supported configuration options are currently the following:
### implicit_slide_ends
This option removes the need to use `<!-- end_slide -->` in between slides and instead assumes that if you use a slide
title, then you're implying that the previous slide ended. For example, the following presentation:
```markdown
---
options:
implicit_slide_ends: true
---
Tasty vegetables
================
* Potato
Awful vegetables
================
* Lettuce
```
Is equivalent to this "vanilla" one that doesn't use implicit slide ends.
```markdown
Tasty vegetables
================
* Potato
<!-- end_slide -->
Awful vegetables
================
* Lettuce
```
### end_slide_shorthand
This option allows using thematic breaks (`---`) as a delimiter between slides. When enabling this option, you can still
use `<!-- end_slide -->` but any thematic break will also be considered a slide terminator.
```
---
options:
end_slide_shorthand: true
---
this is a slide
---------------------
this is another slide
```
### command_prefix
Because _presenterm_ uses HTML comments to represent commands, it is necessary to make some assumptions on _what_ is a
command and what isn't. The current heuristic is:
* If an HTML comment is laid out on a single line, it is assumed to be a command. This means if you want to use a real
HTML comment like `<!-- remember to say "potato" here -->`, this will raise an error.
* If an HTML comment is multi-line, then it is assumed to be a comment and it can have anything inside it. This means
you can't have a multi-line comment that contains a command like `pause` inside.
Depending on how you use HTML comments personally, this may be limiting to you: you cannot use any single line comments
that are not commands. To get around this, the `command_prefix` option lets you configure a prefix that must be set in
all commands for them to be configured as such. Any single line comment that doesn't start with this prefix will not be
considered a command.
For example:
```
---
options:
command_prefix: "cmd:"
---
<!-- remember to say "potato here" -->
Tasty vegetables
================
* Potato
<!-- cmd:pause -->
**That's it!**
```
In the example above, the first comment is ignored because it doesn't start with "cmd:" and the second one is processed
because it does.
### incremental_lists
If you'd like all bullet points in all lists to show up with pauses in between you can enable the `incremental_lists`
option:
```
---
options:
incremental_lists: true
---
* pauses
* in
* between
```
Keep in mind if you only want specific bullet points to show up with pauses in between, you can use the
[`incremental_lists` comment command](basics.html#incremental-lists).
### strict_front_matter_parsing
This option tells _presenterm_ you don't care about extra parameters in presentation's front matter. This can be useful
if you're trying to load a presentation made for another tool. The following presentation would only be successfully
loaded if you set `strict_front_matter_parsing` to `false` in your configuration file:
```markdown
---
potato: 42
---
# Hi
```
### image_attributes_prefix
The [image size](basics.html#image-size) prefix (by default `image:`) can be configured to be anything you would want in
case you don't like the default one. For example, if you'd like to set the image size by simply doing
`![width:50%](path.png)` you would need to set:
```yaml
---
options:
image_attributes_prefix: ""
---
![width:50%](path.png)
```
### auto_render_languages
This option allows indicating a list of languages for which the `+render` attribute can be omitted in their code
snippets and will be implicitly considered to be set. This can be used for languages like `mermaid` so that graphs are
always automatically rendered without the need to specify `+render` everywhere.
```yaml
---
options:
auto_render_languages:
- mermaid
---
```
## Defaults
Defaults **can only be configured via the configuration file**.
### Default theme
The default theme can be configured only via the config file. When this is set, every presentation that doesn't set a
theme explicitly will use this one:
@ -12,7 +194,7 @@ defaults:
theme: light
```
## Terminal font size
### Terminal font size
This is a parameter that lets you explicitly set the terminal font size in use. This should not be used unless you are
in Windows, given there's no (easy) way to get the terminal window size so we use this to figure out how large the
@ -27,7 +209,7 @@ defaults:
terminal_font_size: 16
```
## Preferred image protocol
### Preferred image protocol
By default _presenterm_ will try to detect which image protocol to use based on the terminal you are using. In case
detection for some reason fails in your setup or you'd like to force a different protocol to be used, you can explicitly
@ -47,7 +229,7 @@ Possible values are:
* `iterm2`: use the iterm2 protocol.
* `sixel`: use the sixel protocol. Note that this requires compiling _presenterm_ using the `--features sixel` flag.
## Maximum presentation width
### Maximum presentation width
The `max_columns` property can be set to specify the maximum number of columns that the presentation will stretch to. If
your terminal is larger than that, the presentation will stick to that size and will be centered, preventing it from
@ -58,63 +240,7 @@ defaults:
max_columns: 100
```
If you would like your presentation to be left or right aligned instead of centered when the terminal is too wide, you
can use the `max_columns_alignment` key:
```yaml
defaults:
max_columns: 100
# Valid values: left, center, right
max_columns_alignment: left
```
## Maximum presentation height
The `max_rows` and `max_rows_alignment` properties are analogous to `max_columns*` to allow capping the maximum number
of rows:
```yaml
defaults:
max_rows: 100
# Valid values: top, center, bottom
max_rows_alignment: left
```
## Incremental lists behavior
By default, [incremental lists](../features/commands.md) will pause before and after a list. If you would like to change
this behavior, use the `defaults.incremental_lists` key:
```yaml
defaults:
incremental_lists:
# The defaults, change to false if desired.
pause_before: true
pause_after: true
```
# Slide transitions
Slide transitions allow animating your presentation every time you move from a slide to the next/previous one. The
configuration for slide transitions is the following:
```yaml
transition:
# how long the transition should last.
duration_millis: 750
# how many frames should be rendered during the transition
frames: 45
# the animation to use
animation:
style: <style_name>
```
See the [slide transitions page](../features/slide-transitions.md) for more information on which animation styles are
supported.
# Key bindings
## Key bindings
Key bindings that _presenterm_ uses can be manually configured in the config file via the `bindings` key. The following
is the default configuration:
@ -127,16 +253,6 @@ 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"]
@ -171,15 +287,13 @@ bindings:
You can choose to override any of them. Keep in mind these are overrides so if for example you change `next`, the
default won't apply anymore and only what you've defined will be used.
# Snippet configurations
## Snippet configurations
The configurations that affect code snippets in presentations.
### Snippet execution
## Snippet execution
[Snippet execution](../features/code/execution.md#executing-code-blocks) is disabled by default for security reasons.
Besides passing in the `-x` command line parameter every time you run _presenterm_, you can also configure this globally
for all presentations by setting:
[Snippet execution](code-highlight.html#executing-code-blocks) is disabled by default for security reasons. Besides
passing in the `-x` command line parameter every time you run _presenterm_, you can also configure this globally for all
presentations by setting:
```yaml
snippet:
@ -189,11 +303,11 @@ snippet:
**Use this at your own risk**, especially if you're running someone else's presentations!
## Snippet execution + replace
### Snippet execution + replace
[Snippet execution + replace](../features/code/execution.md#executing-and-replacing) is disabled by default for security
reasons. Similar to `+exec`, this can be enabled by passing in the `-X` command line parameter or configuring it
globally by setting:
[Snippet execution + replace](code-highlight.html#executing-and-replacing) is disabled by default for security reasons.
Similar to `+exec`, this can be enabled by passing in the `-X` command line parameter or configuring it globally by
setting:
```yaml
snippet:
@ -204,7 +318,7 @@ snippet:
**Use this at your own risk**. This will cause _presenterm_ to execute code without user intervention so don't blindly
enable this and open a presentation unless you trust its origin!
## Custom snippet executors
### Custom snippet executors
If _presenterm_ doesn't support executing code snippets for your language of choice, please [create an
issue](https://github.com/mfontanini/presenterm/issues/new)! Alternatively, you can configure this locally yourself by
@ -244,7 +358,7 @@ example above).
See more examples in the [executors.yaml](https://github.com/mfontanini/presenterm/blob/master/executors.yaml) file
which defines all of the built-in executors.
## Snippet rendering threads
### Snippet rendering threads
Because some `+render` code blocks can take some time to be rendered into an image, especially if you're using
[mermaid](https://mermaid.js.org/) charts, this is run asychronously. The number of threads used to render these, which
@ -256,7 +370,7 @@ snippet:
threads: 2
```
## Mermaid scaling
### Mermaid scaling
[mermaid](https://mermaid.js.org/) graphs will use a default scaling of `2` when invoking the mermaid CLI. If you'd like
to change this use:
@ -267,39 +381,13 @@ mermaid:
scale: 2
```
## Enabling speaker note publishing
### Enabling speaker note publishing
If you don't want to run _presenterm_ with `--publish-speaker-notes` every time you want to publish speaker notes, you
can set the `speaker_notes.always_publish` attribute to `true`.
```yaml
speaker_notes:
always_publish: true
always_pubblish: 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
```

View File

@ -1,4 +1,4 @@
# Installing _presenterm_
# Installation
_presenterm_ works on Linux, macOS, and Windows and can be installed in different ways:

View File

@ -1,22 +1,8 @@
# LaTeX and typst
`latex` and `typst` code blocks can be marked with the `+render` attribute (see [highlighting](highlighting.md)) to have
them rendered into images when the presentation is loaded. This allows you to define formulas in text rather than having
to define them somewhere else, transform them into an image, and them embed it.
For example, the following presentation:
~~~
# Formulas
```latex +render
\[ \sum_{n=1}^{\infty} 2^{-n} = 1 \]
```
~~~
Would be rendered like this:
![](../../assets/formula.png)
`latex` and `typst` code blocks can be marked with the `+render` attribute (see [highlighting](code-highlight.html)) to
have them rendered into images when the presentation is loaded. This allows you to define formulas in text rather than
having to define them somewhere else, transform them into an image, and them embed it.
## Dependencies
@ -27,9 +13,9 @@ install, lightweight, and boilerplate free as compared to _LaTeX_.
### pandoc
For _LaTeX_ code rendering both _typst_ and [pandoc](https://github.com/jgm/pandoc) are required. How this works is the
_LaTeX_ code you write gets transformed into _typst_ code via _pandoc_ and then rendered by using _typst_. This lets us:
For _LaTeX_ code rendering, besides _typst_ you will need to install [pandoc](https://github.com/jgm/pandoc). How this
works is the _LaTeX_ code you write gets transformed into _typst_ code via _pandoc_ and then rendered by using _typst_.
This lets us:
* Have the same look/feel on generated formulas for both languages.
* Avoid having to write lots of boilerplate _LaTeX_ to make rendering for that language work.
* Have the same logic to render formulas for both languages, except with a small preparation step for _LaTeX_.
@ -42,7 +28,7 @@ generated on the fly will have a fixed size. Configuring the PPI used during the
higher the PPI, the larger the generated images will be.
Because as opposed to most configurations this is a very environment-specific config, the PPI parameter is not part of
the theme definition but is instead has to be set in _presenterm_'s [config file](../../configuration/introduction.md):
the theme definition but is instead has to be set in [_presenterm_'s config file](configuration.html):
```yaml
typst:
@ -86,3 +72,19 @@ typst:
horizontal_margin: 2
vertical_margin: 2
```
# Example
The following example:
~~~
# Formulas
```latex +render
\[ \sum_{n=1}^{\infty} 2^{-n} = 1 \]
```
~~~
Is rendered like this:
![](../assets/formula.png)

View File

@ -15,11 +15,11 @@ sequenceDiagram
Note that because the mermaid CLI will spin up a browser under the hood, this may not work in all environments and can
also be a bit slow (e.g. ~2 seconds to generate every image). Mermaid graphs are rendered asynchronously by a number of
threads that can be configured in the [configuration file](../../configuration/settings.md#snippet-rendering-threads).
This configuration value currently defaults to 2.
threads that can be configured in the [configuration file](configuration.html#snippet-rendering-threads). This
configuration value currently defaults to 2.
The size of the rendered image can be configured by changing:
* The `mermaid.scale` [configuration parameter](../../configuration/settings.md#mermaid-scaling).
* The `mermaid.scale` [configuration parameter](configuration.html#mermaid-scaling).
* Using the `+width:<number>%` attribute in the code snippet.
For example, this diagram will take up 50% of the width of the window and will preserve its aspect ratio:
@ -38,13 +38,12 @@ cause the image to become blurry.
## Theme
The theme of the rendered mermaid diagrams can be changed through the following
[theme](../themes/introduction.md#mermaid) parameters:
The theme of the rendered mermaid diagrams can be changed through the following [theme](themes.html#mermaid) parameters:
* `mermaid.background` the background color passed to the CLI (e.g., `transparent`, `red`, `#F0F0F0`).
* `mermaid.theme` the [mermaid theme](https://mermaid.js.org/config/theming.html#available-themes) to use.
## Always render diagrams
## Always rendering
If you don't want to use `+render` every time, you can configure which languages get this automatically via the [config
file](../../configuration/settings.md#auto_render_languages).
file](configuration.html#auto_render_languages).

View File

@ -0,0 +1,43 @@
# PDF export
Presentations can be converted into PDF by using a [helper tool](https://github.com/mfontanini/presenterm-export). You
can install it by running:
```bash
pip install presenterm-export
```
> [!tip]
> Make sure that `presenterm-export` works by running `presenterm-export --version` before attempting to generate a PDF
> file. If you get errors related to _weasyprint_, follow their [installation instructions](https://doc.courtbouillon.org/weasyprint/stable/first_steps.html) to ensure you meet all of their
> dependencies. This has otherwise caused issues in macOS.
The only external dependency you'll need is [tmux](https://github.com/tmux/tmux/). After you've installed both of these,
simply run _presenterm_ with the `--export-pdf` parameter to generate the output PDF:
```bash
presenterm --export-pdf examples/demo.md
```
The output PDF will be placed in `examples/demo.pdf`.
> [!note]
> If you're using a separate virtual env to install _presenterm-export_ just make sure you activate it before running
> _presenterm_ with the `--export-pdf` parameter.
## Page sizes
The size of each page in the generated PDF will depend on the size of your terminal. Make sure to adjust accordingly
before running the command above, and not to resize it while the generation is happening to avoid issues.
## Active tmux sessions bug
Because of a [bug in tmux <= 3.5a](https://github.com/tmux/tmux/issues/4268), exporting a PDF while having other tmux
sessions running and attached will cause the size of the output PDF to match the size of those other sessions rather
than the size of the terminal you're running _presenterm_ in. The workaround is to only have one attached tmux session
and to run the PDF export from that session.
## How it works
The conversion into PDF format is pretty convoluted. If you'd like to learn more visit
[presenterm-export](https://github.com/mfontanini/presenterm-export)'s repo.

View File

@ -17,11 +17,6 @@ presenterm demo.md --publish-speaker-notes
presenterm demo.md --listen-speaker-notes
```
[![asciicast](https://asciinema.org/a/ETusvlmHuHrcLKzwa0CMQRX2J.svg)](https://asciinema.org/a/ETusvlmHuHrcLKzwa0CMQRX2J)
See the [speaker notes example](https://github.com/mfontanini/presenterm/blob/master/examples/speaker-notes.md) for more
information.
### Defining speaker notes
In order to define speaker notes you can use the `speaker_notes` comment command:
@ -56,16 +51,6 @@ 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

View File

@ -1,7 +1,106 @@
# Themes
Themes are defined in the form of yaml files. A few built-in themes are defined in the [themes][builtin-themes]
directory, but others can be created and referenced directly in every presentation.
## Setting themes
There's various ways of setting the theme you want in your presentation:
### CLI
Passing in the `--theme` parameter when running _presenterm_ to select one of the built-in themes.
### Within the presentation
The presentation's markdown file can contain a front matter that specifies the theme to use. This comes in 3 flavors:
#### By name
Using a built-in theme name makes your presentation use that one regardless of what the default or what the `--theme`
option specifies:
```yaml
---
theme:
name: dark
---
```
#### By path
You can define a theme file in yaml format somewhere in your filesystem and reference it within the presentation:
```yaml
---
theme:
path: /home/me/Documents/epic-theme.yaml
---
```
#### Overrides
You can partially/completely override the theme in use from within the presentation:
```yaml
---
theme:
override:
default:
colors:
foreground: "beeeff"
---
```
This lets you:
1. Create a unique style for your presentation without having to go through the process of taking an existing theme,
copying somewhere, and changing it when you only expect to use it for that one presentation.
2. Iterate quickly on styles given overrides are reloaded whenever you save your presentation file.
# Built-in themes
A few built-in themes are bundled with the application binary, meaning you don't need to have any external files
available to use them. These are packed as part of the [build process][build-rs] as a binary blob and are decoded on
demand only when used.
Currently, the following themes are supported:
* `dark`: A dark theme.
* `light`: A light theme.
* `tokyonight-storm`: A theme inspired by the colors used in [toyonight](https://github.com/folke/tokyonight.nvim).
* A set of themes based on the [catppuccin](https://github.com/catppuccin/catppuccin) color palette:
* `catppuccin-latte`
* `catppuccin-frappe`
* `catppuccin-macchiato`
* `catppuccin-mocha`
* `terminal-dark`: A theme that uses your terminals color and looks best if your terminal uses a dark color scheme. This
means if your terminal background is e.g. transparent, or uses an image, the presentation will inherit that.
* `terminal-light`: The same as `terminal-dark` but works best if your terminal uses a light color scheme.
## Trying out built-in themes
All built-in themes can be tested by using the `--list-themes` parameter:
```bash
presenterm --list-themes
```
This will run a presentation where the same content is rendered using a different theme in each slide:
[![asciicast](https://asciinema.org/a/zeV1QloyrLkfBp6rNltvX7Lle.svg)](https://asciinema.org/a/zeV1QloyrLkfBp6rNltvX7Lle)
# Loading custom themes
On startup, _presenterm_ will look into the `themes` directory under the [configuration directory](configuration.html)
(e.g. `~/.config/presenterm/themes` in Linux) and will load any `.yaml` file as a theme and make it available as if it
was a built-in theme. This means you can use it as an argument to the `--theme` parameter, use it in the `theme.name`
property in a presentation's front matter, etc.
# Theme definition
This section goes through the structure of the theme files. Have a look at some of the [existing
themes](https://github.com/mfontanini/presenterm/tree/master/themes) to have an idea of how to structure themes.
This section goes through the structure of the theme files. Have a look at some of the [existing themes][builtin-themes]
to have an idea of how to structure themes.
## Root elements
@ -126,75 +225,16 @@ intro_slide:
The footer currently comes in 3 flavors:
### Template footers
### None
A template footer lets you put text on the left, center and/or right of the screen. The template strings
can reference `{current_slide}` and `{total_slides}` which will be replaced with the current and total number of slides.
Besides those special variables, any of the attributes defined in the front matter can also be used:
* `title`.
* `sub_title`.
* `event`.
* `location`.
* `date`.
* `author`.
Strings used in template footers can contain arbitrary markdown, including `span` tags that let you use colored text. A
`height` attribute allows specifying how tall, in terminal rows, the footer is. The text in the footer will always be
placed at the center of the footer area. The default footer height is 2.
No footer at all!
```yaml
footer:
style: template
left: "My **name** is {author}"
center: "_@myhandle_"
right: "{current_slide} / {total_slides}"
height: 3
style: empty
```
Do note that:
* Only existing attributes in the front matter can be referenced. That is, if you use `{date}` but the `date` isn't set,
an error will be shown.
* Similarly, referencing unsupported variables (e.g. `{potato}`) will cause an error to be displayed. If you'd like the
`{}` characters to be used in contexts where you don't want to reference a variable, you will need to escape them by
using another brace. e.g. `{{potato}} farms` will be displayed as `{potato} farms`.
#### Footer images
Besides text, images can also be used in the left/center/right positions. This can be done by specifying an `image` key
under each of those attributes:
```yaml
footer:
style: template
left:
image: potato.png
center:
image: banana.png
right:
image: apple.png
# The height of the footer to adjust image sizes
height: 5
```
Images will be looked up:
* First, relative to the presentation file just like any other image.
* If the image is not found, it will be looked up relative to the themes directory. e.g. `~/.config/presenterm/themes`.
This allows you to define a custom theme in your themes directory that points to a local image within that same
location.
Images will preserve their aspect ratio and expand vertically to take up as many terminal rows as `footer.height`
specifies. This parameter should be adjusted accordingly if taller-than-wider images are used in a footer.
See the [footer example](https://github.com/mfontanini/presenterm/blob/master/examples/footer.md) as a showcase of how a
footer can contain images and colored text.
![](../../assets/example-footer.png)
### Progress bar footers
### Progress bar
A progress bar that will advance as you move in your presentation. This will by default use a block-looking character to
draw the progress bar but you can customize it:
@ -207,16 +247,28 @@ footer:
character: 🚀
```
### None
### Template
No footer at all!
A template footer that lets you put something on the left, center and/or right of the screen. The template strings
can reference `{current_slide}` and `{total_slides}` which will be replaced with the current and total number of slides.
Besides those special variables, any of the attributes defined in the front matter can also be used:
* `title`.
* `sub_title`.
* `event`.
* `location`.
* `date`.
* `author`.
```yaml
footer:
style: empty
style: template
left: "My name is {author}"
center: @myhandle
right: "{current_slide} / {total_slides}"
```
## Slide title
Slide titles, as specified by using a setext header, has the following properties:
@ -280,8 +332,8 @@ code:
#### Custom highlighting themes
Besides the built-in highlighting themes, you can drop any `.tmTheme` theme in the `themes/highlighting` directory under
your [configuration directory](../../configuration/introduction.md) (e.g. `~/.config/presenterm/themes/highlighting` in
Linux) and they will be loaded automatically when _presenterm_ starts.
your [configuration directory](configuration.html) (e.g. `~/.config/presenterm/themes/highlighting` in Linux) and they
will be loaded automatically when _presenterm_ starts.
## Block quotes
@ -292,6 +344,10 @@ block_quote:
prefix: "▍ "
```
<!-- links -->
[builtin-themes]: https://github.com/mfontanini/presenterm/tree/master/themes
[build-rs]: https://github.com/mfontanini/presenterm/blob/master/build.rs
## Mermaid
The [mermaid](https://mermaid.js.org/) graphs can be customized using the following parameters:
@ -362,9 +418,8 @@ _almost_ liking a built in theme but there's only some properties you don't like
## Color palette
Every theme can define a color palette, which includes a list of pre-defined colors and a list of background/foreground
pairs called "classes". Colors and classes can be used when styling text via `<span>` HTML tags, whereas colors can also
be used inside themes to avoid duplicating the same colors all over the theme definition.
Every theme can define a color palette, which is essentially a named list of colors. These can then be used both in
other parts of the theme, as well as when styling text via `span` HTML tags.
A palette can de defined as follows:
@ -373,10 +428,6 @@ palette:
colors:
red: "f78ca2"
purple: "986ee2"
classes:
foo:
foreground: "ff0000"
background: "00ff00"
```
Any palette color can be referenced using either `palette:<name>` or `p:<name>`. This means now any part of the theme
@ -386,9 +437,4 @@ Similarly, these colors can be used in `span` tags like:
```html
<span style="color: palette:red">this is red</span>
<span class="foo">this is foo-colored</span>
```
These colors can used anywhere in your presentation as well as in other places such as in
[template footers](#template-footers) and [introduction slides](../introduction.md#introduction-slide).

View File

@ -39,7 +39,7 @@ This example uses a template-style footer, which lets you place some text on the
A few template variables, such as `current_slide` and `total_slides` can be used to reference properties of the
presentation.
![](../docs/src/assets/example-footer.png)
[![asciicast](https://asciinema.org/a/DLpBDpCbEp5pSrNZ2Vh4mmIY1.svg)](https://asciinema.org/a/DLpBDpCbEp5pSrNZ2Vh4mmIY1)
# Columns

View File

@ -1,34 +1,38 @@
---
title: Introducing _presenterm_
title: Introducing presenterm
author: Matias
---
Customizability
Introduction slide
---
_presenterm_ allows configuring almost anything about your presentation:
* The colors used.
* Layouts.
* Footers, including images in the footer.
<!-- pause -->
This is an example on how to configure a footer:
An introduction slide can be defined by using a front matter at the beginning of the markdown file:
```yaml
footer:
style: template
left:
image: doge.png
center: '<span class="noice">Colored</span> _footer_'
right: "{current_slide} / {total_slides}"
height: 5
---
title: My presentation title
sub_title: An optional subtitle
author: Your name which will appear somewhere in the bottom
---
```
palette:
classes:
noice:
foreground: red
The slide's theme can also be configured in the front matter:
```yaml
---
theme:
# Specify it by name for built-in themes
name: my-favorite-theme
# Otherwise specify the path for it
path: /home/myself/themes/epic.yaml
# Or override parts of the theme right here
override:
default:
colors:
foreground: white
---
```
<!-- end_slide -->
@ -36,27 +40,58 @@ palette:
Headers
---
Markdown headers can be used to set slide titles like:
Using commonmark setext headers allows you to set titles for your slides (like seen above!):
```markdown
```
Headers
-------
---
```
# Headers
# Other headers
Each header type can be styled differently.
All other header types are simply treated as headers within your slide.
## Subheaders
### And more
<!-- end_slide -->
Slide commands
---
Certain commands in the form of HTML comments can be used:
# Ending slides
In order to end a single slide, use:
```html
<!-- end_slide -->
```
# Creating pauses
Slides can be paused by using the `pause` command:
```html
<!-- pause -->
```
This allows you to:
<!-- pause -->
* Create suspense.
<!-- pause -->
* Have more interactive presentations.
<!-- pause -->
* Possibly more!
<!-- end_slide -->
Code highlighting
---
Highlight code in 50+ programming languages:
Code highlighting is enabled for code blocks that include the most commonly used programming languages:
```rust
// Rust
@ -71,25 +106,22 @@ def greet() -> str:
return "hi mom"
```
<!-- pause -->
-------
Code snippets can have different styles including no background:
```cpp +no_background +line_numbers
```cpp
// C++
string greet() {
return "hi mom";
}
```
And many more!
<!-- end_slide -->
Dynamic code highlighting
---
Dynamically highlight different subsets of lines:
Select specific subsets of lines to be highlighted dynamically as you move to the next slide. Optionally enable line
numbers to make it easier to specify which lines you're referring to!
```rust {1-4|6-10|all} +line_numbers
#[derive(Clone, Debug)]
@ -109,11 +141,12 @@ impl Person {
Snippet execution
---
Code snippets can be executed on demand:
Code snippets can be executed:
* For 20+ languages, including compiled ones.
* Display their output in real time.
* Comment out unimportant lines to hide them.
* For various languages, including compiled ones.
* Their output is shown in real time.
* Unimportant lines can be hidden so they don't clutter what you're trying to convey.
* By default by pressing `<ctrl-e>`.
```rust +exec
# use std::thread::sleep;
@ -132,17 +165,12 @@ fn main() {
Images
---
Images and animated gifs are supported in terminals such as:
Image rendering is supported as long as you're using iterm2, your terminal supports
the kitty graphics protocol (such as the kitty terminal itself!), or the sixel format.
* kitty
* iterm2
* wezterm
* ghostty
* Any sixel enabled terminal
<!-- column_layout: [1, 3, 1] -->
<!-- column: 1 -->
* Include images in your slides by using `![](path-to-image.extension)`.
* Images will be rendered in **their original size**.
* If they're too big they will be scaled down to fit the screen.
![](doge.png)
@ -153,15 +181,13 @@ _Picture by Alexis Bailey / CC BY-NC 4.0_
Column layouts
---
<!-- column_layout: [7, 3] -->
<!-- column_layout: [2, 1] -->
<!-- column: 0 -->
Use column layouts to structure your presentation:
Column layouts let you organize content into columns.
* Define the number of columns.
* Adjust column widths as needed.
* Write content into every column.
Here you can place code:
```rust
fn potato() -> u32 {
@ -169,15 +195,21 @@ fn potato() -> u32 {
}
```
Plus pretty much anything else:
* Bullet points.
* Images.
* _more_!
<!-- column: 1 -->
![](doge.png)
_Picture by Alexis Bailey / CC BY-NC 4.0_
<!-- reset_layout -->
---
Layouts can be reset at any time.
Because we just reset the layout, this text is now below both of the columns. Code and any other element will now look
like it usually does:
```python
print("Hello world!")
@ -188,29 +220,23 @@ print("Hello world!")
Text formatting
---
Text formatting works including:
Text formatting works as expected:
* **Bold text**.
* _Italics_.
* **_Bold and italic_**.
* ~Strikethrough~.
* `Inline code`.
* Links [](https://example.com/)
* <span style="color: red">Colored</span> text.
* <span style="color: blue; background-color: black">Background color</span> can be changed too.
* **This is bold text**.
* _This is italics_.
* **This is bold _and this is bold and italic_**.
* ~This is strikethrough text.~
* Inline code `is also supported`.
* Links look like this [](https://example.com/)
* Text can be <span style="color: red">colored</span>.
* Text background color can be <span style="color: blue; background-color: black">changed too</span>.
<!-- end_slide -->
More markdown
Other elements
---
Other markdown elements supported are:
# Block quotes
> Lorem ipsum dolor sit amet. Eos laudantium animi ut ipsam beataeet
> et exercitationem deleniti et quia maiores a cumque enim et
> aspernatur nesciunt sed adipisci quis.
Other elements supported are:
# Alerts
@ -219,14 +245,19 @@ Other markdown elements supported are:
# Tables
| Name | Taste |
| Name | Taste |
| ------ | ------ |
| Potato | Great |
| Carrot | Yuck |
| Potato | Great |
| Carrot | Yuck |
<!-- end_slide -->
# Block quotes
<!-- jump_to_middle -->
> Lorem ipsum dolor sit amet. Eos laudantium animi ut ipsam beataeet
> et exercitationem deleniti et quia maiores a cumque enim et
> aspernatur nesciunt sed adipisci quis.
# Thematic breaks
A horizontal line by using `---`.
The end
---

View File

@ -3,15 +3,9 @@ theme:
override:
footer:
style: template
left:
image: doge.png
center: '**Introduction** to <span class="noice">footer</span> _styling_'
left: "@myhandle"
center: "Introduction to footer styling"
right: "{current_slide} / {total_slides}"
height: 5
palette:
classes:
noice:
foreground: red
---
First slide

View File

@ -42,15 +42,10 @@ js:
commands:
- ["node", "$pwd/snippet.js"]
hidden_line_prefix: "/// "
julia:
filename: snippet.jl
commands:
- ["julia", "$pwd/snippet.jl"]
hidden_line_prefix: "/// "
kotlin:
filename: snippet.kts
commands:
- ["kotlinc", "-script", "$pwd/snippet.kts"]
- ["kotlinc", "-script", "$pwd/script.kts"]
hidden_line_prefix: "/// "
lua:
filename: snippet.lua

View File

@ -1,12 +1,10 @@
#!/bin/bash
set -euo pipefail
script_dir=$(dirname "$0")
root_dir="${script_dir}/../"
current_schema=$(mktemp)
cargo run --features json-schema -q -- --generate-config-file-schema >"$current_schema"
cargo run -q -- --generate-config-file-schema >"$current_schema"
diff=$(diff --color=always -u "${root_dir}/config-file-schema.json" "$current_schema")
if [ $? -ne 0 ]; then

View File

@ -1,6 +1,5 @@
//! Code execution.
use super::snippet::{SnippetExec, SnippetRepr};
use crate::{
code::snippet::{Snippet, SnippetLanguage},
config::LanguageSnippetExecutionConfig,
@ -60,9 +59,9 @@ impl SnippetExecutor {
let config = self.language_config(snippet)?;
let script_dir = Self::write_snippet(snippet, config)?;
let state: Arc<Mutex<ExecutionState>> = Default::default();
let output_type = match snippet.attributes.representation {
SnippetRepr::Image => OutputType::Binary,
_ => OutputType::Lines,
let output_type = match snippet.attributes.image {
true => OutputType::Binary,
false => OutputType::Lines,
};
let reader_handle = CommandsRunner::spawn(
state.clone(),
@ -108,9 +107,7 @@ impl SnippetExecutor {
}
fn language_config(&self, snippet: &Snippet) -> Result<&LanguageSnippetExecutionConfig, CodeExecuteError> {
let is_executable = !matches!(snippet.attributes.execution, SnippetExec::None);
let is_exec_replace = matches!(snippet.attributes.representation, SnippetRepr::ExecReplace);
if !is_executable && !is_exec_replace {
if !snippet.attributes.execute && !snippet.attributes.execute_replace {
return Err(CodeExecuteError::NotExecutableCode);
}
self.executors.get(&snippet.language).ok_or(CodeExecuteError::UnsupportedExecution)
@ -329,7 +326,7 @@ echo 'bye'"
let code = Snippet {
contents,
language: SnippetLanguage::Shell,
attributes: SnippetAttributes { execution: SnippetExec::Exec, ..Default::default() },
attributes: SnippetAttributes { execute: true, ..Default::default() },
};
let handle = SnippetExecutor::default().execute_async(&code).expect("execution failed");
let state = loop {
@ -349,7 +346,7 @@ echo 'bye'"
let code = Snippet {
contents,
language: SnippetLanguage::Shell,
attributes: SnippetAttributes { execution: SnippetExec::None, ..Default::default() },
attributes: SnippetAttributes { execute: false, ..Default::default() },
};
let result = SnippetExecutor::default().execute_async(&code);
assert!(result.is_err());
@ -365,7 +362,7 @@ echo 'hello world'
let code = Snippet {
contents,
language: SnippetLanguage::Shell,
attributes: SnippetAttributes { execution: SnippetExec::Exec, ..Default::default() },
attributes: SnippetAttributes { execute: true, ..Default::default() },
};
let handle = SnippetExecutor::default().execute_async(&code).expect("execution failed");
let state = loop {
@ -390,7 +387,7 @@ echo 'hello world'
let code = Snippet {
contents,
language: SnippetLanguage::Shell,
attributes: SnippetAttributes { execution: SnippetExec::Exec, ..Default::default() },
attributes: SnippetAttributes { execute: true, ..Default::default() },
};
let handle = SnippetExecutor::default().execute_async(&code).expect("execution failed");
let state = loop {

View File

@ -131,7 +131,6 @@ impl SnippetHighlighter {
Java => "java",
JavaScript => "js",
Json => "json",
Julia => "jl",
Kotlin => "kt",
Latex => "tex",
Lua => "lua",
@ -208,7 +207,7 @@ pub(crate) struct StyledTokens<'a> {
impl<'a> StyledTokens<'a> {
pub(crate) fn new(style: Style, tokens: &'a str, block_style: &CodeBlockStyle) -> Self {
let has_background = block_style.background;
let has_background = block_style.background.unwrap_or(true);
let background = has_background.then_some(parse_color(style.background)).flatten();
let foreground = parse_color(style.foreground);
let mut style = TextStyle::default();

View File

@ -3,6 +3,7 @@ use super::{
padding::NumberPadder,
};
use crate::{
PresentationTheme,
markdown::{
elements::{Percent, PercentParseError},
text::{WeightedLine, WeightedText},
@ -15,25 +16,27 @@ use crate::{
},
theme::{Alignment, CodeBlockStyle},
};
use schemars::JsonSchema;
use serde::Deserialize;
use serde_with::DeserializeFromStr;
use std::{cell::RefCell, convert::Infallible, fmt::Write, ops::Range, path::PathBuf, rc::Rc, str::FromStr};
use strum::{EnumDiscriminants, EnumIter};
use unicode_width::UnicodeWidthStr;
pub(crate) struct SnippetSplitter<'a> {
style: &'a CodeBlockStyle,
theme: &'a PresentationTheme,
hidden_line_prefix: Option<&'a str>,
}
impl<'a> SnippetSplitter<'a> {
pub(crate) fn new(style: &'a CodeBlockStyle, hidden_line_prefix: Option<&'a str>) -> Self {
Self { style, hidden_line_prefix }
pub(crate) fn new(theme: &'a PresentationTheme, hidden_line_prefix: Option<&'a str>) -> Self {
Self { theme, hidden_line_prefix }
}
pub(crate) fn split(&self, code: &Snippet) -> Vec<SnippetLine> {
let mut lines = Vec::new();
let horizontal_padding = self.style.padding.horizontal;
let vertical_padding = self.style.padding.vertical;
let horizontal_padding = self.theme.code.padding.horizontal.unwrap_or(0);
let vertical_padding = self.theme.code.padding.vertical.unwrap_or(0);
if vertical_padding > 0 {
lines.push(SnippetLine::empty());
}
@ -86,11 +89,8 @@ impl SnippetLine {
&self,
code_highlighter: &mut LanguageHighlighter,
block_style: &CodeBlockStyle,
font_size: u8,
) -> WeightedLine {
let mut line = code_highlighter.highlight_line(&self.code, block_style);
line.apply_style(&TextStyle::default().size(font_size));
line.into()
code_highlighter.highlight_line(&self.code, block_style).0.into()
}
pub(crate) fn dim(&self, dim_style: &TextStyle) -> WeightedLine {
@ -108,7 +108,7 @@ impl SnippetLine {
pub(crate) struct HighlightContext {
pub(crate) groups: Vec<HighlightGroup>,
pub(crate) current: usize,
pub(crate) block_length: u16,
pub(crate) block_length: usize,
pub(crate) alignment: Alignment,
}
@ -139,8 +139,8 @@ impl AsRenderOperations for HighlightedLine {
right_padding_length: self.right_padding_length,
repeat_prefix_on_wrap: false,
text,
block_length: context.block_length,
alignment: context.alignment,
block_length: context.block_length as u16,
alignment: context.alignment.clone(),
block_color: self.block_color,
}),
RenderOperation::RenderLineBreak,
@ -209,7 +209,7 @@ impl SnippetParser {
fn parse_block_info(input: &str) -> ParseResult<(SnippetLanguage, SnippetAttributes)> {
let (language, input) = Self::parse_language(input);
let attributes = Self::parse_attributes(input)?;
if attributes.width.is_some() && !matches!(attributes.representation, SnippetRepr::Render) {
if attributes.width.is_some() && !attributes.render {
return Err(SnippetBlockParseError::NotRenderSnippet("width"));
}
Ok((language, attributes))
@ -231,30 +231,19 @@ impl SnippetParser {
if processed_attributes.contains(&discriminant) {
return Err(SnippetBlockParseError::DuplicateAttribute("duplicate attribute"));
}
use SnippetAttribute::*;
match attribute {
ExecReplace | Image | Render if attributes.representation != SnippetRepr::Snippet => {
return Err(SnippetBlockParseError::MultipleRepresentation);
SnippetAttribute::LineNumbers => attributes.line_numbers = true,
SnippetAttribute::Exec => attributes.execute = true,
SnippetAttribute::ExecReplace => attributes.execute_replace = true,
SnippetAttribute::Image => {
attributes.execute_replace = true;
attributes.image = true;
}
LineNumbers => attributes.line_numbers = true,
Exec => {
if attributes.execution != SnippetExec::AcquireTerminal {
attributes.execution = SnippetExec::Exec;
}
}
ExecReplace => {
attributes.representation = SnippetRepr::ExecReplace;
attributes.execution = SnippetExec::Exec;
}
Image => {
attributes.representation = SnippetRepr::Image;
attributes.execution = SnippetExec::Exec;
}
Render => attributes.representation = SnippetRepr::Render,
AcquireTerminal => attributes.execution = SnippetExec::AcquireTerminal,
NoBackground => attributes.no_background = true,
HighlightedLines(lines) => attributes.highlight_groups = lines,
Width(width) => attributes.width = Some(width),
SnippetAttribute::Render => attributes.render = true,
SnippetAttribute::NoBackground => attributes.no_background = true,
SnippetAttribute::AcquireTerminal => attributes.acquire_terminal = true,
SnippetAttribute::HighlightedLines(lines) => attributes.highlight_groups = lines,
SnippetAttribute::Width(width) => attributes.width = Some(width),
};
processed_attributes.push(discriminant);
input = rest;
@ -380,9 +369,6 @@ pub enum SnippetBlockParseError {
#[error("duplicate attribute: {0}")]
DuplicateAttribute(&'static str),
#[error("+exec_replace +image and +render can't be used together ")]
MultipleRepresentation,
#[error("attribute {0} can only be set in +render blocks")]
NotRenderSnippet(&'static str),
}
@ -438,8 +424,7 @@ impl Snippet {
}
/// The language of a code snippet.
#[derive(Clone, Debug, PartialEq, Eq, EnumIter, PartialOrd, Ord)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[derive(Clone, Debug, PartialEq, Eq, EnumIter, PartialOrd, Ord, DeserializeFromStr, JsonSchema)]
pub enum SnippetLanguage {
Ada,
Asp,
@ -469,7 +454,6 @@ pub enum SnippetLanguage {
Java,
JavaScript,
Json,
Julia,
Kotlin,
Latex,
Lua,
@ -508,8 +492,6 @@ pub enum SnippetLanguage {
Zsh,
}
crate::utils::impl_deserialize_from_str!(SnippetLanguage);
impl FromStr for SnippetLanguage {
type Err = Infallible;
@ -543,7 +525,6 @@ impl FromStr for SnippetLanguage {
"java" => Java,
"javascript" | "js" => JavaScript,
"json" => Json,
"julia" => Julia,
"kotlin" => Kotlin,
"latex" => Latex,
"lua" => Lua,
@ -588,11 +569,22 @@ impl FromStr for SnippetLanguage {
/// Attributes for code snippets.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) struct SnippetAttributes {
/// The way the snippet should be represented.
pub(crate) representation: SnippetRepr,
/// Whether the snippet is marked as executable.
pub(crate) execute: bool,
/// The way the snippet should be executed.
pub(crate) execution: SnippetExec,
/// Whether the snippet is marked as an executable block that will be replaced with the output
/// of its execution.
pub(crate) execute_replace: bool,
/// Whether the snippet should be executed and its output should be considered to be an image
/// and replaced with it.
pub(crate) image: bool,
/// Whether a snippet is marked to be rendered.
///
/// A rendered snippet is transformed during parsing, leading to some visual
/// representation of it being shown rather than the original code.
pub(crate) render: bool,
/// Whether the snippet should show line numbers.
pub(crate) line_numbers: bool,
@ -607,23 +599,9 @@ pub(crate) struct SnippetAttributes {
/// Whether to add no background to a snippet.
pub(crate) no_background: bool,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) enum SnippetRepr {
#[default]
Snippet,
Image,
Render,
ExecReplace,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) enum SnippetExec {
#[default]
None,
Exec,
AcquireTerminal,
/// Whether this code snippet acquires the terminal when ran.
pub(crate) acquire_terminal: bool,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
@ -659,8 +637,6 @@ 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)]
@ -721,33 +697,17 @@ mod test {
#[test]
fn one_attribute() {
let attributes = parse_attributes("bash +exec");
assert_eq!(attributes.execution, SnippetExec::Exec);
assert!(attributes.execute);
assert!(!attributes.line_numbers);
}
#[test]
fn two_attributes() {
let attributes = parse_attributes("bash +exec +line_numbers");
assert_eq!(attributes.execution, SnippetExec::Exec);
assert!(attributes.execute);
assert!(attributes.line_numbers);
}
#[test]
fn acquire_terminal() {
let attributes = parse_attributes("bash +acquire_terminal +exec");
assert_eq!(attributes.execution, SnippetExec::AcquireTerminal);
assert_eq!(attributes.representation, SnippetRepr::Snippet);
assert!(!attributes.line_numbers);
}
#[test]
fn image() {
let attributes = parse_attributes("bash +image +exec");
assert_eq!(attributes.execution, SnippetExec::Exec);
assert_eq!(attributes.representation, SnippetRepr::Image);
assert!(!attributes.line_numbers);
}
#[test]
fn invalid_attributes() {
SnippetParser::parse_block_info("bash +potato").unwrap_err();
@ -802,7 +762,7 @@ mod test {
#[test]
fn parse_width() {
let attributes = parse_attributes("mermaid +width:50% +render");
assert_eq!(attributes.representation, SnippetRepr::Render);
assert!(attributes.render);
assert_eq!(attributes.width, Some(Percent(50)));
}

View File

@ -1,6 +1,8 @@
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.
@ -160,11 +162,8 @@ enum BindingMatch {
None,
}
#[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);
#[derive(Clone, Debug, PartialEq, Eq, DeserializeFromStr, JsonSchema)]
pub struct KeyBinding(#[schemars(with = "String")] Vec<KeyMatcher>);
impl KeyBinding {
fn match_events(&self, mut events: &[KeyEvent]) -> BindingMatch {

View File

@ -36,7 +36,7 @@ impl CommandListener {
return Ok(Some(command));
}
}
match self.keyboard.poll_next_command(Duration::from_millis(100))? {
match self.keyboard.poll_next_command(Duration::from_millis(250))? {
Some(command) => Ok(Some(command)),
None => Ok(None),
}

View File

@ -2,11 +2,11 @@ use crate::{
code::snippet::SnippetLanguage,
commands::keyboard::KeyBinding,
terminal::{
GraphicsMode, capabilities::TerminalCapabilities, emulator::TerminalEmulator,
image::protocols::kitty::KittyMode,
GraphicsMode, emulator::TerminalEmulator, image::protocols::kitty::KittyMode, query::TerminalCapabilities,
},
};
use clap::ValueEnum;
use schemars::JsonSchema;
use serde::Deserialize;
use std::{
collections::{BTreeMap, HashMap},
@ -15,8 +15,7 @@ use std::{
path::Path,
};
#[derive(Clone, Debug, Default, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Config {
/// The default configuration for the presentation.
@ -40,12 +39,6 @@ pub struct Config {
#[serde(default)]
pub speaker_notes: SpeakerNotesConfig,
#[serde(default)]
pub export: ExportConfig,
#[serde(default)]
pub transition: Option<SlideTransitionConfig>,
}
impl Config {
@ -53,7 +46,7 @@ impl Config {
pub fn load(path: &Path) -> Result<Self, ConfigLoadError> {
let contents = match fs::read_to_string(path) {
Ok(contents) => contents,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Err(ConfigLoadError::NotFound),
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Self::default()),
Err(e) => return Err(e.into()),
};
let config = serde_yaml::from_str(&contents)?;
@ -66,23 +59,19 @@ pub enum ConfigLoadError {
#[error("io: {0}")]
Io(#[from] io::Error),
#[error("config file not found")]
NotFound,
#[error("invalid configuration: {0}")]
Invalid(#[from] serde_yaml::Error),
}
#[derive(Clone, Debug, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[derive(Clone, Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct DefaultsConfig {
/// The theme to use by default in every presentation unless overridden.
pub theme: Option<String>,
/// Override the terminal font size when in windows or when using sixel.
#[serde(default = "default_terminal_font_size")]
#[cfg_attr(feature = "json-schema", validate(range(min = 1)))]
#[serde(default = "default_font_size")]
#[validate(range(min = 1))]
pub terminal_font_size: u8,
/// The image protocol to use.
@ -94,96 +83,27 @@ pub struct DefaultsConfig {
pub validate_overflows: ValidateOverflows,
/// A max width in columns that the presentation must always be capped to.
#[serde(default = "default_u16_max")]
#[serde(default = "default_max_columns")]
pub max_columns: u16,
/// The alignment the presentation should have if `max_columns` is set and the terminal is
/// larger than that.
#[serde(default)]
pub max_columns_alignment: MaxColumnsAlignment,
/// A max height in rows that the presentation must always be capped to.
#[serde(default = "default_u16_max")]
pub max_rows: u16,
/// The alignment the presentation should have if `max_rows` is set and the terminal is
/// larger than that.
#[serde(default)]
pub max_rows_alignment: MaxRowsAlignment,
/// The configuration for lists when incremental lists are enabled.
#[serde(default)]
pub incremental_lists: IncrementalListsConfig,
}
impl Default for DefaultsConfig {
fn default() -> Self {
Self {
theme: Default::default(),
terminal_font_size: default_terminal_font_size(),
terminal_font_size: default_font_size(),
image_protocol: Default::default(),
validate_overflows: Default::default(),
max_columns: default_u16_max(),
max_columns_alignment: Default::default(),
max_rows: default_u16_max(),
max_rows_alignment: Default::default(),
incremental_lists: Default::default(),
max_columns: default_max_columns(),
}
}
}
/// 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 {
fn default_font_size() -> u8 {
16
}
/// The alignment to use when `defaults.max_columns` is set.
#[derive(Clone, Copy, Debug, Default, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum MaxColumnsAlignment {
/// Align the presentation to the left.
Left,
/// Align the presentation on the center.
#[default]
Center,
/// Align the presentation to the right.
Right,
}
/// The alignment to use when `defaults.max_rows` is set.
#[derive(Clone, Copy, Debug, Default, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum MaxRowsAlignment {
/// Align the presentation to the top.
Top,
/// Align the presentation on the center.
#[default]
Center,
/// Align the presentation to the bottom.
Bottom,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ValidateOverflows {
#[default]
@ -193,8 +113,7 @@ pub enum ValidateOverflows {
WhenDeveloping,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct OptionsConfig {
/// Whether slides are automatically terminated when a slide title is found.
@ -216,12 +135,10 @@ pub struct OptionsConfig {
pub strict_front_matter_parsing: Option<bool>,
/// Assume snippets for these languages contain `+render` and render them automatically.
#[serde(default)]
pub auto_render_languages: Vec<SnippetLanguage>,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SnippetConfig {
/// The properties for snippet execution.
@ -237,8 +154,7 @@ pub struct SnippetConfig {
pub render: SnippetRenderConfig,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SnippetExecConfig {
/// Whether to enable snippet execution.
@ -249,8 +165,7 @@ pub struct SnippetExecConfig {
pub custom: BTreeMap<SnippetLanguage, LanguageSnippetExecutionConfig>,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SnippetExecReplaceConfig {
/// Whether to enable snippet replace-executions, which automatically run code snippets without
@ -258,8 +173,7 @@ pub struct SnippetExecReplaceConfig {
pub enable: bool,
}
#[derive(Clone, Debug, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[derive(Clone, Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SnippetRenderConfig {
/// The number of threads to use when rendering.
@ -277,8 +191,7 @@ pub(crate) fn default_snippet_render_threads() -> usize {
2
}
#[derive(Clone, Debug, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[derive(Clone, Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct TypstConfig {
/// The pixels per inch when rendering latex/typst formulas.
@ -296,8 +209,7 @@ pub(crate) fn default_typst_ppi() -> u32 {
300
}
#[derive(Clone, Debug, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[derive(Clone, Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct MermaidConfig {
/// The scaling parameter to be used in the mermaid CLI.
@ -315,13 +227,12 @@ pub(crate) fn default_mermaid_scale() -> u32 {
2
}
pub(crate) fn default_u16_max() -> u16 {
pub(crate) fn default_max_columns() -> u16 {
u16::MAX
}
/// The snippet execution configuration for a specific programming language.
#[derive(Clone, Debug, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[derive(Clone, Debug, Deserialize, JsonSchema)]
pub struct LanguageSnippetExecutionConfig {
/// The filename to use for the snippet input file.
pub filename: String,
@ -337,8 +248,7 @@ pub struct LanguageSnippetExecutionConfig {
pub hidden_line_prefix: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize, ValueEnum)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[derive(Clone, Debug, Default, Deserialize, ValueEnum, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum ImageProtocol {
/// Automatically detect the best image protocol to use.
@ -392,8 +302,7 @@ impl TryFrom<&ImageProtocol> for GraphicsMode {
}
}
#[derive(Clone, Debug, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[derive(Clone, Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct KeyBindingsConfig {
/// The keys that cause the presentation to move forwards.
@ -480,8 +389,7 @@ impl Default for KeyBindingsConfig {
}
}
#[derive(Clone, Debug, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[derive(Clone, Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SpeakerNotesConfig {
/// The address in which to listen for speaker note events.
@ -507,76 +415,6 @@ 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 {
@ -641,14 +479,6 @@ 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)
@ -679,9 +509,4 @@ mod test {
let config = KeyBindingsConfig::default();
CommandKeyBindings::try_from(config).expect("construction failed");
}
#[test]
fn default_options_serde() {
serde_yaml::from_str::<'_, OptionsConfig>("implicit_slide_ends: true").expect("failed to parse");
}
}

View File

@ -1,5 +1,5 @@
use crate::{
ImageRegistry, MarkdownParser, PresentationBuilderOptions, Resources, ThemeOptions, Themes, ThirdPartyRender,
ImageRegistry, MarkdownParser, PresentationBuilderOptions, PresentationTheme, Resources, Themes, ThirdPartyRender,
code::execute::SnippetExecutor,
commands::{
keyboard::{CommandKeyBindings, KeyboardListener},
@ -11,10 +11,9 @@ use crate::{
builder::{BuildError, PresentationBuilder},
},
render::TerminalDrawer,
terminal::emulator::TerminalEmulator,
theme::raw::PresentationTheme,
terminal::TerminalWrite,
};
use std::{io, sync::Arc};
use std::{io, rc::Rc};
const PRESENTATION: &str = r#"
# Header 1
@ -41,16 +40,16 @@ fn greet(name: &str) -> String {
<!-- end_slide -->
"#;
pub struct ThemesDemo {
pub struct ThemesDemo<W: TerminalWrite> {
themes: Themes,
input: KeyboardListener,
drawer: TerminalDrawer,
drawer: TerminalDrawer<W>,
}
impl ThemesDemo {
pub fn new(themes: Themes, bindings: CommandKeyBindings) -> io::Result<Self> {
impl<W: TerminalWrite> ThemesDemo<W> {
pub fn new(themes: Themes, bindings: CommandKeyBindings, writer: W) -> io::Result<Self> {
let input = KeyboardListener::new(bindings);
let drawer = TerminalDrawer::new(Default::default(), Default::default())?;
let drawer = TerminalDrawer::new(writer, Default::default(), Default::default())?;
Ok(Self { themes, input, drawer })
}
@ -103,24 +102,21 @@ impl ThemesDemo {
theme: &PresentationTheme,
) -> Result<Presentation, BuildError> {
let image_registry = ImageRegistry::default();
let resources = Resources::new("non_existent", "non_existent", image_registry.clone());
let mut resources = Resources::new("non_existent", image_registry.clone());
let mut third_party = ThirdPartyRender::default();
let options = PresentationBuilderOptions {
theme_options: ThemeOptions { font_size_supported: TerminalEmulator::capabilities().font_size },
..Default::default()
};
let executer = Arc::new(SnippetExecutor::default());
let options = PresentationBuilderOptions::default();
let executer = Rc::new(SnippetExecutor::default());
let bindings_config = Default::default();
let builder = PresentationBuilder::new(
theme,
resources,
&mut resources,
&mut third_party,
executer,
&self.themes,
image_registry,
bindings_config,
options,
)?;
);
let mut elements = vec![MarkdownElement::SetexHeading { text: format!("theme: {theme_name}").into() }];
elements.extend(base_elements.iter().cloned());
builder.build(elements)

421
src/export.rs Normal file
View File

@ -0,0 +1,421 @@
use crate::{
MarkdownParser, PresentationTheme, Resources,
code::execute::SnippetExecutor,
config::KeyBindingsConfig,
markdown::parse::ParseError,
presentation::{
Presentation,
builder::{BuildError, PresentationBuilder, PresentationBuilderOptions, Themes},
},
render::{
operation::{AsRenderOperations, RenderAsyncState, RenderOperation},
properties::WindowSize,
},
terminal::image::{
Image, ImageSource,
printer::{ImageProperties, TerminalImage},
},
third_party::ThirdPartyRender,
tools::{ExecutionError, ThirdPartyTools},
};
use base64::{Engine, engine::general_purpose::STANDARD};
use image::{DynamicImage, ImageEncoder, ImageError, codecs::png::PngEncoder};
use semver::Version;
use serde::Serialize;
use std::{
env, fs, io, iter,
path::{Path, PathBuf},
rc::Rc,
thread::sleep,
time::Duration,
};
const MINIMUM_EXPORTER_VERSION: Version = Version::new(0, 2, 0);
const ASYNC_RENDER_WAIT_COUNT: usize = 8;
/// Allows exporting presentations into PDF.
pub struct Exporter<'a> {
parser: MarkdownParser<'a>,
default_theme: &'a PresentationTheme,
resources: Resources,
third_party: ThirdPartyRender,
code_executor: Rc<SnippetExecutor>,
themes: Themes,
options: PresentationBuilderOptions,
}
impl<'a> Exporter<'a> {
/// Construct a new exporter.
pub fn new(
parser: MarkdownParser<'a>,
default_theme: &'a PresentationTheme,
resources: Resources,
third_party: ThirdPartyRender,
code_executor: Rc<SnippetExecutor>,
themes: Themes,
options: PresentationBuilderOptions,
) -> Self {
Self { parser, default_theme, resources, third_party, code_executor, themes, options }
}
/// Export the given presentation into PDF.
///
/// This uses a separate `presenterm-export` tool.
pub fn export_pdf(&mut self, presentation_path: &Path, extra_args: &[&str]) -> Result<(), ExportError> {
Self::validate_exporter_version()?;
println!("Analyzing presentation...");
let metadata = self.generate_metadata(presentation_path)?;
println!("Invoking presenterm-export...");
Self::execute_exporter(metadata, extra_args)?;
Ok(())
}
/// Generate the metadata for the given presentation.
pub fn generate_metadata(&mut self, presentation_path: &Path) -> Result<ExportMetadata, ExportError> {
let content = fs::read_to_string(presentation_path).map_err(ExportError::ReadPresentation)?;
let metadata = self.extract_metadata(&content, presentation_path)?;
Ok(metadata)
}
fn validate_exporter_version() -> Result<(), ExportError> {
let result = ThirdPartyTools::presenterm_export(&["--version"]).run_and_capture_stdout();
let version = match result {
Ok(version) => String::from_utf8(version).expect("not utf8"),
Err(ExecutionError::Execution { .. }) => return Err(ExportError::MinimumVersion),
Err(e) => return Err(e.into()),
};
let version = Version::parse(version.trim()).map_err(|_| ExportError::MinimumVersion)?;
if version >= MINIMUM_EXPORTER_VERSION { Ok(()) } else { Err(ExportError::MinimumVersion) }
}
/// Extract the metadata necessary to make an export.
fn extract_metadata(&mut self, content: &str, path: &Path) -> Result<ExportMetadata, ExportError> {
let elements = self.parser.parse(content)?;
let path = path.canonicalize().expect("canonicalize");
let mut presentation = PresentationBuilder::new(
self.default_theme,
&mut self.resources,
&mut self.third_party,
self.code_executor.clone(),
&self.themes,
Default::default(),
KeyBindingsConfig::default(),
self.options.clone(),
)
.build(elements)?;
let async_renders = Self::count_async_render_operations(&presentation);
let images = Self::build_image_metadata(&mut presentation)?;
Self::validate_theme_colors(&presentation)?;
let commands = Self::build_capture_commands(presentation, async_renders);
let metadata = ExportMetadata { commands, presentation_path: path, images };
Ok(metadata)
}
fn execute_exporter(metadata: ExportMetadata, extra_args: &[&str]) -> Result<(), ExportError> {
let presenterm_path = env::current_exe().map_err(ExportError::Io)?;
let presenterm_path = presenterm_path.display().to_string();
let presentation_path = metadata.presentation_path.display().to_string();
let metadata = serde_json::to_vec(&metadata).expect("serialization failed");
let mut args = vec![&presenterm_path, "--enable-export-mode"];
args.extend(extra_args);
args.push(&presentation_path);
ThirdPartyTools::presenterm_export(&args).stdin(metadata).run()?;
Ok(())
}
fn build_capture_commands(mut presentation: Presentation, async_renders: usize) -> Vec<CaptureCommand> {
let mut commands = Vec::new();
let slide_chunks: Vec<_> = presentation.iter_slides().map(|slide| slide.iter_chunks().count()).collect();
let mut next_slide = |commands: &mut Vec<CaptureCommand>| {
commands.push(CaptureCommand::SendKeys { keys: "l" });
commands.push(CaptureCommand::WaitForChange);
presentation.jump_next();
};
commands.extend(iter::repeat(CaptureCommand::WaitForChange).take(ASYNC_RENDER_WAIT_COUNT * async_renders));
for chunks in slide_chunks {
for _ in 0..chunks - 1 {
next_slide(&mut commands);
}
commands.push(CaptureCommand::Capture);
next_slide(&mut commands);
}
commands
}
fn count_async_render_operations(presentation: &Presentation) -> usize {
presentation
.iter_slides()
.map(|slide| {
slide.iter_visible_operations().filter(|op| matches!(op, RenderOperation::RenderAsync(_))).count()
})
.sum()
}
fn build_image_metadata(presentation: &mut Presentation) -> Result<Vec<ImageMetadata>, ExportError> {
let mut replacer = ImageReplacer::default();
replacer.replace_presentation_images(presentation);
let mut positions = Vec::new();
for image in replacer.images {
let meta = match image.original.source {
ImageSource::Filesystem(path) => {
let path = Some(path.canonicalize().map_err(ExportError::Io)?);
ImageMetadata { path, color: image.color, contents: None }
}
ImageSource::Generated => {
let mut buffer = Vec::new();
let dimensions = image.original.dimensions();
let TerminalImage::Ascii(resource) = image.original.image.as_ref() else {
panic!("not in ascii mode")
};
PngEncoder::new(&mut buffer).write_image(
resource.as_bytes(),
dimensions.0,
dimensions.1,
resource.color().into(),
)?;
let contents = Some(STANDARD.encode(buffer));
ImageMetadata { path: None, color: image.color, contents }
}
};
positions.push(meta);
}
Ok(positions)
}
fn validate_theme_colors(presentation: &Presentation) -> Result<(), ExportError> {
for slide in presentation.iter_slides() {
for operation in slide.iter_visible_operations() {
let RenderOperation::SetColors(colors) = operation else {
continue;
};
// The PDF requires a specific theme to be set, as "no background" means "what the
// browser uses" which is likely white and it will probably look terrible. It's
// better to err early and let you choose a theme that contains _some_ color.
if colors.background.is_none() {
return Err(ExportError::UnsupportedColor("background"));
}
if colors.foreground.is_none() {
return Err(ExportError::UnsupportedColor("foreground"));
}
}
}
Ok(())
}
}
#[derive(thiserror::Error, Debug)]
pub enum ExportError {
#[error("failed to read presentation: {0}")]
ReadPresentation(io::Error),
#[error("failed to parse presentation: {0}")]
ParsePresentation(#[from] ParseError),
#[error("failed to build presentation: {0}")]
BuildPresentation(#[from] BuildError),
#[error("unsupported {0} color in theme")]
UnsupportedColor(&'static str),
#[error("generating images: {0}")]
GeneratingImages(#[from] ImageError),
#[error(transparent)]
Execution(#[from] ExecutionError),
#[error("minimum presenterm-export version ({MINIMUM_EXPORTER_VERSION}) not met")]
MinimumVersion,
#[error("io: {0}")]
Io(io::Error),
}
/// The metadata necessary to export a presentation.
#[derive(Clone, Debug, Serialize)]
pub struct ExportMetadata {
presentation_path: PathBuf,
images: Vec<ImageMetadata>,
commands: Vec<CaptureCommand>,
}
/// Metadata about an image.
#[derive(Clone, Debug, Serialize)]
struct ImageMetadata {
path: Option<PathBuf>,
contents: Option<String>,
color: u32,
}
/// A command to whoever is capturing us indicating what to do.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "snake_case", tag = "type")]
enum CaptureCommand {
Capture,
SendKeys { keys: &'static str },
WaitForChange,
}
struct ReplacedImage {
original: Image,
color: u32,
}
pub(crate) struct ImageReplacer {
next_color: u32,
images: Vec<ReplacedImage>,
}
impl ImageReplacer {
pub(crate) fn replace_presentation_images(&mut self, presentation: &mut Presentation) {
let callback = |operation: &mut RenderOperation| {
match operation {
RenderOperation::RenderImage(image, properties) => {
let replacement = self.replace_image(image.clone());
*operation = RenderOperation::RenderImage(replacement, properties.clone());
}
RenderOperation::RenderAsync(inner) => {
loop {
match inner.poll_state() {
RenderAsyncState::NotStarted => return,
RenderAsyncState::Rendering { .. } => {
sleep(Duration::from_millis(200));
continue;
}
RenderAsyncState::Rendered | RenderAsyncState::JustFinishedRendering => break,
};
}
let window_size = WindowSize { rows: 0, columns: 0, width: 0, height: 0 };
let mut new_operations = Vec::new();
for operation in inner.as_render_operations(&window_size) {
if let RenderOperation::RenderImage(image, properties) = operation {
let image = self.replace_image(image);
new_operations.push(RenderOperation::RenderImage(image, properties));
} else {
new_operations.push(operation);
}
}
// Replace this operation with a new operation that contains the replaced image
// and any other unmodified operations.
*operation = RenderOperation::RenderDynamic(Rc::new(RenderMany(new_operations)));
}
_ => (),
};
};
presentation.mutate_operations(callback);
}
fn replace_image(&mut self, image: Image) -> Image {
let dimensions = image.dimensions();
let color = self.allocate_color();
let rgb_color = Self::as_rgb(color);
let mut replacement = DynamicImage::new_rgb8(dimensions.0, dimensions.1);
let buffer = replacement.as_mut_rgb8().expect("not rgb8");
for pixel in buffer.pixels_mut() {
pixel.0 = rgb_color;
}
self.images.push(ReplacedImage { original: image, color });
Image::new(TerminalImage::Ascii(replacement.into()), ImageSource::Generated)
}
fn allocate_color(&mut self) -> u32 {
let color = self.next_color;
self.next_color += 1;
color
}
fn as_rgb(color: u32) -> [u8; 3] {
[(color >> 16) as u8, (color >> 8) as u8, (color & 0xff) as u8]
}
}
impl Default for ImageReplacer {
fn default() -> Self {
Self { next_color: 0xffbad3, images: Vec::new() }
}
}
#[derive(Debug)]
struct RenderMany(Vec<RenderOperation>);
impl AsRenderOperations for RenderMany {
fn as_render_operations(&self, _: &WindowSize) -> Vec<RenderOperation> {
self.0.clone()
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::theme::PresentationThemeSet;
use comrak::Arena;
fn extract_metadata(content: &str, path: &str) -> ExportMetadata {
let arena = Arena::new();
let parser = MarkdownParser::new(&arena);
let theme = PresentationThemeSet::default().load_by_name("dark").unwrap();
let resources = Resources::new("examples", Default::default());
let third_party = ThirdPartyRender::default();
let code_executor = Default::default();
let themes = Themes::default();
let options = PresentationBuilderOptions { allow_mutations: false, ..Default::default() };
let mut exporter = Exporter::new(parser, &theme, resources, third_party, code_executor, themes, options);
exporter.extract_metadata(content, Path::new(path)).expect("metadata extraction failed")
}
#[test]
fn metadata() {
let presentation = r"
First
<!-- end_slide -->
hi
<!-- pause -->
mom
<!-- end_slide -->
![](doge.png)
<!-- 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);
}
}

View File

@ -1,313 +0,0 @@
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()
}
}

View File

@ -1,122 +0,0 @@
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>");
}
}

View File

@ -1,3 +0,0 @@
pub mod exporter;
pub(crate) mod html;
pub(crate) mod output;

View File

@ -1,260 +0,0 @@
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(&current_string, &current_style));
current_string = String::new();
current_style = c.style;
}
match c.character {
'<' => current_string.push_str("&lt;"),
'>' => current_string.push_str("&gt;"),
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(&current_string, &current_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(())
}
}

View File

@ -1,45 +0,0 @@
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();
});

View File

@ -3,7 +3,7 @@ use crate::{
commands::listener::CommandListener,
config::{Config, ImageProtocol, ValidateOverflows},
demo::ThemesDemo,
export::exporter::Exporter,
export::Exporter,
markdown::parse::MarkdownParser,
presentation::builder::{PresentationBuilderOptions, Themes},
presenter::{PresentMode, Presenter, PresenterOptions},
@ -12,29 +12,21 @@ use crate::{
GraphicsMode,
image::printer::{ImagePrinter, ImageRegistry},
},
theme::{raw::PresentationTheme, registry::PresentationThemeRegistry},
theme::{PresentationTheme, PresentationThemeSet},
third_party::{ThirdPartyConfigs, ThirdPartyRender},
};
use anyhow::anyhow;
use clap::{CommandFactory, Parser, error::ErrorKind};
use commands::speaker_notes::{SpeakerNotesEventListener, SpeakerNotesEventPublisher};
use comrak::Arena;
use config::ConfigLoadError;
use crossterm::{
execute,
style::{PrintStyledContent, Stylize},
};
use directories::ProjectDirs;
use export::exporter::OutputDirectory;
use render::{engine::MaxSize, properties::WindowSize};
use std::{
env::{self, current_dir},
io,
path::{Path, PathBuf},
rc::Rc,
sync::Arc,
};
use terminal::emulator::TerminalEmulator;
use theme::ThemeOptions;
mod code;
mod commands;
@ -50,13 +42,9 @@ 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)]
@ -68,26 +56,21 @@ struct Cli {
path: Option<PathBuf>,
/// Export the presentation as a PDF rather than displaying it.
#[clap(short, long, group = "export")]
#[clap(short, long)]
export_pdf: 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 the PDF metadata without generating the PDF itself.
#[clap(long, hide = true)]
generate_pdf_metadata: bool,
/// 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,
@ -97,13 +80,9 @@ struct Cli {
theme: Option<String>,
/// List all supported themes.
#[clap(long, group = "target")]
#[clap(long)]
list_themes: bool,
/// Print the theme in use.
#[clap(long, group = "target")]
current_theme: bool,
/// Display acknowledgements.
#[clap(long, group = "target")]
acknowledgements: bool,
@ -155,7 +134,6 @@ fn create_splash() -> String {
struct Customizations {
config: Config,
themes: Themes,
themes_path: Option<PathBuf>,
code_executor: SnippetExecutor,
}
@ -170,25 +148,21 @@ impl Customizations {
project_dirs.config_dir().into()
}
};
let themes_path = configs_path.join("themes");
let themes = Self::load_themes(&themes_path)?;
let require_config_file = config_file_path.is_some();
let themes = Self::load_themes(&configs_path)?;
let config_file_path = config_file_path.unwrap_or_else(|| configs_path.join("config.yaml"));
let config = match Config::load(&config_file_path) {
Ok(config) => config,
Err(ConfigLoadError::NotFound) if !require_config_file => Default::default(),
Err(e) => return Err(e.into()),
};
let config = Config::load(&config_file_path)?;
let code_executor = SnippetExecutor::new(config.snippet.exec.custom.clone(), cwd.to_path_buf())?;
Ok(Customizations { config, themes, themes_path: Some(themes_path), code_executor })
Ok(Customizations { config, themes, code_executor })
}
fn load_themes(themes_path: &Path) -> Result<Themes, Box<dyn std::error::Error>> {
fn load_themes(config_path: &Path) -> Result<Themes, Box<dyn std::error::Error>> {
let themes_path = config_path.join("themes");
let mut highlight_themes = HighlightThemeSet::default();
highlight_themes.register_from_directory(themes_path.join("highlighting"))?;
let mut presentation_themes = PresentationThemeRegistry::default();
presentation_themes.register_from_directory(themes_path)?;
let mut presentation_themes = PresentationThemeSet::default();
presentation_themes.register_from_directory(&themes_path)?;
let themes = Themes { presentation: presentation_themes, highlight: highlight_themes };
Ok(themes)
@ -197,7 +171,7 @@ impl Customizations {
struct CoreComponents {
third_party: ThirdPartyRender,
code_executor: Arc<SnippetExecutor>,
code_executor: Rc<SnippetExecutor>,
resources: Resources,
printer: Arc<ImagePrinter>,
builder_options: PresentationBuilderOptions,
@ -216,17 +190,19 @@ impl CoreComponents {
}
let resources_path = resources_path.canonicalize().unwrap_or(resources_path);
let Customizations { config, themes, code_executor, themes_path } =
let Customizations { config, themes, code_executor } =
Customizations::load(cli.config_file.clone().map(PathBuf::from), &resources_path)?;
let default_theme = Self::load_default_theme(&config, &themes, cli);
let force_default_theme = cli.theme.is_some();
let present_mode = match (cli.present, cli.export_pdf) {
(true, _) | (_, true) => PresentMode::Presentation,
let present_mode = match (cli.present, cli.enable_export_mode) {
(true, _) => PresentMode::Presentation,
(false, true) => PresentMode::Export,
(false, false) => PresentMode::Development,
};
let mut builder_options = Self::make_builder_options(&config, force_default_theme, cli.listen_speaker_notes);
let mut builder_options =
Self::make_builder_options(&config, &present_mode, force_default_theme, cli.listen_speaker_notes);
if cli.enable_snippet_execution {
builder_options.enable_snippet_execution = true;
}
@ -235,19 +211,15 @@ impl CoreComponents {
}
let graphics_mode = Self::select_graphics_mode(cli, &config);
let printer = Arc::new(ImagePrinter::new(graphics_mode.clone())?);
let registry = ImageRegistry::new(printer.clone());
let resources = Resources::new(
resources_path.clone(),
themes_path.unwrap_or_else(|| resources_path.clone()),
registry.clone(),
);
let registry = ImageRegistry(printer.clone());
let resources = Resources::new(resources_path.clone(), registry.clone());
let third_party_config = ThirdPartyConfigs {
typst_ppi: config.typst.ppi.to_string(),
mermaid_scale: config.mermaid.scale.to_string(),
threads: config.snippet.render.threads,
};
let third_party = ThirdPartyRender::new(third_party_config, registry, &resources_path);
let code_executor = Arc::new(code_executor);
let code_executor = Rc::new(code_executor);
Ok(Self {
third_party,
code_executor,
@ -264,11 +236,12 @@ impl CoreComponents {
fn make_builder_options(
config: &Config,
mode: &PresentMode,
force_default_theme: bool,
render_speaker_notes_only: bool,
) -> PresentationBuilderOptions {
PresentationBuilderOptions {
allow_mutations: true,
allow_mutations: !matches!(mode, PresentMode::Export),
implicit_slide_ends: config.options.implicit_slide_ends.unwrap_or_default(),
command_prefix: config.options.command_prefix.clone().unwrap_or_default(),
image_attribute_prefix: config
@ -285,16 +258,12 @@ impl CoreComponents {
enable_snippet_execution_replace: config.snippet.exec_replace.enable,
render_speaker_notes_only,
auto_render_languages: config.options.auto_render_languages.clone(),
theme_options: ThemeOptions { font_size_supported: TerminalEmulator::capabilities().font_size },
pause_before_incremental_lists: config.defaults.incremental_lists.pause_before.unwrap_or(true),
pause_after_incremental_lists: config.defaults.incremental_lists.pause_after.unwrap_or(true),
pause_create_new_slide: false,
}
}
fn select_graphics_mode(cli: &Cli, config: &Config) -> GraphicsMode {
if cli.export_pdf | cli.export_html {
GraphicsMode::Raw
if cli.enable_export_mode || cli.export_pdf || cli.generate_pdf_metadata {
GraphicsMode::AsciiBlocks
} else {
let protocol = cli.image_protocol.as_ref().unwrap_or(&config.defaults.image_protocol);
match GraphicsMode::try_from(protocol) {
@ -353,37 +322,39 @@ 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(());
}
if cli.acknowledgements {
} else if cli.acknowledgements {
let acknowledgements = include_bytes!("../bat/acknowledgements.txt");
println!("{}", String::from_utf8_lossy(acknowledgements));
return Ok(());
} else if cli.list_themes {
// Load this ahead of time so we don't do it when we're already in raw mode.
TerminalEmulator::capabilities();
let Customizations { config, themes, .. } =
Customizations::load(cli.config_file.clone().map(PathBuf::from), &current_dir()?)?;
let bindings = config.bindings.try_into()?;
let demo = ThemesDemo::new(themes, bindings)?;
let demo = ThemesDemo::new(themes, bindings, io::stdout())?;
demo.run()?;
return Ok(());
} else if cli.current_theme {
let Customizations { config, .. } =
Customizations::load(cli.config_file.clone().map(PathBuf::from), &current_dir()?)?;
let theme_name =
cli.theme.as_ref().or(config.defaults.theme.as_ref()).map(|s| s.as_str()).unwrap_or(DEFAULT_THEME);
println!("{theme_name}");
return Ok(());
}
// Disable this so we don't mess things up when generating PDFs
if cli.export_pdf {
TerminalEmulator::disable_capability_detection();
}
let Some(path) = cli.path.clone() else {
@ -405,35 +376,15 @@ 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.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 || cli.generate_pdf_metadata {
let mut exporter =
Exporter::new(parser, &default_theme, resources, third_party, code_executor, themes, builder_options);
if cli.export_pdf {
exporter.export_pdf(&path, output_directory, cli.export_output.as_deref())?;
let args = build_exporter_args(&cli);
exporter.export_pdf(&path, &args)?;
} else {
exporter.export_html(&path, output_directory, cli.export_output.as_deref())?;
let meta = exporter.generate_metadata(&path)?;
println!("{}", serde_json::to_string_pretty(&meta)?);
}
} else {
let SpeakerNotesComponents { events_listener, events_publisher } =
@ -447,13 +398,7 @@ fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
font_size_fallback: config.defaults.terminal_font_size,
bindings: config.bindings,
validate_overflows,
max_size: MaxSize {
max_columns: config.defaults.max_columns,
max_columns_alignment: config.defaults.max_columns_alignment,
max_rows: config.defaults.max_rows,
max_rows_alignment: config.defaults.max_rows_alignment,
},
transition: config.transition,
max_columns: config.defaults.max_columns,
};
let presenter = Presenter::new(
&default_theme,
@ -475,8 +420,7 @@ fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
fn main() {
let cli = Cli::parse();
if let Err(e) = run(cli) {
let _ =
execute!(io::stdout(), PrintStyledContent(format!("{e}\n").stylize().with(crossterm::style::Color::Red)));
eprintln!("{e}");
std::process::exit(1);
}
}

View File

@ -1,5 +1,4 @@
use super::text_style::{Color, TextStyle, UndefinedPaletteColorError};
use crate::theme::{ColorPalette, raw::RawColor};
use super::text_style::TextStyle;
use comrak::nodes::AlertType;
use std::{fmt, iter, path::PathBuf, str::FromStr};
use unicode_width::UnicodeWidthStr;
@ -14,13 +13,13 @@ pub(crate) enum MarkdownElement {
FrontMatter(String),
/// A setex heading.
SetexHeading { text: Line<RawColor> },
SetexHeading { text: Line },
/// A normal heading.
Heading { level: u8, text: Line<RawColor> },
Heading { level: u8, text: Line },
/// A paragraph composed by a list of lines.
Paragraph(Vec<Line<RawColor>>),
Paragraph(Vec<Line>),
/// An image.
Image { path: PathBuf, title: String, source_position: SourcePosition },
@ -52,7 +51,7 @@ pub(crate) enum MarkdownElement {
Comment { comment: String, source_position: SourcePosition },
/// A block quote containing a list of lines.
BlockQuote(Vec<Line<RawColor>>),
BlockQuote(Vec<Line>),
/// An alert.
Alert {
@ -63,7 +62,7 @@ pub(crate) enum MarkdownElement {
title: Option<String>,
/// The content lines in this alert.
lines: Vec<Line<RawColor>>,
lines: Vec<Line>,
},
}
@ -99,23 +98,15 @@ impl From<comrak::nodes::LineColumn> for LineColumn {
/// A text line.
///
/// Text is represented as a series of chunks, each with their own formatting.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct Line<C = Color>(pub(crate) Vec<Text<C>>);
#[derive(Clone, Debug, PartialEq, Eq, Default)]
pub(crate) struct Line(pub(crate) Vec<Text>);
impl<C> Default for Line<C> {
fn default() -> Self {
Self(vec![])
}
}
impl<C> Line<C> {
impl Line {
/// Get the total width for this text.
pub(crate) fn width(&self) -> usize {
self.0.iter().map(|text| text.content.width()).sum()
}
}
impl Line<Color> {
/// Applies the given style to this text.
pub(crate) fn apply_style(&mut self, style: &TextStyle) {
for text in &mut self.0 {
@ -124,19 +115,7 @@ impl Line<Color> {
}
}
impl Line<RawColor> {
/// Resolve the colors in this line.
pub(crate) fn resolve(self, palette: &ColorPalette) -> Result<Line<Color>, UndefinedPaletteColorError> {
let mut output = Vec::with_capacity(self.0.len());
for text in self.0 {
let style = text.style.resolve(palette)?;
output.push(Text::new(text.content, style));
}
Ok(Line(output))
}
}
impl<C, T: Into<Text<C>>> From<T> for Line<C> {
impl<T: Into<Text>> From<T> for Line {
fn from(text: T) -> Self {
Self(vec![text.into()])
}
@ -146,36 +125,25 @@ impl<C, T: Into<Text<C>>> From<T> for Line<C> {
///
/// This is the most granular text representation: a `String` and a style.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct Text<C = Color> {
pub(crate) struct Text {
pub(crate) content: String,
pub(crate) style: TextStyle<C>,
pub(crate) style: TextStyle,
}
impl<C> Default for Text<C> {
fn default() -> Self {
Self { content: Default::default(), style: TextStyle::default() }
}
}
impl<C> Text<C> {
impl Text {
/// Construct a new styled text.
pub(crate) fn new<S: Into<String>>(content: S, style: TextStyle<C>) -> Self {
pub(crate) fn new<S: Into<String>>(content: S, style: TextStyle) -> 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> {
impl From<String> for Text {
fn from(text: String) -> Self {
Self { content: text, style: TextStyle::default() }
}
}
impl<C> From<&str> for Text<C> {
impl From<&str> for Text {
fn from(text: &str) -> Self {
Self { content: text.into(), style: TextStyle::default() }
}
@ -190,7 +158,7 @@ pub(crate) struct ListItem {
pub(crate) depth: u8,
/// The contents of this list item.
pub(crate) contents: Line<RawColor>,
pub(crate) contents: Line,
/// The type of list item.
pub(crate) item_type: ListItemType,
@ -203,10 +171,10 @@ pub(crate) enum ListItemType {
Unordered,
/// A list item for an ordered list that uses parenthesis after the list item number.
OrderedParens(usize),
OrderedParens,
/// A list item for an ordered list that uses a period after the list item number.
OrderedPeriod(usize),
OrderedPeriod,
}
/// A table.
@ -228,7 +196,7 @@ impl Table {
/// Iterates all the text entries in a column.
///
/// This includes the header.
pub(crate) fn iter_column(&self, column: usize) -> impl Iterator<Item = &Line<RawColor>> {
pub(crate) fn iter_column(&self, column: usize) -> impl Iterator<Item = &Line> {
let header_element = &self.header.0[column];
let row_elements = self.rows.iter().map(move |row| &row.0[column]);
iter::once(header_element).chain(row_elements)
@ -237,7 +205,7 @@ impl Table {
/// A table row.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct TableRow(pub(crate) Vec<Line<RawColor>>);
pub(crate) struct TableRow(pub(crate) Vec<Line>);
/// A percentage.
#[derive(Clone, Debug, PartialEq, Eq)]

View File

@ -1,5 +1,4 @@
use super::text_style::{Color, TextStyle};
use crate::theme::raw::{ParseColorError, RawColor};
use super::text_style::{Color, ParseColorError, TextStyle};
use std::{borrow::Cow, str, str::Utf8Error};
use tl::Attributes;
@ -38,16 +37,12 @@ impl HtmlParser {
Ok(HtmlInline::OpenSpan { style })
}
fn parse_attributes(&self, attributes: &Attributes) -> Result<TextStyle<RawColor>, ParseHtmlError> {
fn parse_attributes(&self, attributes: &Attributes) -> Result<TextStyle, ParseHtmlError> {
let mut style = TextStyle::default();
for (name, value) in attributes.iter() {
let value = value.unwrap_or(Cow::Borrowed(""));
match name.as_ref() {
"style" => self.parse_css_attribute(&value, &mut style)?,
"class" => {
style = style.fg_color(RawColor::ForegroundClass(value.to_string()));
style = style.bg_color(RawColor::BackgroundClass(value.to_string()));
}
_ => {
if self.options.strict {
return Err(ParseHtmlError::UnsupportedTagAttribute(name.to_string()));
@ -58,7 +53,7 @@ impl HtmlParser {
Ok(style)
}
fn parse_css_attribute(&self, attribute: &str, style: &mut TextStyle<RawColor>) -> Result<(), ParseHtmlError> {
fn parse_css_attribute(&self, attribute: &str, style: &mut TextStyle) -> Result<(), ParseHtmlError> {
for attribute in attribute.split(';') {
let attribute = attribute.trim();
if attribute.is_empty() {
@ -68,8 +63,8 @@ impl HtmlParser {
let key = key.trim();
let value = value.trim();
match key {
"color" => style.colors.foreground = Some(Self::parse_color(value)?),
"background-color" => style.colors.background = Some(Self::parse_color(value)?),
"color" => *style = style.fg_color(Self::parse_color(value)?),
"background-color" => *style = style.bg_color(Self::parse_color(value)?),
_ => {
if self.options.strict {
return Err(ParseHtmlError::UnsupportedCssAttribute(key.into()));
@ -80,13 +75,13 @@ impl HtmlParser {
Ok(())
}
fn parse_color(input: &str) -> Result<RawColor, ParseHtmlError> {
fn parse_color(input: &str) -> Result<Color, ParseHtmlError> {
if input.starts_with('#') {
let color = input.strip_prefix('#').unwrap().parse()?;
if matches!(color, RawColor::Color(Color::Rgb { .. })) { Ok(color) } else { Ok(input.parse()?) }
if matches!(color, Color::Rgb { .. }) { Ok(color) } else { Ok(input.parse()?) }
} else {
let color = input.parse::<RawColor>()?;
if matches!(color, RawColor::Color(Color::Rgb { .. })) {
let color = input.parse::<Color>()?;
if matches!(color, Color::Rgb { .. }) {
Err(ParseHtmlError::InvalidColor("missing '#' in rgb color".into()))
} else {
Ok(color)
@ -97,7 +92,7 @@ impl HtmlParser {
#[derive(Debug)]
pub(crate) enum HtmlInline {
OpenSpan { style: TextStyle<RawColor> },
OpenSpan { style: TextStyle },
CloseSpan,
}
@ -140,28 +135,17 @@ impl From<ParseColorError> for ParseHtmlError {
#[cfg(test)]
mod tests {
use super::*;
use crate::markdown::text_style::Color;
use rstest::rstest;
#[test]
fn parse_style() {
fn parse() {
let tag =
HtmlParser::default().parse(r#"<span style="color: red; background-color: black">"#).expect("parse failed");
let HtmlInline::OpenSpan { style } = tag else { panic!("not an open tag") };
assert_eq!(style, TextStyle::default().bg_color(Color::Black).fg_color(Color::Red));
}
#[test]
fn parse_class() {
let tag = HtmlParser::default().parse(r#"<span class="foo">"#).expect("parse failed");
let HtmlInline::OpenSpan { style } = tag else { panic!("not an open tag") };
assert_eq!(
style,
TextStyle::default()
.bg_color(RawColor::BackgroundClass("foo".into()))
.fg_color(RawColor::ForegroundClass("foo".into()))
);
}
#[test]
fn parse_end_tag() {
let tag = HtmlParser::default().parse("</span>").expect("parse failed");
@ -182,8 +166,8 @@ mod tests {
#[case::rgb("#ff0000", Color::Rgb{r: 255, g: 0, b: 0})]
#[case::red("red", Color::Red)]
fn parse_color(#[case] input: &str, #[case] expected: Color) {
let color = HtmlParser::parse_color(input).expect("parse failed");
assert_eq!(color, expected.into());
let color: Color = HtmlParser::parse_color(input).expect("parse failed");
assert_eq!(color, expected);
}
#[rstest]

View File

@ -3,7 +3,6 @@ use super::{
html::{HtmlInline, HtmlParser, ParseHtmlError},
text_style::TextStyle,
};
use crate::theme::raw::RawColor;
use comrak::{
Arena, ComrakOptions,
arena_tree::Node,
@ -33,7 +32,6 @@ impl Default for ParserOptions {
options.extension.strikethrough = true;
options.extension.multiline_block_quotes = true;
options.extension.alerts = true;
options.extension.wikilinks_title_before_pipe = true;
Self(options)
}
}
@ -63,35 +61,6 @@ impl<'a> MarkdownParser<'a> {
Ok(elements)
}
/// Parse inlines in a markdown input.
pub(crate) fn parse_inlines(&self, line: &str) -> Result<Line<RawColor>, ParseInlinesError> {
let node = parse_document(self.arena, line, &self.options);
if node.children().count() == 0 {
return Ok(Default::default());
}
if node.children().count() > 1 {
return Err(ParseInlinesError("inline must be simple text".into()));
}
let node = node.first_child().expect("must have one child");
let data = node.data.borrow();
let NodeValue::Paragraph = &data.value else {
return Err(ParseInlinesError("inline must be simple text".into()));
};
let parser = InlinesParser::new(self.arena, SoftBreak::Space, StringifyImages::No);
let inlines = parser.parse(node).map_err(|e| ParseInlinesError(e.to_string()))?;
let mut output = Line::default();
for inline in inlines {
match inline {
Inline::Text(line) => {
output.0.extend(line.0);
}
Inline::Image { .. } => return Err(ParseInlinesError("images not supported".into())),
Inline::LineBreak => return Err(ParseInlinesError("line breaks not supported".into())),
};
}
Ok(output)
}
fn parse_node(&self, node: &'a AstNode<'a>) -> ParseResult<Vec<MarkdownElement>> {
let data = node.data.borrow();
let element = match &data.value {
@ -148,7 +117,7 @@ impl<'a> MarkdownParser<'a> {
Inline::Image { .. } => {}
}
}
if lines.last() == Some(&Line::<RawColor>::from("")) {
if lines.last() == Some(&Line::from("")) {
lines.pop();
}
Ok(MarkdownElement::BlockQuote(lines))
@ -205,7 +174,7 @@ impl<'a> MarkdownParser<'a> {
Ok(elements)
}
fn parse_text(&self, node: &'a AstNode<'a>) -> ParseResult<Line<RawColor>> {
fn parse_text(&self, node: &'a AstNode<'a>) -> ParseResult<Line> {
let inlines = InlinesParser::new(self.arena, SoftBreak::Space, StringifyImages::No).parse(node)?;
let mut chunks = Vec::new();
for inline in inlines {
@ -243,8 +212,8 @@ impl<'a> MarkdownParser<'a> {
fn parse_list_item(&self, item: &NodeList, root: &'a AstNode<'a>, depth: u8) -> ParseResult<Vec<ListItem>> {
let item_type = match (item.list_type, item.delimiter) {
(ListType::Bullet, _) => ListItemType::Unordered,
(ListType::Ordered, ListDelimType::Paren) => ListItemType::OrderedParens(item.start),
(ListType::Ordered, ListDelimType::Period) => ListItemType::OrderedPeriod(item.start),
(ListType::Ordered, ListDelimType::Paren) => ListItemType::OrderedParens,
(ListType::Ordered, ListDelimType::Period) => ListItemType::OrderedPeriod,
};
let mut elements = Vec::new();
for node in root.children() {
@ -321,7 +290,7 @@ enum StringifyImages {
struct InlinesParser<'a> {
inlines: Vec<Inline>,
pending_text: Vec<Text<RawColor>>,
pending_text: Vec<Text>,
arena: &'a Arena<AstNode<'a>>,
soft_break: SoftBreak,
stringify_images: StringifyImages,
@ -349,7 +318,7 @@ impl<'a> InlinesParser<'a> {
&mut self,
node: &'a AstNode<'a>,
parent: &'a AstNode<'a>,
style: TextStyle<RawColor>,
style: TextStyle,
) -> ParseResult<Option<HtmlStyle>> {
let data = node.data.borrow();
match &data.value {
@ -386,9 +355,6 @@ impl<'a> InlinesParser<'a> {
self.pending_text.push(Text::from(")"));
}
}
NodeValue::WikiLink(link) => {
self.pending_text.push(Text::new(link.url.clone(), TextStyle::default().link_url()));
}
NodeValue::LineBreak => {
self.store_pending_text();
self.inlines.push(Inline::LineBreak);
@ -456,18 +422,18 @@ impl<'a> InlinesParser<'a> {
Ok(None)
}
fn process_children(&mut self, root: &'a AstNode<'a>, base_style: TextStyle<RawColor>) -> ParseResult<()> {
fn process_children(&mut self, root: &'a AstNode<'a>, base_style: TextStyle) -> ParseResult<()> {
let mut html_styles = Vec::new();
let mut style = base_style.clone();
let mut style = base_style;
for node in root.children() {
if let Some(html_style) = self.process_node(node, root, style.clone())? {
if let Some(html_style) = self.process_node(node, root, style)? {
match html_style {
HtmlStyle::Add(style) => html_styles.push(style),
HtmlStyle::Remove => {
html_styles.pop();
}
};
style = base_style.clone();
style = base_style;
for html_style in html_styles.iter().rev() {
style.merge(html_style);
}
@ -478,12 +444,12 @@ impl<'a> InlinesParser<'a> {
}
enum HtmlStyle {
Add(TextStyle<RawColor>),
Add(TextStyle),
Remove,
}
enum Inline {
Text(Line<RawColor>),
Text(Line),
Image { path: String, title: String },
LineBreak,
}
@ -611,15 +577,10 @@ impl Identifier for NodeValue {
}
}
#[derive(Debug, thiserror::Error)]
#[error("invalid markdown line: {0}")]
pub(crate) struct ParseInlinesError(String);
#[cfg(test)]
mod test {
use crate::markdown::text_style::Color;
use super::*;
use crate::markdown::text_style::Color;
use rstest::rstest;
use std::path::Path;
@ -752,16 +713,6 @@ boop
assert_eq!(elements, expected_elements);
}
#[test]
fn wikilink_wo_title() {
let parsed = parse_single("[[https://example.com]]");
let MarkdownElement::Paragraph(elements) = parsed else { panic!("not a paragraph: {parsed:?}") };
let expected_chunks = vec![Text::new("https://example.com", TextStyle::default().link_url())];
let expected_elements = &[Line(expected_chunks)];
assert_eq!(elements, expected_elements);
}
#[test]
fn image() {
let parsed = parse_single("![](potato.png)");
@ -822,26 +773,6 @@ Title
assert_eq!(next().depth, 0);
}
#[test]
fn ordered_list_starting_non_one() {
let parsed = parse_single(
r"
4. One
1. Sub1
2. Sub2
5. Two
6. Three",
);
let MarkdownElement::List(items) = parsed else { panic!("not a list: {parsed:?}") };
let mut items = items.into_iter();
let mut next = || items.next().expect("list ended prematurely");
assert_eq!(next().item_type, ListItemType::OrderedPeriod(4));
assert_eq!(next().item_type, ListItemType::OrderedPeriod(1));
assert_eq!(next().item_type, ListItemType::OrderedPeriod(2));
assert_eq!(next().item_type, ListItemType::OrderedPeriod(5));
assert_eq!(next().item_type, ListItemType::OrderedPeriod(6));
}
#[test]
fn line_breaks() {
let parsed = parse_all(
@ -1062,19 +993,4 @@ mom
};
assert_eq!(lines.len(), 2);
}
#[test]
fn parse_inlines() {
let arena = Arena::new();
let input = "hello **mom** how _are you_?";
let parsed = MarkdownParser::new(&arena).parse_inlines(input).expect("parse failed");
let expected = &[
"hello ".into(),
Text::new("mom", TextStyle::default().bold()),
" how ".into(),
Text::new("are you", TextStyle::default().italics()),
"?".into(),
];
assert_eq!(parsed.0, expected);
}
}

View File

@ -12,7 +12,6 @@ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
pub(crate) struct WeightedLine {
text: Vec<WeightedText>,
width: usize,
font_size: u8,
}
impl WeightedLine {
@ -26,11 +25,6 @@ impl WeightedLine {
self.width
}
/// The height of this line.
pub(crate) fn font_size(&self) -> u8 {
self.font_size
}
/// Get an iterator to the underlying text chunks.
#[cfg(test)]
pub(crate) fn iter_texts(&self) -> impl Iterator<Item = &WeightedText> {
@ -49,7 +43,6 @@ impl From<Vec<Text>> for WeightedLine {
let mut output = Vec::new();
let mut index = 0;
let mut width = 0;
let mut font_size = 1;
// Compact chunks so any consecutive chunk with the same style is merged into the same block.
while index < texts.len() {
let mut target = mem::replace(&mut texts[index], Text::from(""));
@ -59,13 +52,11 @@ impl From<Vec<Text>> for WeightedLine {
target.content.push_str(&current_content);
current += 1;
}
let size = target.style.size.max(1);
width += target.content.width() * size as usize;
width += target.content.width();
output.push(target.into());
index = current;
font_size = font_size.max(size);
}
Self { text: output, width, font_size }
Self { text: output, width }
}
}
@ -73,7 +64,7 @@ impl From<String> for WeightedLine {
fn from(text: String) -> Self {
let width = text.width();
let text = vec![WeightedText::from(text)];
Self { text, width, font_size: 1 }
Self { text, width }
}
}
@ -201,7 +192,6 @@ impl<'a> WeightedTextRef<'a> {
return (self.make_ref(0, self.text.len()), self.make_ref(0, 0));
}
let max_length = (max_length / self.style.size as usize).max(1);
let target_chunk = self.substr(max_length + 1);
let output_chunk = match target_chunk.rsplit_once(' ') {
Some((before, _)) => before,
@ -233,7 +223,7 @@ impl<'a> WeightedTextRef<'a> {
pub(crate) fn width(&self) -> usize {
let last_width = self.accumulators.last().map(|a| a.width).unwrap_or(0);
let first_width = self.accumulators.first().map(|a| a.width).unwrap_or(0);
(last_width - first_width) * self.style.size as usize
last_width - first_width
}
fn bytes_until(&self, index: usize) -> usize {
@ -304,15 +294,6 @@ mod test {
assert_eq!(rest.width(), 3);
}
#[test]
fn font_size_split() {
let text = WeightedText::from(Text::new("█████", TextStyle::default().size(2)));
let text_ref = text.to_ref();
let (head, rest) = text_ref.word_split_at_length(3);
assert_eq!(head.width(), 2);
assert_eq!(rest.width(), 8);
}
#[test]
fn make_ref() {
let text = WeightedText::from("hello world");
@ -344,11 +325,7 @@ mod test {
#[test]
fn no_split_necessary() {
let text = WeightedLine {
text: vec![WeightedText::from("short"), WeightedText::from("text")],
width: 0,
font_size: 1,
};
let text = WeightedLine { text: vec![WeightedText::from("short"), WeightedText::from("text")], width: 0 };
let lines = join_lines(text.split(50));
let expected = vec!["short text"];
assert_eq!(lines, expected);
@ -356,8 +333,7 @@ mod test {
#[test]
fn split_lines_single() {
let text =
WeightedLine { text: vec![WeightedText::from("this is a slightly long line")], width: 0, font_size: 1 };
let text = WeightedLine { text: vec![WeightedText::from("this is a slightly long line")], width: 0 };
let lines = join_lines(text.split(6));
let expected = vec!["this", "is a", "slight", "ly", "long", "line"];
assert_eq!(lines, expected);
@ -372,7 +348,6 @@ mod test {
WeightedText::from("yet some other piece"),
],
width: 0,
font_size: 1,
};
let lines = join_lines(text.split(10));
let expected = vec!["this is a", "slightly", "long line", "another", "chunk yet", "some other", "piece"];
@ -388,7 +363,6 @@ mod test {
WeightedText::from("yet some other piece"),
],
width: 0,
font_size: 1,
};
let lines = join_lines(text.split(50));
let expected = vec!["this is a slightly long line another chunk yet some", "other piece"];

View File

@ -1,34 +1,24 @@
use crate::theme::{ColorPalette, raw::RawColor};
use crossterm::style::{StyledContent, Stylize};
use hex::FromHexError;
use crate::theme::ColorPalette;
use crossterm::style::Stylize;
use hex::{FromHex, FromHexError};
use serde::{Deserialize, Serialize};
use std::fmt::{self, Display};
use serde_with::{DeserializeFromStr, SerializeDisplay};
use std::{
fmt::{self, Display},
ops::Deref,
str::FromStr,
};
/// The style of a piece of text.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct TextStyle<C = Color> {
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) struct TextStyle {
flags: u8,
pub(crate) colors: Colors<C>,
pub(crate) size: u8,
pub(crate) colors: Colors,
}
impl<C> Default for TextStyle<C> {
fn default() -> Self {
Self { flags: Default::default(), colors: Default::default(), size: 1 }
}
}
impl<C> TextStyle<C>
where
C: Clone,
{
pub(crate) fn colored(colors: Colors<C>) -> Self {
Self { colors, ..Default::default() }
}
pub(crate) fn size(mut self, size: u8) -> Self {
self.size = size.min(16);
self
impl TextStyle {
pub(crate) fn colored(colors: Colors) -> Self {
Self { flags: Default::default(), colors }
}
/// Add bold to this style.
@ -71,22 +61,32 @@ where
self.italics().underlined()
}
/// Set the colors for this text style.
pub(crate) fn colors(mut self, colors: Colors) -> Self {
self.colors = colors;
self
}
/// Set the background color for this text style.
pub(crate) fn bg_color<U: Into<C>>(mut self, color: U) -> Self {
self.colors.background = Some(color.into());
pub(crate) fn bg_color(mut self, color: Color) -> Self {
self.colors.background = Some(color);
self
}
/// Set the foreground color for this text style.
pub(crate) fn fg_color<U: Into<C>>(mut self, color: U) -> Self {
self.colors.foreground = Some(color.into());
pub(crate) fn fg_color(mut self, color: Color) -> Self {
self.colors.foreground = Some(color);
self
}
/// Set the colors on this style.
pub(crate) fn colors(mut self, colors: Colors<C>) -> Self {
self.colors = colors;
self
/// Check whether this text 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)
}
/// Check whether this text is code.
@ -94,18 +94,46 @@ where
self.has_flag(TextFormatFlags::Code)
}
/// Merge this style with another one.
pub(crate) fn merge(&mut self, other: &TextStyle<C>) {
self.flags |= other.flags;
self.size = self.size.max(other.size);
self.colors.background = self.colors.background.clone().or(other.colors.background.clone());
self.colors.foreground = self.colors.foreground.clone().or(other.colors.foreground.clone());
/// Check whether this text style is strikethrough.
pub(crate) fn is_strikethrough(&self) -> bool {
self.has_flag(TextFormatFlags::Strikethrough)
}
/// Return a new style merged with the one passed in.
pub(crate) fn merged(mut self, other: &TextStyle<C>) -> Self {
self.merge(other);
self
/// 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) {
self.flags |= other.flags;
self.colors.background = self.colors.background.or(other.colors.background);
self.colors.foreground = self.colors.foreground.or(other.colors.foreground);
}
/// Apply this style to a piece of text.
pub(crate) fn apply<T: Into<String>>(&self, text: T) -> Result<<String as Stylize>::Styled, PaletteColorError> {
let text: String = text.into();
let mut styled = text.stylize();
if self.is_bold() {
styled = styled.bold();
}
if self.is_italics() {
styled = styled.italic();
}
if self.is_strikethrough() {
styled = styled.crossed_out();
}
if self.is_underlined() {
styled = styled.underlined();
}
if let Some(color) = self.colors.background {
styled = styled.on(color.try_into()?);
}
if let Some(color) = self.colors.foreground {
styled = styled.with(color.try_into()?);
}
Ok(styled)
}
fn add_flag(mut self, flag: TextFormatFlags) -> Self {
@ -118,118 +146,7 @@ where
}
}
impl TextStyle<Color> {
/// Apply this style to a piece of text.
pub(crate) fn apply<'a>(&self, text: &'a str) -> StyledContent<impl Display + Clone + 'a> {
let text = FontSizedStr { contents: text, font_size: self.size };
let mut styled = StyledContent::new(Default::default(), text);
for attr in self.iter_attributes() {
styled = match attr {
TextAttribute::Bold => styled.bold(),
TextAttribute::Italics => styled.italic(),
TextAttribute::Strikethrough => styled.crossed_out(),
TextAttribute::Underlined => styled.underlined(),
TextAttribute::ForegroundColor(color) => styled.with(color.into()),
TextAttribute::BackgroundColor(color) => styled.on(color.into()),
}
}
styled
}
pub(crate) fn into_raw(self) -> TextStyle<RawColor> {
let colors = Colors {
background: self.colors.background.map(Into::into),
foreground: self.colors.foreground.map(Into::into),
};
TextStyle { flags: self.flags, colors, size: self.size }
}
/// Iterate all attributes in this style.
pub(crate) fn iter_attributes(&self) -> AttributeIterator {
AttributeIterator {
flags: self.flags,
next_mask: Some(TextFormatFlags::Bold),
background_color: self.colors.background,
foreground_color: self.colors.foreground,
}
}
}
impl TextStyle<RawColor> {
pub(crate) fn resolve(&self, palette: &ColorPalette) -> Result<TextStyle, UndefinedPaletteColorError> {
let colors = self.colors.resolve(palette)?;
Ok(TextStyle { flags: self.flags, colors, size: self.size })
}
}
pub(crate) struct AttributeIterator {
flags: u8,
next_mask: Option<TextFormatFlags>,
background_color: Option<Color>,
foreground_color: Option<Color>,
}
impl Iterator for AttributeIterator {
type Item = TextAttribute;
fn next(&mut self) -> Option<Self::Item> {
if let Some(c) = self.background_color.take() {
return Some(TextAttribute::BackgroundColor(c));
}
if let Some(c) = self.foreground_color.take() {
return Some(TextAttribute::ForegroundColor(c));
}
use TextFormatFlags::*;
loop {
let next_mask = self.next_mask?;
self.next_mask = match next_mask {
Bold => Some(Italics),
Italics => Some(Strikethrough),
Code => Some(Strikethrough),
Strikethrough => Some(Underlined),
Underlined => None,
};
if self.flags & next_mask as u8 != 0 {
let attr = match next_mask {
Bold => TextAttribute::Bold,
Italics => TextAttribute::Italics,
Code => panic!("code shouldn't reach here"),
Strikethrough => TextAttribute::Strikethrough,
Underlined => TextAttribute::Underlined,
};
return Some(attr);
}
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum TextAttribute {
Bold,
Italics,
Strikethrough,
Underlined,
ForegroundColor(Color),
BackgroundColor(Color),
}
#[derive(Clone)]
struct FontSizedStr<'a> {
contents: &'a str,
font_size: u8,
}
impl fmt::Display for FontSizedStr<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let contents = &self.contents;
match self.font_size {
0 | 1 => write!(f, "{contents}"),
size => write!(f, "\x1b]66;s={size};{contents}\x1b\\"),
}
}
}
#[derive(Clone, Copy, Debug)]
#[derive(Debug)]
enum TextFormatFlags {
Bold = 1,
Italics = 2,
@ -238,7 +155,7 @@ enum TextFormatFlags {
Underlined = 16,
}
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
#[derive(Debug, Copy, Clone, PartialEq, Eq, SerializeDisplay, DeserializeFromStr)]
pub(crate) enum Color {
Black,
DarkGrey,
@ -257,6 +174,7 @@ pub(crate) enum Color {
White,
Grey,
Rgb { r: u8, g: u8, b: u8 },
Palette(FixedStr),
}
impl Color {
@ -264,6 +182,11 @@ impl Color {
Self::Rgb { r, g, b }
}
pub(crate) fn new_palette(name: &str) -> Result<Self, ParseColorError> {
let color: FixedStr = name.try_into().map_err(|_| ParseColorError::PaletteColorLength(name.to_string()))?;
if color.is_empty() { Err(ParseColorError::PaletteColorEmpty) } else { Ok(Self::Palette(color)) }
}
pub(crate) fn as_rgb(&self) -> Option<(u8, u8, u8)> {
match self {
Self::Rgb { r, g, b } => Some((*r, *g, *b)),
@ -285,12 +208,79 @@ impl Color {
};
Some(color)
}
pub(crate) fn resolve(&self, palette: &ColorPalette) -> Result<Color, UndefinedPaletteColorError> {
match self {
Color::Palette(name) => palette.colors.get(name).cloned().ok_or(UndefinedPaletteColorError(*name)),
_ => Ok(*self),
}
}
}
impl From<Color> for crossterm::style::Color {
fn from(value: Color) -> Self {
impl FromStr for Color {
type Err = ParseColorError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
let output = match input {
"black" => Self::Black,
"white" => Self::White,
"grey" => Self::Grey,
"dark_grey" => Self::DarkGrey,
"red" => Self::Red,
"dark_red" => Self::DarkRed,
"green" => Self::Green,
"dark_green" => Self::DarkGreen,
"blue" => Self::Blue,
"dark_blue" => Self::DarkBlue,
"yellow" => Self::Yellow,
"dark_yellow" => Self::DarkYellow,
"magenta" => Self::Magenta,
"dark_magenta" => Self::DarkMagenta,
"cyan" => Self::Cyan,
"dark_cyan" => Self::DarkCyan,
other if other.starts_with("palette:") => Self::new_palette(other.trim_start_matches("palette:"))?,
other if other.starts_with("p:") => Self::new_palette(other.trim_start_matches("p:"))?,
// Fallback to hex-encoded rgb
_ => {
let values = <[u8; 3]>::from_hex(input)?;
Self::Rgb { r: values[0], g: values[1], b: values[2] }
}
};
Ok(output)
}
}
impl Display for Color {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Rgb { r, g, b } => write!(f, "{}", hex::encode([*r, *g, *b])),
Self::Black => write!(f, "black"),
Self::White => write!(f, "white"),
Self::Grey => write!(f, "grey"),
Self::DarkGrey => write!(f, "dark_grey"),
Self::Red => write!(f, "red"),
Self::DarkRed => write!(f, "dark_red"),
Self::Green => write!(f, "green"),
Self::DarkGreen => write!(f, "dark_green"),
Self::Blue => write!(f, "blue"),
Self::DarkBlue => write!(f, "dark_blue"),
Self::Yellow => write!(f, "yellow"),
Self::DarkYellow => write!(f, "dark_yellow"),
Self::Magenta => write!(f, "magenta"),
Self::DarkMagenta => write!(f, "dark_magenta"),
Self::Cyan => write!(f, "cyan"),
Self::DarkCyan => write!(f, "dark_cyan"),
Self::Palette(name) => write!(f, "palette:{name}"),
}
}
}
impl TryFrom<Color> for crossterm::style::Color {
type Error = PaletteColorError;
fn try_from(value: Color) -> Result<Self, Self::Error> {
use crossterm::style::Color as C;
match value {
let output = match value {
Color::Black => C::Black,
Color::DarkGrey => C::DarkGrey,
Color::Red => C::Red,
@ -308,47 +298,105 @@ impl From<Color> for crossterm::style::Color {
Color::White => C::White,
Color::Grey => C::Grey,
Color::Rgb { r, g, b } => C::Rgb { r, g, b },
Color::Palette(color) => return Err(PaletteColorError(color)),
};
Ok(output)
}
}
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, SerializeDisplay, DeserializeFromStr)]
pub(crate) struct FixedStr<const N: usize = 16> {
data: [u8; N],
length: u8,
}
impl<const N: usize> TryFrom<&str> for FixedStr<N> {
type Error = &'static str;
fn try_from(value: &str) -> Result<Self, Self::Error> {
let data = value.as_bytes();
if data.len() <= N {
let mut this = Self { data: [0; N], length: data.len() as u8 };
this.data[0..data.len()].copy_from_slice(data);
Ok(this)
} else {
Err("string is too long")
}
}
}
impl<const N: usize> FromStr for FixedStr<N> {
type Err = &'static str;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::try_from(s)
}
}
impl<const N: usize> Deref for FixedStr<N> {
type Target = str;
fn deref(&self) -> &str {
let data = &self.data[0..self.length as usize];
std::str::from_utf8(data).expect("invalid utf8")
}
}
impl<const N: usize> fmt::Debug for FixedStr<N> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.deref())
}
}
impl<const N: usize> fmt::Display for FixedStr<N> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.deref())
}
}
#[derive(Debug, thiserror::Error)]
#[error("unresolved palette color: {0}")]
pub(crate) struct PaletteColorError(String);
pub(crate) struct PaletteColorError(FixedStr);
#[derive(Debug, thiserror::Error)]
#[error("undefined palette color: {0}")]
pub(crate) struct UndefinedPaletteColorError(pub(crate) String);
pub(crate) struct UndefinedPaletteColorError(FixedStr);
/// Text colors.
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)]
pub(crate) struct Colors<C = Color> {
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq, Serialize)]
pub(crate) struct Colors {
/// The background color.
pub(crate) background: Option<C>,
pub(crate) background: Option<Color>,
/// The foreground color.
pub(crate) foreground: Option<C>,
pub(crate) foreground: Option<Color>,
}
impl<C> Default for Colors<C> {
fn default() -> Self {
Self { background: None, foreground: None }
impl Colors {
pub(crate) fn merge(&self, other: &Colors) -> Self {
let background = self.background.or(other.background);
let foreground = self.foreground.or(other.foreground);
Self { background, foreground }
}
pub(crate) fn resolve(mut self, palette: &ColorPalette) -> Result<Self, UndefinedPaletteColorError> {
if let Some(color) = self.foreground.as_mut() {
*color = color.resolve(palette)?;
}
if let Some(color) = self.background.as_mut() {
*color = color.resolve(palette)?;
}
Ok(self)
}
}
impl Colors<RawColor> {
pub(crate) fn resolve(&self, palette: &ColorPalette) -> Result<Colors<Color>, UndefinedPaletteColorError> {
let background = self.background.clone().map(|c| c.resolve(palette)).transpose()?.flatten();
let foreground = self.foreground.clone().map(|c| c.resolve(palette)).transpose()?.flatten();
Ok(Colors { foreground, background })
}
}
impl TryFrom<Colors> for crossterm::style::Colors {
type Error = PaletteColorError;
impl From<Colors> for crossterm::style::Colors {
fn from(value: Colors) -> Self {
let foreground = value.foreground.map(Color::into);
let background = value.background.map(Color::into);
Self { foreground, background }
fn try_from(value: Colors) -> Result<Self, Self::Error> {
let foreground = value.foreground.map(Color::try_into).transpose()?;
let background = value.background.map(Color::try_into).transpose()?;
Ok(Self { foreground, background })
}
}
@ -356,35 +404,51 @@ impl From<Colors> for crossterm::style::Colors {
pub(crate) enum ParseColorError {
#[error("invalid hex color: {0}")]
Hex(#[from] FromHexError),
#[error("palette color name is too long: {0}")]
PaletteColorLength(String),
#[error("palette color name is empty")]
PaletteColorEmpty,
}
#[cfg(test)]
mod tests {
mod test {
use super::*;
use rstest::rstest;
#[test]
fn color_serde() {
let color: Color = "beef42".parse().unwrap();
assert_eq!(color.to_string(), "beef42");
}
#[test]
fn invalid_fixed_str() {
FixedStr::<1>::try_from("AB").unwrap_err();
FixedStr::<1>::try_from("🚀").unwrap_err();
}
#[test]
fn valid_fixed_str() {
let str = FixedStr::<3>::try_from("ABC").unwrap();
assert_eq!(str.to_string(), "ABC");
}
#[rstest]
#[case::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);
#[case::empty1("p:")]
#[case::empty2("palette:")]
#[case::too_long("palette:12345678901234567")]
fn invalid_palette_color_names(#[case] input: &str) {
Color::from_str(input).expect_err("not an error");
}
#[rstest]
#[case::short("p:hi", "hi")]
#[case::long("palette:bye", "bye")]
fn valid_palette_color_names(#[case] input: &str, #[case] expected: &str) {
let color = Color::from_str(input).expect("failed to parse");
let Color::Palette(name) = color else { panic!("not a palette color") };
assert_eq!(name.deref(), expected);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,390 +0,0 @@
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);
}
}

View File

@ -120,7 +120,7 @@ mod test {
},
presentation::{Slide, SlideBuilder},
render::{
operation::{AsRenderOperations, BlockLine, Pollable, RenderAsync, ToggleState},
operation::{AsRenderOperations, BlockLine, RenderAsync, RenderAsyncState},
properties::WindowSize,
},
theme::{Alignment, Margin},
@ -138,9 +138,12 @@ mod test {
}
impl RenderAsync for Dynamic {
fn pollable(&self) -> Box<dyn Pollable> {
// Use some random one, we don't care
Box::new(ToggleState::new(Default::default()))
fn start_render(&self) -> bool {
false
}
fn poll_state(&self) -> RenderAsyncState {
RenderAsyncState::Rendered
}
}

View File

@ -1,7 +1,12 @@
use crate::{config::OptionsConfig, render::operation::RenderOperation};
use crate::{
config::OptionsConfig,
render::operation::{RenderAsyncState, RenderOperation},
theme::PresentationTheme,
};
use serde::Deserialize;
use std::{
cell::RefCell,
collections::HashSet,
fmt::Debug,
ops::Deref,
rc::Rc,
@ -10,7 +15,6 @@ use std::{
pub(crate) mod builder;
pub(crate) mod diff;
pub(crate) mod poller;
#[derive(Debug)]
pub(crate) struct Modals {
@ -37,11 +41,6 @@ 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()
@ -53,6 +52,7 @@ impl Presentation {
}
/// Consume this presentation and return its slides.
#[cfg(test)]
pub(crate) fn into_slides(self) -> Vec<Slide> {
self.slides
}
@ -140,7 +140,92 @@ impl Presentation {
self.current_slide().current_chunk_index()
}
pub(crate) fn current_slide_mut(&mut self) -> &mut Slide {
/// 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 {
let index = self.current_slide_index();
&mut self.slides[index]
}
@ -298,7 +383,7 @@ impl Slide {
self.current_chunk().reset_mutations();
}
pub(crate) fn show_all_chunks(&mut self) {
fn show_all_chunks(&mut self) {
self.visible_chunks = self.chunks.len();
for chunk in &self.chunks {
chunk.apply_all_mutations();
@ -463,7 +548,7 @@ pub(crate) struct PresentationThemeMetadata {
/// Any specific overrides for the presentation's theme.
#[serde(default, rename = "override")]
pub(crate) overrides: Option<crate::theme::raw::PresentationTheme>,
pub(crate) overrides: Option<PresentationTheme>,
}
#[cfg(test)]

View File

@ -1,118 +0,0 @@
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);
}
}
}

View File

@ -4,45 +4,33 @@ use crate::{
listener::{Command, CommandListener},
speaker_notes::{SpeakerNotesEvent, SpeakerNotesEventPublisher},
},
config::{KeyBindingsConfig, SlideTransitionConfig, SlideTransitionStyleConfig},
config::KeyBindingsConfig,
export::ImageReplacer,
markdown::parse::{MarkdownParser, ParseError},
presentation::{
Presentation, Slide,
Presentation,
builder::{BuildError, PresentationBuilder, PresentationBuilderOptions, Themes},
diff::PresentationDiffer,
poller::{PollableEffect, Poller, PollerCommand},
},
render::{
ErrorSource, RenderError, RenderResult, TerminalDrawer, TerminalDrawerOptions,
ascii_scaler::AsciiScaler,
engine::{MaxSize, RenderEngine, RenderEngineOptions},
operation::{Pollable, RenderAsyncStartPolicy, RenderOperation},
properties::WindowSize,
validate::OverflowValidator,
ErrorSource, RenderError, RenderResult, TerminalDrawer, TerminalDrawerOptions, operation::RenderAsyncState,
properties::WindowSize, validate::OverflowValidator,
},
resource::Resources,
terminal::{
image::printer::{ImagePrinter, ImageRegistry},
printer::{TerminalCommand, TerminalIo},
virt::{ImageBehavior, TerminalGrid, VirtualTerminal},
},
theme::{ProcessingThemeError, raw::PresentationTheme},
terminal::image::printer::{ImagePrinter, ImageRegistry},
theme::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},
io::{self, Stdout},
mem,
ops::Deref,
path::Path,
rc::Rc,
sync::Arc,
time::{Duration, Instant},
};
pub struct PresenterOptions {
@ -51,8 +39,7 @@ pub struct PresenterOptions {
pub font_size_fallback: u8,
pub bindings: KeyBindingsConfig,
pub validate_overflows: bool,
pub max_size: MaxSize,
pub transition: Option<SlideTransitionConfig>,
pub max_columns: u16,
}
/// A slideshow presenter.
@ -64,13 +51,13 @@ pub struct Presenter<'a> {
parser: MarkdownParser<'a>,
resources: Resources,
third_party: ThirdPartyRender,
code_executor: Arc<SnippetExecutor>,
code_executor: Rc<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> {
@ -82,7 +69,7 @@ impl<'a> Presenter<'a> {
parser: MarkdownParser<'a>,
resources: Resources,
third_party: ThirdPartyRender,
code_executor: Arc<SnippetExecutor>,
code_executor: Rc<SnippetExecutor>,
themes: Themes,
image_printer: Arc<ImagePrinter>,
options: PresenterOptions,
@ -96,11 +83,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(),
}
}
@ -110,19 +97,25 @@ 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_size: self.options.max_size.clone(),
max_columns: self.options.max_columns,
};
let mut drawer = TerminalDrawer::new(self.image_printer.clone(), drawer_options)?;
let mut drawer = TerminalDrawer::new(io::stdout(), 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.process_poller_effects()? {
if self.poll_async_renders()? {
self.render(&mut drawer)?;
}
@ -148,19 +141,10 @@ 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 => (),
@ -172,36 +156,6 @@ 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)?;
@ -227,7 +181,28 @@ impl<'a> Presenter<'a> {
}
}
fn render(&mut self, drawer: &mut TerminalDrawer) -> RenderResult {
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(&current_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(&current_index);
return Ok(true);
}
};
}
Ok(false)
}
fn render(&mut self, drawer: &mut TerminalDrawer<Stdout>) -> RenderResult {
let result = match &self.state {
PresenterState::Presenting(presentation) => {
drawer.render_operations(presentation.current_slide().iter_visible_operations())
@ -282,37 +257,16 @@ impl<'a> Presenter<'a> {
}
};
let needs_redraw = match command {
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::Next => presentation.jump_next(),
Command::NextFast => presentation.jump_next_fast(),
Command::Previous => {
let current_slide = presentation.current_slide_index();
if !presentation.jump_previous() {
false
} else if presentation.current_slide_index() != current_slide {
return CommandSideEffect::AnimatePreviousSlide;
} else {
true
}
}
Command::Previous => presentation.jump_previous(),
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 => {
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() });
}
if presentation.trigger_slide_async_renders() {
self.slides_with_pending_async_renders.insert(self.state.presentation().current_slide_index());
return CommandSideEffect::Redraw;
} else {
return CommandSideEffect::None;
@ -339,11 +293,11 @@ impl<'a> Presenter<'a> {
if needs_redraw { CommandSideEffect::Redraw } else { CommandSideEffect::None }
}
fn try_reload(&mut self, path: &Path, force: bool) -> RenderResult {
fn try_reload(&mut self, path: &Path, force: bool) {
if matches!(self.options.mode, PresentMode::Presentation) && !force {
return Ok(());
return;
}
self.poller.send(PollerCommand::Reset);
self.slides_with_pending_async_renders.clear();
self.resources.clear_watches();
match self.load_presentation(path) {
Ok(mut presentation) => {
@ -355,40 +309,22 @@ impl<'a> Presenter<'a> {
presentation.go_to_slide(current.current_slide_index());
presentation.jump_chunk(current.current_chunk());
}
self.start_automatic_async_renders(&mut presentation);
self.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.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 {
@ -415,17 +351,22 @@ 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 presentation = PresentationBuilder::new(
let export_mode = matches!(self.options.mode, PresentMode::Export);
let mut presentation = PresentationBuilder::new(
self.default_theme,
self.resources.clone(),
&mut self.resources,
&mut self.third_party,
self.code_executor.clone(),
&self.themes,
ImageRegistry::new(self.image_printer.clone()),
ImageRegistry(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)
}
@ -451,7 +392,7 @@ impl<'a> Presenter<'a> {
}
}
fn suspend(&self, drawer: &mut TerminalDrawer) {
fn suspend(&self, drawer: &mut TerminalDrawer<Stdout>) {
#[cfg(unix)]
unsafe {
drawer.terminal.suspend();
@ -459,140 +400,6 @@ 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 {
@ -600,8 +407,6 @@ enum CommandSideEffect {
Suspend,
Redraw,
Reload,
AnimateNextSlide,
AnimatePreviousSlide,
None,
}
@ -673,6 +478,9 @@ 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.
@ -686,9 +494,6 @@ pub enum LoadPresentationError {
#[error(transparent)]
Processing(#[from] BuildError),
#[error("processing theme: {0}")]
ProcessingTheme(#[from] ProcessingThemeError),
}
/// An error during the presentation.
@ -699,4 +504,7 @@ pub enum PresentationError {
#[error("io: {0}")]
Io(#[from] io::Error),
#[error("fatal error: {0}")]
Fatal(String),
}

View File

@ -1,102 +0,0 @@
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
}
}

View File

@ -1,8 +1,5 @@
use super::{
RenderError, RenderResult, layout::Layout, operation::ImagePosition, properties::CursorPosition, text::TextDrawer,
};
use super::{RenderError, RenderResult, layout::Layout, properties::CursorPosition, text::TextDrawer};
use crate::{
config::{MaxColumnsAlignment, MaxRowsAlignment},
markdown::{text::WeightedLine, text_style::Colors},
render::{
layout::Positioning,
@ -13,70 +10,51 @@ use crate::{
properties::WindowSize,
},
terminal::{
Terminal, TerminalWrite,
image::{
Image,
printer::{ImageProperties, PrintOptions},
scale::{ImageScaler, ScaleImage},
scale::{fit_image_to_window, scale_image},
},
printer::{TerminalCommand, TerminalIo},
},
theme::Alignment,
};
use std::mem;
const MINIMUM_LINE_LENGTH: u16 = 10;
#[derive(Clone, Debug)]
pub(crate) struct MaxSize {
pub(crate) max_columns: u16,
pub(crate) max_columns_alignment: MaxColumnsAlignment,
pub(crate) max_rows: u16,
pub(crate) max_rows_alignment: MaxRowsAlignment,
}
impl Default for MaxSize {
fn default() -> Self {
Self {
max_columns: u16::MAX,
max_columns_alignment: Default::default(),
max_rows: u16::MAX,
max_rows_alignment: Default::default(),
}
}
}
#[derive(Clone, Debug)]
#[derive(Debug)]
pub(crate) struct RenderEngineOptions {
pub(crate) validate_overflows: bool,
pub(crate) max_size: MaxSize,
pub(crate) column_layout_margin: u16,
pub(crate) max_columns: u16,
}
impl Default for RenderEngineOptions {
fn default() -> Self {
Self { validate_overflows: false, max_size: Default::default(), column_layout_margin: 4 }
Self { validate_overflows: false, max_columns: u16::MAX }
}
}
pub(crate) struct RenderEngine<'a, T>
pub(crate) struct RenderEngine<'a, W>
where
T: TerminalIo,
W: TerminalWrite,
{
terminal: &'a mut T,
terminal: &'a mut Terminal<W>,
window_rects: Vec<WindowRect>,
colors: Colors,
max_modified_row: u16,
layout: LayoutState,
options: RenderEngineOptions,
image_scaler: Box<dyn ScaleImage>,
}
impl<'a, T> RenderEngine<'a, T>
impl<'a, W> RenderEngine<'a, W>
where
T: TerminalIo,
W: TerminalWrite,
{
pub(crate) fn new(terminal: &'a mut T, window_dimensions: WindowSize, options: RenderEngineOptions) -> Self {
let max_modified_row = terminal.cursor_row();
pub(crate) fn new(
terminal: &'a mut Terminal<W>,
window_dimensions: WindowSize,
options: RenderEngineOptions,
) -> Self {
let max_modified_row = terminal.cursor_row;
let current_rect = Self::starting_rect(window_dimensions, &options);
let window_rects = vec![current_rect.clone()];
Self {
@ -86,46 +64,26 @@ where
max_modified_row,
layout: Default::default(),
options,
image_scaler: Box::<ImageScaler>::default(),
}
}
fn starting_rect(mut dimensions: WindowSize, options: &RenderEngineOptions) -> WindowRect {
let mut start_row = 0;
let mut start_column = 0;
if dimensions.columns > options.max_size.max_columns {
let extra_width = dimensions.columns - options.max_size.max_columns;
dimensions = dimensions.shrink_columns(extra_width);
start_column = match options.max_size.max_columns_alignment {
MaxColumnsAlignment::Left => 0,
MaxColumnsAlignment::Center => extra_width / 2,
MaxColumnsAlignment::Right => extra_width,
};
fn starting_rect(window_dimensions: WindowSize, options: &RenderEngineOptions) -> WindowRect {
if window_dimensions.columns > options.max_columns {
let extra_width = window_dimensions.columns - options.max_columns;
let dimensions = window_dimensions.shrink_columns(extra_width);
WindowRect { dimensions, start_column: extra_width / 2 }
} else {
WindowRect { dimensions: window_dimensions, start_column: 0 }
}
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 {
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 })?;
}
self.terminal.begin_update()?;
for operation in operations {
self.render_one(operation)?;
}
self.terminal.execute(&TerminalCommand::EndUpdate)?;
self.terminal.execute(&TerminalCommand::Flush)?;
self.terminal.end_update()?;
self.terminal.flush()?;
if self.options.validate_overflows && self.max_modified_row > self.window_rects[0].dimensions.rows {
return Err(RenderError::VerticalOverflow);
}
@ -141,8 +99,7 @@ where
RenderOperation::JumpToVerticalCenter => self.jump_to_vertical_center(),
RenderOperation::JumpToRow { index } => self.jump_to_row(*index),
RenderOperation::JumpToBottomRow { index } => self.jump_to_bottom(*index),
RenderOperation::JumpToColumn { index } => self.jump_to_column(*index),
RenderOperation::RenderText { line, alignment } => self.render_text(line, *alignment),
RenderOperation::RenderText { line, alignment } => self.render_text(line, alignment),
RenderOperation::RenderLineBreak => self.render_line_break(),
RenderOperation::RenderImage(image, properties) => self.render_image(image, properties),
RenderOperation::RenderBlockLine(operation) => self.render_block_line(operation),
@ -153,9 +110,9 @@ where
RenderOperation::ExitLayout => self.exit_layout(),
}?;
if let LayoutState::EnteredColumn { column, columns } = &mut self.layout {
columns[*column].current_row = self.terminal.cursor_row();
columns[*column].current_row = self.terminal.cursor_row;
};
self.max_modified_row = self.max_modified_row.max(self.terminal.cursor_row());
self.max_modified_row = self.max_modified_row.max(self.terminal.cursor_row);
Ok(())
}
@ -169,21 +126,17 @@ where
}
fn clear_screen(&mut self) -> RenderResult {
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.terminal.clear_screen()?;
self.terminal.move_to(0, 0)?;
self.max_modified_row = 0;
Ok(())
}
fn apply_margin(&mut self, properties: &MarginProperties) -> RenderResult {
let MarginProperties { horizontal: horizontal_margin, top, bottom } = properties;
let MarginProperties { horizontal_margin, bottom_slide_margin } = properties;
let current = self.current_rect();
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.terminal.execute(&TerminalCommand::MoveToRow(new_rect.start_row))?;
}
let new_rect = current.apply_margin(margin).shrink_rows(*bottom_slide_margin);
self.window_rects.push(new_rect);
Ok(())
}
@ -202,113 +155,78 @@ where
}
fn apply_colors(&mut self) -> RenderResult {
self.terminal.execute(&TerminalCommand::SetColors(self.colors))?;
self.terminal.set_colors(self.colors)?;
Ok(())
}
fn jump_to_vertical_center(&mut self) -> RenderResult {
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))?;
let center_row = self.current_dimensions().rows / 2;
self.terminal.move_to_row(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.execute(&TerminalCommand::MoveToRow(row))?;
fn jump_to_row(&mut self, index: u16) -> RenderResult {
self.terminal.move_to_row(index)?;
Ok(())
}
fn jump_to_bottom(&mut self, index: u16) -> RenderResult {
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))?;
let target_row = self.current_dimensions().rows.saturating_sub(index).saturating_sub(1);
self.terminal.move_to_row(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.execute(&TerminalCommand::MoveToColumn(column))?;
Ok(())
}
fn render_text(&mut self, text: &WeightedLine, alignment: Alignment) -> RenderResult {
let layout = self.build_layout(alignment);
fn render_text(&mut self, text: &WeightedLine, alignment: &Alignment) -> RenderResult {
let layout = self.build_layout(alignment.clone());
let dimensions = self.current_dimensions();
let positioning = layout.compute(dimensions, text.width() as u16);
let prefix = "".into();
let text_drawer = TextDrawer::new(&prefix, 0, text, positioning, &self.colors, MINIMUM_LINE_LENGTH)?;
let center_newlines = matches!(alignment, Alignment::Center { .. });
let text_drawer = text_drawer.center_newlines(center_newlines);
let text_drawer = TextDrawer::new(&prefix, 0, text, positioning, &self.colors)?;
text_drawer.draw(self.terminal)?;
// Restore colors
self.apply_colors()
}
fn render_line_break(&mut self) -> RenderResult {
self.terminal.execute(&TerminalCommand::MoveToNextLine)?;
self.terminal.move_to_next_line(1)?;
Ok(())
}
fn render_image(&mut self, image: &Image, properties: &ImageRenderProperties) -> RenderResult {
let rect = self.current_rect().clone();
let starting_row = self.terminal.cursor_row();
let starting_cursor =
CursorPosition { row: starting_row.saturating_sub(rect.start_row), column: rect.start_column };
let rect = self.current_rect();
let starting_position = CursorPosition { row: self.terminal.cursor_row, column: rect.start_column };
let (width, height) = image.image().dimensions();
let (columns, rows) = match properties.size {
let (width, height) = image.dimensions();
let (cursor_position, columns, rows) = match properties.size {
ImageSize::ShrinkIfNeeded => {
let image_scale =
self.image_scaler.fit_image_to_rect(&rect.dimensions, width, height, &starting_cursor);
(image_scale.columns, image_scale.rows)
let scale = fit_image_to_window(&rect.dimensions, width, height, &starting_position);
(CursorPosition { row: starting_position.row, column: scale.start_column }, scale.columns, scale.rows)
}
ImageSize::Specific(columns, rows) => (columns, rows),
ImageSize::Specific(columns, rows) => (starting_position.clone(), 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 =
self.image_scaler.scale_image(&dimensions, &rect.dimensions, width, height, &starting_cursor);
(image_scale.columns, image_scale.rows)
let scale = scale_image(&dimensions, &rect.dimensions, width, height, &starting_position);
(CursorPosition { row: starting_position.row, column: scale.start_column }, scale.columns, scale.rows)
}
};
let cursor = match &properties.position {
ImagePosition::Cursor => starting_cursor.clone(),
ImagePosition::Center => Self::center_cursor(columns, &rect.dimensions, &starting_cursor),
ImagePosition::Right => Self::align_cursor_right(columns, &rect.dimensions, &starting_cursor),
};
self.terminal.execute(&TerminalCommand::MoveToColumn(cursor.column))?;
let options = PrintOptions {
columns,
rows,
cursor_position,
z_index: properties.z_index,
column_width: rect.dimensions.pixels_per_column() as u16,
row_height: rect.dimensions.pixels_per_row() as u16,
background_color: properties.background_color,
};
self.terminal.execute(&TerminalCommand::PrintImage { image: image.clone(), options })?;
self.terminal.print_image(image, &options)?;
if properties.restore_cursor {
self.terminal.execute(&TerminalCommand::MoveTo { column: starting_cursor.column, row: starting_row })?;
self.terminal.move_to(starting_position.column, starting_position.row)?;
} else {
self.terminal.execute(&TerminalCommand::MoveToRow(starting_row + rows))?;
self.terminal.move_to_row(starting_position.row + rows)?;
}
self.apply_colors()
}
fn center_cursor(columns: u16, window: &WindowSize, cursor: &CursorPosition) -> CursorPosition {
let start_column = window.columns / 2 - (columns / 2);
let start_column = start_column + cursor.column;
CursorPosition { row: cursor.row, column: start_column }
}
fn align_cursor_right(columns: u16, window: &WindowSize, cursor: &CursorPosition) -> CursorPosition {
let start_column = window.columns.saturating_sub(columns).saturating_add(cursor.column);
CursorPosition { row: cursor.row, column: start_column }
Ok(())
}
fn render_block_line(&mut self, operation: &BlockLine) -> RenderResult {
@ -321,7 +239,7 @@ where
right_padding_length,
repeat_prefix_on_wrap,
} = operation;
let layout = self.build_layout(*alignment).with_font_size(text.font_size());
let layout = self.build_layout(alignment.clone());
let dimensions = self.current_dimensions();
let Positioning { max_line_length, start_column } = layout.compute(dimensions, *block_length);
@ -329,13 +247,12 @@ where
return Err(RenderError::HorizontalOverflow);
}
self.terminal.execute(&TerminalCommand::MoveToColumn(start_column))?;
self.terminal.move_to_column(start_column)?;
let positioning = Positioning { max_line_length, start_column };
let text_drawer =
TextDrawer::new(prefix, *right_padding_length, text, positioning, &self.colors, MINIMUM_LINE_LENGTH)?
.with_surrounding_block(*block_color)
.repeat_prefix_on_wrap(*repeat_prefix_on_wrap);
let text_drawer = TextDrawer::new(prefix, *right_padding_length, text, positioning, &self.colors)?
.with_surrounding_block(*block_color)
.repeat_prefix_on_wrap(*repeat_prefix_on_wrap);
text_drawer.draw(self.terminal)?;
// Restore colors
@ -365,7 +282,7 @@ where
}
let columns = columns
.iter()
.map(|width| Column { width: *width as u16, current_row: self.terminal.cursor_row() })
.map(|width| Column { width: *width as u16, current_row: self.terminal.cursor_row })
.collect();
self.layout = LayoutState::InitializedColumn { columns };
Ok(())
@ -391,24 +308,20 @@ where
let current_rect = self.current_rect();
let unit_width = current_rect.dimensions.columns as f64 / total_column_units as f64;
let start_column = current_rect.start_column + (unit_width * column_units_before as f64) as u16;
let start_row = columns[column_index].current_row;
let new_column_count = (total_column_units - columns[column_index].width) * unit_width as u16;
let new_size = current_rect
.dimensions
.shrink_columns(new_column_count)
.shrink_rows(start_row.saturating_sub(current_rect.start_row));
let mut dimensions = WindowRect { dimensions: new_size, start_column, start_row };
let new_size = current_rect.dimensions.shrink_columns(new_column_count);
let mut dimensions = WindowRect { dimensions: new_size, start_column };
// Shrink every column's right edge except for last
if column_index < columns.len() - 1 {
dimensions = dimensions.shrink_right(self.options.column_layout_margin);
dimensions = dimensions.shrink_right(4);
}
// Shrink every column's left edge except for first
if column_index > 0 {
dimensions = dimensions.shrink_left(self.options.column_layout_margin);
dimensions = dimensions.shrink_left(4);
}
self.window_rects.push(dimensions);
self.terminal.execute(&TerminalCommand::MoveToRow(start_row))?;
self.terminal.move_to_row(columns[column_index].current_row)?;
self.layout = LayoutState::EnteredColumn { column: column_index, columns };
Ok(())
}
@ -417,7 +330,7 @@ where
match &self.layout {
LayoutState::Default | LayoutState::InitializedColumn { .. } => Ok(()),
LayoutState::EnteredColumn { .. } => {
self.terminal.execute(&TerminalCommand::MoveTo { column: 0, row: self.max_modified_row })?;
self.terminal.move_to(0, self.max_modified_row)?;
self.layout = LayoutState::Default;
self.pop_margin()?;
Ok(())
@ -452,490 +365,28 @@ struct Column {
struct WindowRect {
dimensions: WindowSize,
start_column: u16,
start_row: u16,
}
impl WindowRect {
fn shrink_horizontal(&self, margin: u16) -> Self {
fn apply_margin(&self, margin: u16) -> Self {
let dimensions = self.dimensions.shrink_columns(margin.saturating_mul(2));
let start_column = self.start_column + margin;
Self { dimensions, start_column, start_row: self.start_row }
Self { dimensions, start_column }
}
fn shrink_left(&self, size: u16) -> Self {
let dimensions = self.dimensions.shrink_columns(size);
let start_column = self.start_column.saturating_add(size);
Self { dimensions, start_column, start_row: self.start_row }
Self { dimensions, start_column }
}
fn shrink_right(&self, size: u16) -> Self {
let dimensions = self.dimensions.shrink_columns(size);
Self { dimensions, start_column: self.start_column, start_row: self.start_row }
Self { dimensions, start_column: self.start_column }
}
fn shrink_top(&self, rows: u16) -> Self {
fn shrink_rows(&self, rows: u16) -> Self {
let dimensions = self.dimensions.shrink_rows(rows);
let start_row = self.start_row.saturating_add(rows);
Self { dimensions, start_column: self.start_column, start_row }
}
fn shrink_bottom(&self, rows: u16) -> Self {
let dimensions = self.dimensions.shrink_rows(rows);
Self { dimensions, start_column: self.start_column, start_row: self.start_row }
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
markdown::text_style::{Color, TextStyle},
terminal::{
image::{
ImageSource,
printer::{PrintImageError, TerminalImage},
scale::TerminalRect,
},
printer::TerminalError,
},
theme::Margin,
};
use ::image::{ColorType, DynamicImage};
use rstest::rstest;
use std::io;
use unicode_width::UnicodeWidthStr;
#[derive(Debug, PartialEq)]
enum Instruction {
MoveTo(u16, u16),
MoveToRow(u16),
MoveToColumn(u16),
MoveDown(u16),
MoveRight(u16),
MoveLeft(u16),
MoveToNextLine,
PrintText(String),
ClearScreen,
SetBackgroundColor(Color),
PrintImage(PrintOptions),
}
#[derive(Default)]
struct TerminalBuf {
instructions: Vec<Instruction>,
cursor_row: u16,
}
impl TerminalBuf {
fn push(&mut self, instruction: Instruction) -> io::Result<()> {
self.instructions.push(instruction);
Ok(())
}
fn move_to(&mut self, column: u16, row: u16) -> io::Result<()> {
self.cursor_row = row;
self.push(Instruction::MoveTo(column, row))
}
fn move_to_row(&mut self, row: u16) -> io::Result<()> {
self.cursor_row = row;
self.push(Instruction::MoveToRow(row))
}
fn move_to_column(&mut self, column: u16) -> io::Result<()> {
self.push(Instruction::MoveToColumn(column))
}
fn move_down(&mut self, amount: u16) -> io::Result<()> {
self.push(Instruction::MoveDown(amount))
}
fn move_right(&mut self, amount: u16) -> io::Result<()> {
self.push(Instruction::MoveRight(amount))
}
fn move_left(&mut self, amount: u16) -> io::Result<()> {
self.push(Instruction::MoveLeft(amount))
}
fn move_to_next_line(&mut self) -> io::Result<()> {
self.push(Instruction::MoveToNextLine)
}
fn print_text(&mut self, content: &str, _style: &TextStyle) -> io::Result<()> {
let content = content.to_string();
if content.is_empty() {
return Ok(());
}
self.cursor_row = content.width() as u16;
self.push(Instruction::PrintText(content))
}
fn clear_screen(&mut self) -> io::Result<()> {
self.cursor_row = 0;
self.push(Instruction::ClearScreen)
}
fn set_colors(&mut self, _colors: Colors) -> io::Result<()> {
Ok(())
}
fn set_background_color(&mut self, color: Color) -> io::Result<()> {
self.push(Instruction::SetBackgroundColor(color))
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
fn print_image(&mut self, _image: &Image, options: &PrintOptions) -> Result<(), PrintImageError> {
let _ = self.push(Instruction::PrintImage(options.clone()));
Ok(())
}
}
impl TerminalIo for TerminalBuf {
fn execute(&mut self, command: &TerminalCommand<'_>) -> Result<(), TerminalError> {
use TerminalCommand::*;
match command {
BeginUpdate => (),
EndUpdate => (),
MoveTo { column, row } => self.move_to(*column, *row)?,
MoveToRow(row) => self.move_to_row(*row)?,
MoveToColumn(column) => self.move_to_column(*column)?,
MoveDown(amount) => self.move_down(*amount)?,
MoveRight(amount) => self.move_right(*amount)?,
MoveLeft(amount) => self.move_left(*amount)?,
MoveToNextLine => self.move_to_next_line()?,
PrintText { content, style } => self.print_text(content, style)?,
ClearScreen => self.clear_screen()?,
SetColors(colors) => self.set_colors(*colors)?,
SetBackgroundColor(color) => self.set_background_color(*color)?,
Flush => self.flush()?,
PrintImage { image, options } => self.print_image(image, options)?,
};
Ok(())
}
fn cursor_row(&self) -> u16 {
self.cursor_row
}
}
struct DummyImageScaler;
impl ScaleImage for DummyImageScaler {
fn scale_image(
&self,
_scale_size: &WindowSize,
_window_dimensions: &WindowSize,
image_width: u32,
image_height: u32,
_position: &CursorPosition,
) -> TerminalRect {
TerminalRect { rows: image_width as u16, columns: image_height as u16 }
}
fn fit_image_to_rect(
&self,
_dimensions: &WindowSize,
image_width: u32,
image_height: u32,
_position: &CursorPosition,
) -> TerminalRect {
TerminalRect { rows: image_width as u16, columns: image_height as u16 }
}
}
fn do_render(max_size: MaxSize, operations: &[RenderOperation]) -> Vec<Instruction> {
let mut buf = TerminalBuf::default();
let dimensions = WindowSize { rows: 100, columns: 100, height: 200, width: 200 };
let options = RenderEngineOptions { validate_overflows: false, max_size, column_layout_margin: 0 };
let mut engine = RenderEngine::new(&mut buf, dimensions, options);
engine.image_scaler = Box::new(DummyImageScaler);
engine.render(operations.iter()).expect("render failed");
buf.instructions
}
fn render(operations: &[RenderOperation]) -> Vec<Instruction> {
do_render(Default::default(), operations)
}
fn render_with_max_size(operations: &[RenderOperation]) -> Vec<Instruction> {
let max_size = MaxSize {
max_rows: 10,
max_rows_alignment: MaxRowsAlignment::Center,
max_columns: 20,
max_columns_alignment: MaxColumnsAlignment::Center,
};
do_render(max_size, operations)
}
#[test]
fn columns() {
let ops = render(&[
RenderOperation::InitColumnLayout { columns: vec![1, 1] },
// print on column 0
RenderOperation::EnterColumn { column: 0 },
RenderOperation::RenderText { line: "A".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },
// print on column 1
RenderOperation::EnterColumn { column: 1 },
RenderOperation::RenderText { line: "B".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },
// go back to column 0 and print
RenderOperation::EnterColumn { column: 0 },
RenderOperation::RenderText { line: "1".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },
]);
let expected = [
Instruction::MoveToRow(0),
Instruction::MoveToColumn(0),
Instruction::PrintText("A".into()),
Instruction::MoveToRow(0),
Instruction::MoveToColumn(50),
Instruction::PrintText("B".into()),
// when we go back we should proceed from where we left off (row == 1)
Instruction::MoveToRow(1),
Instruction::MoveToColumn(0),
Instruction::PrintText("1".into()),
];
assert_eq!(ops, expected);
}
#[test]
fn bottom_margin() {
let ops = render(&[
RenderOperation::ApplyMargin(MarginProperties { horizontal: Margin::Fixed(1), top: 0, bottom: 10 }),
RenderOperation::RenderText { line: "A".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },
RenderOperation::JumpToBottomRow { index: 0 },
RenderOperation::RenderText { line: "B".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },
]);
let expected = [
Instruction::MoveToColumn(1),
Instruction::PrintText("A".into()),
// 100 - 10 (bottom margin)
Instruction::MoveToRow(89),
Instruction::MoveToColumn(1),
Instruction::PrintText("B".into()),
];
assert_eq!(ops, expected);
}
#[test]
fn top_margin() {
let ops = render(&[
RenderOperation::ApplyMargin(MarginProperties { horizontal: Margin::Fixed(1), top: 3, bottom: 0 }),
RenderOperation::RenderText { line: "A".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },
]);
let expected = [Instruction::MoveToRow(3), Instruction::MoveToColumn(1), Instruction::PrintText("A".into())];
assert_eq!(ops, expected);
}
#[test]
fn margins() {
let ops = render(&[
RenderOperation::ApplyMargin(MarginProperties { horizontal: Margin::Fixed(1), top: 3, bottom: 10 }),
RenderOperation::JumpToRow { index: 0 },
RenderOperation::RenderText { line: "A".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },
RenderOperation::JumpToBottomRow { index: 0 },
RenderOperation::RenderText { line: "B".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },
]);
let expected = [
Instruction::MoveToRow(3),
Instruction::MoveToRow(3),
Instruction::MoveToColumn(1),
Instruction::PrintText("A".into()),
// 100 - 10 (bottom margin)
Instruction::MoveToRow(89),
Instruction::MoveToColumn(1),
Instruction::PrintText("B".into()),
];
assert_eq!(ops, expected);
}
#[test]
fn nested_margins() {
let ops = render(&[
RenderOperation::ApplyMargin(MarginProperties { horizontal: Margin::Fixed(1), top: 0, bottom: 10 }),
RenderOperation::ApplyMargin(MarginProperties { horizontal: Margin::Fixed(1), top: 0, bottom: 10 }),
RenderOperation::RenderText { line: "A".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },
RenderOperation::JumpToBottomRow { index: 0 },
RenderOperation::RenderText { line: "B".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },
// pop and go to bottom, this should go back up to the end of the first margin
RenderOperation::PopMargin,
RenderOperation::JumpToBottomRow { index: 0 },
RenderOperation::RenderText { line: "C".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },
]);
let expected = [
Instruction::MoveToColumn(2),
Instruction::PrintText("A".into()),
// 100 - 10 (margin) - 10 (second margin)
Instruction::MoveToRow(79),
Instruction::MoveToColumn(2),
Instruction::PrintText("B".into()),
// 100 - 10 (margin)
Instruction::MoveToRow(89),
Instruction::MoveToColumn(1),
Instruction::PrintText("C".into()),
];
assert_eq!(ops, expected);
}
#[test]
fn margin_with_max_size() {
let ops = render_with_max_size(&[
RenderOperation::RenderText { line: "A".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },
RenderOperation::ApplyMargin(MarginProperties { horizontal: Margin::Fixed(1), top: 2, bottom: 1 }),
RenderOperation::RenderText { line: "B".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },
RenderOperation::JumpToBottomRow { index: 0 },
RenderOperation::RenderText { line: "C".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },
]);
let expected = [
// centered 20x10
Instruction::MoveTo(40, 45),
Instruction::MoveToColumn(40),
Instruction::PrintText("A".into()),
// jump 2 down because of top margin
Instruction::MoveToRow(47),
// jump 1 right because of horizontal margin
Instruction::MoveToColumn(41),
Instruction::PrintText("B".into()),
// rows go from 47 to 53 (7 total)
Instruction::MoveToRow(53),
Instruction::MoveToColumn(41),
Instruction::PrintText("C".into()),
];
assert_eq!(ops, expected);
}
// print the same 2x2 image with all size configs, they should all yield the same
#[rstest]
#[case::shrink(ImageSize::ShrinkIfNeeded)]
#[case::specific(ImageSize::Specific(2, 2))]
#[case::width_scaled(ImageSize::WidthScaled { ratio: 1.0 })]
fn image(#[case] size: ImageSize) {
let image = DynamicImage::new(2, 2, ColorType::Rgba8);
let image = Image::new(TerminalImage::Ascii(image.into()), ImageSource::Generated);
let properties = ImageRenderProperties {
z_index: 0,
size,
restore_cursor: false,
background_color: None,
position: ImagePosition::Cursor,
};
let ops = render_with_max_size(&[RenderOperation::RenderImage(image, properties)]);
let expected = [
// centered 20x10, the image is 2x2 so we stand one away from center
Instruction::MoveTo(40, 45),
Instruction::MoveToColumn(40),
Instruction::PrintImage(PrintOptions {
columns: 2,
rows: 2,
z_index: 0,
background_color: None,
column_width: 2,
row_height: 2,
}),
// place cursor after the image
Instruction::MoveToRow(47),
];
assert_eq!(ops, expected);
}
// same as the above but center it
#[rstest]
#[case::shrink(ImageSize::ShrinkIfNeeded)]
#[case::specific(ImageSize::Specific(2, 2))]
#[case::width_scaled(ImageSize::WidthScaled { ratio: 1.0 })]
fn centered_image(#[case] size: ImageSize) {
let image = DynamicImage::new(2, 2, ColorType::Rgba8);
let image = Image::new(TerminalImage::Ascii(image.into()), ImageSource::Generated);
let properties = ImageRenderProperties {
z_index: 0,
size,
restore_cursor: false,
background_color: None,
position: ImagePosition::Center,
};
let ops = render_with_max_size(&[RenderOperation::RenderImage(image, properties)]);
let expected = [
// centered 20x10, the image is 2x2 so we stand one away from center
Instruction::MoveTo(40, 45),
Instruction::MoveToColumn(49),
Instruction::PrintImage(PrintOptions {
columns: 2,
rows: 2,
z_index: 0,
background_color: None,
column_width: 2,
row_height: 2,
}),
// place cursor after the image
Instruction::MoveToRow(47),
];
assert_eq!(ops, expected);
}
// same as the above but use right alignment
#[rstest]
#[case::shrink(ImageSize::ShrinkIfNeeded)]
#[case::specific(ImageSize::Specific(2, 2))]
#[case::width_scaled(ImageSize::WidthScaled { ratio: 1.0 })]
fn right_aligned_image(#[case] size: ImageSize) {
let image = DynamicImage::new(2, 2, ColorType::Rgba8);
let image = Image::new(TerminalImage::Ascii(image.into()), ImageSource::Generated);
let properties = ImageRenderProperties {
z_index: 0,
size,
restore_cursor: false,
background_color: None,
position: ImagePosition::Right,
};
let ops = render_with_max_size(&[RenderOperation::RenderImage(image, properties)]);
let expected = [
// right aligned 20x10, the image is 2x2 so we stand one away from the right
Instruction::MoveTo(40, 45),
Instruction::MoveToColumn(58),
Instruction::PrintImage(PrintOptions {
columns: 2,
rows: 2,
z_index: 0,
background_color: None,
column_width: 2,
row_height: 2,
}),
// place cursor after the image
Instruction::MoveToRow(47),
];
assert_eq!(ops, expected);
}
// same as the above but center it
#[rstest]
fn restore_cursor_after_image() {
let image = DynamicImage::new(2, 2, ColorType::Rgba8);
let image = Image::new(TerminalImage::Ascii(image.into()), ImageSource::Generated);
let properties = ImageRenderProperties {
z_index: 0,
size: ImageSize::ShrinkIfNeeded,
restore_cursor: true,
background_color: None,
position: ImagePosition::Center,
};
let ops = render_with_max_size(&[RenderOperation::RenderImage(image, properties)]);
let expected = [
// centered 20x10, the image is 2x2 so we stand one away from center
Instruction::MoveTo(40, 45),
Instruction::MoveToColumn(49),
Instruction::PrintImage(PrintOptions {
columns: 2,
rows: 2,
z_index: 0,
background_color: None,
column_width: 2,
row_height: 2,
}),
// place cursor after the image
Instruction::MoveTo(40, 45),
];
assert_eq!(ops, expected);
Self { dimensions, start_column: self.start_column }
}
}

View File

@ -4,12 +4,11 @@ use crate::{render::properties::WindowSize, theme::Alignment};
pub(crate) struct Layout {
alignment: Alignment,
start_column_offset: u16,
font_size: u16,
}
impl Layout {
pub(crate) fn new(alignment: Alignment) -> Self {
Self { alignment, start_column_offset: 0, font_size: 1 }
Self { alignment, start_column_offset: 0 }
}
pub(crate) fn with_start_column(mut self, column: u16) -> Self {
@ -17,13 +16,7 @@ impl Layout {
self
}
pub(crate) fn with_font_size(mut self, font_size: u8) -> Self {
self.font_size = font_size as u16;
self
}
pub(crate) fn compute(&self, dimensions: &WindowSize, text_length: u16) -> Positioning {
let text_length = text_length * self.font_size;
let max_line_length;
let mut start_column;
match &self.alignment {

View File

@ -1,4 +1,3 @@
pub(crate) mod ascii_scaler;
pub(crate) mod engine;
pub(crate) mod layout;
pub(crate) mod operation;
@ -14,44 +13,43 @@ use crate::{
},
render::{operation::RenderOperation, properties::WindowSize},
terminal::{
Terminal,
Terminal, TerminalWrite,
image::printer::{ImagePrinter, PrintImageError},
printer::TerminalError,
},
theme::{Alignment, Margin},
};
use engine::{MaxSize, RenderEngine, RenderEngineOptions};
use operation::AsRenderOperations;
use std::{
io::{self, Stdout},
iter,
rc::Rc,
sync::Arc,
};
use engine::{RenderEngine, RenderEngineOptions};
use std::{io, sync::Arc};
/// The result of a render operation.
pub(crate) type RenderResult = Result<(), RenderError>;
pub(crate) struct TerminalDrawerOptions {
/// The font size to fall back to if we can't find the window size in pixels.
pub(crate) font_size_fallback: u8,
pub(crate) max_size: MaxSize,
/// The max width in columns that the presentation should be capped to.
pub(crate) max_columns: u16,
}
impl Default for TerminalDrawerOptions {
fn default() -> Self {
Self { font_size_fallback: 1, max_size: Default::default() }
Self { font_size_fallback: 1, max_columns: u16::MAX }
}
}
/// Allows drawing on the terminal.
pub(crate) struct TerminalDrawer {
pub(crate) terminal: Terminal<Stdout>,
pub(crate) struct TerminalDrawer<W: TerminalWrite> {
pub(crate) terminal: Terminal<W>,
options: TerminalDrawerOptions,
}
impl TerminalDrawer {
pub(crate) fn new(image_printer: Arc<ImagePrinter>, options: TerminalDrawerOptions) -> io::Result<Self> {
let terminal = Terminal::new(io::stdout(), image_printer)?;
impl<W> TerminalDrawer<W>
where
W: TerminalWrite,
{
pub(crate) fn new(handle: W, image_printer: Arc<ImagePrinter>, options: TerminalDrawerOptions) -> io::Result<Self> {
let terminal = Terminal::new(handle, image_printer)?;
Ok(Self { terminal, options })
}
@ -66,20 +64,41 @@ 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: alignment.clone() },
RenderOperation::RenderLineBreak,
RenderOperation::RenderLineBreak,
];
for line in message.lines() {
let error = vec![Text::from(line)];
let op = RenderOperation::RenderText { line: WeightedLine::from(error), alignment: alignment.clone() };
operations.extend([op, RenderOperation::RenderLineBreak]);
}
let engine = self.create_engine(dimensions);
engine.render(iter::once(&operation))?;
engine.render(operations.iter())?;
Ok(())
}
pub(crate) fn render_engine_options(&self) -> RenderEngineOptions {
RenderEngineOptions { max_size: self.options.max_size.clone(), ..Default::default() }
}
fn create_engine(&mut self, dimensions: WindowSize) -> RenderEngine<Terminal<Stdout>> {
let options = self.render_engine_options();
fn create_engine(&mut self, dimensions: WindowSize) -> RenderEngine<W> {
let options = RenderEngineOptions { max_columns: self.options.max_columns, ..Default::default() };
RenderEngine::new(&mut self.terminal, dimensions, options)
}
}
@ -90,9 +109,6 @@ pub(crate) enum RenderError {
#[error("io: {0}")]
Io(#[from] io::Error),
#[error("terminal: {0}")]
Terminal(#[from] TerminalError),
#[error("screen is too small")]
TerminalTooSmall,
@ -115,47 +131,7 @@ 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
}
}

View File

@ -7,13 +7,7 @@ use crate::{
terminal::image::Image,
theme::{Alignment, Margin},
};
use std::{
fmt::Debug,
rc::Rc,
sync::{Arc, Mutex},
};
const DEFAULT_IMAGE_Z_INDEX: i32 = -2;
use std::{fmt::Debug, rc::Rc};
/// A line of preformatted text to be rendered.
#[derive(Clone, Debug, PartialEq)]
@ -52,9 +46,6 @@ pub(crate) enum RenderOperation {
/// The index is zero based where 0 represents the bottom row.
JumpToBottomRow { index: u16 },
/// Jump to the N-th column in the current layout.
JumpToColumn { index: u16 },
/// Render text.
RenderText { line: WeightedLine, alignment: Alignment },
@ -105,26 +96,6 @@ pub(crate) struct ImageRenderProperties {
pub(crate) size: ImageSize,
pub(crate) restore_cursor: bool,
pub(crate) background_color: Option<Color>,
pub(crate) position: ImagePosition,
}
impl Default for ImageRenderProperties {
fn default() -> Self {
Self {
z_index: DEFAULT_IMAGE_Z_INDEX,
size: Default::default(),
restore_cursor: false,
background_color: None,
position: ImagePosition::Center,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum ImagePosition {
Cursor,
Center,
Right,
}
/// The size used when printing an image.
@ -142,13 +113,10 @@ pub(crate) enum ImageSize {
#[derive(Clone, Debug, Default)]
pub(crate) struct MarginProperties {
/// The horizontal margin.
pub(crate) horizontal: Margin,
pub(crate) horizontal_margin: Margin,
/// The margin at the top.
pub(crate) top: u16,
/// The margin at the bottom.
pub(crate) bottom: u16,
/// The margin at the bottom of the slide.
pub(crate) bottom_slide_margin: u16,
}
/// A type that can generate render operations.
@ -164,57 +132,24 @@ pub(crate) trait AsRenderOperations: Debug + 'static {
/// An operation that can be rendered asynchronously.
pub(crate) trait RenderAsync: AsRenderOperations {
/// Create a pollable for this render async.
/// Start the render for this operation.
///
/// 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>;
/// Should return true if the invocation triggered the rendering (aka if rendering wasn't
/// already started before).
fn start_render(&self) -> bool;
/// Get the start policy for this render.
fn start_policy(&self) -> RenderAsyncStartPolicy {
RenderAsyncStartPolicy::OnDemand
}
}
/// The start policy for an async render.
#[derive(Copy, Clone, Debug)]
pub(crate) enum RenderAsyncStartPolicy {
/// Start automatically.
Automatic,
/// Start on demand.
OnDemand,
}
/// A pollable that can be used to pull and update the state of an operation asynchronously.
pub(crate) trait Pollable: Send + 'static {
/// Update the internal state and return the updated state.
fn poll(&mut self) -> PollableState;
fn poll_state(&self) -> RenderAsyncState;
}
/// 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
}
/// The state of a [RenderAsync].
#[derive(Clone, Debug, Default)]
pub(crate) enum RenderAsyncState {
#[default]
NotStarted,
Rendering {
modified: bool,
},
Rendered,
JustFinishedRendering,
}

View File

@ -27,10 +27,10 @@ impl WindowSize {
};
let font_size_fallback = font_size_fallback as u16;
if size.width == 0 {
size.width = size.columns * font_size_fallback.max(1);
size.width = size.columns * font_size_fallback;
}
if size.height == 0 {
size.height = size.rows * font_size_fallback.max(1) * 2;
size.height = size.rows * font_size_fallback * 2;
}
Ok(size)
}
@ -72,11 +72,6 @@ impl WindowSize {
pub(crate) fn pixels_per_row(&self) -> f64 {
self.height as f64 / self.rows as f64
}
/// The aspect ratio for this size.
pub(crate) fn aspect_ratio(&self) -> f64 {
(self.rows as f64 / self.height as f64) / (self.columns as f64 / self.width as f64)
}
}
impl From<crossterm::terminal::WindowSize> for WindowSize {
@ -92,7 +87,7 @@ impl From<(u16, u16)> for WindowSize {
}
/// The cursor's position.
#[derive(Debug, Clone, Default, PartialEq)]
#[derive(Debug, Clone, Default)]
pub(crate) struct CursorPosition {
pub(crate) column: u16,
pub(crate) row: u16,

View File

@ -2,12 +2,14 @@ use crate::{
markdown::{
elements::Text,
text::{WeightedLine, WeightedText},
text_style::{Color, Colors, TextStyle},
text_style::{Color, Colors},
},
render::{RenderError, RenderResult, layout::Positioning},
terminal::printer::{TerminalCommand, TerminalIo},
terminal::{Terminal, TerminalWrite},
};
const MINIMUM_LINE_LENGTH: u16 = 10;
/// Draws text on the screen.
///
/// This deals with splitting words and doing word wrapping based on the given positioning.
@ -16,12 +18,11 @@ pub(crate) struct TextDrawer<'a> {
right_padding_length: u16,
line: &'a WeightedLine,
positioning: Positioning,
prefix_width: u16,
prefix_length: u16,
default_colors: &'a Colors,
draw_block: bool,
block_color: Option<Color>,
repeat_prefix: bool,
center_newlines: bool,
}
impl<'a> TextDrawer<'a> {
@ -31,18 +32,17 @@ impl<'a> TextDrawer<'a> {
line: &'a WeightedLine,
positioning: Positioning,
default_colors: &'a Colors,
minimum_line_length: u16,
) -> Result<Self, RenderError> {
let text_length = (line.width() + prefix.width() + right_padding_length as usize) as u16;
// If our line doesn't fit and it's just too small then abort
if text_length > positioning.max_line_length && positioning.max_line_length <= minimum_line_length {
if text_length > positioning.max_line_length && positioning.max_line_length <= MINIMUM_LINE_LENGTH {
Err(RenderError::TerminalTooSmall)
} else {
let prefix_width = prefix.width() as u16;
let prefix_length = prefix.width() as u16;
let positioning = Positioning {
max_line_length: positioning
.max_line_length
.saturating_sub(prefix_width)
.saturating_sub(prefix_length)
.saturating_sub(right_padding_length),
start_column: positioning.start_column,
};
@ -51,12 +51,11 @@ impl<'a> TextDrawer<'a> {
right_padding_length,
line,
positioning,
prefix_width,
prefix_length,
default_colors,
draw_block: false,
block_color: None,
repeat_prefix: false,
center_newlines: false,
})
}
}
@ -72,68 +71,53 @@ impl<'a> TextDrawer<'a> {
self
}
pub(crate) fn center_newlines(mut self, value: bool) -> Self {
self.center_newlines = value;
self
}
/// Draw text on the given handle.
///
/// This performs word splitting and word wrapping.
pub(crate) fn draw<T>(self, terminal: &mut T) -> RenderResult
pub(crate) fn draw<W>(self, terminal: &mut Terminal<W>) -> RenderResult
where
T: TerminalIo,
W: TerminalWrite,
{
let mut line_length: u16 = 0;
terminal.execute(&TerminalCommand::MoveToColumn(self.positioning.start_column))?;
let font_size = self.line.font_size();
// Print the prefix at the beginning of the line.
if self.prefix_width > 0 {
let styled_prefix = {
let Text { content, style } = self.prefix.text();
terminal.execute(&TerminalCommand::PrintText { content, style: *style })?;
}
style.apply(content)?
};
terminal.move_to_column(self.positioning.start_column)?;
terminal.print_styled_line(styled_prefix.clone())?;
let start_column = self.positioning.start_column + self.prefix_length;
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.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))?;
terminal.move_down(1)?;
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.prefix_length > 0 {
terminal.move_to_column(self.positioning.start_column)?;
if self.repeat_prefix {
let Text { content, style } = self.prefix.text();
terminal.execute(&TerminalCommand::PrintText { content, style: *style })?;
terminal.print_styled_line(styled_prefix.clone())?;
} else {
if let Some(color) = self.block_color {
terminal.execute(&TerminalCommand::SetBackgroundColor(color))?;
}
let text = " ".repeat(self.prefix_width as usize / font_size as usize);
let style = TextStyle::default().size(font_size);
terminal.execute(&TerminalCommand::PrintText { content: &text, style })?;
self.print_block_background(self.prefix_length, terminal)?;
}
}
}
terminal.move_to_column(start_column)?;
for chunk in line {
line_length = line_length.saturating_add(chunk.width() as u16);
let (text, style) = chunk.into_parts();
terminal.execute(&TerminalCommand::PrintText { content: text, style })?;
let text = style.apply(text)?;
terminal.print_styled_line(text)?;
// 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.execute(&TerminalCommand::SetColors(*self.default_colors))?;
terminal.set_colors(*self.default_colors)?;
}
}
}
@ -141,238 +125,21 @@ impl<'a> TextDrawer<'a> {
Ok(())
}
fn print_block_background<T>(&self, line_length: u16, terminal: &mut T) -> RenderResult
fn print_block_background<W>(&self, line_length: u16, terminal: &mut Terminal<W>) -> RenderResult
where
T: TerminalIo,
W: TerminalWrite,
{
if self.draw_block {
let remaining =
self.positioning.max_line_length.saturating_sub(line_length).saturating_add(self.right_padding_length);
if remaining > 0 {
let font_size = self.line.font_size();
if let Some(color) = self.block_color {
terminal.execute(&TerminalCommand::SetBackgroundColor(color))?;
terminal.set_background_color(color)?;
}
let text = " ".repeat(remaining as usize / font_size as usize);
let style = TextStyle::default().size(font_size);
terminal.execute(&TerminalCommand::PrintText { content: &text, style })?;
let text = " ".repeat(remaining as usize);
terminal.print_line(&text)?;
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::terminal::printer::TerminalError;
use std::io;
use unicode_width::UnicodeWidthStr;
#[derive(Debug, PartialEq)]
enum Instruction {
MoveDown(u16),
MoveToColumn(u16),
PrintText { content: String, font_size: u8 },
}
#[derive(Default)]
struct TerminalBuf {
instructions: Vec<Instruction>,
cursor_row: u16,
}
impl TerminalBuf {
fn push(&mut self, instruction: Instruction) -> io::Result<()> {
self.instructions.push(instruction);
Ok(())
}
fn move_to_column(&mut self, column: u16) -> std::io::Result<()> {
self.push(Instruction::MoveToColumn(column))
}
fn move_down(&mut self, amount: u16) -> std::io::Result<()> {
self.push(Instruction::MoveDown(amount))
}
fn print_text(&mut self, content: &str, style: &TextStyle) -> io::Result<()> {
let content = content.to_string();
if content.is_empty() {
return Ok(());
}
self.cursor_row = content.width() as u16;
self.push(Instruction::PrintText { content, font_size: style.size })?;
Ok(())
}
fn clear_screen(&mut self) -> std::io::Result<()> {
unimplemented!()
}
fn set_colors(&mut self, _colors: Colors) -> std::io::Result<()> {
Ok(())
}
fn set_background_color(&mut self, _color: Color) -> std::io::Result<()> {
Ok(())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl TerminalIo for TerminalBuf {
fn execute(&mut self, command: &TerminalCommand<'_>) -> Result<(), TerminalError> {
use TerminalCommand::*;
match command {
BeginUpdate
| EndUpdate
| MoveToRow(_)
| MoveToNextLine
| MoveTo { .. }
| MoveRight(_)
| MoveLeft(_)
| PrintImage { .. } => {
unimplemented!()
}
MoveToColumn(column) => self.move_to_column(*column)?,
MoveDown(amount) => self.move_down(*amount)?,
PrintText { content, style } => self.print_text(content, style)?,
ClearScreen => self.clear_screen()?,
SetColors(colors) => self.set_colors(*colors)?,
SetBackgroundColor(color) => self.set_background_color(*color)?,
Flush => self.flush()?,
};
Ok(())
}
fn cursor_row(&self) -> u16 {
self.cursor_row
}
}
struct TestDrawer {
prefix: WeightedText,
positioning: Positioning,
right_padding_length: u16,
repeat_prefix_on_wrap: bool,
center_newlines: bool,
}
impl TestDrawer {
fn prefix<T: Into<WeightedText>>(mut self, prefix: T) -> Self {
self.prefix = prefix.into();
self
}
fn start_column(mut self, column: u16) -> Self {
self.positioning.start_column = column;
self
}
fn max_line_length(mut self, length: u16) -> Self {
self.positioning.max_line_length = length;
self
}
fn repeat_prefix_on_wrap(mut self) -> Self {
self.repeat_prefix_on_wrap = true;
self
}
fn center_newlines(mut self) -> Self {
self.center_newlines = true;
self
}
fn draw<L: Into<WeightedLine>>(self, line: L) -> Vec<Instruction> {
let line = line.into();
let colors = Default::default();
let drawer = TextDrawer::new(&self.prefix, self.right_padding_length, &line, self.positioning, &colors, 0)
.expect("failed to create drawer")
.repeat_prefix_on_wrap(self.repeat_prefix_on_wrap)
.center_newlines(self.center_newlines);
let mut buf = TerminalBuf::default();
drawer.draw(&mut buf).expect("drawing failed");
buf.instructions
}
}
impl Default for TestDrawer {
fn default() -> Self {
Self {
prefix: WeightedText::from(""),
positioning: Positioning { max_line_length: 100, start_column: 0 },
right_padding_length: 0,
repeat_prefix_on_wrap: false,
center_newlines: false,
}
}
}
#[test]
fn prefix_on_long_line() {
let instructions = TestDrawer::default().prefix("P").max_line_length(3).start_column(1).draw("AAAA");
let expected = &[
Instruction::MoveToColumn(1),
Instruction::PrintText { content: "P".into(), font_size: 1 },
Instruction::PrintText { content: "AA".into(), font_size: 1 },
Instruction::MoveDown(1),
Instruction::MoveToColumn(1),
Instruction::PrintText { content: " ".into(), font_size: 1 },
Instruction::PrintText { content: "AA".into(), font_size: 1 },
];
assert_eq!(instructions, expected);
}
#[test]
fn prefix_on_long_line_with_font_size() {
let text = WeightedLine::from(vec![Text::new("AAAA", TextStyle::default().size(2))]);
let prefix = WeightedText::from(Text::new("P", TextStyle::default().size(2)));
let instructions = TestDrawer::default().prefix(prefix).max_line_length(6).start_column(1).draw(text);
let expected = &[
Instruction::MoveToColumn(1),
Instruction::PrintText { content: "P".into(), font_size: 2 },
Instruction::PrintText { content: "AA".into(), font_size: 2 },
Instruction::MoveDown(2),
Instruction::MoveToColumn(1),
Instruction::PrintText { content: " ".into(), font_size: 2 },
Instruction::PrintText { content: "AA".into(), font_size: 2 },
];
assert_eq!(instructions, expected);
}
#[test]
fn prefix_on_long_line_with_font_size_and_repeat_prefix() {
let text = WeightedLine::from(vec![Text::new("AAAA", TextStyle::default().size(2))]);
let prefix = WeightedText::from(Text::new("P", TextStyle::default().size(2)));
let instructions =
TestDrawer::default().prefix(prefix).max_line_length(6).start_column(1).repeat_prefix_on_wrap().draw(text);
let expected = &[
Instruction::MoveToColumn(1),
Instruction::PrintText { content: "P".into(), font_size: 2 },
Instruction::PrintText { content: "AA".into(), font_size: 2 },
Instruction::MoveDown(2),
Instruction::MoveToColumn(1),
Instruction::PrintText { content: "P".into(), font_size: 2 },
Instruction::PrintText { content: "AA".into(), font_size: 2 },
];
assert_eq!(instructions, expected);
}
#[test]
fn center_newlines() {
let text = WeightedLine::from(vec![Text::from("hello world foo")]);
let instructions = TestDrawer::default().center_newlines().max_line_length(11).draw(text);
let expected = &[
Instruction::MoveToColumn(0),
Instruction::PrintText { content: "hello world".into(), font_size: 1 },
Instruction::MoveDown(1),
Instruction::MoveToColumn(4),
Instruction::PrintText { content: "foo".into(), font_size: 1 },
];
assert_eq!(instructions, expected);
}
}

View File

@ -1,16 +1,14 @@
use crate::{
terminal::image::{
Image,
printer::{ImageRegistry, ImageSpec, RegisterImageError},
printer::{ImageRegistry, RegisterImageError},
},
theme::{raw::PresentationTheme, registry::LoadThemeError},
theme::{LoadThemeError, PresentationTheme},
};
use std::{
cell::RefCell,
collections::HashMap,
fs, io, mem,
path::{Path, PathBuf},
rc::Rc,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
@ -22,119 +20,100 @@ use std::{
const LOOP_INTERVAL: Duration = Duration::from_millis(250);
#[derive(Debug)]
struct ResourcesInner {
themes: HashMap<PathBuf, PresentationTheme>,
external_snippets: HashMap<PathBuf, String>,
base_path: PathBuf,
themes_path: PathBuf,
image_registry: ImageRegistry,
watcher: FileWatcherHandle,
}
/// Manages resources pulled from the filesystem such as images.
///
/// All resources are cached so once a specific resource is loaded, looking it up with the same
/// path will involve an in-memory lookup.
#[derive(Clone, Debug)]
pub struct Resources {
inner: Rc<RefCell<ResourcesInner>>,
base_path: PathBuf,
images: HashMap<PathBuf, Image>,
themes: HashMap<PathBuf, PresentationTheme>,
external_snippets: HashMap<PathBuf, String>,
image_registry: ImageRegistry,
watcher: FileWatcherHandle,
}
impl Resources {
/// Construct a new resource manager over the provided based path.
///
/// Any relative paths will be assumed to be relative to the given base.
pub fn new<P1, P2>(base_path: P1, themes_path: P2, image_registry: ImageRegistry) -> Self
where
P1: Into<PathBuf>,
P2: Into<PathBuf>,
{
pub fn new<P: Into<PathBuf>>(base_path: P, image_registry: ImageRegistry) -> Self {
let watcher = FileWatcher::spawn();
let inner = ResourcesInner {
Self {
base_path: base_path.into(),
themes_path: themes_path.into(),
images: Default::default(),
themes: Default::default(),
external_snippets: Default::default(),
image_registry,
watcher,
};
Self { inner: Rc::new(RefCell::new(inner)) }
}
}
pub(crate) fn watch_presentation_file(&self, path: PathBuf) {
let inner = self.inner.borrow();
inner.watcher.send(WatchEvent::WatchFile { path, watch_forever: true });
self.watcher.send(WatchEvent::WatchFile { path, watch_forever: true });
}
/// Get the image at the given path.
pub(crate) fn image<P: AsRef<Path>>(&self, path: P) -> Result<Image, RegisterImageError> {
let inner = self.inner.borrow();
let path = inner.base_path.join(path);
let image = inner.image_registry.register(ImageSpec::Filesystem(path.clone()))?;
Ok(image)
}
pub(crate) fn image<P: AsRef<Path>>(&mut self, path: P) -> Result<Image, LoadImageError> {
let path = self.base_path.join(path);
if let Some(image) = self.images.get(&path) {
return Ok(image.clone());
}
pub(crate) fn theme_image<P: AsRef<Path>>(&self, path: P) -> Result<Image, RegisterImageError> {
match self.image(&path) {
Ok(image) => return Ok(image),
Err(RegisterImageError::Io(e)) if e.kind() != io::ErrorKind::NotFound => return Err(e.into()),
_ => (),
};
let inner = self.inner.borrow();
let path = inner.themes_path.join(path);
let image = inner.image_registry.register(ImageSpec::Filesystem(path.clone()))?;
let image = self.image_registry.register_resource(path.clone())?;
self.images.insert(path, image.clone());
Ok(image)
}
/// Get the theme at the given path.
pub(crate) fn theme<P: AsRef<Path>>(&self, path: P) -> Result<PresentationTheme, LoadThemeError> {
let mut inner = self.inner.borrow_mut();
let path = inner.base_path.join(path);
if let Some(theme) = inner.themes.get(&path) {
pub(crate) fn theme<P: AsRef<Path>>(&mut self, path: P) -> Result<PresentationTheme, LoadThemeError> {
let path = self.base_path.join(path);
if let Some(theme) = self.themes.get(&path) {
return Ok(theme.clone());
}
let theme = PresentationTheme::from_path(&path)?;
inner.themes.insert(path, theme.clone());
self.themes.insert(path, theme.clone());
Ok(theme)
}
/// Get the external snippet at the given path.
pub(crate) fn external_snippet<P: AsRef<Path>>(&self, path: P) -> io::Result<String> {
let mut inner = self.inner.borrow_mut();
let path = inner.base_path.join(path);
if let Some(contents) = inner.external_snippets.get(&path) {
pub(crate) fn external_snippet<P: AsRef<Path>>(&mut self, path: P) -> io::Result<String> {
let path = self.base_path.join(path);
if let Some(contents) = self.external_snippets.get(&path) {
return Ok(contents.clone());
}
let contents = fs::read_to_string(&path)?;
inner.watcher.send(WatchEvent::WatchFile { path: path.clone(), watch_forever: false });
inner.external_snippets.insert(path, contents.clone());
self.watcher.send(WatchEvent::WatchFile { path: path.clone(), watch_forever: false });
self.external_snippets.insert(path, contents.clone());
Ok(contents)
}
pub(crate) fn resources_modified(&self) -> bool {
let mut inner = self.inner.borrow_mut();
inner.watcher.has_modifications()
pub(crate) fn resources_modified(&mut self) -> bool {
self.watcher.has_modifications()
}
pub(crate) fn clear_watches(&self) {
let mut inner = self.inner.borrow_mut();
inner.watcher.send(WatchEvent::ClearWatches);
pub(crate) fn clear_watches(&mut self) {
self.watcher.send(WatchEvent::ClearWatches);
// We could do better than this but this works for now.
inner.external_snippets.clear();
self.external_snippets.clear();
}
/// Clears all resources.
pub(crate) fn clear(&self) {
let mut inner = self.inner.borrow_mut();
inner.image_registry.clear();
inner.themes.clear();
pub(crate) fn clear(&mut self) {
self.images.clear();
self.themes.clear();
}
}
/// An error loading an image.
#[derive(thiserror::Error, Debug)]
pub enum LoadImageError {
#[error(transparent)]
RegisterImage(#[from] RegisterImageError),
}
/// Watches for file changes.
///
/// This uses polling rather than something fancier like `inotify`. The latter turned out to make
@ -211,7 +190,6 @@ struct WatchMetadata {
watch_forever: bool,
}
#[derive(Debug)]
struct FileWatcherHandle {
sender: Sender<WatchEvent>,
modifications: Arc<AtomicBool>,

View File

@ -3,61 +3,114 @@ use crate::markdown::{
text::WeightedLine,
text_style::{Color, TextStyle},
};
use std::mem;
use vte::{ParamsIter, Parser, Perform};
use ansi_parser::{AnsiParser, AnsiSequence, Output};
pub(crate) struct AnsiSplitter {
starting_style: TextStyle,
lines: Vec<WeightedLine>,
current_line: Line,
current_style: TextStyle,
}
impl AnsiSplitter {
pub(crate) fn new(current_style: TextStyle) -> Self {
Self { starting_style: current_style }
Self { lines: Default::default(), current_line: Default::default(), current_style }
}
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;
pub(crate) fn split_lines(mut self, lines: &[String]) -> (Vec<WeightedLine>, TextStyle) {
for line in lines {
let mut handler = Handler::new(style);
let mut parser = Parser::new();
parser.advance(&mut handler, line.as_ref().as_bytes());
let (line, ending_style) = handler.into_parts();
output_lines.push(line.into());
style = ending_style;
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)
}
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();
}
_ => (),
}
(output_lines, style)
}
}
struct Handler {
line: Line,
pending_text: Text,
style: TextStyle,
}
struct GraphicsCode<'a>(&'a [u8]);
impl Handler {
fn new(style: TextStyle) -> Self {
Self { line: Default::default(), pending_text: Default::default(), style }
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);
}
}
_ => (),
};
}
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 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 parse_standard_color(value: u16) -> Option<Color> {
fn as_standard_color(value: u8) -> Option<Color> {
let color = match value {
0 | 8 => Color::Black,
1 | 9 => Color::Red,
@ -71,204 +124,4 @@ impl Handler {
};
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()]);
}
}

View File

@ -1,9 +1,7 @@
use super::{GraphicsMode, capabilities::TerminalCapabilities, image::protocols::kitty::KittyMode};
use std::{env, sync::OnceLock};
use super::{GraphicsMode, image::protocols::kitty::KittyMode, query::TerminalCapabilities};
use std::env;
use strum::IntoEnumIterator;
static CAPABILITIES: OnceLock<TerminalCapabilities> = OnceLock::new();
#[derive(Debug, strum::EnumIter)]
pub enum TerminalEmulator {
Iterm2,
@ -32,16 +30,8 @@ impl TerminalEmulator {
TerminalEmulator::Unknown
}
pub(crate) fn capabilities() -> TerminalCapabilities {
CAPABILITIES.get_or_init(|| TerminalCapabilities::query().unwrap_or_default()).clone()
}
pub(crate) fn disable_capability_detection() {
CAPABILITIES.get_or_init(TerminalCapabilities::default);
}
pub fn preferred_protocol(&self) -> GraphicsMode {
let capabilities = Self::capabilities();
let capabilities = TerminalCapabilities::query().unwrap_or_default();
let modes = [
GraphicsMode::Iterm2,
GraphicsMode::Kitty { mode: KittyMode::Local, inside_tmux: capabilities.tmux },

View File

@ -1,59 +1,23 @@
use self::printer::{ImageProperties, TerminalImage};
use image::DynamicImage;
use protocols::ascii::AsciiImage;
use std::{
fmt::Debug,
ops::Deref,
path::PathBuf,
sync::{Arc, Mutex},
};
use std::{fmt::Debug, ops::Deref, path::PathBuf, sync::Arc};
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 {
inner: Arc<Inner>,
pub(crate) image: Arc<TerminalImage>,
pub(crate) source: ImageSource,
}
impl Image {
/// Constructs a new image.
pub(crate) fn new(image: TerminalImage, source: ImageSource) -> Self {
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
Self { image: Arc::new(image), source }
}
}
@ -65,11 +29,19 @@ impl PartialEq for Image {
impl Debug for Image {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let (width, height) = self.inner.image.dimensions();
let (width, height) = self.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),

View File

@ -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},
terminal::{
GraphicsMode,
printer::{TerminalError, TerminalIo},
},
render::properties::CursorPosition,
terminal::GraphicsMode,
};
use image::{DynamicImage, ImageError};
use std::{
borrow::Cow,
collections::HashMap,
fmt, io,
path::PathBuf,
sync::{Arc, Mutex},
path::{Path, PathBuf},
sync::Arc,
};
pub(crate) trait PrintImage {
type Image: ImageProperties;
/// Register an image.
fn register(&self, spec: ImageSpec) -> Result<Self::Image, RegisterImageError>;
fn register(&self, image: DynamicImage) -> Result<Self::Image, RegisterImageError>;
fn print<T>(&self, image: &Self::Image, options: &PrintOptions, terminal: &mut T) -> Result<(), PrintImageError>
/// 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>
where
T: TerminalIo;
W: io::Write;
}
pub(crate) trait ImageProperties {
fn dimensions(&self) -> (u32, u32);
}
#[derive(Clone, Debug, PartialEq)]
#[derive(Debug)]
pub(crate) struct PrintOptions {
pub(crate) columns: u16,
pub(crate) rows: u16,
pub(crate) cursor_position: CursorPosition,
pub(crate) z_index: i32,
pub(crate) background_color: Option<Color>,
// Width/height in pixels.
@ -55,7 +55,6 @@ pub(crate) enum TerminalImage {
Kitty(KittyImage),
Iterm(ItermImage),
Ascii(AsciiImage),
Raw(RawImage),
#[cfg(feature = "sixel")]
Sixel(super::protocols::sixel::SixelImage),
}
@ -66,7 +65,6 @@ 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(),
}
@ -77,7 +75,6 @@ pub enum ImagePrinter {
Kitty(KittyPrinter),
Iterm(ItermPrinter),
Ascii(AsciiPrinter),
Raw(RawPrinter),
Null,
#[cfg(feature = "sixel")]
Sixel(super::protocols::sixel::SixelPrinter),
@ -95,7 +92,6 @@ 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()?,
};
@ -107,17 +103,13 @@ impl ImagePrinter {
}
fn new_iterm() -> Self {
Self::Iterm(ItermPrinter)
Self::Iterm(ItermPrinter::default())
}
fn new_ascii() -> Self {
Self::Ascii(AsciiPrinter)
}
fn new_raw() -> Self {
Self::Raw(RawPrinter)
}
#[cfg(feature = "sixel")]
fn new_sixel() -> Result<Self, CreatePrinterError> {
Ok(Self::Sixel(super::protocols::sixel::SixelPrinter::new()?))
@ -127,56 +119,56 @@ impl ImagePrinter {
impl PrintImage for ImagePrinter {
type Image = TerminalImage;
fn register(&self, spec: ImageSpec) -> Result<Self::Image, RegisterImageError> {
fn register(&self, image: DynamicImage) -> Result<Self::Image, RegisterImageError> {
let image = match self {
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::Kitty(printer) => TerminalImage::Kitty(printer.register(image)?),
Self::Iterm(printer) => TerminalImage::Iterm(printer.register(image)?),
Self::Ascii(printer) => TerminalImage::Ascii(printer.register(image)?),
Self::Null => return Err(RegisterImageError::Unsupported),
Self::Raw(printer) => TerminalImage::Raw(printer.register(spec)?),
#[cfg(feature = "sixel")]
Self::Sixel(printer) => TerminalImage::Sixel(printer.register(spec)?),
Self::Sixel(printer) => TerminalImage::Sixel(printer.register(image)?),
};
Ok(image)
}
fn print<T>(&self, image: &Self::Image, options: &PrintOptions, terminal: &mut T) -> Result<(), PrintImageError>
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>
where
T: TerminalIo,
W: io::Write,
{
match (self, image) {
(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::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::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, terminal),
(Self::Sixel(printer), TerminalImage::Sixel(image)) => printer.print(image, options, writer),
_ => Err(PrintImageError::Unsupported),
}
}
}
#[derive(Clone, Default)]
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() }
}
}
pub(crate) struct ImageRegistry(pub Arc<ImagePrinter>);
impl fmt::Debug for ImageRegistry {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let inner = match self.printer.as_ref() {
let inner = match self.0.as_ref() {
ImagePrinter::Kitty(_) => "Kitty",
ImagePrinter::Iterm(_) => "Iterm",
ImagePrinter::Ascii(_) => "Ascii",
ImagePrinter::Null => "Null",
ImagePrinter::Raw(_) => "Raw",
#[cfg(feature = "sixel")]
ImagePrinter::Sixel(_) => "Sixel",
};
@ -185,36 +177,19 @@ impl fmt::Debug for ImageRegistry {
}
impl ImageRegistry {
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());
}
pub(crate) fn register_image(&self, image: DynamicImage) -> Result<Image, RegisterImageError> {
let resource = self.0.register(image)?;
let image = Image::new(resource, ImageSource::Generated);
Ok(image)
}
pub(crate) fn clear(&self) {
self.images.lock().unwrap().clear();
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) enum ImageSpec {
Generated(DynamicImage),
Filesystem(PathBuf),
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum CreatePrinterError {
#[error("io: {0}")]
@ -242,15 +217,6 @@ 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)]

View File

@ -1,54 +1,36 @@
use crate::{
markdown::text_style::{Color, Colors, TextStyle},
terminal::{
image::printer::{ImageProperties, ImageSpec, PrintImage, PrintImageError, PrintOptions, RegisterImageError},
printer::{TerminalCommand, TerminalIo},
},
use crate::terminal::image::printer::{ImageProperties, PrintImage, PrintImageError, PrintOptions, RegisterImageError};
use crossterm::{
QueueableCommand,
cursor::{MoveRight, MoveToColumn},
style::{Color, Stylize},
};
use image::{DynamicImage, GenericImageView, Pixel, Rgba, RgbaImage, imageops::FilterType};
use image::{DynamicImage, GenericImageView, Pixel, Rgba, imageops::FilterType};
use itertools::Itertools;
use std::{
collections::HashMap,
fs,
sync::{Arc, Mutex},
};
use std::{fs, ops::Deref};
const TOP_CHAR: &str = "";
const BOTTOM_CHAR: &str = "";
const TOP_CHAR: char = '▀';
const BOTTOM_CHAR: char = '▄';
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());
}
}
}
pub(crate) struct AsciiImage(DynamicImage);
impl ImageProperties for AsciiImage {
fn dimensions(&self) -> (u32, u32) {
self.inner.image.dimensions()
self.0.dimensions()
}
}
impl From<DynamicImage> for AsciiImage {
fn from(image: DynamicImage) -> Self {
let image = image.into_rgba8();
let inner = Inner { image: image.into(), cached_sizes: Default::default() };
Self { inner: Arc::new(inner) }
Self(image.into())
}
}
impl Deref for AsciiImage {
type Target = DynamicImage;
fn deref(&self) -> &Self::Target {
&self.0
}
}
@ -82,36 +64,32 @@ impl AsciiPrinter {
impl PrintImage for AsciiPrinter {
type Image = AsciiImage;
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(&self, image: image::DynamicImage) -> Result<Self::Image, RegisterImageError> {
Ok(AsciiImage(image))
}
fn print<T>(&self, image: &Self::Image, options: &PrintOptions, terminal: &mut T) -> Result<(), PrintImageError>
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>
where
T: TerminalIo,
W: std::io::Write,
{
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);
// The strategy here is taken from viuer: use half vertical ascii blocks in combination
// with foreground/background colors to fit 2 vertical pixels per cell. That is, cell (x, y)
// will contain the pixels at (x, y) and (x, y + 1) combined.
let image = image.0.resize_exact(options.columns as u32, 2 * options.rows as u32, FilterType::Triangle);
let image = image.into_rgba8();
let default_background = options.background_color.map(Color::try_from).transpose()?;
// 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 {
@ -123,26 +101,36 @@ 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));
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),
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))?;
}
};
terminal.execute(&command)?;
}
terminal.execute(&TerminalCommand::MoveDown(1))?;
terminal.execute(&TerminalCommand::MoveLeft(options.columns))?;
writeln!(writer)?;
}
Ok(())
}
}
trait StylizeExt: Stylize {
fn maybe_on(self, color: Option<Color>) -> Self::Styled;
}
impl<T: Stylize> StylizeExt for T {
fn maybe_on(self, color: Option<Color>) -> Self::Styled {
match color {
Some(background) => self.on(background),
None => self.stylize(),
}
}
}

View File

@ -1,10 +1,7 @@
use crate::terminal::{
image::printer::{ImageProperties, ImageSpec, PrintImage, PrintImageError, PrintOptions, RegisterImageError},
printer::{TerminalCommand, TerminalIo},
};
use crate::terminal::image::printer::{ImageProperties, PrintImage, PrintImageError, PrintOptions, RegisterImageError};
use base64::{Engine, engine::general_purpose::STANDARD};
use image::{GenericImageView, ImageEncoder, RgbaImage, codecs::png::PngEncoder};
use std::fs;
use image::{GenericImageView, ImageEncoder, codecs::png::PngEncoder};
use std::{env, fs, path::Path};
pub(crate) struct ItermImage {
dimensions: (u32, u32),
@ -18,12 +15,6 @@ 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 {
@ -32,41 +23,58 @@ impl ImageProperties for ItermImage {
}
}
#[derive(Default)]
pub struct ItermPrinter;
pub struct ItermPrinter {
// Whether this is iterm2. Otherwise it can be a terminal that _supports_ the iterm2 protocol.
is_iterm: bool,
}
impl Default for ItermPrinter {
fn default() -> Self {
for key in ["TERM_PROGRAM", "LC_TERMINAL"] {
if let Ok(value) = env::var(key) {
if value.contains("iTerm") {
return Self { is_iterm: true };
}
}
}
Self { is_iterm: false }
}
}
impl PrintImage for ItermPrinter {
type Image = ItermImage;
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(&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 print<T>(&self, image: &Self::Image, options: &PrintOptions, terminal: &mut T) -> Result<(), PrintImageError>
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>
where
T: TerminalIo,
W: std::io::Write,
{
let size = image.raw_length;
let columns = options.columns;
let rows = options.rows;
let contents = &image.base64_contents;
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() })?;
write!(
writer,
"\x1b]1337;File=size={size};width={columns};height={rows};inline=1;preserveAspectRatio=0:{contents}\x07"
)?;
// iterm2 really respects what we say and leaves no space, whereas wezterm does leave an
// extra line here.
if self.is_iterm {
writeln!(writer)?;
}
Ok(())
}
}

View File

@ -1,16 +1,15 @@
use crate::{
markdown::text_style::{Color, TextStyle},
terminal::{
image::printer::{ImageProperties, ImageSpec, PrintImage, PrintImageError, PrintOptions, RegisterImageError},
printer::{TerminalCommand, TerminalIo},
},
markdown::text_style::Color,
terminal::image::printer::{ImageProperties, PrintImage, PrintImageError, PrintOptions, RegisterImageError},
};
use base64::{Engine, engine::general_purpose::STANDARD};
use image::{AnimationDecoder, Delay, EncodableLayout, ImageReader, RgbaImage, codecs::gif::GifDecoder};
use crossterm::{QueueableCommand, cursor::MoveToColumn, style::SetForegroundColor};
use image::{AnimationDecoder, Delay, DynamicImage, EncodableLayout, ImageReader, RgbaImage, codecs::gif::GifDecoder};
use rand::Rng;
use std::{
fmt,
fs::{self, File},
io::{self, BufReader},
io::{self, BufReader, Write},
path::{Path, PathBuf},
sync::atomic::{AtomicU32, Ordering},
};
@ -73,25 +72,6 @@ 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
@ -165,18 +145,18 @@ impl KittyPrinter {
}
fn generate_image_id() -> u32 {
fastrand::u32(1..u32::MAX)
rand::thread_rng().gen_range(1..u32::MAX)
}
fn print_image<T>(
fn print_image<W>(
&self,
dimensions: (u32, u32),
buffer: &KittyBuffer,
terminal: &mut T,
writer: &mut W,
print_options: &PrintOptions,
) -> Result<(), PrintImageError>
where
T: TerminalIo,
W: io::Write,
{
let mut options = vec![
ControlOption::Format(ImageFormat::Rgba),
@ -195,25 +175,25 @@ impl KittyPrinter {
}
match &buffer {
KittyBuffer::Filesystem(path) => self.print_local(options, path, terminal)?,
KittyBuffer::Memory(buffer) => self.print_remote(options, buffer, terminal, false)?,
KittyBuffer::Filesystem(path) => self.print_local(options, path, writer)?,
KittyBuffer::Memory(buffer) => self.print_remote(options, buffer, writer, false)?,
};
if self.tmux {
self.print_unicode_placeholders(terminal, print_options, image_id)?;
self.print_unicode_placeholders(writer, print_options, image_id)?;
}
Ok(())
}
fn print_gif<T>(
fn print_gif<W>(
&self,
dimensions: (u32, u32),
frames: &[GifFrame<KittyBuffer>],
terminal: &mut T,
writer: &mut W,
print_options: &PrintOptions,
) -> Result<(), PrintImageError>
where
T: TerminalIo,
W: io::Write,
{
let image_id = Self::generate_image_id();
for (frame_id, frame) in frames.iter().enumerate() {
@ -243,8 +223,8 @@ impl KittyPrinter {
let is_frame = frame_id > 0;
match &frame.buffer {
KittyBuffer::Filesystem(path) => self.print_local(options, path, terminal)?,
KittyBuffer::Memory(buffer) => self.print_remote(options, buffer, terminal, is_frame)?,
KittyBuffer::Filesystem(path) => self.print_local(options, path, writer)?,
KittyBuffer::Memory(buffer) => self.print_remote(options, buffer, writer, is_frame)?,
};
if frame_id == 0 {
@ -254,8 +234,8 @@ impl KittyPrinter {
ControlOption::FrameId(1),
ControlOption::Loops(1),
];
let command = self.make_command(options, "").to_string();
terminal.execute(&TerminalCommand::PrintText { content: &command, style: Default::default() })?;
let command = self.make_command(options, "");
write!(writer, "{command}")?;
} else if frame_id == 1 {
let options = &[
ControlOption::Action(Action::Animate),
@ -263,12 +243,12 @@ impl KittyPrinter {
ControlOption::FrameId(1),
ControlOption::AnimationState(2),
];
let command = self.make_command(options, "").to_string();
terminal.execute(&TerminalCommand::PrintText { content: &command, style: Default::default() })?;
let command = self.make_command(options, "");
write!(writer, "{command}")?;
}
}
if self.tmux {
self.print_unicode_placeholders(terminal, print_options, image_id)?;
self.print_unicode_placeholders(writer, print_options, image_id)?;
}
let options = &[
ControlOption::Action(Action::Animate),
@ -278,8 +258,8 @@ impl KittyPrinter {
ControlOption::Loops(1),
ControlOption::Quiet(2),
];
let command = self.make_command(options, "").to_string();
terminal.execute(&TerminalCommand::PrintText { content: &command, style: Default::default() })?;
let command = self.make_command(options, "");
write!(writer, "{command}")?;
Ok(())
}
@ -287,14 +267,14 @@ impl KittyPrinter {
ControlCommand { options, payload, tmux: self.tmux }
}
fn print_local<T>(
fn print_local<W>(
&self,
mut options: Vec<ControlOption>,
path: &Path,
terminal: &mut T,
writer: &mut W,
) -> Result<(), PrintImageError>
where
T: TerminalIo,
W: io::Write,
{
let Some(path) = path.to_str() else {
return Err(PrintImageError::other("path is not valid utf8"));
@ -302,20 +282,20 @@ impl KittyPrinter {
let encoded_path = STANDARD.encode(path);
options.push(ControlOption::Medium(TransmissionMedium::LocalFile));
let command = self.make_command(&options, &encoded_path).to_string();
terminal.execute(&TerminalCommand::PrintText { content: &command, style: Default::default() })?;
let command = self.make_command(&options, &encoded_path);
write!(writer, "{command}")?;
Ok(())
}
fn print_remote<T>(
fn print_remote<W>(
&self,
mut options: Vec<ControlOption>,
frame: &[u8],
terminal: &mut T,
writer: &mut W,
is_frame: bool,
) -> Result<(), PrintImageError>
where
T: TerminalIo,
W: io::Write,
{
options.push(ControlOption::Medium(TransmissionMedium::Direct));
@ -331,8 +311,8 @@ impl KittyPrinter {
options.push(ControlOption::MoreData(more));
let payload = &payload[start..end];
let command = self.make_command(&options, payload).to_string();
terminal.execute(&TerminalCommand::PrintText { content: &command, style: Default::default() })?;
let command = self.make_command(&options, payload);
write!(writer, "{command}")?;
options.clear();
if is_frame {
@ -342,17 +322,14 @@ impl KittyPrinter {
Ok(())
}
fn print_unicode_placeholders<T>(
fn print_unicode_placeholders<W: Write>(
&self,
terminal: &mut T,
writer: &mut W,
options: &PrintOptions,
image_id: u32,
) -> Result<(), PrintImageError>
where
T: TerminalIo,
{
) -> Result<(), PrintImageError> {
let color = Color::new((image_id >> 16) as u8, (image_id >> 8) as u8, image_id as u8);
let style = TextStyle::default().fg_color(color);
writer.queue(SetForegroundColor(color.try_into()?))?;
if options.rows.max(options.columns) >= DIACRITICS.len() as u16 {
return Err(PrintImageError::other("image is too large to fit in tmux"));
}
@ -362,13 +339,12 @@ 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();
let content = format!("{IMAGE_PLACEHOLDER}{row_diacritic}{column_diacritic}{last_byte}");
terminal.execute(&TerminalCommand::PrintText { content: &content, style })?;
write!(writer, "{IMAGE_PLACEHOLDER}{row_diacritic}{column_diacritic}{last_byte}")?;
}
if row != options.rows - 1 {
terminal.execute(&TerminalCommand::MoveDown(1))?;
writeln!(writer)?;
}
terminal.execute(&TerminalCommand::MoveLeft(options.columns))?;
writer.queue(MoveToColumn(options.cursor_position.column))?;
}
Ok(())
}
@ -395,26 +371,35 @@ impl KittyPrinter {
impl PrintImage for KittyPrinter {
type Image = KittyImage;
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)?,
};
fn register(&self, image: DynamicImage) -> Result<Self::Image, RegisterImageError> {
let resource = RawResource::Image(image.into_rgba8());
let resource = match &self.mode {
KittyMode::Local => self.persist_resource(image)?,
KittyMode::Remote => image.into_memory_resource(),
KittyMode::Local => self.persist_resource(resource)?,
KittyMode::Remote => resource.into_memory_resource(),
};
Ok(resource)
}
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, terminal, options)?,
GenericResource::Gif(frames) => self.print_gif(image.dimensions, frames, terminal, options)?,
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> {
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)?,
};
writeln!(writer)?;
Ok(())
}
}

View File

@ -1,6 +1,5 @@
pub(crate) mod ascii;
pub(crate) mod iterm;
pub(crate) mod kitty;
pub(crate) mod raw;
#[cfg(feature = "sixel")]
pub(crate) mod sixel;

View File

@ -1,61 +0,0 @@
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()))
}
}

View File

@ -1,25 +1,16 @@
use crate::terminal::{
image::printer::{
CreatePrinterError, ImageProperties, ImageSpec, PrintImage, PrintImageError, PrintOptions, RegisterImageError,
},
printer::{TerminalCommand, TerminalIo},
use crate::terminal::image::printer::{
CreatePrinterError, ImageProperties, PrintImage, PrintImageError, PrintOptions, RegisterImageError,
};
use image::{DynamicImage, GenericImageView, RgbaImage, imageops::FilterType};
use image::{DynamicImage, GenericImageView, imageops::FilterType};
use sixel_rs::{
encoder::{Encoder, QuickFrameBuilder},
optflags::EncodePolicy,
sys::PixelFormat,
};
use std::fs;
use std::{fs, io};
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()
@ -38,23 +29,22 @@ impl SixelPrinter {
impl PrintImage for SixelPrinter {
type Image = SixelImage;
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(&self, image: image::DynamicImage) -> Result<Self::Image, RegisterImageError> {
Ok(SixelImage(image))
}
fn print<T>(&self, image: &Self::Image, options: &PrintOptions, terminal: &mut T) -> Result<(), PrintImageError>
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>
where
T: TerminalIo,
W: io::Write,
{
// We're already positioned in the right place but we may not have flushed that yet.
terminal.execute(&TerminalCommand::Flush)?;
writer.flush()?;
let encoder = Encoder::new().map_err(|e| PrintImageError::other(format!("creating sixel encoder: {e:?}")))?;
encoder

View File

@ -1,125 +1,64 @@
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;
/// Scale an image to a specific size.
pub(crate) fn scale_image(
scale_size: &WindowSize,
window_dimensions: &WindowSize,
image_width: u32,
image_height: u32,
position: &CursorPosition,
) -> TerminalRect {
let aspect_ratio = image_height as f64 / image_width as f64;
let column_in_pixels = scale_size.pixels_per_column();
let width_in_columns = scale_size.columns;
let image_width = width_in_columns as f64 * column_in_pixels;
let image_height = image_width * aspect_ratio;
/// 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;
fit_image_to_window(window_dimensions, image_width as u32, image_height as u32, position)
}
pub(crate) struct ImageScaler {
horizontal_margin: f64,
}
/// Shrink an image so it fits the dimensions of the layout it's being displayed in.
pub(crate) fn fit_image_to_window(
dimensions: &WindowSize,
image_width: u32,
image_height: u32,
position: &CursorPosition,
) -> TerminalRect {
let aspect_ratio = image_height as f64 / image_width as f64;
impl ScaleImage for ImageScaler {
fn scale_image(
&self,
scale_size: &WindowSize,
window_dimensions: &WindowSize,
image_width: u32,
image_height: u32,
position: &CursorPosition,
) -> TerminalRect {
let aspect_ratio = image_height as f64 / image_width as f64;
let column_in_pixels = scale_size.pixels_per_column();
let width_in_columns = scale_size.columns;
let image_width = width_in_columns as f64 * column_in_pixels;
let image_height = image_width * aspect_ratio;
// Compute the image's width in columns by translating pixels -> columns.
let column_in_pixels = dimensions.pixels_per_column();
let column_margin = (dimensions.columns as f64 * 0.95) as u32;
let mut width_in_columns = (image_width as f64 / column_in_pixels) as u32;
self.fit_image_to_rect(window_dimensions, image_width as u32, image_height as u32, position)
// Do the same for its height.
let row_in_pixels = dimensions.pixels_per_row();
let height_in_rows = (image_height as f64 / row_in_pixels) as u32;
// If the image doesn't fit vertically, shrink it.
let available_height = dimensions.rows.saturating_sub(position.row) as u32;
if height_in_rows > available_height {
// Because we only use the width to draw, here we scale the width based on how much we
// need to shrink the height.
let shrink_ratio = available_height as f64 / height_in_rows as f64;
width_in_columns = (width_in_columns as f64 * shrink_ratio).ceil() as u32;
}
// Don't go too far wide.
let width_in_columns = width_in_columns.min(column_margin);
let height_in_rows = (width_in_columns as f64 * aspect_ratio / 2.0) as u16;
fn fit_image_to_rect(
&self,
dimensions: &WindowSize,
image_width: u32,
image_height: u32,
position: &CursorPosition,
) -> TerminalRect {
let aspect_ratio = image_height as f64 / image_width as f64;
let width_in_columns = width_in_columns.max(1);
let height_in_rows = height_in_rows.max(1);
// Compute the image's width in columns by translating pixels -> columns.
let column_in_pixels = dimensions.pixels_per_column();
let column_margin = (dimensions.columns as f64 * (1.0 - self.horizontal_margin)) as u32;
let mut width_in_columns = (image_width as f64 / column_in_pixels) as u32;
// Do the same for its height.
let row_in_pixels = dimensions.pixels_per_row();
let height_in_rows = (image_height as f64 / row_in_pixels) as u32;
// If the image doesn't fit vertically, shrink it.
let available_height = dimensions.rows.saturating_sub(position.row) as u32;
if height_in_rows > available_height {
// Because we only use the width to draw, here we scale the width based on how much we
// need to shrink the height.
let shrink_ratio = available_height as f64 / height_in_rows as f64;
width_in_columns = (width_in_columns as f64 * shrink_ratio).round() as u32;
}
// Don't go too far wide.
let width_in_columns = width_in_columns.min(column_margin);
// Now translate width -> height by using the original aspect ratio + translate based on
// the window size's aspect ratio.
let height_in_rows = (width_in_columns as f64 * aspect_ratio * dimensions.aspect_ratio()).round() as u16;
let width_in_columns = width_in_columns.max(1);
let height_in_rows = height_in_rows.max(1);
TerminalRect { columns: width_in_columns as u16, rows: height_in_rows }
}
// Draw it in the middle
let start_column = dimensions.columns / 2 - (width_in_columns / 2) as u16;
let start_column = start_column + position.column;
TerminalRect { start_column, columns: width_in_columns as u16, rows: height_in_rows }
}
impl Default for ImageScaler {
fn default() -> Self {
Self { horizontal_margin: 0.05 }
}
}
#[derive(Debug, PartialEq)]
#[derive(Debug)]
pub(crate) struct TerminalRect {
pub(crate) start_column: u16,
pub(crate) columns: u16,
pub(crate) rows: u16,
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
const WINDOW: WindowSize = WindowSize { rows: 50, columns: 100, height: 200, width: 200 };
const SMALL_WINDOW: WindowSize = WindowSize { rows: 3, columns: 6, height: 10, width: 10 };
const OTHER_RATIO: WindowSize = WindowSize { rows: 10, columns: 10, height: 10, width: 10 };
#[rstest]
#[case::squares(WINDOW, 100, 100, TerminalRect { columns: 50, rows: 25 })]
#[case::squares_smaller(WINDOW, 50, 50, TerminalRect { columns: 25, rows: 13 })]
#[case::square_too_large(WINDOW, 400, 400, TerminalRect { columns: 100, rows: 50 })]
#[case::too_tall(WINDOW, 200, 400, TerminalRect { columns: 50, rows: 50 })]
#[case::too_wide(WINDOW, 400, 200, TerminalRect { columns: 100, rows: 25 })]
#[case::small(SMALL_WINDOW, 899, 872, TerminalRect { columns: 6, rows: 3 })]
#[case::other_ratio(OTHER_RATIO, 100, 100, TerminalRect { columns: 10, rows: 10 })]
fn image_fitting(
#[case] window: WindowSize,
#[case] width: u32,
#[case] height: u32,
#[case] expected: TerminalRect,
) {
let cursor = CursorPosition::default();
let rect = ImageScaler { horizontal_margin: 0.0 }.fit_image_to_rect(&window, width, height, &cursor);
assert_eq!(rect, expected);
}
}

View File

@ -1,9 +1,8 @@
pub(crate) mod ansi;
pub(crate) mod capabilities;
pub(crate) mod emulator;
pub(crate) mod image;
pub(crate) mod printer;
pub(crate) mod virt;
pub(crate) mod query;
pub(crate) use printer::{Terminal, TerminalWrite, should_hide_cursor};
@ -15,7 +14,6 @@ pub enum GraphicsMode {
inside_tmux: bool,
},
AsciiBlocks,
Raw,
#[cfg(feature = "sixel")]
Sixel,
}

View File

@ -1,12 +1,13 @@
use crate::{
markdown::text_style::{Color, Colors, TextStyle},
markdown::text_style::{Color, Colors},
terminal::image::{
Image,
printer::{ImagePrinter, PrintImage, PrintImageError, PrintOptions},
},
};
use crossterm::{
QueueableCommand, cursor, style,
QueueableCommand, cursor,
style::{self, StyledContent},
terminal::{self},
};
use std::{
@ -14,139 +15,98 @@ 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 execute(&mut self, command: &TerminalCommand<'_>) -> Result<(), TerminalError>;
fn cursor_row(&self) -> u16;
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum TerminalError {
#[error("io: {0}")]
Io(#[from] io::Error),
#[error("image: {0}")]
Image(#[from] PrintImageError),
}
/// A wrapper over the terminal write handle.
pub(crate) struct Terminal<I: TerminalWrite> {
writer: I,
pub(crate) struct Terminal<W>
where
W: TerminalWrite,
{
writer: W,
image_printer: Arc<ImagePrinter>,
cursor_row: u16,
current_row_height: u16,
pub(crate) cursor_row: u16,
}
impl<I: TerminalWrite> Terminal<I> {
pub(crate) fn new(mut writer: I, image_printer: Arc<ImagePrinter>) -> io::Result<Self> {
impl<W: TerminalWrite> Terminal<W> {
pub(crate) fn new(mut writer: W, image_printer: Arc<ImagePrinter>) -> io::Result<Self> {
writer.init()?;
Ok(Self { writer, image_printer, cursor_row: 0, current_row_height: 1 })
Ok(Self { writer, image_printer, cursor_row: 0 })
}
fn begin_update(&mut self) -> io::Result<()> {
pub(crate) fn begin_update(&mut self) -> io::Result<()> {
self.writer.queue(terminal::BeginSynchronizedUpdate)?;
Ok(())
}
fn end_update(&mut self) -> io::Result<()> {
pub(crate) fn end_update(&mut self) -> io::Result<()> {
self.writer.queue(terminal::EndSynchronizedUpdate)?;
Ok(())
}
fn move_to(&mut self, column: u16, row: u16) -> io::Result<()> {
pub(crate) fn move_to(&mut self, column: u16, row: u16) -> io::Result<()> {
self.writer.queue(cursor::MoveTo(column, row))?;
self.cursor_row = row;
Ok(())
}
fn move_to_row(&mut self, row: u16) -> io::Result<()> {
pub(crate) fn move_to_row(&mut self, row: u16) -> io::Result<()> {
self.writer.queue(cursor::MoveToRow(row))?;
self.cursor_row = row;
Ok(())
}
fn move_to_column(&mut self, column: u16) -> io::Result<()> {
pub(crate) fn move_to_column(&mut self, column: u16) -> io::Result<()> {
self.writer.queue(cursor::MoveToColumn(column))?;
Ok(())
}
fn move_down(&mut self, amount: u16) -> io::Result<()> {
pub(crate) fn move_down(&mut self, amount: u16) -> io::Result<()> {
self.writer.queue(cursor::MoveDown(amount))?;
self.cursor_row += amount;
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;
pub(crate) fn move_to_next_line(&mut self, amount: u16) -> io::Result<()> {
self.writer.queue(cursor::MoveToNextLine(amount))?;
self.cursor_row += amount;
self.current_row_height = 1;
Ok(())
}
fn print_text(&mut self, content: &str, style: &TextStyle) -> io::Result<()> {
let content = style.apply(content);
pub(crate) fn print_line(&mut self, text: &str) -> io::Result<()> {
self.writer.queue(style::Print(text))?;
Ok(())
}
pub(crate) fn print_styled_line(&mut self, content: StyledContent<String>) -> io::Result<()> {
self.writer.queue(style::PrintStyledContent(content))?;
self.current_row_height = self.current_row_height.max(style.size as u16);
Ok(())
}
fn clear_screen(&mut self) -> io::Result<()> {
pub(crate) 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(())
}
fn set_colors(&mut self, colors: Colors) -> io::Result<()> {
let colors = colors.into();
pub(crate) fn set_colors(&mut self, colors: Colors) -> io::Result<()> {
let colors = colors.try_into().map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
self.writer.queue(style::ResetColor)?;
self.writer.queue(style::SetColors(colors))?;
Ok(())
}
fn set_background_color(&mut self, color: Color) -> io::Result<()> {
let color = color.into();
pub(crate) fn set_background_color(&mut self, color: Color) -> io::Result<()> {
let color = color.try_into().map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
self.writer.queue(style::SetBackgroundColor(color))?;
Ok(())
}
fn flush(&mut self) -> io::Result<()> {
pub(crate) fn flush(&mut self) -> io::Result<()> {
self.writer.flush()?;
Ok(())
}
fn print_image(&mut self, image: &Image, options: &PrintOptions) -> Result<(), PrintImageError> {
let image_printer = self.image_printer.clone();
image_printer.print(image.image(), options, self)?;
pub(crate) fn print_image(&mut self, image: &Image, options: &PrintOptions) -> Result<(), PrintImageError> {
self.move_to_column(options.cursor_position.column)?;
self.image_printer.print(&image.image, options, &mut self.writer)?;
self.cursor_row += options.rows;
Ok(())
}
@ -160,35 +120,10 @@ impl<I: TerminalWrite> Terminal<I> {
}
}
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> {
impl<W> Drop for Terminal<W>
where
W: TerminalWrite,
{
fn drop(&mut self) {
self.writer.deinit();
}
@ -208,7 +143,7 @@ fn is_windows_based_os() -> bool {
is_windows || is_wsl
}
pub(crate) trait TerminalWrite: io::Write {
pub trait TerminalWrite: io::Write {
fn init(&mut self) -> io::Result<()>;
fn deinit(&mut self);
}

View File

@ -1,31 +1,19 @@
use super::image::protocols::kitty::{Action, ControlCommand, ControlOption, ImageFormat, TransmissionMedium};
use base64::{Engine, engine::general_purpose::STANDARD};
use crossterm::{
QueueableCommand,
cursor::{self},
style::Print,
terminal,
};
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
use image::{DynamicImage, EncodableLayout};
use std::{
env,
io::{self, Write},
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
thread,
time::Duration,
};
use tempfile::NamedTempFile;
#[derive(Default, Debug, Clone)]
#[derive(Default, Debug)]
pub(crate) struct TerminalCapabilities {
pub(crate) kitty_local: bool,
pub(crate) kitty_remote: bool,
pub(crate) sixel: bool,
pub(crate) tmux: bool,
pub(crate) font_size: bool,
}
impl TerminalCapabilities {
@ -45,7 +33,7 @@ impl TerminalCapabilities {
};
let encoded_path = STANDARD.encode(path);
let base_image_id = fastrand::u32(0..=u32::MAX);
let base_image_id = rand::random();
let ids = KittyImageIds { local: base_image_id, remote: base_image_id.wrapping_add(1) };
Self::write_kitty_local_query(ids.local, encoded_path, tmux)?;
Self::write_kitty_remote_query(ids.remote, image_bytes, tmux)?;
@ -58,34 +46,8 @@ impl TerminalCapabilities {
write!(stdout, "{start}{sequence}[c{end}")?;
stdout.flush()?;
// Spawn a thread to "save us" in case we don't get an answer from the terminal.
let running = Arc::new(AtomicBool::new(true));
Self::launch_timeout_trigger(running.clone());
let response = Self::build_capabilities(ids);
running.store(false, Ordering::Relaxed);
let mut response = response?;
response.tmux = tmux;
Ok(response)
}
fn build_capabilities(ids: KittyImageIds) -> io::Result<TerminalCapabilities> {
let mut response = Self::parse_response(io::stdin(), ids)?;
// Use kitty's font size protocol to write 1 character using size 2. If after writing the
// cursor has moves 2 columns, the protocol is supported.
let mut stdout = io::stdout();
stdout.queue(terminal::EnterAlternateScreen)?;
stdout.queue(cursor::MoveTo(0, 0))?;
stdout.queue(Print("\x1b]66;s=2; \x1b\\"))?;
stdout.flush()?;
let position = cursor::position()?;
if position.0 == 2 {
response.font_size = true;
}
stdout.queue(terminal::LeaveAlternateScreen)?;
stdout.flush()?;
response.tmux = tmux;
Ok(response)
}
@ -141,42 +103,24 @@ impl TerminalCapabilities {
capabilities.sixel = sixel;
return Ok(capabilities);
}
Response::StatusReport => {
return Ok(capabilities);
}
}
}
}
}
fn launch_timeout_trigger(running: Arc<AtomicBool>) {
// Spawn a thread that will wait a second and if we still are running, will request the
// device status report straight from whoever is on top of us (tmux or terminal if no
// tmux), which will cause it to answer and wake up our main thread that's reading on
// stdin.
thread::spawn(move || {
thread::sleep(Duration::from_secs(1));
if !running.load(Ordering::Relaxed) {
return;
}
let _ = write!(io::stdout(), "\x1b[5n");
let _ = io::stdout().flush();
});
}
}
struct RawModeGuard;
impl RawModeGuard {
fn new() -> io::Result<Self> {
terminal::enable_raw_mode()?;
enable_raw_mode()?;
Ok(Self)
}
}
impl Drop for RawModeGuard {
fn drop(&mut self) {
let _ = terminal::disable_raw_mode();
let _ = disable_raw_mode();
}
}
@ -198,9 +142,6 @@ impl QueryParseState {
("[", '?') => {
self.current = ResponseType::Capabilities;
}
("[", '0') => {
self.current = ResponseType::StatusReport;
}
("_Gi", '=') => {
self.current = ResponseType::Kitty;
}
@ -222,18 +163,11 @@ impl QueryParseState {
'c' => {
let mut caps = self.data[2..].split(';');
let sixel = caps.any(|cap| cap == "4");
*self = Default::default();
return Some(Response::Capabilities { sixel });
}
_ => self.data.push(next),
},
ResponseType::StatusReport => match next {
'n' => {
*self = Default::default();
return Some(Response::StatusReport);
}
_ => self.data.push(next),
},
};
None
}
@ -255,13 +189,11 @@ enum ResponseType {
Unknown,
Kitty,
Capabilities,
StatusReport,
}
enum Response {
KittySupported { image_id: u32 },
Capabilities { sixel: bool },
StatusReport,
}
struct KittyImageIds {

View File

@ -1,319 +0,0 @@
use super::{
image::{
Image,
printer::{PrintImage, PrintImageError, PrintOptions},
protocols::ascii::AsciiPrinter,
},
printer::{TerminalError, TerminalIo},
};
use crate::{
WindowSize,
markdown::{
elements::Text,
text_style::{Color, Colors, TextStyle},
},
terminal::printer::TerminalCommand,
};
use std::{collections::HashMap, io};
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct PrintedImage {
pub(crate) image: Image,
pub(crate) width_columns: u16,
}
pub(crate) struct TerminalRowIterator<'a> {
row: &'a [StyledChar],
}
impl<'a> TerminalRowIterator<'a> {
pub(crate) fn new(row: &'a [StyledChar]) -> Self {
Self { row }
}
}
impl Iterator for TerminalRowIterator<'_> {
type Item = Text;
fn next(&mut self) -> Option<Self::Item> {
let style = self.row.first()?.style;
let mut output = String::new();
while let Some(c) = self.row.first() {
if c.style != style {
break;
}
output.push(c.character);
self.row = &self.row[1..];
}
Some(Text::new(output, style))
}
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct TerminalGrid {
pub(crate) rows: Vec<Vec<StyledChar>>,
pub(crate) background_color: Option<Color>,
pub(crate) images: HashMap<(u16, u16), PrintedImage>,
}
pub(crate) struct VirtualTerminal {
row: u16,
column: u16,
colors: Colors,
rows: Vec<Vec<StyledChar>>,
background_color: Option<Color>,
images: HashMap<(u16, u16), PrintedImage>,
row_heights: Vec<u16>,
image_behavior: ImageBehavior,
}
impl VirtualTerminal {
pub(crate) fn new(dimensions: WindowSize, image_behavior: ImageBehavior) -> Self {
let rows = vec![vec![StyledChar::default(); dimensions.columns as usize]; dimensions.rows as usize];
let row_heights = vec![1; dimensions.rows as usize];
Self {
row: 0,
column: 0,
colors: Default::default(),
rows,
background_color: None,
images: Default::default(),
row_heights,
image_behavior,
}
}
pub(crate) fn into_contents(self) -> TerminalGrid {
TerminalGrid { rows: self.rows, background_color: self.background_color, images: self.images }
}
fn current_cell_mut(&mut self) -> Option<&mut StyledChar> {
self.rows.get_mut(self.row as usize).and_then(|row| row.get_mut(self.column as usize))
}
fn set_current_row_height(&mut self, height: u16) {
if let Some(current) = self.row_heights.get_mut(self.row as usize) {
*current = height;
}
}
fn current_row_height(&self) -> u16 {
*self.row_heights.get(self.row as usize).unwrap_or(&1)
}
fn move_to(&mut self, column: u16, row: u16) -> io::Result<()> {
self.column = column;
self.row = row;
Ok(())
}
fn move_to_row(&mut self, row: u16) -> io::Result<()> {
self.row = row;
self.set_current_row_height(1);
Ok(())
}
fn move_to_column(&mut self, column: u16) -> io::Result<()> {
self.column = column;
Ok(())
}
fn move_down(&mut self, amount: u16) -> io::Result<()> {
self.row += amount;
Ok(())
}
fn move_right(&mut self, amount: u16) -> io::Result<()> {
self.column += amount;
Ok(())
}
fn move_left(&mut self, amount: u16) -> io::Result<()> {
self.column = self.column.saturating_sub(amount);
Ok(())
}
fn move_to_next_line(&mut self) -> io::Result<()> {
let amount = self.current_row_height();
self.row += amount;
self.column = 0;
self.set_current_row_height(1);
Ok(())
}
fn print_text(&mut self, content: &str, style: &TextStyle) -> io::Result<()> {
let style = style.merged(&TextStyle::default().colors(self.colors));
for c in content.chars() {
let Some(cell) = self.current_cell_mut() else {
continue;
};
cell.character = c;
cell.style = style;
self.column += style.size as u16;
}
let height = self.current_row_height().max(style.size as u16);
self.set_current_row_height(height);
Ok(())
}
fn clear_screen(&mut self) -> io::Result<()> {
for row in &mut self.rows {
for cell in row {
cell.character = ' ';
}
}
self.background_color = self.colors.background;
Ok(())
}
fn set_colors(&mut self, colors: crate::markdown::text_style::Colors) -> io::Result<()> {
self.colors = colors;
Ok(())
}
fn set_background_color(&mut self, color: Color) -> io::Result<()> {
self.colors.background = Some(color);
Ok(())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
fn print_image(&mut self, image: &Image, options: &PrintOptions) -> Result<(), PrintImageError> {
match &self.image_behavior {
ImageBehavior::Store => {
let key = (self.row, self.column);
let image = PrintedImage { image: image.clone(), width_columns: options.columns };
self.images.insert(key, image);
}
ImageBehavior::PrintAscii => {
let image = image.to_ascii();
let image_printer = AsciiPrinter;
image_printer.print(&image, options, self)?
}
};
Ok(())
}
}
impl TerminalIo for VirtualTerminal {
fn execute(&mut self, command: &TerminalCommand<'_>) -> Result<(), TerminalError> {
use TerminalCommand::*;
match command {
BeginUpdate | EndUpdate => (),
MoveTo { column, row } => self.move_to(*column, *row)?,
MoveToRow(row) => self.move_to_row(*row)?,
MoveToColumn(column) => self.move_to_column(*column)?,
MoveDown(amount) => self.move_down(*amount)?,
MoveRight(amount) => self.move_right(*amount)?,
MoveLeft(amount) => self.move_left(*amount)?,
MoveToNextLine => self.move_to_next_line()?,
PrintText { content, style } => self.print_text(content, style)?,
ClearScreen => self.clear_screen()?,
SetColors(colors) => self.set_colors(*colors)?,
SetBackgroundColor(color) => self.set_background_color(*color)?,
Flush => self.flush()?,
PrintImage { image, options } => self.print_image(image, options)?,
};
Ok(())
}
fn cursor_row(&self) -> u16 {
self.row
}
}
#[derive(Clone, Debug, Default)]
pub(crate) enum ImageBehavior {
#[default]
Store,
PrintAscii,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct StyledChar {
pub(crate) character: char,
pub(crate) style: TextStyle,
}
impl StyledChar {
#[cfg(test)]
pub(crate) fn new(character: char, style: TextStyle) -> Self {
Self { character, style }
}
}
impl From<char> for StyledChar {
fn from(character: char) -> Self {
Self { character, style: Default::default() }
}
}
impl Default for StyledChar {
fn default() -> Self {
Self { character: ' ', style: Default::default() }
}
}
#[cfg(test)]
mod tests {
use super::*;
trait TerminalGridExt {
fn assert_contents(&self, lines: &[&str]);
}
impl TerminalGridExt for TerminalGrid {
fn assert_contents(&self, lines: &[&str]) {
assert_eq!(self.rows.len(), lines.len());
for (line, expected) in self.rows.iter().zip(lines) {
let line: String = line.iter().map(|c| c.character).collect();
assert_eq!(line, *expected);
}
}
}
#[test]
fn text() {
let dimensions = WindowSize { rows: 2, columns: 3, height: 0, width: 0 };
let mut term = VirtualTerminal::new(dimensions, Default::default());
for c in "abc".chars() {
term.print_text(&c.to_string(), &Default::default()).expect("print failed");
}
term.move_to_next_line().unwrap();
term.print_text("A", &Default::default()).expect("print failed");
let grid = term.into_contents();
grid.assert_contents(&["abc", "A "]);
}
#[test]
fn movement() {
let dimensions = WindowSize { rows: 2, columns: 3, height: 0, width: 0 };
let mut term = VirtualTerminal::new(dimensions, Default::default());
term.print_text("A", &Default::default()).unwrap();
term.move_down(1).unwrap();
term.print_text("B", &Default::default()).unwrap();
term.move_to(2, 0).unwrap();
term.print_text("C", &Default::default()).unwrap();
term.move_to_row(1).unwrap();
term.move_to_column(2).unwrap();
term.print_text("D", &Default::default()).unwrap();
let grid = term.into_contents();
grid.assert_contents(&["A C", " BD"]);
}
#[test]
fn iterator() {
let row = &[
StyledChar { character: ' ', style: TextStyle::default() },
StyledChar { character: 'A', style: TextStyle::default() },
StyledChar { character: 'B', style: TextStyle::default().bold() },
StyledChar { character: 'C', style: TextStyle::default().bold() },
StyledChar { character: 'D', style: TextStyle::default() },
];
let texts: Vec<_> = TerminalRowIterator::new(row).collect();
assert_eq!(texts, &[Text::from(" A"), Text::new("BC", TextStyle::default().bold()), Text::from("D")]);
}
}

1194
src/theme.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,729 +0,0 @@
use super::{
AuthorPositioning, FooterTemplate, Margin,
raw::{self, RawColor},
};
use crate::{
markdown::text_style::{Color, Colors, TextStyle, UndefinedPaletteColorError},
resource::Resources,
terminal::image::{Image, printer::RegisterImageError},
};
use std::collections::BTreeMap;
const DEFAULT_CODE_HIGHLIGHT_THEME: &str = "base16-eighties.dark";
const DEFAULT_BLOCK_QUOTE_PREFIX: &str = "";
const DEFAULT_PROGRESS_BAR_CHAR: char = '█';
const DEFAULT_FOOTER_HEIGHT: u16 = 3;
const DEFAULT_TYPST_HORIZONTAL_MARGIN: u16 = 5;
const DEFAULT_TYPST_VERTICAL_MARGIN: u16 = 7;
const DEFAULT_MERMAID_THEME: &str = "default";
const DEFAULT_MERMAID_BACKGROUND: &str = "transparent";
#[derive(Clone, Debug, Default)]
pub(crate) struct ThemeOptions {
pub(crate) font_size_supported: bool,
}
impl ThemeOptions {
fn adjust_font_size(&self, font_size: Option<u8>) -> u8 {
if !self.font_size_supported { 1 } else { font_size.unwrap_or(1).clamp(1, 7) }
}
}
#[derive(Clone, Debug)]
pub(crate) struct PresentationTheme {
pub(crate) slide_title: SlideTitleStyle,
pub(crate) code: CodeBlockStyle,
pub(crate) execution_output: ExecutionOutputBlockStyle,
pub(crate) inline_code: InlineCodeStyle,
pub(crate) table: Alignment,
pub(crate) block_quote: BlockQuoteStyle,
pub(crate) alert: AlertStyle,
pub(crate) default_style: DefaultStyle,
pub(crate) headings: HeadingStyles,
pub(crate) intro_slide: IntroSlideStyle,
pub(crate) footer: FooterStyle,
pub(crate) typst: TypstStyle,
pub(crate) mermaid: MermaidStyle,
pub(crate) modals: ModalStyle,
pub(crate) palette: ColorPalette,
}
impl PresentationTheme {
pub(crate) fn new(
raw: &raw::PresentationTheme,
resources: &Resources,
options: &ThemeOptions,
) -> Result<Self, ProcessingThemeError> {
let raw::PresentationTheme {
slide_title,
code,
execution_output,
inline_code,
table,
block_quote,
alert,
default_style,
headings,
intro_slide,
footer,
typst,
mermaid,
modals,
palette,
extends: _,
} = raw;
let palette = ColorPalette::try_from(palette)?;
let default_style = DefaultStyle::new(default_style, &palette)?;
Ok(Self {
slide_title: SlideTitleStyle::new(slide_title, &palette, options)?,
code: CodeBlockStyle::new(code),
execution_output: ExecutionOutputBlockStyle::new(execution_output, &palette)?,
inline_code: InlineCodeStyle::new(inline_code, &palette)?,
table: table.clone().unwrap_or_default().into(),
block_quote: BlockQuoteStyle::new(block_quote, &palette)?,
alert: AlertStyle::new(alert, &palette)?,
default_style: default_style.clone(),
headings: HeadingStyles::new(headings, &palette, options)?,
intro_slide: IntroSlideStyle::new(intro_slide, &palette, options)?,
footer: FooterStyle::new(&footer.clone().unwrap_or_default(), &palette, resources)?,
typst: TypstStyle::new(typst, &palette)?,
mermaid: MermaidStyle::new(mermaid),
modals: ModalStyle::new(modals, &default_style, &palette)?,
palette,
})
}
pub(crate) fn alignment(&self, element: &ElementType) -> Alignment {
use ElementType::*;
match element {
SlideTitle => self.slide_title.alignment,
Heading1 => self.headings.h1.alignment,
Heading2 => self.headings.h2.alignment,
Heading3 => self.headings.h3.alignment,
Heading4 => self.headings.h4.alignment,
Heading5 => self.headings.h5.alignment,
Heading6 => self.headings.h6.alignment,
Paragraph => Default::default(),
PresentationTitle => self.intro_slide.title.alignment,
PresentationSubTitle => self.intro_slide.subtitle.alignment,
PresentationEvent => self.intro_slide.event.alignment,
PresentationLocation => self.intro_slide.location.alignment,
PresentationDate => self.intro_slide.date.alignment,
PresentationAuthor => self.intro_slide.author.alignment,
Table => self.table,
}
}
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum ProcessingThemeError {
#[error(transparent)]
Palette(#[from] UndefinedPaletteColorError),
#[error("palette cannot contain other palette colors")]
PaletteColorInPalette,
#[error("invalid footer image: {0}")]
FooterImage(RegisterImageError),
}
#[derive(Clone, Debug)]
pub(crate) struct SlideTitleStyle {
pub(crate) alignment: Alignment,
pub(crate) separator: bool,
pub(crate) padding_top: u8,
pub(crate) padding_bottom: u8,
pub(crate) style: TextStyle,
}
impl SlideTitleStyle {
fn new(
raw: &raw::SlideTitleStyle,
palette: &ColorPalette,
options: &ThemeOptions,
) -> Result<Self, ProcessingThemeError> {
let raw::SlideTitleStyle {
alignment,
separator,
padding_top,
padding_bottom,
colors,
bold,
italics,
underlined,
font_size,
} = raw;
let colors = colors.resolve(palette)?;
let mut style = TextStyle::colored(colors).size(options.adjust_font_size(*font_size));
if bold.unwrap_or_default() {
style = style.bold();
}
if italics.unwrap_or_default() {
style = style.italics();
}
if underlined.unwrap_or_default() {
style = style.underlined();
}
Ok(Self {
alignment: alignment.clone().unwrap_or_default().into(),
separator: *separator,
padding_top: padding_top.unwrap_or_default(),
padding_bottom: padding_bottom.unwrap_or_default(),
style,
})
}
}
#[derive(Clone, Debug)]
pub(crate) struct HeadingStyles {
pub(crate) h1: HeadingStyle,
pub(crate) h2: HeadingStyle,
pub(crate) h3: HeadingStyle,
pub(crate) h4: HeadingStyle,
pub(crate) h5: HeadingStyle,
pub(crate) h6: HeadingStyle,
}
impl HeadingStyles {
fn new(
raw: &raw::HeadingStyles,
palette: &ColorPalette,
options: &ThemeOptions,
) -> Result<Self, ProcessingThemeError> {
let raw::HeadingStyles { h1, h2, h3, h4, h5, h6 } = raw;
Ok(Self {
h1: HeadingStyle::new(h1, palette, options)?,
h2: HeadingStyle::new(h2, palette, options)?,
h3: HeadingStyle::new(h3, palette, options)?,
h4: HeadingStyle::new(h4, palette, options)?,
h5: HeadingStyle::new(h5, palette, options)?,
h6: HeadingStyle::new(h6, palette, options)?,
})
}
}
#[derive(Clone, Debug)]
pub(crate) struct HeadingStyle {
pub(crate) alignment: Alignment,
pub(crate) prefix: Option<String>,
pub(crate) style: TextStyle,
}
impl HeadingStyle {
fn new(
raw: &raw::HeadingStyle,
palette: &ColorPalette,
options: &ThemeOptions,
) -> Result<Self, ProcessingThemeError> {
let raw::HeadingStyle { alignment, prefix, colors, font_size } = raw;
let alignment = alignment.clone().unwrap_or_default().into();
let style = TextStyle::colored(colors.resolve(palette)?).size(options.adjust_font_size(*font_size));
Ok(Self { alignment, prefix: prefix.clone(), style })
}
}
#[derive(Clone, Debug)]
pub(crate) struct BlockQuoteStyle {
pub(crate) alignment: Alignment,
pub(crate) prefix: String,
pub(crate) base_style: TextStyle,
pub(crate) prefix_style: TextStyle,
}
impl BlockQuoteStyle {
fn new(raw: &raw::BlockQuoteStyle, palette: &ColorPalette) -> Result<Self, ProcessingThemeError> {
let raw::BlockQuoteStyle { alignment, prefix, colors } = raw;
let alignment = alignment.clone().unwrap_or_default().into();
let prefix = prefix.as_deref().unwrap_or(DEFAULT_BLOCK_QUOTE_PREFIX).to_string();
let base_style = TextStyle::colored(colors.base.resolve(palette)?);
let mut prefix_style = TextStyle::colored(colors.base.resolve(palette)?);
if let Some(color) = &colors.prefix {
prefix_style.colors.foreground = color.resolve(palette)?;
}
Ok(Self { alignment, prefix, base_style, prefix_style })
}
}
#[derive(Clone, Debug)]
pub(crate) struct AlertStyle {
pub(crate) alignment: Alignment,
pub(crate) base_style: TextStyle,
pub(crate) prefix: String,
pub(crate) styles: AlertTypeStyles,
}
impl AlertStyle {
fn new(raw: &raw::AlertStyle, palette: &ColorPalette) -> Result<Self, ProcessingThemeError> {
let raw::AlertStyle { alignment, base_colors, prefix, styles } = raw;
let alignment = alignment.clone().unwrap_or_default().into();
let base_style = TextStyle::colored(base_colors.resolve(palette)?);
let prefix = prefix.as_deref().unwrap_or(DEFAULT_BLOCK_QUOTE_PREFIX).to_string();
let styles = AlertTypeStyles::new(styles, base_style, palette)?;
Ok(Self { alignment, base_style, prefix, styles })
}
}
#[derive(Clone, Debug)]
pub(crate) struct AlertTypeStyles {
pub(crate) note: AlertTypeStyle,
pub(crate) tip: AlertTypeStyle,
pub(crate) important: AlertTypeStyle,
pub(crate) warning: AlertTypeStyle,
pub(crate) caution: AlertTypeStyle,
}
impl AlertTypeStyles {
fn new(
raw: &raw::AlertTypeStyles,
base_style: TextStyle,
palette: &ColorPalette,
) -> Result<Self, ProcessingThemeError> {
let raw::AlertTypeStyles { note, tip, important, warning, caution } = raw;
Ok(Self {
note: AlertTypeStyle::new(
note,
&AlertTypeDefaults { title: "Note", icon: "󰋽", color: Color::Blue },
base_style,
palette,
)?,
tip: AlertTypeStyle::new(
tip,
&AlertTypeDefaults { title: "Tip", icon: "", color: Color::Green },
base_style,
palette,
)?,
important: AlertTypeStyle::new(
important,
&AlertTypeDefaults { title: "Important", icon: "", color: Color::Cyan },
base_style,
palette,
)?,
warning: AlertTypeStyle::new(
warning,
&AlertTypeDefaults { title: "Warning", icon: "", color: Color::Yellow },
base_style,
palette,
)?,
caution: AlertTypeStyle::new(
caution,
&AlertTypeDefaults { title: "Caution", icon: "󰳦", color: Color::Red },
base_style,
palette,
)?,
})
}
}
#[derive(Clone, Debug)]
pub(crate) struct AlertTypeStyle {
pub(crate) style: TextStyle,
pub(crate) title: String,
pub(crate) icon: String,
}
impl AlertTypeStyle {
fn new(
raw: &raw::AlertTypeStyle,
defaults: &AlertTypeDefaults,
base_style: TextStyle,
palette: &ColorPalette,
) -> Result<Self, ProcessingThemeError> {
let raw::AlertTypeStyle { color, title, icon, .. } = raw;
let color = color.as_ref().map(|c| c.resolve(palette)).transpose()?.flatten().unwrap_or(defaults.color);
let style = base_style.fg_color(color);
let title = title.as_deref().unwrap_or(defaults.title).to_string();
let icon = icon.as_deref().unwrap_or(defaults.icon).to_string();
Ok(Self { style, title, icon })
}
}
struct AlertTypeDefaults {
title: &'static str,
icon: &'static str,
color: Color,
}
#[derive(Clone, Debug)]
pub(crate) struct IntroSlideStyle {
pub(crate) title: IntroSlideTitleStyle,
pub(crate) subtitle: IntroSlideLabelStyle,
pub(crate) event: IntroSlideLabelStyle,
pub(crate) location: IntroSlideLabelStyle,
pub(crate) date: IntroSlideLabelStyle,
pub(crate) author: AuthorStyle,
pub(crate) footer: bool,
}
impl IntroSlideStyle {
fn new(
raw: &raw::IntroSlideStyle,
palette: &ColorPalette,
options: &ThemeOptions,
) -> Result<Self, ProcessingThemeError> {
let raw::IntroSlideStyle { title, subtitle, event, location, date, author, footer } = raw;
Ok(Self {
title: IntroSlideTitleStyle::new(title, palette, options)?,
subtitle: IntroSlideLabelStyle::new(subtitle, palette)?,
event: IntroSlideLabelStyle::new(event, palette)?,
location: IntroSlideLabelStyle::new(location, palette)?,
date: IntroSlideLabelStyle::new(date, palette)?,
author: AuthorStyle::new(author, palette)?,
footer: footer.unwrap_or(false),
})
}
}
#[derive(Clone, Debug, Default)]
pub(crate) struct IntroSlideLabelStyle {
pub(crate) alignment: Alignment,
pub(crate) style: TextStyle,
}
impl IntroSlideLabelStyle {
fn new(raw: &raw::BasicStyle, palette: &ColorPalette) -> Result<Self, ProcessingThemeError> {
let raw::BasicStyle { alignment, colors } = raw;
let style = TextStyle::colored(colors.resolve(palette)?);
Ok(Self { alignment: alignment.clone().unwrap_or_default().into(), style })
}
}
#[derive(Clone, Debug, Default)]
pub(crate) struct IntroSlideTitleStyle {
pub(crate) alignment: Alignment,
pub(crate) style: TextStyle,
}
impl IntroSlideTitleStyle {
fn new(
raw: &raw::IntroSlideTitleStyle,
palette: &ColorPalette,
options: &ThemeOptions,
) -> Result<Self, ProcessingThemeError> {
let raw::IntroSlideTitleStyle { alignment, colors, font_size } = raw;
let style = TextStyle::colored(colors.resolve(palette)?).size(options.adjust_font_size(*font_size));
Ok(Self { alignment: alignment.clone().unwrap_or_default().into(), style })
}
}
#[derive(Clone, Debug, Default)]
pub(crate) struct AuthorStyle {
pub(crate) alignment: Alignment,
pub(crate) style: TextStyle,
pub(crate) positioning: AuthorPositioning,
}
impl AuthorStyle {
fn new(raw: &raw::AuthorStyle, palette: &ColorPalette) -> Result<Self, ProcessingThemeError> {
let raw::AuthorStyle { alignment, colors, positioning } = raw;
let style = TextStyle::colored(colors.resolve(palette)?);
Ok(Self { alignment: alignment.clone().unwrap_or_default().into(), style, positioning: positioning.clone() })
}
}
#[derive(Clone, Debug, Default)]
pub(crate) struct DefaultStyle {
pub(crate) margin: Margin,
pub(crate) style: TextStyle,
}
impl DefaultStyle {
fn new(raw: &raw::DefaultStyle, palette: &ColorPalette) -> Result<Self, ProcessingThemeError> {
let raw::DefaultStyle { margin, colors } = raw;
let margin = margin.unwrap_or_default();
let style = TextStyle::colored(colors.resolve(palette)?);
Ok(Self { margin, style })
}
}
#[derive(Copy, Clone, Debug, PartialEq)]
pub(crate) enum Alignment {
Left { margin: Margin },
Right { margin: Margin },
Center { minimum_margin: Margin, minimum_size: u16 },
}
impl Alignment {
pub(crate) fn adjust_size(&self, size: u16) -> u16 {
match self {
Self::Left { .. } | Self::Right { .. } => size,
Self::Center { minimum_size, .. } => size.max(*minimum_size),
}
}
}
impl From<raw::Alignment> for Alignment {
fn from(alignment: raw::Alignment) -> Self {
match alignment {
raw::Alignment::Left { margin } => Self::Left { margin },
raw::Alignment::Right { margin } => Self::Right { margin },
raw::Alignment::Center { minimum_margin, minimum_size } => Self::Center { minimum_margin, minimum_size },
}
}
}
impl Default for Alignment {
fn default() -> Self {
Self::Left { margin: Margin::Fixed(0) }
}
}
#[derive(Clone, Debug, Default)]
pub(crate) enum FooterStyle {
Template {
left: Option<FooterContent>,
center: Option<FooterContent>,
right: Option<FooterContent>,
style: TextStyle,
height: u16,
},
ProgressBar {
character: char,
style: TextStyle,
},
#[default]
Empty,
}
impl FooterStyle {
fn new(
raw: &raw::FooterStyle,
palette: &ColorPalette,
resources: &Resources,
) -> Result<Self, ProcessingThemeError> {
match raw {
raw::FooterStyle::Template { left, center, right, colors, height } => {
let left = left.as_ref().map(|t| FooterContent::new(t, resources)).transpose()?;
let center = center.as_ref().map(|t| FooterContent::new(t, resources)).transpose()?;
let right = right.as_ref().map(|t| FooterContent::new(t, resources)).transpose()?;
let style = TextStyle::colored(colors.resolve(palette)?);
let height = height.unwrap_or(DEFAULT_FOOTER_HEIGHT);
Ok(Self::Template { left, center, right, style, height })
}
raw::FooterStyle::ProgressBar { character, colors } => {
let character = character.unwrap_or(DEFAULT_PROGRESS_BAR_CHAR);
let style = TextStyle::colored(colors.resolve(palette)?);
Ok(Self::ProgressBar { character, style })
}
raw::FooterStyle::Empty => Ok(Self::Empty),
}
}
pub(crate) fn height(&self) -> u16 {
match self {
Self::Template { height, .. } => *height,
_ => DEFAULT_FOOTER_HEIGHT,
}
}
}
#[derive(Clone, Debug)]
pub(crate) enum FooterContent {
Template(FooterTemplate),
Image(Image),
}
impl FooterContent {
fn new(raw: &raw::FooterContent, resources: &Resources) -> Result<Self, ProcessingThemeError> {
match raw {
raw::FooterContent::Template(template) => Ok(Self::Template(template.clone())),
raw::FooterContent::Image { path } => {
let image = resources.theme_image(path).map_err(ProcessingThemeError::FooterImage)?;
Ok(Self::Image(image))
}
}
}
}
#[derive(Clone, Debug, Default)]
pub(crate) struct CodeBlockStyle {
pub(crate) alignment: Alignment,
pub(crate) padding: PaddingRect,
pub(crate) theme_name: String,
pub(crate) background: bool,
}
impl CodeBlockStyle {
fn new(raw: &raw::CodeBlockStyle) -> Self {
let raw::CodeBlockStyle { alignment, padding, theme_name, background } = raw;
let padding = PaddingRect {
horizontal: padding.horizontal.unwrap_or_default(),
vertical: padding.vertical.unwrap_or_default(),
};
Self {
alignment: alignment.clone().unwrap_or_default().into(),
padding,
theme_name: theme_name.as_deref().unwrap_or(DEFAULT_CODE_HIGHLIGHT_THEME).to_string(),
background: background.unwrap_or(true),
}
}
}
/// Vertical/horizontal padding.
#[derive(Clone, Debug, Default)]
pub(crate) struct PaddingRect {
/// The number of columns to use as horizontal padding.
pub(crate) horizontal: u8,
/// The number of rows to use as vertical padding.
pub(crate) vertical: u8,
}
#[derive(Clone, Debug, Default)]
pub(crate) struct ExecutionOutputBlockStyle {
pub(crate) style: TextStyle,
pub(crate) status: ExecutionStatusBlockStyle,
}
impl ExecutionOutputBlockStyle {
fn new(raw: &raw::ExecutionOutputBlockStyle, palette: &ColorPalette) -> Result<Self, ProcessingThemeError> {
let raw::ExecutionOutputBlockStyle { colors, status } = raw;
let colors = colors.resolve(palette)?;
let style = TextStyle::colored(colors);
Ok(Self { style, status: ExecutionStatusBlockStyle::new(status, palette)? })
}
}
#[derive(Clone, Debug, Default)]
pub(crate) struct ExecutionStatusBlockStyle {
pub(crate) running_style: TextStyle,
pub(crate) success_style: TextStyle,
pub(crate) failure_style: TextStyle,
pub(crate) not_started_style: TextStyle,
}
impl ExecutionStatusBlockStyle {
fn new(raw: &raw::ExecutionStatusBlockStyle, palette: &ColorPalette) -> Result<Self, ProcessingThemeError> {
let raw::ExecutionStatusBlockStyle { running, success, failure, not_started } = raw;
let running_style = TextStyle::colored(running.resolve(palette)?);
let success_style = TextStyle::colored(success.resolve(palette)?);
let failure_style = TextStyle::colored(failure.resolve(palette)?);
let not_started_style = TextStyle::colored(not_started.resolve(palette)?);
Ok(Self { running_style, success_style, failure_style, not_started_style })
}
}
#[derive(Clone, Debug, Default)]
pub(crate) struct InlineCodeStyle {
pub(crate) style: TextStyle,
}
impl InlineCodeStyle {
fn new(raw: &raw::InlineCodeStyle, palette: &ColorPalette) -> Result<Self, ProcessingThemeError> {
let raw::InlineCodeStyle { colors } = raw;
let style = TextStyle::colored(colors.resolve(palette)?);
Ok(Self { style })
}
}
#[derive(Clone, Debug)]
pub(crate) enum ElementType {
SlideTitle,
Heading1,
Heading2,
Heading3,
Heading4,
Heading5,
Heading6,
Paragraph,
PresentationTitle,
PresentationSubTitle,
PresentationEvent,
PresentationLocation,
PresentationDate,
PresentationAuthor,
Table,
}
#[derive(Clone, Debug)]
pub(crate) struct TypstStyle {
pub(crate) horizontal_margin: u16,
pub(crate) vertical_margin: u16,
pub(crate) style: TextStyle,
}
impl TypstStyle {
fn new(raw: &raw::TypstStyle, palette: &ColorPalette) -> Result<Self, ProcessingThemeError> {
let raw::TypstStyle { horizontal_margin, vertical_margin, colors } = raw;
let horizontal_margin = horizontal_margin.unwrap_or(DEFAULT_TYPST_HORIZONTAL_MARGIN);
let vertical_margin = vertical_margin.unwrap_or(DEFAULT_TYPST_VERTICAL_MARGIN);
let style = TextStyle::colored(colors.resolve(palette)?);
Ok(Self { horizontal_margin, vertical_margin, style })
}
}
#[derive(Clone, Debug)]
pub(crate) struct MermaidStyle {
pub(crate) theme: String,
pub(crate) background: String,
}
impl MermaidStyle {
fn new(raw: &raw::MermaidStyle) -> Self {
let raw::MermaidStyle { theme, background } = raw;
let theme = theme.as_deref().unwrap_or(DEFAULT_MERMAID_THEME).to_string();
let background = background.as_deref().unwrap_or(DEFAULT_MERMAID_BACKGROUND).to_string();
Self { theme, background }
}
}
#[derive(Clone, Debug)]
pub(crate) struct ModalStyle {
pub(crate) style: TextStyle,
pub(crate) selection_style: TextStyle,
}
impl ModalStyle {
fn new(
raw: &raw::ModalStyle,
default_style: &DefaultStyle,
palette: &ColorPalette,
) -> Result<Self, ProcessingThemeError> {
let raw::ModalStyle { colors, selection_colors } = raw;
let mut style = default_style.style;
style.merge(&TextStyle::colored(colors.resolve(palette)?));
let mut selection_style = style.bold();
selection_style.merge(&TextStyle::colored(selection_colors.resolve(palette)?));
Ok(Self { style, selection_style })
}
}
/// The color palette.
#[derive(Clone, Debug, Default)]
pub(crate) struct ColorPalette {
pub(crate) colors: BTreeMap<String, Color>,
pub(crate) classes: BTreeMap<String, Colors>,
}
impl TryFrom<&raw::ColorPalette> for ColorPalette {
type Error = ProcessingThemeError;
fn try_from(palette: &raw::ColorPalette) -> Result<Self, Self::Error> {
let mut colors = BTreeMap::new();
let mut classes = BTreeMap::new();
for (name, color) in &palette.colors {
let raw::RawColor::Color(color) = color else {
return Err(ProcessingThemeError::PaletteColorInPalette);
};
colors.insert(name.clone(), *color);
}
let resolve_local = |color: &RawColor| match color {
raw::RawColor::Color(c) => Ok(*c),
raw::RawColor::Palette(name) => colors
.get(name)
.copied()
.ok_or_else(|| ProcessingThemeError::Palette(UndefinedPaletteColorError(name.clone()))),
_ => Err(ProcessingThemeError::PaletteColorInPalette),
};
for (name, colors) in &palette.classes {
let foreground = colors.foreground.as_ref().map(resolve_local).transpose()?;
let background = colors.background.as_ref().map(resolve_local).transpose()?;
classes.insert(name.clone(), Colors { foreground, background });
}
Ok(Self { colors, classes })
}
}

View File

@ -1,6 +0,0 @@
pub(crate) mod clean;
pub(crate) mod raw;
pub(crate) mod registry;
pub(crate) use clean::*;
pub(crate) use raw::{AuthorPositioning, FooterTemplate, FooterTemplateChunk, Margin};

View File

@ -1,995 +0,0 @@
use super::registry::LoadThemeError;
use crate::markdown::text_style::{Color, Colors, UndefinedPaletteColorError};
use hex::{FromHex, FromHexError};
use serde::{Deserialize, Serialize, de::Visitor};
use std::{
collections::BTreeMap,
fmt, fs,
path::{Path, PathBuf},
str::FromStr,
};
pub(crate) type RawColors = Colors<RawColor>;
/// A presentation theme.
#[derive(Default, Clone, Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct PresentationTheme {
/// The theme this theme extends from.
#[serde(default)]
pub(crate) extends: Option<String>,
/// The style for a slide's title.
#[serde(default)]
pub(super) slide_title: SlideTitleStyle,
/// The style for a block of code.
#[serde(default)]
pub(super) code: CodeBlockStyle,
/// The style for the execution output of a piece of code.
#[serde(default)]
pub(super) execution_output: ExecutionOutputBlockStyle,
/// The style for inline code.
#[serde(default)]
pub(super) inline_code: InlineCodeStyle,
/// The style for a table.
#[serde(default)]
pub(super) table: Option<Alignment>,
/// The style for a block quote.
#[serde(default)]
pub(super) block_quote: BlockQuoteStyle,
/// The style for an alert.
#[serde(default)]
pub(super) alert: AlertStyle,
/// The default style.
#[serde(rename = "default", default)]
pub(super) default_style: DefaultStyle,
//// The style of all headings.
#[serde(default)]
pub(super) headings: HeadingStyles,
/// The style of the introduction slide.
#[serde(default)]
pub(super) intro_slide: IntroSlideStyle,
/// The style of the presentation footer.
#[serde(default)]
pub(super) footer: Option<FooterStyle>,
/// The style for typst auto-rendered code blocks.
#[serde(default)]
pub(super) typst: TypstStyle,
/// The style for mermaid auto-rendered code blocks.
#[serde(default)]
pub(super) mermaid: MermaidStyle,
/// The style for modals.
#[serde(default)]
pub(super) modals: ModalStyle,
/// The color palette.
#[serde(default)]
pub(super) palette: ColorPalette,
}
impl PresentationTheme {
/// Construct a presentation from a path.
pub(crate) fn from_path<P: AsRef<Path>>(path: P) -> Result<Self, LoadThemeError> {
let contents = fs::read_to_string(&path)?;
let theme = serde_yaml::from_str(&contents)
.map_err(|e| LoadThemeError::Corrupted(path.as_ref().display().to_string(), e.into()))?;
Ok(theme)
}
}
/// The style of a slide title.
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub(super) struct SlideTitleStyle {
/// The alignment.
#[serde(flatten, default)]
pub(super) alignment: Option<Alignment>,
/// Whether to use a separator line.
#[serde(default)]
pub(super) separator: bool,
/// The padding that should be added before the text.
#[serde(default)]
pub(super) padding_top: Option<u8>,
/// The padding that should be added after the text.
#[serde(default)]
pub(super) padding_bottom: Option<u8>,
/// The colors to be used.
#[serde(default)]
pub(super) colors: RawColors,
/// Whether to use bold font for slide titles.
#[serde(default)]
pub(super) bold: Option<bool>,
/// Whether to use italics font for slide titles.
#[serde(default)]
pub(super) italics: Option<bool>,
/// Whether to use underlined font for slide titles.
#[serde(default)]
pub(super) underlined: Option<bool>,
/// The font size to be used if the terminal supports it.
#[serde(default)]
pub(super) font_size: Option<u8>,
}
/// The style for all headings.
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub(super) struct HeadingStyles {
/// H1 style.
#[serde(default)]
pub(super) h1: HeadingStyle,
/// H2 style.
#[serde(default)]
pub(super) h2: HeadingStyle,
/// H3 style.
#[serde(default)]
pub(super) h3: HeadingStyle,
/// H4 style.
#[serde(default)]
pub(super) h4: HeadingStyle,
/// H5 style.
#[serde(default)]
pub(super) h5: HeadingStyle,
/// H6 style.
#[serde(default)]
pub(super) h6: HeadingStyle,
}
/// The style for a heading.
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub(super) struct HeadingStyle {
/// The alignment.
#[serde(flatten, default)]
pub(super) alignment: Option<Alignment>,
/// The prefix to be added to this heading.
///
/// This allows adding text like "->" to every heading.
#[serde(default)]
pub(super) prefix: Option<String>,
/// The colors to be used.
#[serde(default)]
pub(super) colors: RawColors,
/// The font size to be used if the terminal supports it.
#[serde(default)]
pub(super) font_size: Option<u8>,
}
/// The style of a block quote.
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub(super) struct BlockQuoteStyle {
/// The alignment.
#[serde(flatten, default)]
pub(super) alignment: Option<Alignment>,
/// The prefix to be added to this block quote.
///
/// This allows adding something like a vertical bar before the text.
#[serde(default)]
pub(super) prefix: Option<String>,
/// The colors to be used.
#[serde(default)]
pub(super) colors: BlockQuoteColors,
}
/// The colors of a block quote.
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub(super) struct BlockQuoteColors {
/// The foreground/background colors.
#[serde(flatten)]
pub(super) base: RawColors,
/// The color of the vertical bar that prefixes each line in the quote.
#[serde(default)]
pub(super) prefix: Option<RawColor>,
}
/// The style of an alert.
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub(super) struct AlertStyle {
/// The alignment.
#[serde(flatten, default)]
pub(super) alignment: Option<Alignment>,
/// The base colors.
#[serde(default)]
pub(super) base_colors: RawColors,
/// The prefix to be added to this block quote.
///
/// This allows adding something like a vertical bar before the text.
#[serde(default)]
pub(super) prefix: Option<String>,
/// The style for each alert type.
#[serde(default)]
pub(super) styles: AlertTypeStyles,
}
/// The style for each alert type.
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub(super) struct AlertTypeStyles {
/// The style for note alert types.
#[serde(default)]
pub(super) note: AlertTypeStyle,
/// The style for tip alert types.
#[serde(default)]
pub(super) tip: AlertTypeStyle,
/// The style for important alert types.
#[serde(default)]
pub(super) important: AlertTypeStyle,
/// The style for warning alert types.
#[serde(default)]
pub(super) warning: AlertTypeStyle,
/// The style for caution alert types.
#[serde(default)]
pub(super) caution: AlertTypeStyle,
}
/// The style for an alert type.
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub(super) struct AlertTypeStyle {
/// The color to be used.
#[serde(default)]
pub(super) color: Option<RawColor>,
/// The title to be used.
#[serde(default)]
pub(super) title: Option<String>,
/// The icon to be used.
#[serde(default)]
pub(super) icon: Option<String>,
}
/// The style for the presentation introduction slide.
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub(super) struct IntroSlideStyle {
/// The style of the title line.
#[serde(default)]
pub(super) title: IntroSlideTitleStyle,
/// The style of the subtitle line.
#[serde(default)]
pub(super) subtitle: BasicStyle,
/// The style of the event line.
#[serde(default)]
pub(super) event: BasicStyle,
/// The style of the location line.
#[serde(default)]
pub(super) location: BasicStyle,
/// The style of the date line.
#[serde(default)]
pub(super) date: BasicStyle,
/// The style of the author line.
#[serde(default)]
pub(super) author: AuthorStyle,
/// Whether we want a footer in the intro slide.
#[serde(default)]
pub(super) footer: Option<bool>,
}
/// A simple style.
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub(super) struct DefaultStyle {
/// The margin on the left/right of the screen.
#[serde(default, with = "serde_yaml::with::singleton_map")]
pub(super) margin: Option<Margin>,
/// The colors to be used.
#[serde(default)]
pub(super) colors: RawColors,
}
/// A simple style.
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub(super) struct BasicStyle {
/// The alignment.
#[serde(flatten, default)]
pub(super) alignment: Option<Alignment>,
/// The colors to be used.
#[serde(default)]
pub(super) colors: RawColors,
}
/// The intro slide title's style.
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub(super) struct IntroSlideTitleStyle {
/// The alignment.
#[serde(flatten, default)]
pub(super) alignment: Option<Alignment>,
/// The colors to be used.
#[serde(default)]
pub(super) colors: RawColors,
/// The font size to be used if the terminal supports it.
#[serde(default)]
pub(super) font_size: Option<u8>,
}
/// Text alignment.
///
/// This allows anchoring presentation elements to the left, center, or right of the screen.
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[serde(tag = "alignment", rename_all = "snake_case")]
pub(super) enum Alignment {
/// Left alignment.
Left {
/// The margin before any text.
#[serde(default)]
margin: Margin,
},
/// Right alignment.
Right {
/// The margin after any text.
#[serde(default)]
margin: Margin,
},
/// Center alignment.
Center {
/// The minimum margin expected.
#[serde(default)]
minimum_margin: Margin,
/// The minimum size of this element, in columns.
#[serde(default)]
minimum_size: u16,
},
}
impl Default for Alignment {
fn default() -> Self {
Self::Left { margin: Margin::Fixed(0) }
}
}
/// The style for the author line in the presentation intro slide.
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub(super) struct AuthorStyle {
/// The alignment.
#[serde(flatten, default)]
pub(super) alignment: Option<Alignment>,
/// The colors to be used.
#[serde(default)]
pub(super) colors: RawColors,
/// The positioning of the author's name.
#[serde(default)]
pub(super) positioning: AuthorPositioning,
}
/// The style of the footer that's shown in every slide.
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(tag = "style", rename_all = "snake_case")]
pub(super) enum FooterStyle {
/// Use a template to generate the footer.
Template {
/// The content to be put on the left.
left: Option<FooterContent>,
/// The content to be put on the center.
center: Option<FooterContent>,
/// The content to be put on the right.
right: Option<FooterContent>,
/// The colors to be used.
#[serde(default)]
colors: RawColors,
/// The height of the footer area.
height: Option<u16>,
},
/// Use a progress bar.
ProgressBar {
/// The character that will be used for the progress bar.
character: Option<char>,
/// The colors to be used.
#[serde(default)]
colors: RawColors,
},
/// No footer.
Empty,
}
impl Default for FooterStyle {
fn default() -> Self {
Self::Template { left: None, center: None, right: None, colors: RawColors::default(), height: None }
}
}
#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
pub(crate) enum FooterTemplateChunk {
Literal(String),
OpenBrace,
ClosedBrace,
CurrentSlide,
TotalSlides,
Author,
Title,
SubTitle,
Event,
Location,
Date,
}
#[derive(Clone, Debug, Serialize)]
#[serde(untagged)]
pub(super) enum FooterContent {
Template(FooterTemplate),
Image {
#[serde(rename = "image")]
path: PathBuf,
},
}
struct FooterContentVisitor;
impl<'de> Visitor<'de> for FooterContentVisitor {
type Value = FooterContent;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a valid footer")
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
let template = FooterTemplate::from_str(v).map_err(|e| E::custom(e.to_string()))?;
Ok(FooterContent::Template(template))
}
fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
where
A: serde::de::MapAccess<'de>,
{
let Some((key, value)): Option<(String, PathBuf)> = map.next_entry()? else {
return Err(serde::de::Error::custom("invalid footer"));
};
match key.as_str() {
"image" => Ok(FooterContent::Image { path: value }),
_ => Err(serde::de::Error::invalid_value(serde::de::Unexpected::Str(&key), &self)),
}
}
}
impl<'de> Deserialize<'de> for FooterContent {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
deserializer.deserialize_any(FooterContentVisitor)
}
}
#[derive(Clone, Debug)]
pub(crate) struct FooterTemplate(pub(crate) Vec<FooterTemplateChunk>);
crate::utils::impl_deserialize_from_str!(FooterTemplate);
crate::utils::impl_serialize_from_display!(FooterTemplate);
impl FromStr for FooterTemplate {
type Err = ParseFooterTemplateError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut chunks = Vec::new();
let mut chunk_start = 0;
let mut in_variable = false;
let mut iter = s.char_indices().peekable();
while let Some((index, c)) = iter.next() {
if c == '{' {
if in_variable {
return Err(ParseFooterTemplateError::NestedOpenBrace);
}
let double_brace = iter.peek() == Some(&(index + 1, '{'));
if double_brace {
iter.next();
if chunk_start != index {
chunks.push(FooterTemplateChunk::Literal(s[chunk_start..index].to_string()));
}
chunks.push(FooterTemplateChunk::OpenBrace);
chunk_start = index + 2;
} else {
in_variable = true;
if chunk_start != index {
chunks.push(FooterTemplateChunk::Literal(s[chunk_start..index].to_string()));
}
chunk_start = index + 1;
}
} else if c == '}' {
if !in_variable {
let double_brace = iter.peek() == Some(&(index + 1, '}'));
if double_brace {
iter.next();
chunks.push(FooterTemplateChunk::Literal(s[chunk_start..index].to_string()));
chunks.push(FooterTemplateChunk::ClosedBrace);
in_variable = false;
chunk_start = index + 2;
continue;
}
return Err(ParseFooterTemplateError::ClosedBraceWithoutOpen);
}
let variable = &s[chunk_start..index];
let chunk = match variable {
"current_slide" => FooterTemplateChunk::CurrentSlide,
"total_slides" => FooterTemplateChunk::TotalSlides,
"author" => FooterTemplateChunk::Author,
"title" => FooterTemplateChunk::Title,
"sub_title" => FooterTemplateChunk::SubTitle,
"event" => FooterTemplateChunk::Event,
"location" => FooterTemplateChunk::Location,
"date" => FooterTemplateChunk::Date,
_ => return Err(ParseFooterTemplateError::UnsupportedVariable(variable.to_string())),
};
chunks.push(chunk);
in_variable = false;
chunk_start = index + 1;
}
}
if in_variable {
return Err(ParseFooterTemplateError::TrailingBrace);
} else if chunk_start != s.len() {
chunks.push(FooterTemplateChunk::Literal(s[chunk_start..].to_string()));
}
Ok(Self(chunks))
}
}
impl fmt::Display for FooterTemplate {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
use FooterTemplateChunk::*;
for c in &self.0 {
match c {
Literal(l) => write!(f, "{l}"),
OpenBrace => write!(f, "{{{{"),
ClosedBrace => write!(f, "}}}}"),
CurrentSlide => write!(f, "{{current_slide}}"),
TotalSlides => write!(f, "{{total_slides}}"),
Author => write!(f, "{{author}}"),
Title => write!(f, "{{title}}"),
SubTitle => write!(f, "{{sub_title}}"),
Event => write!(f, "{{event}}"),
Location => write!(f, "{{location}}"),
Date => write!(f, "{{date}}"),
}?;
}
Ok(())
}
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum ParseFooterTemplateError {
#[error("found '{{' while already inside '{{' scope")]
NestedOpenBrace,
#[error("open '{{' was not closed")]
TrailingBrace,
#[error("found '}}' but no '{{' was found")]
ClosedBraceWithoutOpen,
#[error("unsupported variable: '{0}'")]
UnsupportedVariable(String),
}
/// The style for a piece of code.
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub(super) struct CodeBlockStyle {
/// The alignment.
#[serde(flatten)]
pub(super) alignment: Option<Alignment>,
/// The padding.
#[serde(default)]
pub(super) padding: PaddingRect,
/// The syntect theme name to use.
#[serde(default)]
pub(super) theme_name: Option<String>,
/// Whether to use the theme's background color.
pub(super) background: Option<bool>,
}
/// The style for the output of a code execution block.
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub(super) struct ExecutionOutputBlockStyle {
/// The colors to be used for the output pane.
#[serde(default)]
pub(super) colors: RawColors,
/// The colors to be used for the text that represents the status of the execution block.
#[serde(default)]
pub(super) status: ExecutionStatusBlockStyle,
}
/// The style for the status of a code execution block.
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub(super) struct ExecutionStatusBlockStyle {
/// The colors for the "running" status.
#[serde(default)]
pub(super) running: RawColors,
/// The colors for the "finished" status.
#[serde(default)]
pub(super) success: RawColors,
/// The colors for the "finished with error" status.
#[serde(default)]
pub(super) failure: RawColors,
/// The colors for the "not started" status.
#[serde(default)]
pub(super) not_started: RawColors,
}
/// The style for inline code.
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub(super) struct InlineCodeStyle {
/// The colors to be used.
#[serde(default)]
pub(super) colors: RawColors,
}
/// Vertical/horizontal padding.
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub(super) struct PaddingRect {
/// The number of columns to use as horizontal padding.
#[serde(default)]
pub(super) horizontal: Option<u8>,
/// The number of rows to use as vertical padding.
#[serde(default)]
pub(super) vertical: Option<u8>,
}
/// A margin.
#[derive(Copy, Clone, Debug, Deserialize, Serialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub(crate) enum Margin {
/// A fixed number of characters.
Fixed(u16),
/// A percent of the screen size.
Percent(u16),
}
impl Margin {
pub(crate) fn as_characters(&self, screen_size: u16) -> u16 {
match *self {
Self::Fixed(value) => value,
Self::Percent(percent) => {
let ratio = percent as f64 / 100.0;
(screen_size as f64 * ratio).ceil() as u16
}
}
}
pub(crate) fn is_empty(&self) -> bool {
matches!(self, Self::Fixed(0) | Self::Percent(0))
}
}
impl Default for Margin {
fn default() -> Self {
Self::Fixed(0)
}
}
/// An element type.
#[derive(Clone, Deserialize, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "snake_case")]
pub(super) enum ElementType {
SlideTitle,
Heading1,
Heading2,
Heading3,
Heading4,
Heading5,
Heading6,
Paragraph,
List,
Code,
PresentationTitle,
PresentationSubTitle,
PresentationEvent,
PresentationLocation,
PresentationDate,
PresentationAuthor,
Table,
BlockQuote,
}
/// Where to position the author's name in the intro slide.
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum AuthorPositioning {
/// Right below the title.
BelowTitle,
/// At the bottom of the page.
#[default]
PageBottom,
}
/// Typst styles.
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub(super) struct TypstStyle {
/// The horizontal margin on the generated images.
pub(super) horizontal_margin: Option<u16>,
/// The vertical margin on the generated images.
pub(super) vertical_margin: Option<u16>,
/// The colors to be used.
#[serde(default)]
pub(super) colors: RawColors,
}
/// Mermaid styles.
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub(super) struct MermaidStyle {
/// The mermaidjs theme to use.
pub(super) theme: Option<String>,
/// The background color to use.
pub(super) background: Option<String>,
}
/// Modals style.
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub(super) struct ModalStyle {
/// The default colors to use for everything in the modal.
#[serde(default)]
pub(super) colors: RawColors,
/// The colors to use for selected lines.
#[serde(default)]
pub(super) selection_colors: RawColors,
}
/// The color palette.
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub(super) struct ColorPalette {
#[serde(default)]
pub(super) colors: BTreeMap<String, RawColor>,
#[serde(default)]
pub(super) classes: BTreeMap<String, RawColors>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum RawColor {
Color(Color),
Palette(String),
ForegroundClass(String),
BackgroundClass(String),
}
crate::utils::impl_deserialize_from_str!(RawColor);
crate::utils::impl_serialize_from_display!(RawColor);
impl RawColor {
fn new_palette(name: &str) -> Result<Self, ParseColorError> {
if name.is_empty() { Err(ParseColorError::PaletteColorEmpty) } else { Ok(Self::Palette(name.into())) }
}
pub(crate) fn resolve(
&self,
palette: &crate::theme::clean::ColorPalette,
) -> Result<Option<Color>, UndefinedPaletteColorError> {
let color = match self {
Self::Color(c) => Some(*c),
Self::Palette(name) => {
Some(palette.colors.get(name).copied().ok_or(UndefinedPaletteColorError(name.clone()))?)
}
Self::ForegroundClass(name) => {
palette.classes.get(name).ok_or(UndefinedPaletteColorError(name.clone()))?.foreground
}
Self::BackgroundClass(name) => {
palette.classes.get(name).ok_or(UndefinedPaletteColorError(name.clone()))?.background
}
};
Ok(color)
}
}
impl From<Color> for RawColor {
fn from(color: Color) -> Self {
Self::Color(color)
}
}
impl FromStr for RawColor {
type Err = ParseColorError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
let output = match input {
"black" => Color::Black.into(),
"white" => Color::White.into(),
"grey" => Color::Grey.into(),
"dark_grey" => Color::DarkGrey.into(),
"red" => Color::Red.into(),
"dark_red" => Color::DarkRed.into(),
"green" => Color::Green.into(),
"dark_green" => Color::DarkGreen.into(),
"blue" => Color::Blue.into(),
"dark_blue" => Color::DarkBlue.into(),
"yellow" => Color::Yellow.into(),
"dark_yellow" => Color::DarkYellow.into(),
"magenta" => Color::Magenta.into(),
"dark_magenta" => Color::DarkMagenta.into(),
"cyan" => Color::Cyan.into(),
"dark_cyan" => Color::DarkCyan.into(),
other if other.starts_with("palette:") => Self::new_palette(other.trim_start_matches("palette:"))?,
other if other.starts_with("p:") => Self::new_palette(other.trim_start_matches("p:"))?,
// Fallback to hex-encoded rgb
_ => {
let values = <[u8; 3]>::from_hex(input)?;
Color::Rgb { r: values[0], g: values[1], b: values[2] }.into()
}
};
Ok(output)
}
}
impl fmt::Display for RawColor {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
use Color::*;
match self {
Self::Color(Rgb { r, g, b }) => write!(f, "{}", hex::encode([*r, *g, *b])),
Self::Color(Black) => write!(f, "black"),
Self::Color(White) => write!(f, "white"),
Self::Color(Grey) => write!(f, "grey"),
Self::Color(DarkGrey) => write!(f, "dark_grey"),
Self::Color(Red) => write!(f, "red"),
Self::Color(DarkRed) => write!(f, "dark_red"),
Self::Color(Green) => write!(f, "green"),
Self::Color(DarkGreen) => write!(f, "dark_green"),
Self::Color(Blue) => write!(f, "blue"),
Self::Color(DarkBlue) => write!(f, "dark_blue"),
Self::Color(Yellow) => write!(f, "yellow"),
Self::Color(DarkYellow) => write!(f, "dark_yellow"),
Self::Color(Magenta) => write!(f, "magenta"),
Self::Color(DarkMagenta) => write!(f, "dark_magenta"),
Self::Color(Cyan) => write!(f, "cyan"),
Self::Color(DarkCyan) => write!(f, "dark_cyan"),
Self::Palette(name) => write!(f, "palette:{name}"),
Self::ForegroundClass(_) => Err(fmt::Error),
Self::BackgroundClass(_) => Err(fmt::Error),
}
}
}
#[derive(thiserror::Error, Debug)]
pub(crate) enum ParseColorError {
#[error("invalid hex color: {0}")]
Hex(#[from] FromHexError),
#[error("palette color name is empty")]
PaletteColorEmpty,
}
#[cfg(test)]
mod test {
use super::*;
use rstest::rstest;
#[test]
fn parse_all_footer_template_variables() {
use FooterTemplateChunk::*;
let raw = "hi {current_slide} {total_slides} {author} {title} {sub_title} {event} {location} {event}";
let t: FooterTemplate = raw.parse().expect("invalid input");
let expected = vec![
Literal("hi ".into()),
CurrentSlide,
Literal(" ".into()),
TotalSlides,
Literal(" ".into()),
Author,
Literal(" ".into()),
Title,
Literal(" ".into()),
SubTitle,
Literal(" ".into()),
Event,
Literal(" ".into()),
Location,
Literal(" ".into()),
Event,
];
assert_eq!(t.0, expected);
assert_eq!(t.to_string(), raw);
}
#[test]
fn parse_double_braces() {
use FooterTemplateChunk::*;
let raw = "hi {{beep}} {{author}} {{{{}}}}";
let t: FooterTemplate = raw.parse().expect("invalid input");
let merged: String =
t.0.into_iter()
.map(|l| match l {
Literal(s) => s,
OpenBrace => "{".to_string(),
ClosedBrace => "}".to_string(),
_ => panic!("not a literal"),
})
.collect();
assert_eq!(merged, "hi {beep} {author} {{}}");
}
#[rstest]
#[case::trailing("{author")]
#[case::close_without_open2("author}")]
fn invalid_footer_templates(#[case] input: &str) {
FooterTemplate::from_str(input).expect_err("parse succeeded");
}
#[test]
fn color_serde() {
let color: RawColor = "beef42".parse().unwrap();
assert_eq!(color.to_string(), "beef42");
}
#[rstest]
#[case::empty1("p:")]
#[case::empty2("palette:")]
fn invalid_palette_color_names(#[case] input: &str) {
RawColor::from_str(input).expect_err("not an error");
}
#[rstest]
#[case::short("p:hi", "hi")]
#[case::long("palette:bye", "bye")]
fn valid_palette_color_names(#[case] input: &str, #[case] expected: &str) {
let color = RawColor::from_str(input).expect("failed to parse");
let RawColor::Palette(name) = color else { panic!("not a palette color") };
assert_eq!(name, expected);
}
}

View File

@ -1,239 +0,0 @@
use super::raw::PresentationTheme;
use std::{collections::BTreeMap, fs, io, path::Path};
include!(concat!(env!("OUT_DIR"), "/themes.rs"));
#[derive(Default)]
pub struct PresentationThemeRegistry {
custom_themes: BTreeMap<String, PresentationTheme>,
}
impl PresentationThemeRegistry {
/// Loads a theme from its name.
pub fn load_by_name(&self, name: &str) -> Option<PresentationTheme> {
match THEMES.get(name) {
Some(contents) => {
// This is going to be caught by the test down here.
let theme = serde_yaml::from_slice(contents).expect("corrupted theme");
Some(theme)
}
None => self.custom_themes.get(name).cloned(),
}
}
/// Register all the themes in the given directory.
pub fn register_from_directory<P: AsRef<Path>>(&mut self, path: P) -> Result<(), LoadThemeError> {
let handle = match fs::read_dir(&path) {
Ok(handle) => handle,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(e.into()),
};
let mut dependencies = BTreeMap::new();
for entry in handle {
let entry = entry?;
let metadata = entry.metadata()?;
let Some(file_name) = entry.file_name().to_str().map(ToOwned::to_owned) else {
continue;
};
if metadata.is_file() && file_name.ends_with(".yaml") {
let theme_name = file_name.trim_end_matches(".yaml");
if THEMES.contains_key(theme_name) {
return Err(LoadThemeError::Duplicate(theme_name.into()));
}
let theme = PresentationTheme::from_path(entry.path())?;
let base = theme.extends.clone();
self.custom_themes.insert(theme_name.into(), theme);
dependencies.insert(theme_name.to_string(), base);
}
}
let mut graph = ThemeGraph::new(dependencies);
for theme_name in graph.dependents.keys() {
let theme_name = theme_name.as_str();
if !THEMES.contains_key(theme_name) && !self.custom_themes.contains_key(theme_name) {
return Err(LoadThemeError::ExtendedThemeNotFound(theme_name.into()));
}
}
while let Some(theme_name) = graph.pop() {
self.extend_theme(&theme_name)?;
}
if !graph.dependents.is_empty() {
return Err(LoadThemeError::ExtensionLoop(graph.dependents.into_keys().collect()));
}
Ok(())
}
fn extend_theme(&mut self, theme_name: &str) -> Result<(), LoadThemeError> {
let Some(base_name) = self.custom_themes.get(theme_name).expect("theme not found").extends.clone() else {
return Ok(());
};
let Some(base_theme) = self.load_by_name(&base_name) else {
return Err(LoadThemeError::ExtendedThemeNotFound(base_name.clone()));
};
let theme = self.custom_themes.get_mut(theme_name).expect("theme not found");
*theme = merge_struct::merge(&base_theme, theme)
.map_err(|e| LoadThemeError::Corrupted(base_name.to_string(), e.into()))?;
Ok(())
}
/// Get all the registered theme names.
pub fn theme_names(&self) -> Vec<String> {
let builtin_themes = THEMES.keys().map(|name| name.to_string());
let themes = self.custom_themes.keys().cloned().chain(builtin_themes).collect();
themes
}
}
struct ThemeGraph {
dependents: BTreeMap<String, Vec<String>>,
ready: Vec<String>,
}
impl ThemeGraph {
fn new<I>(dependencies: I) -> Self
where
I: IntoIterator<Item = (String, Option<String>)>,
{
let mut dependents: BTreeMap<_, Vec<_>> = BTreeMap::new();
let mut ready = Vec::new();
for (name, extends) in dependencies {
dependents.entry(name.clone()).or_default();
match extends {
// If we extend from a non built in theme, make ourselves their dependent
Some(base) if !THEMES.contains_key(base.as_str()) => {
dependents.entry(base).or_default().push(name);
}
// Otherwise this theme is ready to be processed
_ => ready.push(name),
}
}
Self { dependents, ready }
}
fn pop(&mut self) -> Option<String> {
let theme = self.ready.pop()?;
if let Some(dependents) = self.dependents.remove(&theme) {
self.ready.extend(dependents);
}
Some(theme)
}
}
/// An error loading a presentation theme.
#[derive(thiserror::Error, Debug)]
pub enum LoadThemeError {
#[error(transparent)]
Io(#[from] io::Error),
#[error("theme '{0}' is corrupted: {1}")]
Corrupted(String, Box<dyn std::error::Error>),
#[error("duplicate custom theme '{0}'")]
Duplicate(String),
#[error("extended theme does not exist: {0}")]
ExtendedThemeNotFound(String),
#[error("theme has an extension loop involving: {0:?}")]
ExtensionLoop(Vec<String>),
}
#[cfg(test)]
mod test {
use crate::resource::Resources;
use super::*;
use tempfile::{TempDir, tempdir};
fn write_theme(name: &str, theme: PresentationTheme, directory: &TempDir) {
let theme = serde_yaml::to_string(&theme).unwrap();
let file_name = format!("{name}.yaml");
fs::write(directory.path().join(file_name), theme).expect("writing theme");
}
#[test]
fn validate_themes() {
let themes = PresentationThemeRegistry::default();
for theme_name in THEMES.keys() {
let Some(theme) = themes.load_by_name(theme_name).clone() else {
panic!("theme '{theme_name}' is corrupted");
};
// Built-in themes can't use this because... I don't feel like supporting this now.
assert!(theme.extends.is_none(), "theme '{theme_name}' uses extends");
let merged = merge_struct::merge(&PresentationTheme::default(), &theme);
assert!(merged.is_ok(), "theme '{theme_name}' can't be merged: {}", merged.unwrap_err());
let resources = Resources::new("/tmp/foo", "/tmp/foo", Default::default());
crate::theme::PresentationTheme::new(&theme, &resources, &Default::default()).expect("malformed theme");
}
}
#[test]
fn load_custom() {
let directory = tempdir().expect("creating tempdir");
write_theme(
"potato",
PresentationTheme { extends: Some("dark".to_string()), ..Default::default() },
&directory,
);
let mut themes = PresentationThemeRegistry::default();
themes.register_from_directory(directory.path()).expect("loading themes");
let mut theme = themes.load_by_name("potato").expect("theme not found");
// Since we extend the dark theme they must match after we remove the "extends" field.
let dark = themes.load_by_name("dark");
theme.extends.take().expect("no extends");
assert_eq!(serde_yaml::to_string(&theme).unwrap(), serde_yaml::to_string(&dark).unwrap());
}
#[test]
fn load_derive_chain() {
let directory = tempdir().expect("creating tempdir");
write_theme("A", PresentationTheme { extends: Some("dark".to_string()), ..Default::default() }, &directory);
write_theme("B", PresentationTheme { extends: Some("C".to_string()), ..Default::default() }, &directory);
write_theme("C", PresentationTheme { extends: Some("A".to_string()), ..Default::default() }, &directory);
write_theme("D", PresentationTheme::default(), &directory);
let mut themes = PresentationThemeRegistry::default();
themes.register_from_directory(directory.path()).expect("loading themes");
themes.load_by_name("A").expect("A not found");
themes.load_by_name("B").expect("B not found");
themes.load_by_name("C").expect("C not found");
themes.load_by_name("D").expect("D not found");
}
#[test]
fn invalid_derives() {
let directory = tempdir().expect("creating tempdir");
write_theme(
"A",
PresentationTheme { extends: Some("non-existent-theme".to_string()), ..Default::default() },
&directory,
);
let mut themes = PresentationThemeRegistry::default();
themes.register_from_directory(directory.path()).expect_err("loading themes succeeded");
}
#[test]
fn load_derive_chain_loop() {
let directory = tempdir().expect("creating tempdir");
write_theme("A", PresentationTheme { extends: Some("B".to_string()), ..Default::default() }, &directory);
write_theme("B", PresentationTheme { extends: Some("A".to_string()), ..Default::default() }, &directory);
let mut themes = PresentationThemeRegistry::default();
let err = themes.register_from_directory(directory.path()).expect_err("loading themes succeeded");
let LoadThemeError::ExtensionLoop(names) = err else { panic!("not an extension loop error") };
assert_eq!(names, &["A", "B"]);
}
#[test]
fn register_from_missing_directory() {
let mut themes = PresentationThemeRegistry::default();
let result = themes.register_from_directory("/tmp/presenterm/8ee2027983915ec78acc45027d874316");
result.expect("loading failed");
}
}

View File

@ -1,22 +1,19 @@
use crate::{
ImageRegistry,
ImageRegistry, PresentationTheme,
config::{default_mermaid_scale, default_snippet_render_threads, default_typst_ppi},
markdown::{
elements::{Line, Percent, Text},
text_style::{Color, TextStyle},
text_style::{Color, Colors, TextStyle},
},
presentation::{AsyncPresentationError, AsyncPresentationErrorHolder, builder::DEFAULT_IMAGE_Z_INDEX},
render::{
operation::{
AsRenderOperations, ImageRenderProperties, ImageSize, Pollable, PollableState, RenderAsync,
RenderAsyncStartPolicy, RenderOperation,
AsRenderOperations, ImageRenderProperties, ImageSize, RenderAsync, RenderAsyncState, RenderOperation,
},
properties::WindowSize,
},
terminal::image::{
Image,
printer::{ImageSpec, RegisterImageError},
},
theme::{Alignment, MermaidStyle, PresentationTheme, TypstStyle, raw::RawColor},
terminal::image::{Image, printer::RegisterImageError},
theme::{Alignment, MermaidStyle, TypstStyle},
tools::{ExecutionError, ThirdPartyTools},
};
use std::{
@ -28,6 +25,9 @@ use std::{
thread,
};
const DEFAULT_HORIZONTAL_MARGIN: u16 = 5;
const DEFAULT_VERTICAL_MARGIN: u16 = 7;
pub struct ThirdPartyConfigs {
pub typst_ppi: String,
pub mermaid_scale: String,
@ -53,10 +53,12 @@ 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, width));
let operation = Rc::new(RenderThirdParty::new(result, theme.default_style.colors, error_holder, slide, width));
Ok(RenderOperation::RenderAsync(operation))
}
}
@ -201,9 +203,9 @@ impl Worker {
"-s",
&self.shared.config.mermaid_scale,
"-t",
&style.theme,
style.theme.as_deref().unwrap_or("default"),
"-b",
&style.background,
style.background.as_deref().unwrap_or("transparent"),
])
.run()?;
@ -241,19 +243,14 @@ impl Worker {
}
fn generate_page_header(style: &TypstStyle) -> Result<String, ThirdPartyRenderError> {
let x_margin = style.horizontal_margin;
let y_margin = style.vertical_margin;
let background = style
.style
.colors
.background
.as_ref()
.map(Self::as_typst_color)
.unwrap_or_else(|| Ok(String::from("none")))?;
let x_margin = style.horizontal_margin.unwrap_or(DEFAULT_HORIZONTAL_MARGIN);
let y_margin = style.vertical_margin.unwrap_or(DEFAULT_VERTICAL_MARGIN);
let background =
style.colors.background.as_ref().map(Self::as_typst_color).unwrap_or_else(|| Ok(String::from("none")))?;
let mut header = format!(
"#set page(width: auto, height: auto, margin: (x: {x_margin}pt, y: {y_margin}pt), fill: {background})\n"
);
if let Some(color) = &style.style.colors.foreground {
if let Some(color) = &style.colors.foreground {
let color = Self::as_typst_color(color)?;
header.push_str(&format!("#set text(fill: {color})\n"));
}
@ -263,14 +260,14 @@ impl Worker {
fn as_typst_color(color: &Color) -> Result<String, ThirdPartyRenderError> {
match color.as_rgb() {
Some((r, g, b)) => Ok(format!("rgb(\"#{r:02x}{g:02x}{b:02x}\")")),
None => Err(ThirdPartyRenderError::UnsupportedColor(RawColor::from(*color).to_string())),
None => Err(ThirdPartyRenderError::UnsupportedColor(color.to_string())),
}
}
fn load_image(&self, snippet: ImageSnippet, path: &Path) -> Result<Image, ThirdPartyRenderError> {
let contents = fs::read(path)?;
let image = image::load_from_memory(&contents)?;
let image = self.state.lock().unwrap().image_registry.register(ImageSpec::Generated(image))?;
let image = self.state.lock().unwrap().image_registry.register_image(image)?;
self.state.lock().unwrap().cache.insert(snippet, image.clone());
Ok(image)
}
@ -309,45 +306,70 @@ struct ImageSnippet {
#[derive(Debug)]
pub(crate) struct RenderThirdParty {
contents: Arc<Mutex<Option<Output>>>,
contents: Arc<Mutex<Option<Image>>>,
pending_result: Arc<Mutex<RenderResult>>,
default_style: TextStyle,
default_colors: Colors,
error_holder: AsyncPresentationErrorHolder,
slide: usize,
width: Option<Percent>,
}
impl RenderThirdParty {
fn new(pending_result: Arc<Mutex<RenderResult>>, default_style: TextStyle, width: Option<Percent>) -> Self {
Self { contents: Default::default(), pending_result, default_style, width }
fn new(
pending_result: Arc<Mutex<RenderResult>>,
default_colors: Colors,
error_holder: AsyncPresentationErrorHolder,
slide: usize,
width: Option<Percent>,
) -> Self {
Self { contents: Default::default(), pending_result, default_colors, error_holder, slide, width }
}
}
impl RenderAsync for RenderThirdParty {
fn pollable(&self) -> Box<dyn Pollable> {
Box::new(OperationPollable { contents: self.contents.clone(), pending_result: self.pending_result.clone() })
fn start_render(&self) -> bool {
false
}
fn start_policy(&self) -> RenderAsyncStartPolicy {
RenderAsyncStartPolicy::Automatic
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 },
}
}
}
impl AsRenderOperations for RenderThirdParty {
fn as_render_operations(&self, _: &WindowSize) -> Vec<RenderOperation> {
match &*self.contents.lock().unwrap() {
Some(Output::Image(image)) => {
Some(image) => {
let size = match &self.width {
Some(percent) => ImageSize::WidthScaled { ratio: percent.as_ratio() },
None => Default::default(),
};
let properties = ImageRenderProperties {
z_index: DEFAULT_IMAGE_Z_INDEX,
size,
background_color: self.default_style.colors.background,
..Default::default()
restore_cursor: false,
background_color: self.default_colors.background,
};
vec![RenderOperation::RenderImage(image.clone(), properties)]
vec![
RenderOperation::RenderImage(image.clone(), properties),
RenderOperation::SetColors(self.default_colors),
]
}
Some(Output::Error) => Vec::new(),
None => {
let text = Line::from(Text::new("Loading...", TextStyle::default().bold()));
vec![RenderOperation::RenderText {
@ -358,35 +380,3 @@ 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,
}
}
}

View File

@ -22,8 +22,8 @@ impl ThirdPartyTools {
Tool::new(mmdc, args)
}
pub(crate) fn weasyprint(args: &[&str]) -> Tool {
Tool::new("weasyprint", args).inherit_stdout().max_error_lines(100)
pub(crate) fn presenterm_export(args: &[&str]) -> Tool {
Tool::new("presenterm-export", args).inherit_stdout().max_error_lines(100)
}
}

View File

@ -1,65 +0,0 @@
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);
}
}

View File

@ -1,134 +0,0 @@
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);
}
}

View File

@ -1,160 +0,0 @@
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);
}
}

View File

@ -1,97 +0,0 @@
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);
}
}

507
src/ui/execution.rs Normal file
View File

@ -0,0 +1,507 @@
use super::separator::{RenderSeparator, SeparatorWidth};
use crate::{
code::{
execute::{ExecutionHandle, ExecutionState, ProcessStatus, SnippetExecutor},
snippet::Snippet,
},
markdown::{
elements::{Line, Text},
text::WeightedLine,
text_style::{Colors, TextStyle},
},
render::{
operation::{
AsRenderOperations, BlockLine, ImageRenderProperties, ImageSize, RenderAsync, RenderAsyncState,
RenderOperation,
},
properties::WindowSize,
},
terminal::{
ansi::AnsiSplitter,
image::{Image, printer::ImageRegistry},
should_hide_cursor,
},
theme::{Alignment, ExecutionOutputBlockStyle, ExecutionStatusBlockStyle, Margin},
};
use crossterm::{
ExecutableCommand, cursor,
terminal::{self, disable_raw_mode, enable_raw_mode},
};
use std::{
cell::RefCell,
io::{self, BufRead},
mem,
ops::{Deref, DerefMut},
rc::Rc,
};
const MINIMUM_SEPARATOR_WIDTH: u16 = 32;
#[derive(Debug)]
struct RunSnippetOperationInner {
handle: Option<ExecutionHandle>,
output_lines: Vec<WeightedLine>,
state: RenderAsyncState,
max_line_length: u16,
starting_style: TextStyle,
last_length: usize,
}
#[derive(Debug)]
pub(crate) struct RunSnippetOperation {
code: Snippet,
executor: Rc<SnippetExecutor>,
default_colors: Colors,
block_colors: Colors,
status_colors: ExecutionStatusBlockStyle,
block_length: u16,
alignment: Alignment,
inner: Rc<RefCell<RunSnippetOperationInner>>,
state_description: RefCell<Text>,
separator: DisplaySeparator,
}
impl RunSnippetOperation {
pub(crate) fn new(
code: Snippet,
executor: Rc<SnippetExecutor>,
default_colors: Colors,
execution_output_style: ExecutionOutputBlockStyle,
block_length: u16,
separator: DisplaySeparator,
alignment: Alignment,
) -> Self {
let block_colors = execution_output_style.colors;
let status_colors = execution_output_style.status.clone();
let not_started_colors = status_colors.not_started;
let block_length = match &alignment {
Alignment::Left { .. } | Alignment::Right { .. } => block_length,
Alignment::Center { minimum_size, .. } => block_length.max(*minimum_size),
};
let inner = RunSnippetOperationInner {
handle: None,
output_lines: Vec::new(),
state: RenderAsyncState::default(),
max_line_length: 0,
starting_style: TextStyle::default(),
last_length: 0,
};
Self {
code,
executor,
default_colors,
block_colors,
status_colors,
block_length,
alignment,
inner: Rc::new(RefCell::new(inner)),
state_description: Text::new("not started", TextStyle::default().colors(not_started_colors)).into(),
separator,
}
}
}
#[derive(Debug)]
pub(crate) enum DisplaySeparator {
On,
Off,
}
impl AsRenderOperations for RunSnippetOperation {
fn as_render_operations(&self, _dimensions: &WindowSize) -> Vec<RenderOperation> {
let inner = self.inner.borrow();
let description = self.state_description.borrow();
let mut operations = match self.separator {
DisplaySeparator::On => {
let heading = Line(vec![" [".into(), description.clone(), "] ".into()]);
let separator_width = match &self.alignment {
Alignment::Left { .. } | Alignment::Right { .. } => SeparatorWidth::FitToWindow,
// We need a minimum here otherwise if the code/block length is too narrow, the separator is
// word-wrapped and looks bad.
Alignment::Center { .. } => SeparatorWidth::Fixed(self.block_length.max(MINIMUM_SEPARATOR_WIDTH)),
};
let separator = RenderSeparator::new(heading, separator_width);
vec![
RenderOperation::RenderLineBreak,
RenderOperation::RenderDynamic(Rc::new(separator)),
RenderOperation::RenderLineBreak,
]
}
DisplaySeparator::Off => vec![],
};
if matches!(inner.state, RenderAsyncState::NotStarted) {
return operations;
}
operations.push(RenderOperation::RenderLineBreak);
if self.block_colors.background.is_some() {
operations.push(RenderOperation::SetColors(self.block_colors));
}
let has_margin = match &self.alignment {
Alignment::Left { margin } => !margin.is_empty(),
Alignment::Right { margin } => !margin.is_empty(),
Alignment::Center { minimum_margin, minimum_size } => !minimum_margin.is_empty() || minimum_size != &0,
};
let block_length =
if has_margin { self.block_length.max(inner.max_line_length) } else { inner.max_line_length };
for line in &inner.output_lines {
operations.push(RenderOperation::RenderBlockLine(BlockLine {
prefix: "".into(),
right_padding_length: 0,
repeat_prefix_on_wrap: false,
text: line.clone(),
block_length,
alignment: self.alignment.clone(),
block_color: self.block_colors.background,
}));
operations.push(RenderOperation::RenderLineBreak);
}
operations.push(RenderOperation::SetColors(self.default_colors));
operations
}
}
impl RenderAsync for RunSnippetOperation {
fn poll_state(&self) -> RenderAsyncState {
let mut inner = self.inner.borrow_mut();
let last_length = inner.last_length;
if let Some(handle) = inner.handle.as_mut() {
let mut state = handle.state.lock().unwrap();
let ExecutionState { output, status } = &mut *state;
*self.state_description.borrow_mut() = match status {
ProcessStatus::Running => Text::new("running", TextStyle::default().colors(self.status_colors.running)),
ProcessStatus::Success => {
Text::new("finished", TextStyle::default().colors(self.status_colors.success))
}
ProcessStatus::Failure => {
Text::new("finished with error", TextStyle::default().colors(self.status_colors.failure))
}
};
let modified = output.len() != last_length;
let is_finished = status.is_finished();
let mut lines = Vec::new();
for line in output.lines() {
let mut line = line.expect("invalid utf8");
if line.contains('\t') {
line = line.replace('\t', " ");
}
lines.push(line);
}
drop(state);
let mut max_line_length = 0;
let (lines, style) = AnsiSplitter::new(inner.starting_style).split_lines(&lines);
for line in &lines {
let width = u16::try_from(line.width()).unwrap_or(u16::MAX);
max_line_length = max_line_length.max(width);
}
inner.starting_style = style;
if is_finished {
inner.handle.take();
inner.state = RenderAsyncState::JustFinishedRendering;
} else {
inner.state = RenderAsyncState::Rendering { modified };
}
inner.output_lines = lines;
inner.max_line_length = inner.max_line_length.max(max_line_length);
}
inner.state.clone()
}
fn start_render(&self) -> bool {
let mut inner = self.inner.borrow_mut();
if !matches!(inner.state, RenderAsyncState::NotStarted) {
return false;
}
match self.executor.execute_async(&self.code) {
Ok(handle) => {
inner.handle = Some(handle);
inner.state = RenderAsyncState::Rendering { modified: false };
true
}
Err(e) => {
inner.output_lines = vec![WeightedLine::from(e.to_string())];
inner.state = RenderAsyncState::Rendered;
true
}
}
}
}
#[derive(Clone, Debug)]
pub(crate) struct SnippetExecutionDisabledOperation {
colors: Colors,
alignment: Alignment,
started: RefCell<bool>,
}
impl SnippetExecutionDisabledOperation {
pub(crate) fn new(colors: Colors, alignment: Alignment) -> Self {
Self { colors, alignment, started: Default::default() }
}
}
impl AsRenderOperations for SnippetExecutionDisabledOperation {
fn as_render_operations(&self, _: &WindowSize) -> Vec<RenderOperation> {
if !*self.started.borrow() {
return Vec::new();
}
vec![
RenderOperation::RenderLineBreak,
RenderOperation::RenderText {
line: vec![Text::new("snippet execution is disabled", TextStyle::default().colors(self.colors))].into(),
alignment: self.alignment.clone(),
},
RenderOperation::RenderLineBreak,
]
}
}
impl RenderAsync for SnippetExecutionDisabledOperation {
fn start_render(&self) -> bool {
let was_started = mem::replace(&mut *self.started.borrow_mut(), true);
!was_started
}
fn poll_state(&self) -> RenderAsyncState {
RenderAsyncState::Rendered
}
}
#[derive(Default, Clone)]
enum AcquireTerminalSnippetState {
#[default]
NotStarted,
Success,
Failure(Vec<String>),
}
impl std::fmt::Debug for AcquireTerminalSnippetState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotStarted => write!(f, "NotStarted"),
Self::Success => write!(f, "Success"),
Self::Failure(_) => write!(f, "Failure"),
}
}
}
#[derive(Debug)]
pub(crate) struct RunAcquireTerminalSnippet {
snippet: Snippet,
block_length: u16,
executor: Rc<SnippetExecutor>,
colors: ExecutionStatusBlockStyle,
state: RefCell<AcquireTerminalSnippetState>,
}
impl RunAcquireTerminalSnippet {
pub(crate) fn new(
snippet: Snippet,
executor: Rc<SnippetExecutor>,
colors: ExecutionStatusBlockStyle,
block_length: u16,
) -> Self {
Self { snippet, block_length, executor, colors, state: Default::default() }
}
}
impl RunAcquireTerminalSnippet {
fn invoke(&self) -> Result<(), String> {
let mut stdout = io::stdout();
stdout
.execute(terminal::LeaveAlternateScreen)
.and_then(|_| disable_raw_mode())
.map_err(|e| format!("failed to deinit terminal: {e}"))?;
// save result for later, but first reinit the terminal
let result = self.executor.execute_sync(&self.snippet).map_err(|e| format!("failed to run snippet: {e}"));
stdout
.execute(terminal::EnterAlternateScreen)
.and_then(|_| enable_raw_mode())
.map_err(|e| format!("failed to reinit terminal: {e}"))?;
if should_hide_cursor() {
stdout.execute(cursor::Hide).map_err(|e| e.to_string())?;
}
result
}
}
impl AsRenderOperations for RunAcquireTerminalSnippet {
fn as_render_operations(&self, _dimensions: &WindowSize) -> Vec<RenderOperation> {
let state = self.state.borrow();
let separator_text = match state.deref() {
AcquireTerminalSnippetState::NotStarted => {
Text::new("not started", TextStyle::colored(self.colors.not_started))
}
AcquireTerminalSnippetState::Success => Text::new("finished", TextStyle::colored(self.colors.success)),
AcquireTerminalSnippetState::Failure(_) => {
Text::new("finished with error", TextStyle::colored(self.colors.failure))
}
};
let heading = Line(vec![" [".into(), separator_text, "] ".into()]);
let separator_width = SeparatorWidth::Fixed(self.block_length.max(MINIMUM_SEPARATOR_WIDTH));
let separator = RenderSeparator::new(heading, separator_width);
let mut ops = vec![
RenderOperation::RenderLineBreak,
RenderOperation::RenderDynamic(Rc::new(separator)),
RenderOperation::RenderLineBreak,
];
if let AcquireTerminalSnippetState::Failure(lines) = state.deref() {
ops.push(RenderOperation::RenderLineBreak);
for line in lines {
ops.extend([
RenderOperation::RenderText {
line: vec![Text::new(line, TextStyle::default().colors(self.colors.failure))].into(),
alignment: Alignment::Left { margin: Margin::Percent(25) },
},
RenderOperation::RenderLineBreak,
]);
}
}
ops
}
}
impl RenderAsync for RunAcquireTerminalSnippet {
fn start_render(&self) -> bool {
if !matches!(*self.state.borrow(), AcquireTerminalSnippetState::NotStarted) {
return false;
}
if let Err(e) = self.invoke() {
let lines = e.lines().map(ToString::to_string).collect();
*self.state.borrow_mut() = AcquireTerminalSnippetState::Failure(lines);
} else {
*self.state.borrow_mut() = AcquireTerminalSnippetState::Success;
}
true
}
fn poll_state(&self) -> RenderAsyncState {
RenderAsyncState::Rendered
}
}
#[derive(Debug)]
pub(crate) struct RunImageSnippet {
snippet: Snippet,
executor: Rc<SnippetExecutor>,
state: RefCell<RunImageSnippetState>,
image_registry: ImageRegistry,
colors: ExecutionStatusBlockStyle,
}
impl RunImageSnippet {
pub(crate) fn new(
snippet: Snippet,
executor: Rc<SnippetExecutor>,
image_registry: ImageRegistry,
colors: ExecutionStatusBlockStyle,
) -> Self {
Self { snippet, executor, image_registry, colors, state: Default::default() }
}
fn load_image(&self, data: &[u8]) -> Result<Image, String> {
let image = match image::load_from_memory(data) {
Ok(image) => image,
Err(e) => {
return Err(e.to_string());
}
};
self.image_registry.register_image(image).map_err(|e| e.to_string())
}
}
impl RenderAsync for RunImageSnippet {
fn start_render(&self) -> bool {
if !matches!(*self.state.borrow(), RunImageSnippetState::NotStarted) {
return false;
}
let state = match self.executor.execute_async(&self.snippet) {
Ok(handle) => RunImageSnippetState::Running(handle),
Err(e) => RunImageSnippetState::Failure(e.to_string().lines().map(ToString::to_string).collect()),
};
*self.state.borrow_mut() = state;
true
}
fn poll_state(&self) -> RenderAsyncState {
let mut state = self.state.borrow_mut();
match state.deref_mut() {
RunImageSnippetState::NotStarted => RenderAsyncState::NotStarted,
RunImageSnippetState::Running(handle) => {
let mut inner = handle.state.lock().unwrap();
match inner.status {
ProcessStatus::Running => RenderAsyncState::Rendering { modified: false },
ProcessStatus::Success => {
let data = mem::take(&mut inner.output);
drop(inner);
let image = match self.load_image(&data) {
Ok(image) => image,
Err(e) => {
*state = RunImageSnippetState::Failure(vec![e.to_string()]);
return RenderAsyncState::JustFinishedRendering;
}
};
*state = RunImageSnippetState::Success(image);
RenderAsyncState::JustFinishedRendering
}
ProcessStatus::Failure => {
let mut lines = Vec::new();
for line in inner.output.lines() {
lines.push(line.unwrap_or_else(|_| String::new()));
}
drop(inner);
*state = RunImageSnippetState::Failure(lines);
RenderAsyncState::JustFinishedRendering
}
}
}
RunImageSnippetState::Success(_) | RunImageSnippetState::Failure(_) => RenderAsyncState::Rendered,
}
}
}
impl AsRenderOperations for RunImageSnippet {
fn as_render_operations(&self, _dimensions: &WindowSize) -> Vec<RenderOperation> {
let state = self.state.borrow();
match state.deref() {
RunImageSnippetState::NotStarted | RunImageSnippetState::Running(_) => vec![],
RunImageSnippetState::Success(image) => {
vec![RenderOperation::RenderImage(
image.clone(),
ImageRenderProperties {
z_index: 0,
size: ImageSize::ShrinkIfNeeded,
restore_cursor: false,
background_color: None,
},
)]
}
RunImageSnippetState::Failure(lines) => {
let mut output = Vec::new();
for line in lines {
output.extend([RenderOperation::RenderText {
line: vec![Text::new(line, TextStyle::default().colors(self.colors.failure))].into(),
alignment: Alignment::Left { margin: Margin::Percent(25) },
}]);
}
output
}
}
}
}
#[derive(Debug, Default)]
enum RunImageSnippetState {
#[default]
NotStarted,
Running(ExecutionHandle),
Success(Image),
Failure(Vec<String>),
}

View File

@ -1,141 +0,0 @@
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
}
}

View File

@ -1,64 +0,0 @@
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,
}

View File

@ -1,159 +0,0 @@
use crate::{
code::{
execute::{ExecutionHandle, ProcessStatus, SnippetExecutor},
snippet::Snippet,
},
markdown::elements::Text,
render::{
operation::{
AsRenderOperations, ImageRenderProperties, Pollable, PollableState, RenderAsync, RenderAsyncStartPolicy,
RenderOperation,
},
properties::WindowSize,
},
terminal::image::{
Image,
printer::{ImageRegistry, ImageSpec},
},
theme::{Alignment, ExecutionStatusBlockStyle, Margin},
};
use std::{
io::BufRead,
mem,
ops::Deref,
sync::{Arc, Mutex},
};
#[derive(Debug)]
pub(crate) struct RunImageSnippet {
snippet: Snippet,
executor: Arc<SnippetExecutor>,
state: Arc<Mutex<State>>,
image_registry: ImageRegistry,
colors: ExecutionStatusBlockStyle,
}
impl RunImageSnippet {
pub(crate) fn new(
snippet: Snippet,
executor: Arc<SnippetExecutor>,
image_registry: ImageRegistry,
colors: ExecutionStatusBlockStyle,
) -> Self {
Self { snippet, executor, image_registry, colors, state: Default::default() }
}
}
impl RenderAsync for RunImageSnippet {
fn pollable(&self) -> Box<dyn Pollable> {
Box::new(OperationPollable {
state: self.state.clone(),
executor: self.executor.clone(),
snippet: self.snippet.clone(),
image_registry: self.image_registry.clone(),
})
}
fn start_policy(&self) -> RenderAsyncStartPolicy {
RenderAsyncStartPolicy::Automatic
}
}
impl AsRenderOperations for RunImageSnippet {
fn as_render_operations(&self, _dimensions: &WindowSize) -> Vec<RenderOperation> {
let state = self.state.lock().unwrap();
match state.deref() {
State::NotStarted | State::Running(_) => vec![],
State::Success(image) => {
vec![RenderOperation::RenderImage(image.clone(), ImageRenderProperties::default())]
}
State::Failure(lines) => {
let mut output = Vec::new();
for line in lines {
output.extend([RenderOperation::RenderText {
line: vec![Text::new(line, self.colors.failure_style)].into(),
alignment: Alignment::Left { margin: Margin::Percent(25) },
}]);
}
output
}
}
}
}
struct OperationPollable {
state: Arc<Mutex<State>>,
executor: Arc<SnippetExecutor>,
snippet: Snippet,
image_registry: ImageRegistry,
}
impl OperationPollable {
fn load_image(&self, data: &[u8]) -> Result<Image, String> {
let image = match image::load_from_memory(data) {
Ok(image) => image,
Err(e) => {
return Err(e.to_string());
}
};
self.image_registry.register(ImageSpec::Generated(image)).map_err(|e| e.to_string())
}
}
impl Pollable for OperationPollable {
fn poll(&mut self) -> PollableState {
let mut state = self.state.lock().unwrap();
match state.deref() {
State::NotStarted => match self.executor.execute_async(&self.snippet) {
Ok(handle) => {
*state = State::Running(handle);
PollableState::Unmodified
}
Err(e) => {
*state = State::Failure(e.to_string().lines().map(ToString::to_string).collect());
PollableState::Done
}
},
State::Running(handle) => {
let mut inner = handle.state.lock().unwrap();
match inner.status {
ProcessStatus::Running => PollableState::Unmodified,
ProcessStatus::Success => {
let data = mem::take(&mut inner.output);
drop(inner);
match self.load_image(&data) {
Ok(image) => {
*state = State::Success(image);
}
Err(e) => {
*state = State::Failure(vec![e.to_string()]);
}
};
PollableState::Done
}
ProcessStatus::Failure => {
let mut lines = Vec::new();
for line in inner.output.lines() {
lines.push(line.unwrap_or_else(|_| String::new()));
}
drop(inner);
*state = State::Failure(lines);
PollableState::Done
}
}
}
State::Success(_) | State::Failure(_) => PollableState::Done,
}
}
}
#[derive(Debug, Default)]
enum State {
#[default]
NotStarted,
Running(ExecutionHandle),
Success(Image),
Failure(Vec<String>),
}

Some files were not shown because too many files have changed in this diff Show More