Compare commits

..

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

77 changed files with 1904 additions and 4648 deletions

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,96 +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

181
Cargo.lock generated
View File

@ -17,6 +17,21 @@ dependencies = [
"memchr",
]
[[package]]
name = "android-tzdata"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
[[package]]
name = "android_system_properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
dependencies = [
"libc",
]
[[package]]
name = "anstream"
version = "0.6.18"
@ -157,6 +172,19 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "chrono"
version = "0.4.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c"
dependencies = [
"android-tzdata",
"iana-time-zone",
"num-traits",
"serde",
"windows-link",
]
[[package]]
name = "clap"
version = "4.5.31"
@ -223,6 +251,12 @@ dependencies = [
"unicode_categories",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "crc32fast"
version = "1.4.2"
@ -258,6 +292,41 @@ dependencies = [
"winapi",
]
[[package]]
name = "darling"
version = "0.20.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989"
dependencies = [
"darling_core",
"darling_macro",
]
[[package]]
name = "darling_core"
version = "0.20.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn",
]
[[package]]
name = "darling_macro"
version = "0.20.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806"
dependencies = [
"darling_core",
"quote",
"syn",
]
[[package]]
name = "deranged"
version = "0.3.11"
@ -265,6 +334,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4"
dependencies = [
"powerfmt",
"serde",
]
[[package]]
@ -386,6 +456,12 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2"
[[package]]
name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.15.2"
@ -404,6 +480,35 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "iana-time-zone"
version = "0.1.61"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "image"
version = "0.25.5"
@ -420,6 +525,17 @@ dependencies = [
"zune-jpeg",
]
[[package]]
name = "indexmap"
version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
dependencies = [
"autocfg",
"hashbrown 0.12.3",
"serde",
]
[[package]]
name = "indexmap"
version = "2.7.1"
@ -427,7 +543,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c9c992b02b5b4c94ea26e32fe5bccb7aa7d9f390ab5c1221ff895bc7ea8b652"
dependencies = [
"equivalent",
"hashbrown",
"hashbrown 0.15.2",
"serde",
]
[[package]]
@ -451,6 +568,16 @@ version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674"
[[package]]
name = "js-sys"
version = "0.3.77"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f"
dependencies = [
"once_cell",
"wasm-bindgen",
]
[[package]]
name = "libc"
version = "0.2.170"
@ -628,7 +755,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42cf17e9a1800f5f396bc67d193dc9411b59012a5876445ef450d449881e1016"
dependencies = [
"base64",
"indexmap",
"indexmap 2.7.1",
"quick-xml",
"serde",
"time",
@ -655,7 +782,7 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "presenterm"
version = "0.13.0"
version = "0.11.0"
dependencies = [
"anyhow",
"base64",
@ -677,6 +804,7 @@ dependencies = [
"schemars",
"serde",
"serde_json",
"serde_with",
"serde_yaml",
"sixel-rs",
"socket2",
@ -920,13 +1048,43 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_with"
version = "3.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6b6f7f2fcb69f747921f79f3926bd1e203fce4fef62c268dd3abfb6d86029aa"
dependencies = [
"base64",
"chrono",
"hex",
"indexmap 1.9.3",
"indexmap 2.7.1",
"serde",
"serde_derive",
"serde_json",
"serde_with_macros",
"time",
]
[[package]]
name = "serde_with_macros"
version = "3.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d00caa5193a3c8362ac2b73be6b9e768aa5a4b2f721d8f4b339600c3cb51f8e"
dependencies = [
"darling",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap",
"indexmap 2.7.1",
"itoa",
"ryu",
"serde",
@ -1350,6 +1508,21 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-core"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-link"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dccfd733ce2b1753b03b6d3c65edf020262ea35e20ccdf3e288043e6dd620e3"
[[package]]
name = "windows-sys"
version = "0.52.0"

View File

@ -4,7 +4,7 @@ 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.11.0"
edition = "2021"
[dependencies]
@ -23,10 +23,11 @@ sixel-rs = { version = "0.4.1", optional = true }
merge-struct = "0.1.0"
itertools = "0.14"
once_cell = "1.19"
schemars = { version = "0.8", optional = true }
schemars = "0.8"
serde = { version = "1.0", features = ["derive"] }
serde_yaml = "0.9"
serde_json = "1.0"
serde_with = "3.6"
syntect = { version = "5.2", features = ["parsing", "default-themes", "regex-onig", "plist-load"], default-features = false }
socket2 = "0.5.8"
strum = { version = "0.27", features = ["derive"] }
@ -44,7 +45,6 @@ rstest = { version = "0.25", default-features = false }
[features]
default = []
sixel = ["sixel-rs"]
json-schema = ["dep:schemars"]
[profile.dev]
opt-level = 0

View File

@ -45,7 +45,6 @@ Visit the [documentation][docs-introduction] to get started.
* [Slide titles][docs-slide-titles].
* [Snippet execution][docs-code-execute] for various programming languages.
* [Export presentations to PDF][docs-pdf-export].
* [Slide transitions][docs-slide-transitions].
* [Pause][docs-pauses] portions of your slides.
* [Custom key bindings][docs-key-bindings].
* [Automatically reload your presentation][docs-hot-reload] every time it changes for a fast development loop.
@ -53,16 +52,6 @@ Visit the [documentation][docs-introduction] to get started.
See the [introduction page][docs-introduction] to learn more.
# Presenterm in action
Here are some talks and demos that feature _presenterm_:
- [Bringing Terminal Aesthetics to the Web With Rust][bringing-terminal-aesthetics] by [Orhun Parmaksız][orhun-github]
- [7 Rust Terminal Tools That You Should Use][rust-terminal-tools] by [Orhun Parmaksız][orhun-github]
- [Renaissance of Terminal User Interfaces with Rust][renaissance-tui] by [Orhun Parmaksız][orhun-github]
Gave a talk using _presenterm_? We would love to feature it here! Open a PR or issue to get it added.
<!-- links -->
[docs-introduction]: https://mfontanini.github.io/presenterm/
@ -77,7 +66,6 @@ Gave a talk using _presenterm_? We would love to feature it here! Open a PR or i
[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
@ -87,7 +75,5 @@ Gave a talk using _presenterm_? We would love to feature it here! Open a PR or i
[docs-speaker-notes]: https://mfontanini.github.io/presenterm/features/speaker-notes.html
[bat]: https://github.com/sharkdp/bat
[syntect]: https://github.com/trishume/syntect
[bringing-terminal-aesthetics]: https://www.youtube.com/watch?v=iepbyYrF_YQ
[rust-terminal-tools]: https://www.youtube.com/watch?v=ATiKwUiqnAU
[renaissance-tui]: https://www.youtube.com/watch?v=hWG51Mc1DlM
[orhun-github]: https://github.com/orhun

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"
}
@ -79,21 +66,6 @@
}
]
},
"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 +91,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": [
{
@ -409,32 +332,6 @@
}
]
},
"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": {
@ -503,108 +400,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 +497,6 @@
"Java",
"JavaScript",
"Json",
"Julia",
"Kotlin",
"Latex",
"Lua",

View File

@ -16,7 +16,6 @@
- [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)

View File

@ -10,7 +10,7 @@ custom themes, in the following directories:
The configuration file will be looked up automatically in the directories above under the name `config.yaml`. e.g. on
Linux you should create it under `~/.config/presenterm/config.yaml`. You can also specify a custom path to this file
when running _presenterm_ via the `--config-file` parameter.
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.

View File

@ -61,59 +61,13 @@ defaults:
If you would like your presentation to be left or right aligned instead of centered when the terminal is too wide, you
can use the `max_columns_alignment` key:
```yaml
defaults:
max_columns: 100
# Valid values: left, center, right
max_columns_alignment: left
```
## Maximum presentation height
The `max_rows` and `max_rows_alignment` properties are analogous to `max_columns*` to allow capping the maximum number
of rows:
```yaml
defaults:
max_rows: 100
# Valid values: top, center, bottom
max_rows_alignment: left
```
## Incremental lists behavior
By default, [incremental lists](../features/commands.md) will pause before and after a list. If you would like to change
this behavior, use the `defaults.incremental_lists` key:
```yaml
defaults:
incremental_lists:
# The defaults, change to false if desired.
pause_before: true
pause_after: true
```
# Slide transitions
Slide transitions allow animating your presentation every time you move from a slide to the next/previous one. The
configuration for slide transitions is the following:
```yaml
transition:
# how long the transition should last.
duration_millis: 750
# how many frames should be rendered during the transition
frames: 45
# the animation to use
animation:
style: <style_name>
```
See the [slide transitions page](../features/slide-transitions.md) for more information on which animation styles are
supported.
# Key bindings
Key bindings that _presenterm_ uses can be manually configured in the config file via the `bindings` key. The following
@ -127,16 +81,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"]
@ -173,8 +117,6 @@ default won't apply anymore and only what you've defined will be used.
# Snippet configurations
The configurations that affect code snippets in presentations.
## Snippet execution
[Snippet execution](../features/code/execution.md#executing-code-blocks) is disabled by default for security reasons.
@ -274,32 +216,7 @@ 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

@ -30,7 +30,6 @@ Code highlighting is supported for the following languages:
| java | ✓ |
| javascript | ✓ |
| json | |
| julia | ✓ |
| kotlin | ✓ |
| latex | |
| lua | ✓ |
@ -141,18 +140,6 @@ 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

View File

@ -94,30 +94,3 @@ If you don't want the footer to show up in some particular slide for some reason
<!-- no_footer -->
```
## Skip slide
If you don't want a specific slide to be included in the presentation use the `skip_slide` command:
```html
<!-- skip_slide -->
```
## Text alignment
The text alignment for the remainder of the slide can be configured via the `alignment` command, which can use values:
`left`, `center`, and `right`:
```markdown
<!-- alignment: left -->
left alignment, the default
<!-- alignment: center -->
centered
<!-- alignment: right -->
right aligned
```

View File

@ -1,35 +1,45 @@
# 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.
Presentations can be converted into PDF by using a [helper tool](https://github.com/mfontanini/presenterm-export). You
can install it by running:
> [!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.
```bash
pip install presenterm-export
```
> [!important]
> Make sure that `presenterm-export` works by running `presenterm-export --version` before attempting to generate a PDF
> file. If you get errors related to _weasyprint_, follow their [installation instructions](https://doc.courtbouillon.org/weasyprint/stable/first_steps.html) to ensure you meet all of their
> dependencies. This has otherwise caused issues in macOS.
After you've installed _weasyprint_, run _presenterm_ with the `--export-pdf` parameter to generate the output PDF:
_presenterm-export_ uses [tmux](https://github.com/tmux/tmux/) to run _presenterm_ inside it and capture its output.
After you've installed both _presenterm-export_ and _tmux_, run _presenterm_ with the `--export-pdf` parameter to
generate the output PDF:
```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.
The output PDF will be placed in `examples/demo.pdf`.
> [!note]
> If you're using a separate virtual env to install _weasyprint_ just make sure you activate it before running
> 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.
## PDF page size
## Page sizes
By default, the size of each page in the generated PDF will depend on the size of your terminal.
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.
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).
## tmux <= 3.5a active sessions bug
## Pause behavior
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.
See the [settings page](../configuration/settings.md#pause-behavior) to learn how to configure the behavior of pauses in
generated PDFs.
## 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

@ -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

@ -56,16 +56,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

@ -163,7 +163,7 @@ using another brace. e.g. `{{potato}} farms` will be displayed as `{potato} farm
#### Footer images
Besides text, images can also be used in the left/center/right positions. This can be done by specifying an `image` key
Besides text, images can also be used in the left and center positions. This can be done by specifying an `image` key
under each of those attributes:
```yaml
@ -173,8 +173,7 @@ footer:
image: potato.png
center:
image: banana.png
right:
image: apple.png
right: "{current_slide} / {total_slides}"
# The height of the footer to adjust image sizes
height: 5
```

View File

@ -69,18 +69,17 @@ when used.
Currently, the following themes are supported:
* `dark`: A dark theme.
* `light`: A light theme.
* `tokyonight-storm`: A theme inspired by the colors used in [toyonight](https://github.com/folke/tokyonight.nvim).
* A set of themes based on the [catppuccin](https://github.com/catppuccin/catppuccin) color palette:
* `catppuccin-latte`
* `catppuccin-frappe`
* `catppuccin-macchiato`
* `catppuccin-mocha`
* `dark`: A dark theme.
* `gruvbox`: A theme inspired by the colors used in [gruvbox](https://github.com/morhetz/gruvbox).
* `light`: A light theme.
* `terminal-dark`: A theme that uses your terminals color and looks best if your terminal uses a dark color scheme. This
means if your terminal background is e.g. transparent, or uses an image, the presentation will inherit that.
means if your terminal background is e.g. transparent, or uses an image, the presentation will inherit that.
* `terminal-light`: The same as `terminal-dark` but works best if your terminal uses a light color scheme.
* `tokyonight-storm`: A theme inspired by the colors used in [toyonight](https://github.com/folke/tokyonight.nvim).
## Trying out built-in themes

View File

@ -42,11 +42,6 @@ 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:

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

@ -131,7 +131,6 @@ impl SnippetHighlighter {
Java => "java",
JavaScript => "js",
Json => "json",
Julia => "jl",
Kotlin => "kt",
Latex => "tex",
Lua => "lua",

View File

@ -15,7 +15,9 @@ 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;
@ -438,8 +440,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 +470,6 @@ pub enum SnippetLanguage {
Java,
JavaScript,
Json,
Julia,
Kotlin,
Latex,
Lua,
@ -508,8 +508,6 @@ pub enum SnippetLanguage {
Zsh,
}
crate::utils::impl_deserialize_from_str!(SnippetLanguage);
impl FromStr for SnippetLanguage {
type Err = Infallible;
@ -543,7 +541,6 @@ impl FromStr for SnippetLanguage {
"java" => Java,
"javascript" | "js" => JavaScript,
"json" => Json,
"julia" => Julia,
"kotlin" => Kotlin,
"latex" => Latex,
"lua" => Lua,
@ -659,8 +656,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)]

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

@ -7,6 +7,7 @@ use crate::{
},
};
use clap::ValueEnum;
use schemars::JsonSchema;
use serde::Deserialize;
use std::{
collections::{BTreeMap, HashMap},
@ -15,8 +16,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 +40,6 @@ pub struct Config {
#[serde(default)]
pub speaker_notes: SpeakerNotesConfig,
#[serde(default)]
pub export: ExportConfig,
#[serde(default)]
pub transition: Option<SlideTransitionConfig>,
}
impl Config {
@ -73,8 +67,7 @@ pub enum ConfigLoadError {
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.
@ -82,7 +75,7 @@ pub struct DefaultsConfig {
/// 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)))]
#[validate(range(min = 1))]
pub terminal_font_size: u8,
/// The image protocol to use.
@ -94,7 +87,7 @@ 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
@ -102,15 +95,6 @@ pub struct DefaultsConfig {
#[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,
@ -123,18 +107,15 @@ impl Default for DefaultsConfig {
terminal_font_size: default_terminal_font_size(),
image_protocol: Default::default(),
validate_overflows: Default::default(),
max_columns: default_u16_max(),
max_columns: default_max_columns(),
max_columns_alignment: Default::default(),
max_rows: default_u16_max(),
max_rows_alignment: Default::default(),
incremental_lists: Default::default(),
}
}
}
/// The configuration for lists when incremental lists are enabled.
#[derive(Clone, Debug, Default, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct IncrementalListsConfig {
/// Whether to pause before a list begins.
@ -151,8 +132,7 @@ fn default_terminal_font_size() -> u8 {
}
/// The alignment to use when `defaults.max_columns` is set.
#[derive(Clone, Copy, Debug, Default, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[derive(Clone, Copy, Debug, Default, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum MaxColumnsAlignment {
/// Align the presentation to the left.
@ -166,24 +146,7 @@ pub enum MaxColumnsAlignment {
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 +156,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.
@ -220,8 +182,7 @@ pub struct OptionsConfig {
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 +198,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 +209,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 +217,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 +235,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 +253,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 +271,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 +292,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 +346,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 +433,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 +459,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 +523,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)

View File

@ -14,7 +14,7 @@ use crate::{
terminal::emulator::TerminalEmulator,
theme::raw::PresentationTheme,
};
use std::{io, sync::Arc};
use std::{io, rc::Rc};
const PRESENTATION: &str = r#"
# Header 1
@ -109,7 +109,7 @@ impl ThemesDemo {
theme_options: ThemeOptions { font_size_supported: TerminalEmulator::capabilities().font_size },
..Default::default()
};
let executer = Arc::new(SnippetExecutor::default());
let executer = Rc::new(SnippetExecutor::default());
let bindings_config = Default::default();
let builder = PresentationBuilder::new(
theme,

View File

@ -1,17 +1,16 @@
use crate::{
MarkdownParser, Resources,
code::execute::SnippetExecutor,
config::{KeyBindingsConfig, PauseExportPolicy},
export::output::{ExportRenderer, OutputFormat},
config::KeyBindingsConfig,
export::pdf::PdfRender,
markdown::{parse::ParseError, text_style::Color},
presentation::{
Presentation,
Presentation, Slide,
builder::{BuildError, PresentationBuilder, PresentationBuilderOptions, Themes},
poller::{Poller, PollerCommand},
},
render::{
RenderError,
operation::{AsRenderOperations, PollableState, RenderOperation},
operation::{AsRenderOperations, RenderAsyncState, RenderOperation},
properties::WindowSize,
},
theme::{ProcessingThemeError, raw::PresentationTheme},
@ -25,37 +24,7 @@ use crossterm::{
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,
}
}
}
use std::{fs, io, path::Path, rc::Rc, thread::sleep, time::Duration};
/// Allows exporting presentations into PDF.
pub struct Exporter<'a> {
@ -63,7 +32,7 @@ pub struct Exporter<'a> {
default_theme: &'a PresentationTheme,
resources: Resources,
third_party: ThirdPartyRender,
code_executor: Arc<SnippetExecutor>,
code_executor: Rc<SnippetExecutor>,
themes: Themes,
dimensions: WindowSize,
options: PresentationBuilderOptions,
@ -77,19 +46,14 @@ impl<'a> Exporter<'a> {
default_theme: &'a PresentationTheme,
resources: Resources,
third_party: ThirdPartyRender,
code_executor: Arc<SnippetExecutor>,
code_executor: Rc<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);
@ -98,12 +62,19 @@ impl<'a> Exporter<'a> {
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> {
/// Export the given presentation into PDF.
///
/// This uses a separate `presenterm-export` tool.
pub fn export_pdf(mut self, presentation_path: &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 content = fs::read_to_string(presentation_path).map_err(ExportError::ReadPresentation)?;
let elements = self.parser.parse(&content)?;
@ -120,10 +91,11 @@ impl<'a> Exporter<'a> {
.build(elements)?;
Self::validate_theme_colors(&presentation)?;
let mut render = ExportRenderer::new(self.dimensions.clone(), output_directory, renderer);
let mut render = PdfRender::new(self.dimensions)?;
Self::log("waiting for images to be generated and code to be executed, if any...")?;
Self::render_async_images(&mut presentation);
for slide in presentation.iter_slides_mut() {
Self::render_async_images(slide);
}
for (index, slide) in presentation.into_slides().into_iter().enumerate() {
let index = index + 1;
Self::log(&format!("processing slide {index}..."))?;
@ -131,32 +103,7 @@ impl<'a> Exporter<'a> {
}
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"),
};
let pdf_path = presentation_path.with_extension("pdf");
render.generate(&pdf_path)?;
execute!(
@ -168,63 +115,24 @@ impl<'a> Exporter<'a> {
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 render_async_images(slide: &mut Slide) {
for op in slide.iter_operations_mut() {
if let RenderOperation::RenderAsync(inner) = op {
loop {
match inner.poll_state() {
RenderAsyncState::Rendering { .. } => {
sleep(Duration::from_millis(200));
continue;
}
RenderAsyncState::Rendered | RenderAsyncState::JustFinishedRendering => break,
RenderAsyncState::NotStarted => inner.start_render(),
};
}
let window_size = WindowSize { rows: 0, columns: 0, width: 0, height: 0 };
let new_operations = inner.as_render_operations(&window_size);
// Replace this operation with a new operation that contains the replaced image
// and any other unmodified operations.
*op = RenderOperation::RenderDynamic(Rc::new(RenderMany(new_operations)));
}
}
}

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 +1,2 @@
pub mod exporter;
pub(crate) mod html;
pub(crate) mod output;
pub(crate) mod pdf;

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(())
}
}

262
src/export/pdf.rs Normal file
View File

@ -0,0 +1,262 @@
use super::exporter::ExportError;
use crate::{
markdown::text_style::{Color, TextStyle},
presentation::Slide,
render::{engine::RenderEngine, properties::WindowSize},
terminal::{
image::{
Image, ImageSource,
printer::{ImageProperties, TerminalImage},
},
virt::{TerminalGrid, VirtualTerminal},
},
tools::ThirdPartyTools,
};
use image::{ImageEncoder, codecs::png::PngEncoder};
use std::{
borrow::Cow,
fs, io,
path::{Path, PathBuf},
};
use tempfile::TempDir;
// 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, content_manager: &mut ContentManager) -> Result<Self, ExportError> {
let mut rows = Vec::new();
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();
for (x, c) in row.into_iter().enumerate() {
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 image_path = content_manager.persist_image(&image.image)?;
let image_path_str = image_path.display();
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=\"file://{image_path_str}\" style=\"position: absolute\" />"
);
current_string.push_str(&image_tag);
}
}
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);
}
Ok(HtmlSlide { rows, background_color: grid.background_color.as_ref().map(Self::color_to_html) })
}
fn finalize_string(s: &str, style: &TextStyle) -> String {
if style == &TextStyle::default() {
return s.to_string();
}
let mut css_styles = Vec::new();
if style.is_bold() {
css_styles.push(Cow::Borrowed("font-weight: bold"));
}
if style.is_italics() {
css_styles.push(Cow::Borrowed("font-style: italic"));
}
if style.is_strikethrough() && style.is_underlined() {
css_styles.push(Cow::Borrowed("text-decoration: line-through underline"));
} else if style.is_strikethrough() {
css_styles.push(Cow::Borrowed("text-decoration: line-through"));
} else if style.is_underlined() {
css_styles.push(Cow::Borrowed("text-decoration: underline"));
}
if let Some(color) = &style.colors.background {
let color = Self::color_to_html(color);
css_styles.push(format!("background-color: {color}").into());
}
if let Some(color) = &style.colors.foreground {
let color = Self::color_to_html(color);
css_styles.push(format!("color: {color}").into());
}
if style.size > 1 {
let font_size = FONT_SIZE * style.size as u16;
css_styles.push(format!("font-size: {font_size}px").into());
}
let css_style = css_styles.join("; ");
format!("<span style=\"{css_style}\">{s}</span>")
}
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}"),
}
}
}
pub(crate) struct ContentManager {
output_directory: TempDir,
image_count: usize,
}
impl ContentManager {
pub(crate) fn new() -> io::Result<Self> {
let output_directory = TempDir::with_suffix("presenterm")?;
Ok(Self { output_directory, image_count: 0 })
}
fn persist_image(&mut self, image: &Image) -> Result<PathBuf, ExportError> {
match image.source.clone() {
ImageSource::Filesystem(path) => Ok(path),
ImageSource::Generated => {
let mut buffer = Vec::new();
let dimensions = image.dimensions();
let TerminalImage::Ascii(resource) = image.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 name = format!("img-{}.png", self.image_count);
let path = self.output_directory.path().join(name);
fs::write(&path, buffer)?;
self.image_count += 1;
Ok(path)
}
}
}
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) struct PdfRender {
content_manager: ContentManager,
dimensions: WindowSize,
html_body: String,
background_color: Option<String>,
}
impl PdfRender {
pub(crate) fn new(dimensions: WindowSize) -> io::Result<Self> {
let image_manager = ContentManager::new()?;
Ok(Self { content_manager: image_manager, dimensions, html_body: "".to_string(), background_color: None })
}
pub(crate) fn process_slide(&mut self, slide: Slide) -> Result<(), ExportError> {
let mut terminal = VirtualTerminal::new(self.dimensions.clone());
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, &mut self.content_manager)?;
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, pdf_path: &Path) -> Result<(), ExportError> {
let html_body = &self.html_body;
let html = format!(
r#"<html>
<head>
</head>
<body>
{html_body}</body>
</html>"#
);
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 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;
background-color: {background_color};
width: {width}px;
}}
.content-line {{
line-height: {LINE_HEIGHT}px;
height: {LINE_HEIGHT}px;
margin: 0px;
width: {width}px;
}}
@page {{
margin: 0;
height: {height}px;
width: {width}px;
}}"
);
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())?;
ThirdPartyTools::weasyprint(&[
"-s",
css_path.to_string_lossy().as_ref(),
"--presentational-hints",
"-e",
"utf8",
html_path.to_string_lossy().as_ref(),
pdf_path.to_string_lossy().as_ref(),
])
.run()?;
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

@ -25,12 +25,12 @@ use crossterm::{
style::{PrintStyledContent, Stylize},
};
use directories::ProjectDirs;
use export::exporter::OutputDirectory;
use render::{engine::MaxSize, properties::WindowSize};
use render::properties::WindowSize;
use std::{
env::{self, current_dir},
io,
path::{Path, PathBuf},
rc::Rc,
sync::Arc,
};
use terminal::emulator::TerminalEmulator;
@ -50,13 +50,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,24 +64,11 @@ 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 a JSON schema for the configuration file.
#[clap(long)]
#[cfg(feature = "json-schema")]
generate_config_file_schema: bool,
/// Use presentation mode.
@ -197,7 +180,7 @@ impl Customizations {
struct CoreComponents {
third_party: ThirdPartyRender,
code_executor: Arc<SnippetExecutor>,
code_executor: Rc<SnippetExecutor>,
resources: Resources,
printer: Arc<ImagePrinter>,
builder_options: PresentationBuilderOptions,
@ -235,7 +218,7 @@ 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 registry = ImageRegistry(printer.clone());
let resources = Resources::new(
resources_path.clone(),
themes_path.unwrap_or_else(|| resources_path.clone()),
@ -247,7 +230,7 @@ impl CoreComponents {
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,
@ -288,13 +271,12 @@ impl CoreComponents {
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.export_pdf {
GraphicsMode::AsciiBlocks
} else {
let protocol = cli.image_protocol.as_ref().unwrap_or(&config.defaults.image_protocol);
match GraphicsMode::try_from(protocol) {
@ -354,13 +336,11 @@ fn overflow_validation_enabled(mode: &PresentMode, config: &ValidateOverflows) -
}
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(());
@ -405,16 +385,8 @@ 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)?,
};
if cli.export_pdf {
let dimensions = WindowSize::current(config.defaults.terminal_font_size)?;
let exporter = Exporter::new(
parser,
&default_theme,
@ -424,17 +396,8 @@ fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
themes,
builder_options,
dimensions,
config.export.pauses,
);
let output_directory = match cli.export_temporary_path {
Some(path) => OutputDirectory::external(path),
None => OutputDirectory::temporary(),
}?;
if cli.export_pdf {
exporter.export_pdf(&path, output_directory, cli.export_output.as_deref())?;
} else {
exporter.export_html(&path, output_directory, cli.export_output.as_deref())?;
}
exporter.export_pdf(&path)?;
} else {
let SpeakerNotesComponents { events_listener, events_publisher } =
SpeakerNotesComponents::new(&cli, &config, &path)?;
@ -447,13 +410,8 @@ 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,
max_columns_alignment: config.defaults.max_columns_alignment,
};
let presenter = Presenter::new(
&default_theme,

View File

@ -162,11 +162,6 @@ impl<C> Text<C> {
pub(crate) fn new<S: Into<String>>(content: S, style: TextStyle<C>) -> Self {
Self { content: content.into(), style }
}
/// Get the width of this text.
pub(crate) fn width(&self) -> usize {
self.content.width()
}
}
impl<C> From<String> for Text<C> {

View File

@ -89,11 +89,31 @@ where
self
}
/// Check whether this text style is bold.
pub(crate) fn is_bold(&self) -> bool {
self.has_flag(TextFormatFlags::Bold)
}
/// Check whether this text style has italics.
pub(crate) fn is_italics(&self) -> bool {
self.has_flag(TextFormatFlags::Italics)
}
/// Check whether this text is code.
pub(crate) fn is_code(&self) -> bool {
self.has_flag(TextFormatFlags::Code)
}
/// Check whether this text style is strikethrough.
pub(crate) fn is_strikethrough(&self) -> bool {
self.has_flag(TextFormatFlags::Strikethrough)
}
/// Check whether this text style is underlined.
pub(crate) fn is_underlined(&self) -> bool {
self.has_flag(TextFormatFlags::Underlined)
}
/// Merge this style with another one.
pub(crate) fn merge(&mut self, other: &TextStyle<C>) {
self.flags |= other.flags;
@ -123,15 +143,23 @@ impl TextStyle<Color> {
pub(crate) fn apply<'a>(&self, text: &'a str) -> StyledContent<impl Display + Clone + 'a> {
let text = FontSizedStr { contents: text, font_size: self.size };
let mut styled = StyledContent::new(Default::default(), text);
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()),
}
if self.is_bold() {
styled = styled.bold();
}
if self.is_italics() {
styled = styled.italic();
}
if self.is_strikethrough() {
styled = styled.crossed_out();
}
if self.is_underlined() {
styled = styled.underlined();
}
if let Some(color) = self.colors.background {
styled = styled.on(color.into());
}
if let Some(color) = self.colors.foreground {
styled = styled.with(color.into());
}
styled
}
@ -143,16 +171,6 @@ impl TextStyle<Color> {
};
TextStyle { flags: self.flags, colors, size: self.size }
}
/// Iterate all attributes in this style.
pub(crate) fn iter_attributes(&self) -> AttributeIterator {
AttributeIterator {
flags: self.flags,
next_mask: Some(TextFormatFlags::Bold),
background_color: self.colors.background,
foreground_color: self.colors.foreground,
}
}
}
impl TextStyle<RawColor> {
@ -162,57 +180,6 @@ impl TextStyle<RawColor> {
}
}
pub(crate) struct AttributeIterator {
flags: u8,
next_mask: Option<TextFormatFlags>,
background_color: Option<Color>,
foreground_color: Option<Color>,
}
impl Iterator for AttributeIterator {
type Item = TextAttribute;
fn next(&mut self) -> Option<Self::Item> {
if let Some(c) = self.background_color.take() {
return Some(TextAttribute::BackgroundColor(c));
}
if let Some(c) = self.foreground_color.take() {
return Some(TextAttribute::ForegroundColor(c));
}
use TextFormatFlags::*;
loop {
let next_mask = self.next_mask?;
self.next_mask = match next_mask {
Bold => Some(Italics),
Italics => Some(Strikethrough),
Code => Some(Strikethrough),
Strikethrough => Some(Underlined),
Underlined => None,
};
if self.flags & next_mask as u8 != 0 {
let attr = match next_mask {
Bold => TextAttribute::Bold,
Italics => TextAttribute::Italics,
Code => panic!("code shouldn't reach here"),
Strikethrough => TextAttribute::Strikethrough,
Underlined => TextAttribute::Underlined,
};
return Some(attr);
}
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum TextAttribute {
Bold,
Italics,
Strikethrough,
Underlined,
ForegroundColor(Color),
BackgroundColor(Color),
}
#[derive(Clone)]
struct FontSizedStr<'a> {
contents: &'a str,
@ -229,7 +196,7 @@ impl fmt::Display for FontSizedStr<'_> {
}
}
#[derive(Clone, Copy, Debug)]
#[derive(Debug)]
enum TextFormatFlags {
Bold = 1,
Italics = 2,
@ -357,34 +324,3 @@ pub(crate) enum ParseColorError {
#[error("invalid hex color: {0}")]
Hex(#[from] FromHexError),
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
#[rstest]
#[case::default(TextStyle::default(), &[])]
#[case::code(TextStyle::default().code(), &[])]
#[case::bold(TextStyle::default().bold(), &[TextAttribute::Bold])]
#[case::italics(TextStyle::default().italics(), &[TextAttribute::Italics])]
#[case::strikethrough(TextStyle::default().strikethrough(), &[TextAttribute::Strikethrough])]
#[case::underlined(TextStyle::default().underlined(), &[TextAttribute::Underlined])]
#[case::bg_color(TextStyle::default().bg_color(Color::Red), &[TextAttribute::BackgroundColor(Color::Red)])]
#[case::bg_color(TextStyle::default().fg_color(Color::Red), &[TextAttribute::ForegroundColor(Color::Red)])]
#[case::all(
TextStyle::default().bold().code().italics().strikethrough().underlined().bg_color(Color::Black).fg_color(Color::Red),
&[
TextAttribute::BackgroundColor(Color::Black),
TextAttribute::ForegroundColor(Color::Red),
TextAttribute::Bold,
TextAttribute::Italics,
TextAttribute::Strikethrough,
TextAttribute::Underlined,
]
)]
fn iterate_attributes(#[case] style: TextStyle, #[case] expected: &[TextAttribute]) {
let attrs: Vec<_> = style.iter_attributes().collect();
assert_eq!(attrs, expected);
}
}

View File

@ -22,10 +22,10 @@ use crate::{
resource::Resources,
terminal::image::{
Image,
printer::{ImageRegistry, ImageSpec, RegisterImageError},
printer::{ImageRegistry, RegisterImageError},
},
theme::{
Alignment, AuthorPositioning, ElementType, PresentationTheme, ProcessingThemeError, ThemeOptions,
Alignment, AuthorPositioning, ElementType, Margin, PresentationTheme, ProcessingThemeError, ThemeOptions,
raw::{self, RawColor},
registry::{LoadThemeError, PresentationThemeRegistry},
},
@ -40,7 +40,7 @@ use comrak::{Arena, nodes::AlertType};
use image::DynamicImage;
use serde::Deserialize;
use snippet::{SnippetOperations, SnippetProcessor, SnippetProcessorState};
use std::{collections::HashSet, fmt::Display, iter, mem, path::PathBuf, rc::Rc, str::FromStr, sync::Arc};
use std::{collections::HashSet, fmt::Display, iter, mem, path::PathBuf, rc::Rc, str::FromStr};
use unicode_width::UnicodeWidthStr;
mod snippet;
@ -71,7 +71,6 @@ pub struct PresentationBuilderOptions {
pub theme_options: ThemeOptions,
pub pause_before_incremental_lists: bool,
pub pause_after_incremental_lists: bool,
pub pause_create_new_slide: bool,
}
impl PresentationBuilderOptions {
@ -112,7 +111,6 @@ impl Default for PresentationBuilderOptions {
theme_options: ThemeOptions { font_size_supported: false },
pause_before_incremental_lists: true,
pause_after_incremental_lists: true,
pause_create_new_slide: false,
}
}
}
@ -127,7 +125,7 @@ pub(crate) struct PresentationBuilder<'a> {
chunk_mutators: Vec<Box<dyn ChunkMutator>>,
slide_builders: Vec<SlideBuilder>,
highlighter: SnippetHighlighter,
code_executor: Arc<SnippetExecutor>,
code_executor: Rc<SnippetExecutor>,
theme: PresentationTheme,
default_raw_theme: &'a raw::PresentationTheme,
resources: Resources,
@ -150,7 +148,7 @@ impl<'a> PresentationBuilder<'a> {
default_raw_theme: &'a raw::PresentationTheme,
resources: Resources,
third_party: &'a mut ThirdPartyRender,
code_executor: Arc<SnippetExecutor>,
code_executor: Rc<SnippetExecutor>,
themes: &'a Themes,
image_registry: ImageRegistry,
bindings_config: KeyBindingsConfig,
@ -248,7 +246,7 @@ impl<'a> PresentationBuilder<'a> {
};
let mut image = DynamicImage::new_rgba8(1, 1);
image.as_mut_rgba8().unwrap().get_pixel_mut(0, 0).0 = rgba;
let image = self.image_registry.register(ImageSpec::Generated(image))?;
let image = self.image_registry.register_image(image)?;
Ok(image)
}
@ -379,16 +377,7 @@ impl<'a> PresentationBuilder<'a> {
new_theme = Some(theme);
}
if let Some(theme_path) = &metadata.path {
let mut theme = self.resources.theme(theme_path)?;
if let Some(name) = &theme.extends {
let base = self
.themes
.presentation
.load_by_name(name)
.ok_or_else(|| BuildError::InvalidMetadata(format!("extended theme {name} not found")))?;
theme = merge_struct::merge(&theme, &base)
.map_err(|e| BuildError::InvalidMetadata(format!("invalid theme: {e}")))?;
}
let theme = self.resources.theme(theme_path)?;
new_theme = Some(theme);
}
}
@ -396,9 +385,8 @@ impl<'a> PresentationBuilder<'a> {
if overrides.extends.is_some() {
return Err(BuildError::InvalidMetadata("theme overrides can't use 'extends'".into()));
}
let base = new_theme.as_ref().unwrap_or(self.default_raw_theme);
// This shouldn't fail as the models are already correct.
let theme = merge_struct::merge(base, overrides)
let theme = merge_struct::merge(self.default_raw_theme, overrides)
.map_err(|e| BuildError::InvalidMetadata(format!("invalid theme: {e}")))?;
new_theme = Some(theme);
}
@ -610,12 +598,6 @@ impl<'a> PresentationBuilder<'a> {
}
fn push_pause(&mut self) {
if self.options.pause_create_new_slide {
let operations = self.chunk_operations.clone();
self.terminate_slide();
self.chunk_operations = operations;
return;
}
self.slide_state.last_chunk_ended_in_list = matches!(self.slide_state.last_element, LastElement::List { .. });
let chunk_operations = mem::take(&mut self.chunk_operations);
@ -667,11 +649,9 @@ impl<'a> PresentationBuilder<'a> {
other => panic!("unexpected heading level {other}"),
};
if let Some(prefix) = &style.prefix {
if !prefix.is_empty() {
let mut prefix = prefix.clone();
prefix.push(' ');
text.0.insert(0, Text::from(prefix));
}
let mut prefix = prefix.clone();
prefix.push(' ');
text.0.insert(0, Text::from(prefix));
}
text.apply_style(&style.style);
@ -741,9 +721,6 @@ impl<'a> PresentationBuilder<'a> {
_ => 0,
};
let block_length =
list.iter().map(|l| self.list_item_prefix(l).width() + l.contents.width()).max().unwrap_or_default() as u16;
let block_length = block_length * self.slide_font_size() as u16;
let incremental_lists = self.slide_state.incremental_lists.unwrap_or(self.options.incremental_lists);
let iter = ListIterator::new(list, start_index);
if incremental_lists && self.options.pause_before_incremental_lists {
@ -753,7 +730,7 @@ impl<'a> PresentationBuilder<'a> {
if index > 0 && incremental_lists {
self.push_pause();
}
self.push_list_item(item.index, item.item, block_length)?;
self.push_list_item(item.index, item.item)?;
}
if incremental_lists && self.options.pause_after_incremental_lists {
self.push_pause();
@ -761,46 +738,8 @@ impl<'a> PresentationBuilder<'a> {
Ok(())
}
fn push_list_item(&mut self, index: usize, item: ListItem, block_length: u16) -> BuildResult {
let prefix = self.list_item_prefix(&item);
let mut text = item.contents.resolve(&self.theme.palette)?;
let font_size = self.slide_font_size();
for piece in &mut text.0 {
if piece.style.is_code() {
piece.style.colors = self.theme.inline_code.style.colors;
}
piece.style = piece.style.size(font_size);
}
let alignment = self.slide_state.alignment.unwrap_or_default();
self.chunk_operations.push(RenderOperation::RenderBlockLine(BlockLine {
prefix: prefix.into(),
right_padding_length: 0,
repeat_prefix_on_wrap: false,
text: text.into(),
block_length,
alignment,
block_color: None,
}));
self.push_line_break();
if item.depth == 0 {
self.slide_state.last_element = LastElement::List { last_index: index };
}
Ok(())
}
fn list_item_prefix(&self, item: &ListItem) -> Text {
let font_size = self.slide_font_size();
let spaces_per_indent = match item.depth {
0 => 3_u8.div_ceil(font_size),
_ => {
if font_size == 1 {
3
} else {
2
}
}
};
let padding_length = (item.depth as usize + 1) * spaces_per_indent as usize;
fn push_list_item(&mut self, index: usize, item: ListItem) -> BuildResult {
let padding_length = (item.depth as usize + 1) * 3;
let mut prefix: String = " ".repeat(padding_length);
match item.item_type {
ListItemType::Unordered => {
@ -810,7 +749,6 @@ impl<'a> PresentationBuilder<'a> {
_ => '▪',
};
prefix.push(delimiter);
prefix.push_str(" ");
}
ListItemType::OrderedParens(value) => {
prefix.push_str(&value.to_string());
@ -821,7 +759,17 @@ impl<'a> PresentationBuilder<'a> {
prefix.push_str(". ");
}
};
Text::new(prefix, TextStyle::default().size(font_size))
let prefix_length = prefix.len() as u16 * self.slide_font_size() as u16;
self.push_text(prefix.into(), ElementType::List);
let text = item.contents.resolve(&self.theme.palette)?;
self.push_aligned_text(text, Alignment::Left { margin: Margin::Fixed(prefix_length) });
self.push_line_break();
if item.depth == 0 {
self.slide_state.last_element = LastElement::List { last_index: index };
}
Ok(())
}
fn push_block_quote(&mut self, lines: Vec<Line<RawColor>>) -> BuildResult {
@ -942,9 +890,11 @@ impl<'a> PresentationBuilder<'a> {
image_registry: &self.image_registry,
snippet_executor: self.code_executor.clone(),
theme: &self.theme,
presentation_state: &self.presentation_state,
third_party: self.third_party,
highlighter: &self.highlighter,
options: &self.options,
slide_number: self.slide_builders.len() + 1,
font_size: self.slide_font_size(),
};
let processor = SnippetProcessor::new(state);
@ -959,10 +909,7 @@ impl<'a> PresentationBuilder<'a> {
let mutators = mem::take(&mut self.chunk_mutators);
if !self.slide_state.skip_slide {
// Don't allow a last empty pause in slide since it adds nothing
if self.slide_chunks.is_empty() || !Self::is_chunk_empty(&operations) {
self.slide_chunks.push(SlideChunk::new(operations, mutators));
}
self.slide_chunks.push(SlideChunk::new(operations, mutators));
let chunks = mem::take(&mut self.slide_chunks);
let builder = SlideBuilder::default().chunks(chunks);
@ -980,18 +927,6 @@ impl<'a> PresentationBuilder<'a> {
self.slide_state.last_element = LastElement::None;
}
fn is_chunk_empty(operations: &[RenderOperation]) -> bool {
if operations.is_empty() {
return true;
}
for operation in operations {
if !matches!(operation, RenderOperation::RenderLineBreak) {
return false;
}
}
true
}
fn generate_footer(&self) -> Result<Vec<RenderOperation>, BuildError> {
let generator = FooterGenerator::new(self.theme.footer.clone(), &self.footer_vars, &self.theme.palette)?;
Ok(vec![
@ -1195,7 +1130,6 @@ pub enum BuildError {
InvalidFooter(#[from] InvalidFooterTemplateError),
}
#[derive(Debug)]
enum ExecutionMode {
AlongSnippet,
ReplaceSnippet,
@ -1390,10 +1324,9 @@ mod test {
options: PresentationBuilderOptions,
) -> Result<Presentation, BuildError> {
let theme = raw::PresentationTheme::default();
let tmp_dir = std::env::temp_dir();
let resources = Resources::new(&tmp_dir, &tmp_dir, Default::default());
let resources = Resources::new("/tmp", "/tmp", Default::default());
let mut third_party = ThirdPartyRender::default();
let code_executor = Arc::new(SnippetExecutor::default());
let code_executor = Rc::new(SnippetExecutor::default());
let themes = Themes::default();
let bindings = KeyBindingsConfig::default();
let builder = PresentationBuilder::new(
@ -1454,13 +1387,8 @@ mod test {
for operation in operations {
match operation {
RenderOperation::RenderText { line, .. } => {
let text: String = line.iter_texts().map(|text| text.text().content.clone()).collect();
current_line.push_str(&text);
}
RenderOperation::RenderBlockLine(line) => {
current_line.push_str(&line.prefix.text().content);
current_line
.push_str(&line.text.iter_texts().map(|text| text.text().content.clone()).collect::<String>());
let texts: Vec<_> = line.iter_texts().map(|text| text.text().content.clone()).collect();
current_line.push_str(&texts.join(""));
}
RenderOperation::RenderLineBreak if !current_line.is_empty() => {
output.push(mem::take(&mut current_line));
@ -1479,25 +1407,6 @@ mod test {
extract_text_lines(&operations)
}
#[test]
fn empty_heading_prefix() {
let frontmatter = r#"
theme:
override:
headings:
h1:
prefix: ""
"#;
let elements = vec![
MarkdownElement::FrontMatter(frontmatter.into()),
MarkdownElement::Heading { text: "hi".into(), level: 1 },
];
let slides = build_presentation(elements).into_slides();
let lines = extract_slide_text_lines(slides.into_iter().next().unwrap());
let expected_lines = &["hi"];
assert_eq!(lines, expected_lines);
}
#[test]
fn prelude_appears_once() {
let elements = vec![
@ -1692,30 +1601,6 @@ theme:
assert_eq!(lines, expected_lines);
}
#[rstest]
#[case::two(2, &[" • 0", " ◦ 00"])]
#[case::three(3, &[" • 0", " ◦ 00"])]
#[case::four(4, &[" • 0", " ◦ 00"])]
fn list_font_size(#[case] font_size: u8, #[case] expected: &[&str]) {
let elements = vec![
MarkdownElement::Comment {
comment: format!("font_size: {font_size}"),
source_position: Default::default(),
},
MarkdownElement::List(vec![
ListItem { depth: 0, contents: "0".into(), item_type: ListItemType::Unordered },
ListItem { depth: 1, contents: "00".into(), item_type: ListItemType::Unordered },
]),
];
let options = PresentationBuilderOptions {
theme_options: ThemeOptions { font_size_supported: true },
..Default::default()
};
let slides = build_presentation_with_options(elements, options).into_slides();
let lines = extract_slide_text_lines(slides.into_iter().next().unwrap());
assert_eq!(lines, expected);
}
#[rstest]
#[case::default(Default::default(), 5)]
#[case::no_pause_before(PresentationBuilderOptions{pause_before_incremental_lists: false, ..Default::default()}, 4)]
@ -1736,7 +1621,6 @@ theme:
ListItem { depth: 1, contents: "two".into(), item_type: ListItemType::Unordered },
ListItem { depth: 0, contents: "three".into(), item_type: ListItemType::Unordered },
]),
MarkdownElement::Paragraph(vec!["hi".into()]),
];
let slides = build_presentation_with_options(elements, options).into_slides();
assert_eq!(slides[0].iter_chunks().count(), expected_chunks);
@ -1760,32 +1644,6 @@ theme:
assert_eq!(slides[0].iter_chunks().count(), 1);
}
#[test]
fn pause_new_slide() {
let elements = vec![
MarkdownElement::Paragraph(vec![Line::from("hi")]),
MarkdownElement::Comment { comment: "pause".into(), source_position: Default::default() },
MarkdownElement::Paragraph(vec![Line::from("bye")]),
];
let options = PresentationBuilderOptions { pause_create_new_slide: true, ..Default::default() };
let slides = build_presentation_with_options(elements, options).into_slides();
assert_eq!(slides.len(), 2);
}
#[test]
fn incremental_lists_end_of_slide() {
let elements = vec![
MarkdownElement::Comment { comment: "incremental_lists: true".into(), source_position: Default::default() },
MarkdownElement::List(vec![
ListItem { depth: 0, contents: "one".into(), item_type: ListItemType::Unordered },
ListItem { depth: 1, contents: "two".into(), item_type: ListItemType::Unordered },
]),
];
let slides = build_presentation(elements).into_slides();
// There shouldn't be an extra one at the end
assert_eq!(slides[0].iter_chunks().count(), 3);
}
#[test]
fn skip_slide() {
let elements = vec![

View File

@ -10,30 +10,31 @@ use crate::{
},
},
markdown::elements::SourcePosition,
presentation::ChunkMutator,
presentation::{ChunkMutator, PresentationState},
render::{
operation::{AsRenderOperations, RenderAsyncStartPolicy, RenderOperation},
operation::{AsRenderOperations, RenderAsync, RenderOperation},
properties::WindowSize,
},
resource::Resources,
theme::{Alignment, CodeBlockStyle, PresentationTheme},
theme::{CodeBlockStyle, PresentationTheme},
third_party::{ThirdPartyRender, ThirdPartyRenderRequest},
ui::execution::{
RunAcquireTerminalSnippet, RunImageSnippet, RunSnippetOperation, SnippetExecutionDisabledOperation,
disabled::ExecutionType, snippet::DisplaySeparator,
DisplaySeparator, RunAcquireTerminalSnippet, RunImageSnippet, RunSnippetOperation,
SnippetExecutionDisabledOperation,
},
};
use itertools::Itertools;
use std::{cell::RefCell, rc::Rc, sync::Arc};
use std::{cell::RefCell, rc::Rc};
pub(crate) struct SnippetProcessorState<'a> {
pub(crate) resources: &'a Resources,
pub(crate) image_registry: &'a ImageRegistry,
pub(crate) snippet_executor: Arc<SnippetExecutor>,
pub(crate) snippet_executor: Rc<SnippetExecutor>,
pub(crate) theme: &'a PresentationTheme,
pub(crate) presentation_state: &'a PresentationState,
pub(crate) third_party: &'a ThirdPartyRender,
pub(crate) highlighter: &'a SnippetHighlighter,
pub(crate) options: &'a PresentationBuilderOptions,
pub(crate) slide_number: usize,
pub(crate) font_size: u8,
}
@ -42,11 +43,13 @@ pub(crate) struct SnippetProcessor<'a> {
mutators: Vec<Box<dyn ChunkMutator>>,
resources: &'a Resources,
image_registry: &'a ImageRegistry,
snippet_executor: Arc<SnippetExecutor>,
snippet_executor: Rc<SnippetExecutor>,
theme: &'a PresentationTheme,
presentation_state: &'a PresentationState,
third_party: &'a ThirdPartyRender,
highlighter: &'a SnippetHighlighter,
options: &'a PresentationBuilderOptions,
slide_number: usize,
font_size: u8,
}
@ -57,9 +60,11 @@ impl<'a> SnippetProcessor<'a> {
image_registry,
snippet_executor,
theme,
presentation_state,
third_party,
highlighter,
options,
slide_number,
font_size,
} = state;
Self {
@ -69,9 +74,11 @@ impl<'a> SnippetProcessor<'a> {
image_registry,
snippet_executor,
theme,
presentation_state,
third_party,
highlighter,
options,
slide_number,
font_size,
}
}
@ -121,12 +128,11 @@ impl<'a> SnippetProcessor<'a> {
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,
let auto_start = match snippet.attributes.representation {
SnippetRepr::Image | SnippetRepr::ExecReplace => true,
SnippetRepr::Render | SnippetRepr::Snippet => false,
};
self.push_execution_disabled_operation(exec_type);
self.push_execution_disabled_operation(auto_start);
Ok(())
}
SnippetExec::Exec => self.push_code_execution(snippet, block_length, ExecutionMode::AlongSnippet),
@ -172,22 +178,13 @@ impl<'a> SnippetProcessor<'a> {
error: format!("failed to load {path_display}: {e}"),
})?;
code.language = file.language;
code.contents = Self::filter_lines(contents, file.start_line, file.end_line);
code.contents = contents;
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 error_holder = self.presentation_state.async_error_holder();
let request = match language {
SnippetLanguage::Typst => ThirdPartyRenderRequest::Typst(contents, self.theme.typst.clone()),
SnippetLanguage::Latex => ThirdPartyRenderRequest::Latex(contents, self.theme.typst.clone()),
@ -199,7 +196,8 @@ impl<'a> SnippetProcessor<'a> {
})?;
}
};
let operation = self.third_party.render(request, self.theme, attributes.width)?;
let operation =
self.third_party.render(request, self.theme, error_holder, self.slide_number, attributes.width)?;
self.operations.push(operation);
Ok(())
}
@ -253,17 +251,14 @@ impl<'a> SnippetProcessor<'a> {
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,
};
fn push_execution_disabled_operation(&mut self, auto_start: bool) {
let operation = SnippetExecutionDisabledOperation::new(
self.theme.execution_output.status.failure_style,
self.theme.code.alignment,
policy,
exec_type,
);
if auto_start {
operation.start_render();
}
self.operations.push(RenderOperation::RenderAsync(Rc::new(operation)));
}
@ -277,6 +272,8 @@ impl<'a> SnippetProcessor<'a> {
self.image_registry.clone(),
self.theme.execution_output.status.clone(),
);
operation.start_render();
let operation = RenderOperation::RenderAsync(Rc::new(operation));
self.operations.push(operation);
Ok(())
@ -307,24 +304,12 @@ impl<'a> SnippetProcessor<'a> {
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 alignment = self.code_style(&snippet).alignment;
let default_colors = self.theme.default_style.style.colors;
let mut execution_output_style = self.theme.execution_output.clone();
if snippet.attributes.no_background {
execution_output_style.style.colors.background = None;
}
let policy = match mode {
ExecutionMode::AlongSnippet => RenderAsyncStartPolicy::OnDemand,
ExecutionMode::ReplaceSnippet => RenderAsyncStartPolicy::Automatic,
};
let operation = RunSnippetOperation::new(
snippet,
self.snippet_executor.clone(),
@ -334,8 +319,10 @@ impl<'a> SnippetProcessor<'a> {
separator,
alignment,
self.font_size,
policy,
);
if matches!(mode, ExecutionMode::ReplaceSnippet) {
operation.start_render();
}
let operation = RenderOperation::RenderAsync(Rc::new(operation));
self.operations.push(operation);
Ok(())
@ -363,28 +350,3 @@ impl AsRenderOperations for Differ {
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,11 @@
use crate::{config::OptionsConfig, render::operation::RenderOperation};
use crate::{
config::OptionsConfig,
render::operation::{RenderAsyncState, RenderOperation},
};
use serde::Deserialize;
use std::{
cell::RefCell,
collections::HashSet,
fmt::Debug,
ops::Deref,
rc::Rc,
@ -10,7 +14,6 @@ use std::{
pub(crate) mod builder;
pub(crate) mod diff;
pub(crate) mod poller;
#[derive(Debug)]
pub(crate) struct Modals {
@ -140,7 +143,61 @@ impl Presentation {
self.current_slide().current_chunk_index()
}
pub(crate) fn current_slide_mut(&mut self) -> &mut Slide {
/// 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
}
fn current_slide_mut(&mut self) -> &mut Slide {
let index = self.current_slide_index();
&mut self.slides[index]
}
@ -298,7 +355,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();

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,35 @@ use crate::{
listener::{Command, CommandListener},
speaker_notes::{SpeakerNotesEvent, SpeakerNotesEventPublisher},
},
config::{KeyBindingsConfig, SlideTransitionConfig, SlideTransitionStyleConfig},
config::{KeyBindingsConfig, MaxColumnsAlignment},
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},
printer::TerminalIo,
},
theme::{ProcessingThemeError, raw::PresentationTheme},
third_party::ThirdPartyRender,
transitions::{
AnimateTransition, AnimationFrame, LinesFrame, TransitionDirection,
collapse_horizontal::CollapseHorizontalAnimation, fade::FadeAnimation,
slide_horizontal::SlideHorizontalAnimation,
},
};
use std::{
collections::HashSet,
fmt::Display,
fs,
io::{self},
mem,
ops::Deref,
path::Path,
rc::Rc,
sync::Arc,
time::{Duration, Instant},
};
pub struct PresenterOptions {
@ -51,8 +41,8 @@ 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,
pub max_columns_alignment: MaxColumnsAlignment,
}
/// A slideshow presenter.
@ -64,13 +54,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 +72,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 +86,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 +100,21 @@ 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,
max_columns_alignment: self.options.max_columns_alignment,
};
let mut drawer = TerminalDrawer::new(self.image_printer.clone(), drawer_options)?;
loop {
// 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 +140,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 +155,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,6 +180,27 @@ impl<'a> Presenter<'a> {
}
}
fn poll_async_renders(&mut self) -> Result<bool, RenderError> {
if matches!(self.state, PresenterState::Failure { .. }) {
return Ok(false);
}
let current_index = self.state.presentation().current_slide_index();
if self.slides_with_pending_async_renders.contains(&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) -> RenderResult {
let result = match &self.state {
PresenterState::Presenting(presentation) => {
@ -282,37 +256,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 +292,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 +308,14 @@ 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 = presentation.slides_with_async_renders().into_iter().collect();
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 {
@ -421,7 +348,7 @@ impl<'a> Presenter<'a> {
&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(),
)?
@ -459,140 +386,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 +393,6 @@ enum CommandSideEffect {
Suspend,
Redraw,
Reload,
AnimateNextSlide,
AnimatePreviousSlide,
None,
}

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,6 @@
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},
config::MaxColumnsAlignment,
markdown::{text::WeightedLine, text_style::Colors},
render::{
layout::Positioning,
@ -16,9 +14,9 @@ use crate::{
image::{
Image,
printer::{ImageProperties, PrintOptions},
scale::{ImageScaler, ScaleImage},
scale::{ImageScaler, TerminalRect},
},
printer::{TerminalCommand, TerminalIo},
printer::TerminalIo,
},
theme::Alignment,
};
@ -26,35 +24,22 @@ 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) max_columns: u16,
pub(crate) max_columns_alignment: MaxColumnsAlignment,
pub(crate) column_layout_margin: u16,
}
impl Default for RenderEngineOptions {
fn default() -> Self {
Self { validate_overflows: false, max_size: Default::default(), column_layout_margin: 4 }
Self {
validate_overflows: false,
max_columns: u16::MAX,
max_columns_alignment: Default::default(),
column_layout_margin: 4,
}
}
}
@ -68,7 +53,6 @@ where
max_modified_row: u16,
layout: LayoutState,
options: RenderEngineOptions,
image_scaler: Box<dyn ScaleImage>,
}
impl<'a, T> RenderEngine<'a, T>
@ -86,46 +70,32 @@ 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 {
fn starting_rect(window_dimensions: WindowSize, options: &RenderEngineOptions) -> WindowRect {
let start_row = 0;
if window_dimensions.columns > options.max_columns {
let extra_width = window_dimensions.columns - options.max_columns;
let dimensions = window_dimensions.shrink_columns(extra_width);
let start_column = match options.max_columns_alignment {
MaxColumnsAlignment::Left => 0,
MaxColumnsAlignment::Center => extra_width / 2,
MaxColumnsAlignment::Right => extra_width,
};
WindowRect { dimensions, start_column, start_row }
} else {
WindowRect { dimensions: window_dimensions, start_column: 0, start_row }
}
if dimensions.rows > options.max_size.max_rows {
let extra_height = dimensions.rows - options.max_size.max_rows;
dimensions = dimensions.shrink_rows(extra_height);
start_row = match options.max_size.max_rows_alignment {
MaxRowsAlignment::Top => 0,
MaxRowsAlignment::Center => extra_height / 2,
MaxRowsAlignment::Bottom => extra_height,
};
}
WindowRect { dimensions, start_column, start_row }
}
pub(crate) fn render<'b>(mut self, operations: impl Iterator<Item = &'b RenderOperation>) -> RenderResult {
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);
}
@ -169,9 +139,8 @@ 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(())
}
@ -182,7 +151,7 @@ where
let margin = horizontal_margin.as_characters(current.dimensions.columns);
let new_rect = current.shrink_horizontal(margin).shrink_bottom(*bottom).shrink_top(*top);
if new_rect.start_row != self.terminal.cursor_row() {
self.terminal.execute(&TerminalCommand::MoveToRow(new_rect.start_row))?;
self.jump_to_row(new_rect.start_row)?;
}
self.window_rects.push(new_rect);
Ok(())
@ -202,37 +171,33 @@ 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))?;
self.terminal.move_to_row(row)?;
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))?;
self.terminal.move_to_column(column)?;
Ok(())
}
@ -242,75 +207,69 @@ where
let positioning = layout.compute(dimensions, text.width() as u16);
let prefix = "".into();
let text_drawer = TextDrawer::new(&prefix, 0, text, positioning, &self.colors, MINIMUM_LINE_LENGTH)?;
let center_newlines = matches!(alignment, Alignment::Center { .. });
let text_drawer = text_drawer.center_newlines(center_newlines);
text_drawer.draw(self.terminal)?;
// Restore colors
self.apply_colors()
}
fn render_line_break(&mut self) -> RenderResult {
self.terminal.execute(&TerminalCommand::MoveToNextLine)?;
self.terminal.move_to_next_line()?;
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_cursor = 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, 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)
ImageScaler::default().fit_image_to_rect(&rect.dimensions, width, height, &starting_cursor);
let cursor = match properties.center {
true => Self::center_cursor(&image_scale, &rect.dimensions, &starting_cursor),
false => starting_cursor.clone(),
};
(cursor, image_scale.columns, image_scale.rows)
}
ImageSize::Specific(columns, rows) => (columns, rows),
ImageSize::Specific(columns, rows) => (starting_cursor.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)
ImageScaler::default().scale_image(&dimensions, &rect.dimensions, width, height, &starting_cursor);
let cursor = match properties.center {
true => Self::center_cursor(&image_scale, &rect.dimensions, &starting_cursor),
false => starting_cursor.clone(),
};
(cursor, image_scale.columns, image_scale.rows)
}
};
let cursor = match &properties.position {
ImagePosition::Cursor => starting_cursor.clone(),
ImagePosition::Center => Self::center_cursor(columns, &rect.dimensions, &starting_cursor),
ImagePosition::Right => Self::align_cursor_right(columns, &rect.dimensions, &starting_cursor),
};
self.terminal.execute(&TerminalCommand::MoveToColumn(cursor.column))?;
let options = PrintOptions {
columns,
rows,
cursor_position: cursor,
z_index: properties.z_index,
column_width: rect.dimensions.pixels_per_column() as u16,
row_height: rect.dimensions.pixels_per_row() as u16,
background_color: properties.background_color,
};
self.terminal.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_cursor.column, starting_cursor.row)?;
} else {
self.terminal.execute(&TerminalCommand::MoveToRow(starting_row + rows))?;
self.terminal.move_to_row(starting_cursor.row + rows)?;
}
self.apply_colors()
}
fn center_cursor(columns: u16, window: &WindowSize, cursor: &CursorPosition) -> CursorPosition {
let start_column = window.columns / 2 - (columns / 2);
fn center_cursor(rect: &TerminalRect, window: &WindowSize, cursor: &CursorPosition) -> CursorPosition {
let start_column = window.columns / 2 - (rect.columns / 2);
let start_column = start_column + cursor.column;
CursorPosition { row: cursor.row, column: start_column }
}
fn align_cursor_right(columns: u16, window: &WindowSize, cursor: &CursorPosition) -> CursorPosition {
let start_column = window.columns.saturating_sub(columns).saturating_add(cursor.column);
CursorPosition { row: cursor.row, column: start_column }
}
fn render_block_line(&mut self, operation: &BlockLine) -> RenderResult {
let BlockLine {
text,
@ -329,7 +288,7 @@ 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 =
@ -393,10 +352,7 @@ where
let start_column = current_rect.start_column + (unit_width * column_units_before as f64) as u16;
let start_row = columns[column_index].current_row;
let new_column_count = (total_column_units - columns[column_index].width) * unit_width as u16;
let new_size = current_rect
.dimensions
.shrink_columns(new_column_count)
.shrink_rows(start_row.saturating_sub(current_rect.start_row));
let new_size = current_rect.dimensions.shrink_columns(new_column_count);
let mut dimensions = WindowRect { dimensions: new_size, start_column, start_row };
// Shrink every column's right edge except for last
if column_index < columns.len() - 1 {
@ -408,7 +364,7 @@ where
}
self.window_rects.push(dimensions);
self.terminal.execute(&TerminalCommand::MoveToRow(start_row))?;
self.terminal.move_to_row(start_row)?;
self.layout = LayoutState::EnteredColumn { column: column_index, columns };
Ok(())
}
@ -417,7 +373,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(())
@ -474,9 +430,8 @@ impl WindowRect {
}
fn shrink_top(&self, rows: u16) -> Self {
let dimensions = self.dimensions.shrink_rows(rows);
let start_row = self.start_row.saturating_add(rows);
Self { dimensions, start_column: self.start_column, start_row }
Self { dimensions: self.dimensions.clone(), start_column: self.start_column, start_row }
}
fn shrink_bottom(&self, rows: u16) -> Self {
@ -490,18 +445,9 @@ mod tests {
use super::*;
use crate::{
markdown::text_style::{Color, TextStyle},
terminal::{
image::{
ImageSource,
printer::{PrintImageError, TerminalImage},
scale::TerminalRect,
},
printer::TerminalError,
},
terminal::printer::TextProperties,
theme::Margin,
};
use ::image::{ColorType, DynamicImage};
use rstest::rstest;
use std::io;
use unicode_width::UnicodeWidthStr;
@ -511,13 +457,13 @@ mod tests {
MoveToRow(u16),
MoveToColumn(u16),
MoveDown(u16),
MoveRight(u16),
MoveLeft(u16),
MoveToNextLine,
PrintText(String),
ClearScreen,
SetBackgroundColor(Color),
PrintImage(PrintOptions),
PrintImage(Image),
Suspend,
Resume,
}
#[derive(Default)]
@ -531,38 +477,44 @@ mod tests {
self.instructions.push(instruction);
Ok(())
}
}
fn move_to(&mut self, column: u16, row: u16) -> io::Result<()> {
impl TerminalIo for TerminalBuf {
fn begin_update(&mut self) -> std::io::Result<()> {
Ok(())
}
fn end_update(&mut self) -> std::io::Result<()> {
Ok(())
}
fn cursor_row(&self) -> u16 {
self.cursor_row
}
fn move_to(&mut self, column: u16, row: u16) -> std::io::Result<()> {
self.cursor_row = row;
self.push(Instruction::MoveTo(column, row))
}
fn move_to_row(&mut self, row: u16) -> io::Result<()> {
fn move_to_row(&mut self, row: u16) -> std::io::Result<()> {
self.cursor_row = row;
self.push(Instruction::MoveToRow(row))
}
fn move_to_column(&mut self, column: u16) -> io::Result<()> {
fn move_to_column(&mut self, column: u16) -> std::io::Result<()> {
self.push(Instruction::MoveToColumn(column))
}
fn move_down(&mut self, amount: u16) -> io::Result<()> {
fn move_down(&mut self, amount: u16) -> std::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<()> {
fn move_to_next_line(&mut self) -> std::io::Result<()> {
self.push(Instruction::MoveToNextLine)
}
fn print_text(&mut self, content: &str, _style: &TextStyle) -> io::Result<()> {
fn print_text(&mut self, content: &str, _style: &TextStyle, _properties: &TextProperties) -> io::Result<()> {
let content = content.to_string();
if content.is_empty() {
return Ok(());
@ -571,104 +523,53 @@ mod tests {
self.push(Instruction::PrintText(content))
}
fn clear_screen(&mut self) -> io::Result<()> {
fn clear_screen(&mut self) -> std::io::Result<()> {
self.cursor_row = 0;
self.push(Instruction::ClearScreen)
}
fn set_colors(&mut self, _colors: Colors) -> io::Result<()> {
fn set_colors(&mut self, _colors: Colors) -> std::io::Result<()> {
Ok(())
}
fn set_background_color(&mut self, color: Color) -> io::Result<()> {
fn set_background_color(&mut self, color: Color) -> std::io::Result<()> {
self.push(Instruction::SetBackgroundColor(color))
}
fn flush(&mut self) -> io::Result<()> {
fn flush(&mut self) -> std::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)?,
};
fn print_image(
&mut self,
image: &Image,
_options: &PrintOptions,
) -> Result<(), crate::terminal::image::printer::PrintImageError> {
let _ = self.push(Instruction::PrintImage(image.clone()));
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 suspend(&mut self) {
let _ = self.push(Instruction::Suspend);
}
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 resume(&mut self) {
let _ = self.push(Instruction::Resume);
}
}
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,
let mut buf = TerminalBuf::default();
let dimensions = WindowSize { rows: 100, columns: 100, height: 200, width: 200 };
let options = RenderEngineOptions {
validate_overflows: false,
max_columns: u16::MAX,
max_columns_alignment: Default::default(),
column_layout_margin: 0,
};
do_render(max_size, operations)
let engine = RenderEngine::new(&mut buf, dimensions, options);
engine.render(operations.iter()).expect("render failed");
buf.instructions
}
#[test]
@ -778,164 +679,4 @@ mod tests {
];
assert_eq!(ops, expected);
}
#[test]
fn margin_with_max_size() {
let ops = render_with_max_size(&[
RenderOperation::RenderText { line: "A".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },
RenderOperation::ApplyMargin(MarginProperties { horizontal: Margin::Fixed(1), top: 2, bottom: 1 }),
RenderOperation::RenderText { line: "B".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },
RenderOperation::JumpToBottomRow { index: 0 },
RenderOperation::RenderText { line: "C".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },
]);
let expected = [
// centered 20x10
Instruction::MoveTo(40, 45),
Instruction::MoveToColumn(40),
Instruction::PrintText("A".into()),
// jump 2 down because of top margin
Instruction::MoveToRow(47),
// jump 1 right because of horizontal margin
Instruction::MoveToColumn(41),
Instruction::PrintText("B".into()),
// rows go from 47 to 53 (7 total)
Instruction::MoveToRow(53),
Instruction::MoveToColumn(41),
Instruction::PrintText("C".into()),
];
assert_eq!(ops, expected);
}
// print the same 2x2 image with all size configs, they should all yield the same
#[rstest]
#[case::shrink(ImageSize::ShrinkIfNeeded)]
#[case::specific(ImageSize::Specific(2, 2))]
#[case::width_scaled(ImageSize::WidthScaled { ratio: 1.0 })]
fn image(#[case] size: ImageSize) {
let image = DynamicImage::new(2, 2, ColorType::Rgba8);
let image = Image::new(TerminalImage::Ascii(image.into()), ImageSource::Generated);
let properties = ImageRenderProperties {
z_index: 0,
size,
restore_cursor: false,
background_color: None,
position: ImagePosition::Cursor,
};
let ops = render_with_max_size(&[RenderOperation::RenderImage(image, properties)]);
let expected = [
// centered 20x10, the image is 2x2 so we stand one away from center
Instruction::MoveTo(40, 45),
Instruction::MoveToColumn(40),
Instruction::PrintImage(PrintOptions {
columns: 2,
rows: 2,
z_index: 0,
background_color: None,
column_width: 2,
row_height: 2,
}),
// place cursor after the image
Instruction::MoveToRow(47),
];
assert_eq!(ops, expected);
}
// same as the above but center it
#[rstest]
#[case::shrink(ImageSize::ShrinkIfNeeded)]
#[case::specific(ImageSize::Specific(2, 2))]
#[case::width_scaled(ImageSize::WidthScaled { ratio: 1.0 })]
fn centered_image(#[case] size: ImageSize) {
let image = DynamicImage::new(2, 2, ColorType::Rgba8);
let image = Image::new(TerminalImage::Ascii(image.into()), ImageSource::Generated);
let properties = ImageRenderProperties {
z_index: 0,
size,
restore_cursor: false,
background_color: None,
position: ImagePosition::Center,
};
let ops = render_with_max_size(&[RenderOperation::RenderImage(image, properties)]);
let expected = [
// centered 20x10, the image is 2x2 so we stand one away from center
Instruction::MoveTo(40, 45),
Instruction::MoveToColumn(49),
Instruction::PrintImage(PrintOptions {
columns: 2,
rows: 2,
z_index: 0,
background_color: None,
column_width: 2,
row_height: 2,
}),
// place cursor after the image
Instruction::MoveToRow(47),
];
assert_eq!(ops, expected);
}
// same as the above but use right alignment
#[rstest]
#[case::shrink(ImageSize::ShrinkIfNeeded)]
#[case::specific(ImageSize::Specific(2, 2))]
#[case::width_scaled(ImageSize::WidthScaled { ratio: 1.0 })]
fn right_aligned_image(#[case] size: ImageSize) {
let image = DynamicImage::new(2, 2, ColorType::Rgba8);
let image = Image::new(TerminalImage::Ascii(image.into()), ImageSource::Generated);
let properties = ImageRenderProperties {
z_index: 0,
size,
restore_cursor: false,
background_color: None,
position: ImagePosition::Right,
};
let ops = render_with_max_size(&[RenderOperation::RenderImage(image, properties)]);
let expected = [
// right aligned 20x10, the image is 2x2 so we stand one away from the right
Instruction::MoveTo(40, 45),
Instruction::MoveToColumn(58),
Instruction::PrintImage(PrintOptions {
columns: 2,
rows: 2,
z_index: 0,
background_color: None,
column_width: 2,
row_height: 2,
}),
// place cursor after the image
Instruction::MoveToRow(47),
];
assert_eq!(ops, expected);
}
// same as the above but center it
#[rstest]
fn restore_cursor_after_image() {
let image = DynamicImage::new(2, 2, ColorType::Rgba8);
let image = Image::new(TerminalImage::Ascii(image.into()), ImageSource::Generated);
let properties = ImageRenderProperties {
z_index: 0,
size: ImageSize::ShrinkIfNeeded,
restore_cursor: true,
background_color: None,
position: ImagePosition::Center,
};
let ops = render_with_max_size(&[RenderOperation::RenderImage(image, properties)]);
let expected = [
// centered 20x10, the image is 2x2 so we stand one away from center
Instruction::MoveTo(40, 45),
Instruction::MoveToColumn(49),
Instruction::PrintImage(PrintOptions {
columns: 2,
rows: 2,
z_index: 0,
background_color: None,
column_width: 2,
row_height: 2,
}),
// place cursor after the image
Instruction::MoveTo(40, 45),
];
assert_eq!(ops, expected);
}
}

View File

@ -1,4 +1,3 @@
pub(crate) mod ascii_scaler;
pub(crate) mod engine;
pub(crate) mod layout;
pub(crate) mod operation;
@ -7,6 +6,7 @@ pub(crate) mod text;
pub(crate) mod validate;
use crate::{
config::MaxColumnsAlignment,
markdown::{
elements::Text,
text::WeightedLine,
@ -16,16 +16,12 @@ use crate::{
terminal::{
Terminal,
image::printer::{ImagePrinter, PrintImageError},
printer::TerminalError,
},
theme::{Alignment, Margin},
};
use engine::{MaxSize, RenderEngine, RenderEngineOptions};
use operation::AsRenderOperations;
use engine::{RenderEngine, RenderEngineOptions};
use std::{
io::{self, Stdout},
iter,
rc::Rc,
sync::Arc,
};
@ -34,12 +30,13 @@ pub(crate) type RenderResult = Result<(), RenderError>;
pub(crate) struct TerminalDrawerOptions {
pub(crate) font_size_fallback: u8,
pub(crate) max_size: MaxSize,
pub(crate) max_columns: u16,
pub(crate) max_columns_alignment: MaxColumnsAlignment,
}
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, max_columns_alignment: Default::default() }
}
}
@ -66,20 +63,45 @@ impl TerminalDrawer {
}
pub(crate) fn render_error(&mut self, message: &str, source: &ErrorSource) -> RenderResult {
let operation = RenderErrorOperation { message: message.into(), source: source.clone() };
let operation = RenderOperation::RenderDynamic(Rc::new(operation));
let dimensions = WindowSize::current(self.options.font_size_fallback)?;
let heading_text = match source {
ErrorSource::Presentation => "Error loading presentation".to_string(),
ErrorSource::Slide(slide) => {
format!("Error in slide {slide}")
}
};
let heading = vec![Text::new(heading_text, TextStyle::default().bold()), Text::from(": ")];
let total_lines = message.lines().count();
let starting_row = (dimensions.rows / 2).saturating_sub(total_lines as u16 / 2 + 3);
let alignment = Alignment::Left { margin: Margin::Percent(25) };
let mut operations = vec![
RenderOperation::SetColors(Colors {
foreground: Some(Color::new(255, 0, 0)),
background: Some(Color::new(0, 0, 0)),
}),
RenderOperation::ClearScreen,
RenderOperation::JumpToRow { index: starting_row },
RenderOperation::RenderText { line: WeightedLine::from(heading), alignment },
RenderOperation::RenderLineBreak,
RenderOperation::RenderLineBreak,
];
for line in message.lines() {
let error = vec![Text::from(line)];
let op = RenderOperation::RenderText { line: WeightedLine::from(error), alignment };
operations.extend([op, RenderOperation::RenderLineBreak]);
}
let engine = self.create_engine(dimensions);
engine.render(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();
let options = RenderEngineOptions {
max_columns: self.options.max_columns,
max_columns_alignment: self.options.max_columns_alignment,
..Default::default()
};
RenderEngine::new(&mut self.terminal, dimensions, options)
}
}
@ -90,9 +112,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 +134,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,11 +7,7 @@ use crate::{
terminal::image::Image,
theme::{Alignment, Margin},
};
use std::{
fmt::Debug,
rc::Rc,
sync::{Arc, Mutex},
};
use std::{fmt::Debug, rc::Rc};
const DEFAULT_IMAGE_Z_INDEX: i32 = -2;
@ -105,7 +101,7 @@ pub(crate) struct ImageRenderProperties {
pub(crate) size: ImageSize,
pub(crate) restore_cursor: bool,
pub(crate) background_color: Option<Color>,
pub(crate) position: ImagePosition,
pub(crate) center: bool,
}
impl Default for ImageRenderProperties {
@ -115,18 +111,11 @@ impl Default for ImageRenderProperties {
size: Default::default(),
restore_cursor: false,
background_color: None,
position: ImagePosition::Center,
center: true,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum ImagePosition {
Cursor,
Center,
Right,
}
/// The size used when printing an image.
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) enum ImageSize {
@ -164,57 +153,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

@ -92,7 +92,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

@ -5,7 +5,7 @@ use crate::{
text_style::{Color, Colors, TextStyle},
},
render::{RenderError, RenderResult, layout::Positioning},
terminal::printer::{TerminalCommand, TerminalIo},
terminal::printer::{TerminalIo, TextProperties},
};
/// Draws text on the screen.
@ -21,7 +21,7 @@ pub(crate) struct TextDrawer<'a> {
draw_block: bool,
block_color: Option<Color>,
repeat_prefix: bool,
center_newlines: bool,
properties: TextProperties,
}
impl<'a> TextDrawer<'a> {
@ -56,7 +56,7 @@ impl<'a> TextDrawer<'a> {
draw_block: false,
block_color: None,
repeat_prefix: false,
center_newlines: false,
properties: TextProperties { height: line.font_size() },
})
}
}
@ -72,11 +72,6 @@ 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.
@ -85,42 +80,33 @@ impl<'a> TextDrawer<'a> {
T: TerminalIo,
{
let mut line_length: u16 = 0;
terminal.execute(&TerminalCommand::MoveToColumn(self.positioning.start_column))?;
let font_size = self.line.font_size();
terminal.move_to_column(self.positioning.start_column)?;
// Print the prefix at the beginning of the line.
if self.prefix_width > 0 {
let Text { content, style } = self.prefix.text();
terminal.execute(&TerminalCommand::PrintText { content, style: *style })?;
terminal.print_text(content, style, &self.properties)?;
}
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(self.properties.height as u16)?;
terminal.move_to_column(self.positioning.start_column)?;
line_length = 0;
// Complete the new line in this block to the left where the prefix would be.
if self.prefix_width > 0 {
if self.repeat_prefix {
let Text { content, style } = self.prefix.text();
terminal.execute(&TerminalCommand::PrintText { content, style: *style })?;
terminal.print_text(content, style, &self.properties)?;
} else {
if let Some(color) = self.block_color {
terminal.execute(&TerminalCommand::SetBackgroundColor(color))?;
terminal.set_background_color(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 })?;
let text = " ".repeat(self.prefix_width as usize / self.properties.height as usize);
let style = TextStyle::default().size(self.properties.height);
terminal.print_text(&text, &style, &self.properties)?;
}
}
}
@ -128,12 +114,12 @@ impl<'a> TextDrawer<'a> {
line_length = line_length.saturating_add(chunk.width() as u16);
let (text, style) = chunk.into_parts();
terminal.execute(&TerminalCommand::PrintText { content: text, style })?;
terminal.print_text(text, &style, &self.properties)?;
// 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)?;
}
}
}
@ -149,13 +135,12 @@ impl<'a> TextDrawer<'a> {
let remaining =
self.positioning.max_line_length.saturating_sub(line_length).saturating_add(self.right_padding_length);
if remaining > 0 {
let font_size = self.line.font_size();
if let Some(color) = self.block_color {
terminal.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 / self.properties.height as usize);
let style = TextStyle::default().size(self.properties.height);
terminal.print_text(&text, &style, &self.properties)?;
}
}
Ok(())
@ -165,7 +150,7 @@ impl<'a> TextDrawer<'a> {
#[cfg(test)]
mod tests {
use super::*;
use crate::terminal::printer::TerminalError;
use crate::terminal::image::{Image, printer::PrintOptions};
use std::io;
use unicode_width::UnicodeWidthStr;
@ -187,6 +172,28 @@ mod tests {
self.instructions.push(instruction);
Ok(())
}
}
impl TerminalIo for TerminalBuf {
fn begin_update(&mut self) -> std::io::Result<()> {
unimplemented!()
}
fn end_update(&mut self) -> std::io::Result<()> {
unimplemented!()
}
fn cursor_row(&self) -> u16 {
self.cursor_row
}
fn move_to(&mut self, _column: u16, _row: u16) -> std::io::Result<()> {
unimplemented!()
}
fn move_to_row(&mut self, _row: u16) -> std::io::Result<()> {
unimplemented!()
}
fn move_to_column(&mut self, column: u16) -> std::io::Result<()> {
self.push(Instruction::MoveToColumn(column))
@ -196,7 +203,11 @@ mod tests {
self.push(Instruction::MoveDown(amount))
}
fn print_text(&mut self, content: &str, style: &TextStyle) -> io::Result<()> {
fn move_to_next_line(&mut self) -> std::io::Result<()> {
unimplemented!()
}
fn print_text(&mut self, content: &str, style: &TextStyle, _properties: &TextProperties) -> io::Result<()> {
let content = content.to_string();
if content.is_empty() {
return Ok(());
@ -221,35 +232,21 @@ mod tests {
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 print_image(
&mut self,
_image: &Image,
_options: &PrintOptions,
) -> Result<(), crate::terminal::image::printer::PrintImageError> {
unimplemented!()
}
fn cursor_row(&self) -> u16 {
self.cursor_row
fn suspend(&mut self) {
unimplemented!()
}
fn resume(&mut self) {
unimplemented!()
}
}
@ -258,7 +255,6 @@ mod tests {
positioning: Positioning,
right_padding_length: u16,
repeat_prefix_on_wrap: bool,
center_newlines: bool,
}
impl TestDrawer {
@ -282,18 +278,12 @@ mod tests {
self
}
fn center_newlines(mut self) -> Self {
self.center_newlines = true;
self
}
fn draw<L: Into<WeightedLine>>(self, line: L) -> Vec<Instruction> {
let line = line.into();
let colors = Default::default();
let drawer = TextDrawer::new(&self.prefix, self.right_padding_length, &line, self.positioning, &colors, 0)
.expect("failed to create drawer")
.repeat_prefix_on_wrap(self.repeat_prefix_on_wrap)
.center_newlines(self.center_newlines);
.repeat_prefix_on_wrap(self.repeat_prefix_on_wrap);
let mut buf = TerminalBuf::default();
drawer.draw(&mut buf).expect("drawing failed");
buf.instructions
@ -307,7 +297,6 @@ mod tests {
positioning: Positioning { max_line_length: 100, start_column: 0 },
right_padding_length: 0,
repeat_prefix_on_wrap: false,
center_newlines: false,
}
}
}
@ -361,18 +350,4 @@ mod tests {
];
assert_eq!(instructions, expected);
}
#[test]
fn center_newlines() {
let text = WeightedLine::from(vec![Text::from("hello world foo")]);
let instructions = TestDrawer::default().center_newlines().max_line_length(11).draw(text);
let expected = &[
Instruction::MoveToColumn(0),
Instruction::PrintText { content: "hello world".into(), font_size: 1 },
Instruction::MoveDown(1),
Instruction::MoveToColumn(4),
Instruction::PrintText { content: "foo".into(), font_size: 1 },
];
assert_eq!(instructions, expected);
}
}

View File

@ -1,7 +1,7 @@
use crate::{
terminal::image::{
Image,
printer::{ImageRegistry, ImageSpec, RegisterImageError},
printer::{ImageRegistry, RegisterImageError},
},
theme::{raw::PresentationTheme, registry::LoadThemeError},
};
@ -24,6 +24,8 @@ const LOOP_INTERVAL: Duration = Duration::from_millis(250);
#[derive(Debug)]
struct ResourcesInner {
images: HashMap<PathBuf, Image>,
theme_images: HashMap<PathBuf, Image>,
themes: HashMap<PathBuf, PresentationTheme>,
external_snippets: HashMap<PathBuf, String>,
base_path: PathBuf,
@ -54,6 +56,8 @@ impl Resources {
let inner = ResourcesInner {
base_path: base_path.into(),
themes_path: themes_path.into(),
images: Default::default(),
theme_images: Default::default(),
themes: Default::default(),
external_snippets: Default::default(),
image_registry,
@ -69,9 +73,14 @@ impl Resources {
/// 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 mut inner = self.inner.borrow_mut();
let path = inner.base_path.join(path);
let image = inner.image_registry.register(ImageSpec::Filesystem(path.clone()))?;
if let Some(image) = inner.images.get(&path) {
return Ok(image.clone());
}
let image = inner.image_registry.register_resource(path.clone())?;
inner.images.insert(path, image.clone());
Ok(image)
}
@ -82,9 +91,14 @@ impl Resources {
_ => (),
};
let inner = self.inner.borrow();
let mut inner = self.inner.borrow_mut();
let path = inner.themes_path.join(path);
let image = inner.image_registry.register(ImageSpec::Filesystem(path.clone()))?;
if let Some(image) = inner.theme_images.get(&path) {
return Ok(image.clone());
}
let image = inner.image_registry.register_resource(path.clone())?;
inner.theme_images.insert(path, image.clone());
Ok(image)
}
@ -130,7 +144,7 @@ impl Resources {
/// Clears all resources.
pub(crate) fn clear(&self) {
let mut inner = self.inner.borrow_mut();
inner.image_registry.clear();
inner.images.clear();
inner.themes.clear();
}
}

View File

@ -85,7 +85,6 @@ impl TerminalCapabilities {
response.font_size = true;
}
stdout.queue(terminal::LeaveAlternateScreen)?;
stdout.flush()?;
Ok(response)
}

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()?,
};
@ -114,10 +110,6 @@ impl ImagePrinter {
Self::Ascii(AsciiPrinter)
}
fn new_raw() -> Self {
Self::Raw(RawPrinter)
}
#[cfg(feature = "sixel")]
fn new_sixel() -> Result<Self, CreatePrinterError> {
Ok(Self::Sixel(super::protocols::sixel::SixelPrinter::new()?))
@ -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");
// The strategy here is taken from viuer: use half vertical ascii blocks in combination
// with foreground/background colors to fit 2 vertical pixels per cell. That is, cell (x, y)
// will contain the pixels at (x, y) and (x, y + 1) combined.
let image = image.0.resize_exact(options.columns as u32, 2 * options.rows as u32, FilterType::Triangle);
let image = image.into_rgba8();
let default_background = options.background_color.map(Color::from);
// Iterate pixel rows in pairs to be able to merge both pixels in a single iteration.
// Note that may not have a second row if there's an odd number of them.
for mut rows in &image.rows().chunks(2) {
writer.queue(MoveToColumn(options.cursor_position.column))?;
let top_row = rows.next().unwrap();
let mut bottom_row = rows.next();
for top_pixel in top_row {
@ -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::{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 {
@ -38,35 +29,32 @@ pub struct ItermPrinter;
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!(
write!(
writer,
"\x1b]1337;File=size={size};width={columns};height={rows};inline=1;preserveAspectRatio=1:{contents}\x07"
);
terminal.execute(&TerminalCommand::PrintText { content: &content, style: Default::default() })?;
)?;
Ok(())
}
}

View File

@ -1,16 +1,14 @@
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 std::{
fmt,
fs::{self, File},
io::{self, BufReader},
io::{self, BufReader, Write},
path::{Path, PathBuf},
sync::atomic::{AtomicU32, Ordering},
};
@ -73,25 +71,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
@ -168,15 +147,15 @@ impl KittyPrinter {
fastrand::u32(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 +174,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 +222,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 +233,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 +242,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 +257,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 +266,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 +281,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 +310,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 +321,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.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 +338,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,25 +370,33 @@ 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,
{
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, terminal, options)?,
GenericResource::Gif(frames) => self.print_gif(image.dimensions, frames, terminal, options)?,
GenericResource::Image(resource) => self.print_image(image.dimensions, resource, writer, options)?,
GenericResource::Gif(frames) => self.print_gif(image.dimensions, frames, writer, options)?,
};
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,32 +1,12 @@
use crate::render::properties::{CursorPosition, WindowSize};
pub(crate) trait ScaleImage {
/// Scale an image to a specific size.
fn scale_image(
&self,
scale_size: &WindowSize,
window_dimensions: &WindowSize,
image_width: u32,
image_height: u32,
position: &CursorPosition,
) -> TerminalRect;
/// Shrink an image so it fits the dimensions of the layout it's being displayed in.
fn fit_image_to_rect(
&self,
dimensions: &WindowSize,
image_width: u32,
image_height: u32,
position: &CursorPosition,
) -> TerminalRect;
}
pub(crate) struct ImageScaler {
horizontal_margin: f64,
}
impl ScaleImage for ImageScaler {
fn scale_image(
impl ImageScaler {
/// Scale an image to a specific size.
pub(crate) fn scale_image(
&self,
scale_size: &WindowSize,
window_dimensions: &WindowSize,
@ -43,7 +23,8 @@ impl ScaleImage for ImageScaler {
self.fit_image_to_rect(window_dimensions, image_width as u32, image_height as u32, position)
}
fn fit_image_to_rect(
/// Shrink an image so it fits the dimensions of the layout it's being displayed in.
pub(crate) fn fit_image_to_rect(
&self,
dimensions: &WindowSize,
image_width: u32,

View File

@ -15,7 +15,6 @@ pub enum GraphicsMode {
inside_tmux: bool,
},
AsciiBlocks,
Raw,
#[cfg(feature = "sixel")]
Sixel,
}

View File

@ -14,37 +14,23 @@ 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 begin_update(&mut self) -> io::Result<()>;
fn end_update(&mut self) -> io::Result<()>;
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),
fn move_to(&mut self, column: u16, row: u16) -> io::Result<()>;
fn move_to_row(&mut self, row: u16) -> io::Result<()>;
fn move_to_column(&mut self, column: u16) -> io::Result<()>;
fn move_down(&mut self, amount: u16) -> io::Result<()>;
fn move_to_next_line(&mut self) -> io::Result<()>;
fn print_text(&mut self, content: &str, style: &TextStyle, properties: &TextProperties) -> io::Result<()>;
fn clear_screen(&mut self) -> io::Result<()>;
fn set_colors(&mut self, colors: Colors) -> io::Result<()>;
fn set_background_color(&mut self, color: Color) -> io::Result<()>;
fn flush(&mut self) -> io::Result<()>;
fn print_image(&mut self, image: &Image, options: &PrintOptions) -> Result<(), PrintImageError>;
fn suspend(&mut self);
fn resume(&mut self);
}
/// A wrapper over the terminal write handle.
@ -60,7 +46,9 @@ impl<I: TerminalWrite> Terminal<I> {
writer.init()?;
Ok(Self { writer, image_printer, cursor_row: 0, current_row_height: 1 })
}
}
impl<I: TerminalWrite> TerminalIo for Terminal<I> {
fn begin_update(&mut self) -> io::Result<()> {
self.writer.queue(terminal::BeginSynchronizedUpdate)?;
Ok(())
@ -71,6 +59,10 @@ impl<I: TerminalWrite> Terminal<I> {
Ok(())
}
fn cursor_row(&self) -> u16 {
self.cursor_row
}
fn move_to(&mut self, column: u16, row: u16) -> io::Result<()> {
self.writer.queue(cursor::MoveTo(column, row))?;
self.cursor_row = row;
@ -94,16 +86,6 @@ impl<I: TerminalWrite> Terminal<I> {
Ok(())
}
fn move_right(&mut self, amount: u16) -> io::Result<()> {
self.writer.queue(cursor::MoveRight(amount))?;
Ok(())
}
fn move_left(&mut self, amount: u16) -> io::Result<()> {
self.writer.queue(cursor::MoveLeft(amount))?;
Ok(())
}
fn move_to_next_line(&mut self) -> io::Result<()> {
let amount = self.current_row_height;
self.writer.queue(cursor::MoveToNextLine(amount))?;
@ -112,17 +94,16 @@ impl<I: TerminalWrite> Terminal<I> {
Ok(())
}
fn print_text(&mut self, content: &str, style: &TextStyle) -> io::Result<()> {
fn print_text(&mut self, content: &str, style: &TextStyle, properties: &TextProperties) -> io::Result<()> {
let content = style.apply(content);
self.writer.queue(style::PrintStyledContent(content))?;
self.current_row_height = self.current_row_height.max(style.size as u16);
self.current_row_height = self.current_row_height.max(properties.height as u16);
Ok(())
}
fn clear_screen(&mut self) -> io::Result<()> {
self.writer.queue(terminal::Clear(terminal::ClearType::All))?;
self.cursor_row = 0;
self.current_row_height = 1;
Ok(())
}
@ -145,55 +126,38 @@ impl<I: TerminalWrite> Terminal<I> {
}
fn print_image(&mut self, image: &Image, options: &PrintOptions) -> Result<(), PrintImageError> {
let image_printer = self.image_printer.clone();
image_printer.print(image.image(), options, self)?;
self.move_to_column(options.cursor_position.column)?;
self.image_printer.print(&image.image, options, &mut self.writer)?;
self.cursor_row += options.rows;
Ok(())
}
pub(crate) fn suspend(&mut self) {
fn suspend(&mut self) {
self.writer.deinit();
}
pub(crate) fn resume(&mut self) {
fn resume(&mut self) {
let _ = self.writer.init();
}
}
impl<I: TerminalWrite> TerminalIo for Terminal<I> {
fn execute(&mut self, command: &TerminalCommand<'_>) -> Result<(), TerminalError> {
use TerminalCommand::*;
match command {
BeginUpdate => self.begin_update()?,
EndUpdate => self.end_update()?,
MoveTo { column, row } => self.move_to(*column, *row)?,
MoveToRow(row) => self.move_to_row(*row)?,
MoveToColumn(column) => self.move_to_column(*column)?,
MoveDown(amount) => self.move_down(*amount)?,
MoveRight(amount) => self.move_right(*amount)?,
MoveLeft(amount) => self.move_left(*amount)?,
MoveToNextLine => self.move_to_next_line()?,
PrintText { content, style } => self.print_text(content, style)?,
ClearScreen => self.clear_screen()?,
SetColors(colors) => self.set_colors(*colors)?,
SetBackgroundColor(color) => self.set_background_color(*color)?,
Flush => self.flush()?,
PrintImage { image, options } => self.print_image(image, options)?,
};
Ok(())
}
fn cursor_row(&self) -> u16 {
self.cursor_row
}
}
impl<I: TerminalWrite> Drop for Terminal<I> {
fn drop(&mut self) {
self.writer.deinit();
}
}
#[derive(Clone, Debug)]
pub(crate) struct TextProperties {
pub(crate) height: u8,
}
impl Default for TextProperties {
fn default() -> Self {
Self { height: 1 }
}
}
pub(crate) fn should_hide_cursor() -> bool {
// WezTerm on Windows fails to display images if we've hidden the cursor so we **always** hide it
// unless we're on WezTerm on Windows.

View File

@ -1,55 +1,21 @@
use super::{
image::{
Image,
printer::{PrintImage, PrintImageError, PrintOptions},
protocols::ascii::AsciiPrinter,
printer::{PrintImageError, PrintOptions},
},
printer::{TerminalError, TerminalIo},
printer::{TerminalIo, TextProperties},
};
use crate::{
WindowSize,
markdown::{
elements::Text,
text_style::{Color, Colors, TextStyle},
},
terminal::printer::TerminalCommand,
markdown::text_style::{Color, Colors, TextStyle},
};
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>,
@ -63,24 +29,12 @@ pub(crate) struct VirtualTerminal {
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 {
pub(crate) fn new(dimensions: WindowSize) -> 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,
}
Self { row: 0, column: 0, colors: Default::default(), rows, background_color: None, images: Default::default() }
}
pub(crate) fn into_contents(self) -> TerminalGrid {
@ -90,15 +44,19 @@ impl VirtualTerminal {
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;
}
impl TerminalIo for VirtualTerminal {
fn begin_update(&mut self) -> io::Result<()> {
Ok(())
}
fn current_row_height(&self) -> u16 {
*self.row_heights.get(self.row as usize).unwrap_or(&1)
fn end_update(&mut self) -> io::Result<()> {
Ok(())
}
fn cursor_row(&self) -> u16 {
self.row
}
fn move_to(&mut self, column: u16, row: u16) -> io::Result<()> {
@ -109,7 +67,6 @@ impl VirtualTerminal {
fn move_to_row(&mut self, row: u16) -> io::Result<()> {
self.row = row;
self.set_current_row_height(1);
Ok(())
}
@ -123,25 +80,13 @@ impl VirtualTerminal {
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.row += 1;
self.column = 0;
self.set_current_row_height(1);
Ok(())
}
fn print_text(&mut self, content: &str, style: &TextStyle) -> io::Result<()> {
fn print_text(&mut self, content: &str, style: &TextStyle, _properties: &TextProperties) -> io::Result<()> {
let style = style.merged(&TextStyle::default().colors(self.colors));
for c in content.chars() {
let Some(cell) = self.current_cell_mut() else {
@ -149,10 +94,8 @@ impl VirtualTerminal {
};
cell.character = c;
cell.style = style;
self.column += style.size as u16;
self.column += 1;
}
let height = self.current_row_height().max(style.size as u16);
self.set_current_row_height(height);
Ok(())
}
@ -181,54 +124,14 @@ impl VirtualTerminal {
}
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)?,
};
let key = (options.cursor_position.row, options.cursor_position.column);
let image = PrintedImage { image: image.clone(), width_columns: options.columns };
self.images.insert(key, image);
Ok(())
}
fn cursor_row(&self) -> u16 {
self.row
}
}
#[derive(Clone, Debug, Default)]
pub(crate) enum ImageBehavior {
#[default]
Store,
PrintAscii,
fn suspend(&mut self) {}
fn resume(&mut self) {}
}
#[derive(Clone, Copy, Debug, PartialEq)]
@ -237,19 +140,6 @@ pub(crate) struct StyledChar {
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() }
@ -277,12 +167,12 @@ mod tests {
#[test]
fn text() {
let dimensions = WindowSize { rows: 2, columns: 3, height: 0, width: 0 };
let mut term = VirtualTerminal::new(dimensions, Default::default());
let mut term = VirtualTerminal::new(dimensions);
for c in "abc".chars() {
term.print_text(&c.to_string(), &Default::default()).expect("print failed");
term.print_text(&c.to_string(), &Default::default(), &Default::default()).expect("print failed");
}
term.move_to_next_line().unwrap();
term.print_text("A", &Default::default()).expect("print failed");
term.print_text("A", &Default::default(), &Default::default()).expect("print failed");
let grid = term.into_contents();
grid.assert_contents(&["abc", "A "]);
}
@ -290,30 +180,17 @@ mod tests {
#[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();
let mut term = VirtualTerminal::new(dimensions);
term.print_text("A", &Default::default(), &Default::default()).unwrap();
term.move_down(1).unwrap();
term.print_text("B", &Default::default()).unwrap();
term.print_text("B", &Default::default(), &Default::default()).unwrap();
term.move_to(2, 0).unwrap();
term.print_text("C", &Default::default()).unwrap();
term.print_text("C", &Default::default(), &Default::default()).unwrap();
term.move_to_row(1).unwrap();
term.move_to_column(2).unwrap();
term.print_text("D", &Default::default()).unwrap();
term.print_text("D", &Default::default(), &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")]);
}
}

View File

@ -105,7 +105,7 @@ impl PresentationTheme {
Heading4 => self.headings.h4.alignment,
Heading5 => self.headings.h5.alignment,
Heading6 => self.headings.h6.alignment,
Paragraph => Default::default(),
Paragraph | List => Default::default(),
PresentationTitle => self.intro_slide.title.alignment,
PresentationSubTitle => self.intro_slide.subtitle.alignment,
PresentationEvent => self.intro_slide.event.alignment,
@ -474,7 +474,7 @@ pub(crate) enum FooterStyle {
Template {
left: Option<FooterContent>,
center: Option<FooterContent>,
right: Option<FooterContent>,
right: Option<FooterTemplate>,
style: TextStyle,
height: u16,
},
@ -496,7 +496,7 @@ impl FooterStyle {
raw::FooterStyle::Template { left, center, right, colors, height } => {
let left = left.as_ref().map(|t| FooterContent::new(t, resources)).transpose()?;
let center = center.as_ref().map(|t| FooterContent::new(t, resources)).transpose()?;
let right = right.as_ref().map(|t| FooterContent::new(t, resources)).transpose()?;
let right = right.clone();
let style = TextStyle::colored(colors.resolve(palette)?);
let height = height.unwrap_or(DEFAULT_FOOTER_HEIGHT);
Ok(Self::Template { left, center, right, style, height })
@ -627,6 +627,7 @@ pub(crate) enum ElementType {
Heading5,
Heading6,
Paragraph,
List,
PresentationTitle,
PresentationSubTitle,
PresentationEvent,

View File

@ -2,6 +2,7 @@ use super::registry::LoadThemeError;
use crate::markdown::text_style::{Color, Colors, UndefinedPaletteColorError};
use hex::{FromHex, FromHexError};
use serde::{Deserialize, Serialize, de::Visitor};
use serde_with::{DeserializeFromStr, SerializeDisplay};
use std::{
collections::BTreeMap,
fmt, fs,
@ -411,7 +412,7 @@ pub(super) enum FooterStyle {
center: Option<FooterContent>,
/// The content to be put on the right.
right: Option<FooterContent>,
right: Option<FooterTemplate>,
/// The colors to be used.
#[serde(default)]
@ -507,12 +508,9 @@ impl<'de> Deserialize<'de> for FooterContent {
}
}
#[derive(Clone, Debug)]
#[derive(Clone, Debug, SerializeDisplay, DeserializeFromStr)]
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;
@ -803,7 +801,7 @@ pub(super) struct ColorPalette {
pub(super) classes: BTreeMap<String, RawColors>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, SerializeDisplay, DeserializeFromStr)]
pub(crate) enum RawColor {
Color(Color),
Palette(String),
@ -811,9 +809,6 @@ pub(crate) enum RawColor {
BackgroundClass(String),
}
crate::utils::impl_deserialize_from_str!(RawColor);
crate::utils::impl_serialize_from_display!(RawColor);
impl RawColor {
fn new_palette(name: &str) -> Result<Self, ParseColorError> {
if name.is_empty() { Err(ParseColorError::PaletteColorEmpty) } else { Ok(Self::Palette(name.into())) }

View File

@ -5,17 +5,14 @@ use crate::{
elements::{Line, Percent, Text},
text_style::{Color, TextStyle},
},
presentation::{AsyncPresentationError, AsyncPresentationErrorHolder},
render::{
operation::{
AsRenderOperations, ImageRenderProperties, ImageSize, Pollable, PollableState, RenderAsync,
RenderAsyncStartPolicy, RenderOperation,
AsRenderOperations, ImageRenderProperties, ImageSize, RenderAsync, RenderAsyncState, RenderOperation,
},
properties::WindowSize,
},
terminal::image::{
Image,
printer::{ImageSpec, RegisterImageError},
},
terminal::image::{Image, printer::RegisterImageError},
theme::{Alignment, MermaidStyle, PresentationTheme, TypstStyle, raw::RawColor},
tools::{ExecutionError, ThirdPartyTools},
};
@ -53,10 +50,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.style, error_holder, slide, width));
Ok(RenderOperation::RenderAsync(operation))
}
}
@ -270,7 +269,7 @@ impl Worker {
fn load_image(&self, snippet: ImageSnippet, path: &Path) -> Result<Image, ThirdPartyRenderError> {
let contents = fs::read(path)?;
let image = image::load_from_memory(&contents)?;
let image = self.state.lock().unwrap().image_registry.register(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,32 +308,54 @@ 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,
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_style: TextStyle,
error_holder: AsyncPresentationErrorHolder,
slide: usize,
width: Option<Percent>,
) -> Self {
Self { contents: Default::default(), pending_result, default_style, 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(),
@ -347,7 +368,6 @@ impl AsRenderOperations for RenderThirdParty {
vec![RenderOperation::RenderImage(image.clone(), properties)]
}
Some(Output::Error) => Vec::new(),
None => {
let text = Line::from(Text::new("Loading...", TextStyle::default().bold()));
vec![RenderOperation::RenderText {
@ -358,35 +378,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

@ -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);
}
}

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

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

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>),
}

View File

@ -1,9 +0,0 @@
pub(crate) mod acquire_terminal;
pub(crate) mod disabled;
pub(crate) mod image;
pub(crate) mod snippet;
pub(crate) use acquire_terminal::RunAcquireTerminalSnippet;
pub(crate) use disabled::SnippetExecutionDisabledOperation;
pub(crate) use image::RunImageSnippet;
pub(crate) use snippet::RunSnippetOperation;

View File

@ -1,243 +0,0 @@
use crate::{
code::{
execute::{ExecutionHandle, ExecutionState, ProcessStatus, SnippetExecutor},
snippet::Snippet,
},
markdown::{
elements::{Line, Text},
text::WeightedLine,
text_style::{Colors, TextStyle},
},
render::{
operation::{
AsRenderOperations, BlockLine, Pollable, PollableState, RenderAsync, RenderAsyncStartPolicy,
RenderOperation,
},
properties::WindowSize,
},
terminal::ansi::AnsiSplitter,
theme::{Alignment, ExecutionOutputBlockStyle, ExecutionStatusBlockStyle},
ui::separator::{RenderSeparator, SeparatorWidth},
};
use std::{
io::BufRead,
rc::Rc,
sync::{Arc, Mutex},
};
const MINIMUM_SEPARATOR_WIDTH: u16 = 32;
#[derive(Debug)]
struct Inner {
output_lines: Vec<WeightedLine>,
max_line_length: u16,
process_status: Option<ProcessStatus>,
started: bool,
}
#[derive(Debug)]
pub(crate) struct RunSnippetOperation {
code: Snippet,
executor: Arc<SnippetExecutor>,
default_colors: Colors,
block_colors: Colors,
style: ExecutionStatusBlockStyle,
block_length: u16,
alignment: Alignment,
inner: Arc<Mutex<Inner>>,
separator: DisplaySeparator,
font_size: u8,
policy: RenderAsyncStartPolicy,
}
impl RunSnippetOperation {
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
code: Snippet,
executor: Arc<SnippetExecutor>,
default_colors: Colors,
style: ExecutionOutputBlockStyle,
block_length: u16,
separator: DisplaySeparator,
alignment: Alignment,
font_size: u8,
policy: RenderAsyncStartPolicy,
) -> Self {
let block_colors = style.style.colors;
let status_colors = style.status.clone();
let block_length = alignment.adjust_size(block_length);
let inner = Inner { output_lines: Vec::new(), max_line_length: 0, process_status: None, started: false };
Self {
code,
executor,
default_colors,
block_colors,
style: status_colors,
block_length,
alignment,
inner: Arc::new(Mutex::new(inner)),
separator,
font_size,
policy,
}
}
}
#[derive(Debug)]
pub(crate) enum DisplaySeparator {
On,
Off,
}
impl AsRenderOperations for RunSnippetOperation {
fn as_render_operations(&self, _dimensions: &WindowSize) -> Vec<RenderOperation> {
let inner = self.inner.lock().unwrap();
let description = match &inner.process_status {
Some(ProcessStatus::Running) => Text::new("running", self.style.running_style),
Some(ProcessStatus::Success) => Text::new("finished", self.style.success_style),
Some(ProcessStatus::Failure) => Text::new("finished with error", self.style.failure_style),
None => Text::new("not started", self.style.not_started_style),
};
let mut operations = match self.separator {
DisplaySeparator::On => {
let heading = Line(vec![" [".into(), description.clone(), "] ".into()]);
let separator_width = match &self.alignment {
Alignment::Left { .. } | Alignment::Right { .. } => SeparatorWidth::FitToWindow,
// We need a minimum here otherwise if the code/block length is too narrow, the separator is
// word-wrapped and looks bad.
Alignment::Center { .. } => SeparatorWidth::Fixed(self.block_length.max(MINIMUM_SEPARATOR_WIDTH)),
};
let separator = RenderSeparator::new(heading, separator_width, self.font_size);
vec![
RenderOperation::RenderLineBreak,
RenderOperation::RenderDynamic(Rc::new(separator)),
RenderOperation::RenderLineBreak,
]
}
DisplaySeparator::Off => vec![],
};
if !inner.started {
return operations;
}
operations.push(RenderOperation::RenderLineBreak);
if self.block_colors.background.is_some() {
operations.push(RenderOperation::SetColors(self.block_colors));
}
let has_margin = match &self.alignment {
Alignment::Left { margin } => !margin.is_empty(),
Alignment::Right { margin } => !margin.is_empty(),
Alignment::Center { minimum_margin, minimum_size } => !minimum_margin.is_empty() || minimum_size != &0,
};
let block_length =
if has_margin { self.block_length.max(inner.max_line_length) } else { inner.max_line_length };
for line in &inner.output_lines {
operations.push(RenderOperation::RenderBlockLine(BlockLine {
prefix: "".into(),
right_padding_length: 0,
repeat_prefix_on_wrap: false,
text: line.clone(),
block_length,
alignment: self.alignment,
block_color: self.block_colors.background,
}));
operations.push(RenderOperation::RenderLineBreak);
}
operations.push(RenderOperation::SetColors(self.default_colors));
operations
}
}
impl RenderAsync for RunSnippetOperation {
fn pollable(&self) -> Box<dyn Pollable> {
Box::new(OperationPollable {
inner: self.inner.clone(),
executor: self.executor.clone(),
code: self.code.clone(),
handle: None,
last_length: 0,
starting_style: TextStyle::default().size(self.font_size),
})
}
fn start_policy(&self) -> RenderAsyncStartPolicy {
self.policy
}
}
struct OperationPollable {
inner: Arc<Mutex<Inner>>,
executor: Arc<SnippetExecutor>,
code: Snippet,
handle: Option<ExecutionHandle>,
last_length: usize,
starting_style: TextStyle,
}
impl OperationPollable {
fn try_start(&mut self) {
let mut inner = self.inner.lock().unwrap();
if inner.started {
return;
}
inner.started = true;
match self.executor.execute_async(&self.code) {
Ok(handle) => {
self.handle = Some(handle);
}
Err(e) => {
inner.output_lines = vec![WeightedLine::from(e.to_string())];
}
}
}
}
impl Pollable for OperationPollable {
fn poll(&mut self) -> PollableState {
self.try_start();
// At this point if we don't have a handle it's because we're done.
let Some(handle) = self.handle.as_mut() else { return PollableState::Done };
// Pull data out of the process' output and drop the handle state.
let mut state = handle.state.lock().unwrap();
let ExecutionState { output, status } = &mut *state;
let status = status.clone();
let modified = output.len() != self.last_length;
let mut lines = Vec::new();
for line in output.lines() {
let mut line = line.expect("invalid utf8");
if line.contains('\t') {
line = line.replace('\t', " ");
}
lines.push(line);
}
drop(state);
let mut max_line_length = 0;
let (lines, style) = AnsiSplitter::new(self.starting_style).split_lines(&lines);
for line in &lines {
let width = u16::try_from(line.width()).unwrap_or(u16::MAX);
max_line_length = max_line_length.max(width);
}
let mut inner = self.inner.lock().unwrap();
let is_finished = status.is_finished();
inner.process_status = Some(status);
inner.output_lines = lines;
inner.max_line_length = inner.max_line_length.max(max_line_length);
if is_finished {
self.handle.take();
PollableState::Done
} else {
// Save the style so we continue with it next time
self.starting_style = style;
match modified {
true => PollableState::Modified,
false => PollableState::Unmodified,
}
}
}
}

View File

@ -5,7 +5,7 @@ use crate::{
text_style::{TextStyle, UndefinedPaletteColorError},
},
render::{
operation::{AsRenderOperations, ImagePosition, ImageRenderProperties, MarginProperties, RenderOperation},
operation::{AsRenderOperations, ImageRenderProperties, MarginProperties, RenderOperation},
properties::WindowSize,
},
terminal::image::Image,
@ -53,8 +53,14 @@ impl FooterGenerator {
]);
}
fn push_image(&self, image: &Image, alignment: Alignment, operations: &mut Vec<RenderOperation>) {
let mut properties = ImageRenderProperties::default();
fn push_image(
&self,
image: &Image,
alignment: Alignment,
dimensions: &WindowSize,
operations: &mut Vec<RenderOperation>,
) {
let mut properties = ImageRenderProperties { center: false, ..Default::default() };
operations.push(RenderOperation::ApplyMargin(MarginProperties {
horizontal: Margin::Fixed(0),
@ -64,12 +70,11 @@ impl FooterGenerator {
match alignment {
Alignment::Left { .. } => {
operations.push(RenderOperation::JumpToColumn { index: 0 });
properties.position = ImagePosition::Cursor;
}
Alignment::Right { .. } => {
properties.position = ImagePosition::Right;
operations.push(RenderOperation::JumpToColumn { index: dimensions.columns.saturating_sub(1) });
}
Alignment::Center { .. } => properties.position = ImagePosition::Center,
Alignment::Center { .. } => properties.center = true,
};
operations.extend([
// Start printing the image at the top of the footer rect
@ -96,20 +101,23 @@ impl AsRenderOperations for FooterGenerator {
let alignments = [
Alignment::Left { margin: Default::default() },
Alignment::Center { minimum_size: 0, minimum_margin: Default::default() },
Alignment::Right { margin: Default::default() },
];
for (content, alignment) in [left, center, right].iter().zip(alignments) {
for (content, alignment) in [left, center].iter().zip(alignments) {
if let Some(content) = content {
match content {
RenderedFooterContent::Line(line) => {
Self::render_line(line, alignment, *height, &mut operations);
}
RenderedFooterContent::Image(image) => {
self.push_image(image, alignment, &mut operations);
self.push_image(image, alignment, dimensions, &mut operations);
}
};
}
}
// We don't support images on the right so treat this differently
if let Some(line) = right {
Self::render_line(line, Alignment::Right { margin: Default::default() }, *height, &mut operations);
}
operations.push(RenderOperation::PopMargin);
operations
}
@ -138,7 +146,7 @@ enum RenderedFooterStyle {
Template {
left: Option<RenderedFooterContent>,
center: Option<RenderedFooterContent>,
right: Option<RenderedFooterContent>,
right: Option<FooterLine>,
height: u16,
},
ProgressBar {
@ -158,7 +166,7 @@ impl RenderedFooterStyle {
FooterStyle::Template { left, center, right, style, height } => {
let left = left.map(|c| RenderedFooterContent::new(c, &style, vars, palette)).transpose()?;
let center = center.map(|c| RenderedFooterContent::new(c, &style, vars, palette)).transpose()?;
let right = right.map(|c| RenderedFooterContent::new(c, &style, vars, palette)).transpose()?;
let right = right.map(|c| FooterLine::new(c, &style, vars, palette)).transpose()?;
Ok(Self::Template { left, center, right, height })
}
FooterStyle::ProgressBar { character, style } => Ok(Self::ProgressBar { character, style }),
@ -178,9 +186,10 @@ impl FooterLine {
palette: &ColorPalette,
) -> Result<Self, InvalidFooterTemplateError> {
use FooterTemplateChunk::*;
let mut line = Line::default();
let FooterVariables { current_slide, total_slides, author, title, sub_title, event, location, date } = vars;
let arena = Arena::default();
let mut reassembled = String::new();
let parser = MarkdownParser::new(&arena);
for chunk in template.0 {
let raw_text = match chunk {
CurrentSlide => Cow::Owned(current_slide.to_string()),
@ -198,22 +207,20 @@ impl FooterLine {
if raw_text.lines().count() != 1 {
return Err(InvalidFooterTemplateError::NoNewlines);
}
reassembled.push_str(&raw_text);
}
// Inline parsing loses leading/trailing whitespaces so re-add them ourselves
let starting_length = reassembled.len();
let raw_text = reassembled.trim_start();
let left_whitespace = starting_length - raw_text.len();
let raw_text = raw_text.trim_end();
let right_whitespace = starting_length - raw_text.len() - left_whitespace;
let parser = MarkdownParser::new(&arena);
let inlines = parser.parse_inlines(&reassembled)?;
let mut line = inlines.resolve(palette)?;
if left_whitespace != 0 {
line.0.insert(0, " ".repeat(left_whitespace).into());
}
if right_whitespace != 0 {
line.0.push(" ".repeat(right_whitespace).into());
let starting_length = raw_text.len();
let raw_text = raw_text.trim_start();
let left_whitespace = starting_length - raw_text.len();
let raw_text = raw_text.trim_end();
let right_whitespace = starting_length - raw_text.len() - left_whitespace;
let inlines = parser.parse_inlines(raw_text)?;
let mut contents = inlines.resolve(palette)?;
if left_whitespace != 0 {
contents.0.insert(0, " ".repeat(left_whitespace).into());
}
if right_whitespace != 0 {
contents.0.push(" ".repeat(right_whitespace).into());
}
line.0.extend(contents.0);
}
line.apply_style(style);
Ok(Self(line))
@ -321,25 +328,4 @@ mod tests {
let template = FooterTemplate(vec![chunk]);
FooterLine::new(template, &Default::default(), &VARIABLES, &PALETTE).expect_err("render succeeded");
}
#[test]
fn interleaved_spans() {
let chunks = vec![
FooterTemplateChunk::Literal("<span style=\"color: palette:red\">".into()),
FooterTemplateChunk::CurrentSlide,
FooterTemplateChunk::Literal(" / ".into()),
FooterTemplateChunk::TotalSlides,
FooterTemplateChunk::Literal("</span>".into()),
FooterTemplateChunk::Literal("<span style=\"color: green\">".into()),
FooterTemplateChunk::Title,
FooterTemplateChunk::Literal("</span>".into()),
];
let template = FooterTemplate(chunks);
let line = FooterLine::new(template, &Default::default(), &VARIABLES, &PALETTE).expect("render failed");
let expected = &[
Text::new("1 / 5", TextStyle::default().fg_color(Color::new(255, 0, 0))),
Text::new("hi", TextStyle::default().fg_color(Color::Green)),
];
assert_eq!(line.0.0, expected);
}
}

View File

@ -9,9 +9,7 @@ use crate::{
},
presentation::PresentationState,
render::{
operation::{
AsRenderOperations, ImagePosition, ImageRenderProperties, ImageSize, MarginProperties, RenderOperation,
},
operation::{AsRenderOperations, ImageRenderProperties, ImageSize, MarginProperties, RenderOperation},
properties::WindowSize,
},
terminal::image::Image,
@ -309,7 +307,7 @@ impl AsRenderOperations for CenterModalContent {
size: ImageSize::Specific(self.content_width, content_height),
restore_cursor: true,
background_color: None,
position: ImagePosition::Center,
center: true,
};
operations.push(RenderOperation::RenderImage(image.clone(), properties));
}

View File

@ -1,75 +0,0 @@
use serde::{Deserializer, Serializer};
use std::{
fmt::{self, Display},
marker::PhantomData,
str::FromStr,
};
macro_rules! impl_deserialize_from_str {
($ty:ty) => {
impl<'de> serde::de::Deserialize<'de> for $ty {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::de::Deserializer<'de>,
{
$crate::utils::deserialize_from_str(deserializer)
}
}
};
}
macro_rules! impl_serialize_from_display {
($ty:ty) => {
impl serde::Serialize for $ty {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
$crate::utils::serialize_display(self, serializer)
}
}
};
}
pub(crate) use impl_deserialize_from_str;
pub(crate) use impl_serialize_from_display;
// Same behavior as serde_with::DeserializeFromStr
pub(crate) fn deserialize_from_str<'de, D, T>(deserializer: D) -> Result<T, D::Error>
where
D: Deserializer<'de>,
T: FromStr,
T::Err: Display,
{
struct Visitor<S>(PhantomData<S>);
impl<S> serde::de::Visitor<'_> for Visitor<S>
where
S: FromStr,
<S as FromStr>::Err: Display,
{
type Value = S;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
write!(formatter, "a string")
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
value.parse::<S>().map_err(serde::de::Error::custom)
}
}
deserializer.deserialize_str(Visitor(PhantomData))
}
// Same behavior as serde_with::SerializeDisplay
pub(crate) fn serialize_display<T, S>(value: &T, serializer: S) -> Result<S::Ok, S::Error>
where
T: Display,
S: Serializer,
{
serializer.serialize_str(&value.to_string())
}

View File

@ -125,6 +125,7 @@ alert:
typst:
colors:
foreground: "f0f0f0"
background: "292e42"
footer:
style: template

View File

@ -125,6 +125,7 @@ alert:
typst:
colors:
foreground: "212529"
background: "e9ecef"
footer:
style: template