Repository: mfontanini/presenterm Branch: master Commit: 952061abec94 Files: 155 Total size: 1.1 MB Directory structure: gitextract_hsyzsbc9/ ├── .editorconfig ├── .github/ │ ├── FUNDING.yml │ └── workflows/ │ ├── docs.yaml │ ├── merge.yaml │ ├── nightly.yaml │ └── release.yaml ├── .gitignore ├── CHANGELOG.md ├── Cargo.toml ├── LICENSE ├── README.md ├── bat/ │ ├── acknowledgements.txt │ ├── bat.git-hash │ ├── syntaxes.git-hash │ ├── update.sh │ └── verify.sh ├── build.rs ├── config-file-schema.json ├── config.sample.yaml ├── docs/ │ ├── .gitignore │ ├── book.toml │ └── src/ │ ├── SUMMARY.md │ ├── acknowledgements.md │ ├── configuration/ │ │ ├── introduction.md │ │ ├── options.md │ │ └── settings.md │ ├── features/ │ │ ├── code/ │ │ │ ├── d2.md │ │ │ ├── execution.md │ │ │ ├── highlighting.md │ │ │ ├── latex.md │ │ │ └── mermaid.md │ │ ├── commands.md │ │ ├── exports.md │ │ ├── images.md │ │ ├── introduction.md │ │ ├── layout.md │ │ ├── slide-transitions.md │ │ ├── speaker-notes.md │ │ └── themes/ │ │ ├── definition.md │ │ └── introduction.md │ ├── install.md │ ├── internals/ │ │ └── parse.md │ └── introduction.md ├── examples/ │ ├── README.md │ ├── code.md │ ├── columns.md │ ├── custom-intro-slides.md │ ├── demo.md │ ├── footer.md │ └── speaker-notes.md ├── executors.yaml ├── flake.nix ├── rustfmt.toml ├── scripts/ │ ├── generate-config-file-schema.sh │ ├── parse-changelog.sh │ ├── test-pdf-generation.sh │ └── validate-config-file-schema.sh ├── src/ │ ├── code/ │ │ ├── execute.rs │ │ ├── highlighting.rs │ │ ├── mod.rs │ │ ├── padding.rs │ │ └── snippet.rs │ ├── commands/ │ │ ├── keyboard.rs │ │ ├── listener.rs │ │ ├── mod.rs │ │ └── speaker_notes.rs │ ├── config.rs │ ├── demo.rs │ ├── export/ │ │ ├── exporter.rs │ │ ├── html.rs │ │ ├── mod.rs │ │ ├── output.rs │ │ └── script.js │ ├── main.rs │ ├── markdown/ │ │ ├── elements.rs │ │ ├── html.rs │ │ ├── mod.rs │ │ ├── parse.rs │ │ ├── text.rs │ │ └── text_style.rs │ ├── presentation/ │ │ ├── builder/ │ │ │ ├── comment.rs │ │ │ ├── error.rs │ │ │ ├── frontmatter.rs │ │ │ ├── heading.rs │ │ │ ├── images.rs │ │ │ ├── list.rs │ │ │ ├── mod.rs │ │ │ ├── quote.rs │ │ │ ├── snippet.rs │ │ │ ├── sources.rs │ │ │ ├── table.rs │ │ │ └── tests.rs │ │ ├── diff.rs │ │ ├── mod.rs │ │ └── poller.rs │ ├── presenter.rs │ ├── render/ │ │ ├── ascii_scaler.rs │ │ ├── engine.rs │ │ ├── layout.rs │ │ ├── mod.rs │ │ ├── operation.rs │ │ ├── properties.rs │ │ ├── text.rs │ │ └── validate.rs │ ├── resource.rs │ ├── terminal/ │ │ ├── ansi.rs │ │ ├── capabilities.rs │ │ ├── emulator.rs │ │ ├── image/ │ │ │ ├── mod.rs │ │ │ ├── printer.rs │ │ │ ├── protocols/ │ │ │ │ ├── ascii.rs │ │ │ │ ├── iterm.rs │ │ │ │ ├── kitty.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── raw.rs │ │ │ │ └── sixel.rs │ │ │ └── scale.rs │ │ ├── mod.rs │ │ ├── printer.rs │ │ └── virt.rs │ ├── theme/ │ │ ├── clean.rs │ │ ├── mod.rs │ │ ├── raw.rs │ │ └── registry.rs │ ├── third_party.rs │ ├── tools.rs │ ├── transitions/ │ │ ├── collapse_horizontal.rs │ │ ├── fade.rs │ │ ├── mod.rs │ │ └── slide_horizontal.rs │ ├── ui/ │ │ ├── execution/ │ │ │ ├── acquire_terminal.rs │ │ │ ├── disabled.rs │ │ │ ├── image.rs │ │ │ ├── mod.rs │ │ │ ├── output.rs │ │ │ ├── pty.rs │ │ │ └── validator.rs │ │ ├── footer.rs │ │ ├── mod.rs │ │ ├── modals.rs │ │ └── separator.rs │ └── utils.rs └── themes/ ├── catppuccin-frappe.yaml ├── catppuccin-latte.yaml ├── catppuccin-macchiato.yaml ├── catppuccin-mocha.yaml ├── dark.yaml ├── gruvbox-dark.yaml ├── light.yaml ├── terminal-dark.yaml ├── terminal-light.yaml ├── tokyonight-day.yaml ├── tokyonight-moon.yaml ├── tokyonight-night.yaml └── tokyonight-storm.yaml ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ [*.sh] indent_style = space indent_size = 4 ================================================ FILE: .github/FUNDING.yml ================================================ github: mfontanini ================================================ FILE: .github/workflows/docs.yaml ================================================ name: Deploy docs on: push: branches: - master permissions: contents: write jobs: build-and-deploy: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v3 - name: Install cargo-binstall uses: cargo-bins/cargo-binstall@v1.10.22 - name: Install mdbook run: | cargo binstall -y mdbook@0.4.44 mdbook-alerts@0.7.0 - name: Build the book run: | cd docs mdbook build - name: Deploy build to gh-pages branch uses: crazy-max/ghaction-github-pages@v4 with: target_branch: gh-pages build_dir: docs/book env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/merge.yaml ================================================ on: pull_request: push: branches: - master name: Merge checks jobs: check: name: Checks runs-on: ubuntu-latest steps: - name: Checkout sources uses: actions/checkout@v4 - name: Install rust toolchain uses: dtolnay/rust-toolchain@1.90.0 with: components: clippy - name: Run cargo check run: cargo check - name: Run cargo test run: cargo test - name: Run cargo clippy run: cargo clippy -- -D warnings - name: Install nightly toolchain uses: dtolnay/rust-toolchain@nightly with: components: rustfmt - name: Run cargo fmt run: cargo +nightly fmt --all -- --check - name: Install uv uses: astral-sh/setup-uv@v5 - name: Install weasyprint run: | uv venv source ./.venv/bin/activate uv pip install weasyprint - name: Export demo presentation as PDF and HTML run: | cat >/tmp/config.yaml <> "$GITHUB_OUTPUT" echo "git_hash=$git_hash" >> "$GITHUB_OUTPUT" echo "latest_nightly_hash=$latest_nightly_hash" >> "$GITHUB_OUTPUT" publish-github: name: Publish on GitHub runs-on: ${{ matrix.config.OS }} needs: vars # Don't run this if the nightly hash already points to the current hash if: needs.vars.outputs.git_hash != needs.vars.outputs.latest_nightly_hash strategy: fail-fast: false matrix: config: - { OS: ubuntu-latest, TARGET: "x86_64-unknown-linux-gnu" } - { OS: ubuntu-latest, TARGET: "x86_64-unknown-linux-musl" } - { OS: ubuntu-latest, TARGET: "i686-unknown-linux-gnu" } - { OS: ubuntu-latest, TARGET: "i686-unknown-linux-musl" } - { OS: ubuntu-latest, TARGET: "armv5te-unknown-linux-gnueabi" } - { OS: ubuntu-latest, TARGET: "armv7-unknown-linux-gnueabihf" } - { OS: ubuntu-latest, TARGET: "aarch64-unknown-linux-gnu" } - { OS: ubuntu-latest, TARGET: "aarch64-unknown-linux-musl" } - { OS: macos-latest, TARGET: "x86_64-apple-darwin" } - { OS: macos-latest, TARGET: "aarch64-apple-darwin" } - { OS: windows-latest, TARGET: "x86_64-pc-windows-msvc" } - { OS: windows-latest, TARGET: "i686-pc-windows-msvc" } steps: - name: Checkout the repository uses: actions/checkout@v4 - name: Build binary uses: houseabsolute/actions-rust-cross@a448c4b13769d56b63b035024fef8577e1d81915 with: command: build toolchain: 1.90.0 target: ${{ matrix.config.TARGET }} args: "--locked --release" - name: Prepare release assets shell: bash run: | mkdir release/ cp {LICENSE,README.md} release/ cp target/${{ matrix.config.TARGET }}/release/presenterm release/ mv release/ presenterm-${{ env.RELEASE_VERSION }}/ - name: Create release artifacts shell: bash run: | if [ "${{ matrix.config.OS }}" = "windows-latest" ]; then 7z a -tzip "presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.zip" \ presenterm-${{ env.RELEASE_VERSION }} sha512sum "presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.zip" \ > presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.zip.sha512 else tar -czvf presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.tar.gz \ presenterm-${{ env.RELEASE_VERSION }}/ shasum -a 512 presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.tar.gz \ > presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.tar.gz.sha512 fi - name: Upload the release uses: svenstaro/upload-release-action@e2a63377780a8bacc68dcac9b0979ee20ad5a791 with: repo_token: ${{ secrets.GITHUB_TOKEN }} tag: nightly file: presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.* file_glob: true overwrite: true prerelease: true release_name: Nightly body: | This is a nightly build based on ref [${{ needs.vars.outputs.git_hash }}](https://github.com/mfontanini/presenterm/commit/${{ needs.vars.outputs.git_hash }}) Generated on `${{ needs.vars.outputs.timestamp }}` ================================================ FILE: .github/workflows/release.yaml ================================================ name: Release on: push: tags: - "v*.*.*" jobs: changelog: name: Parse changelog runs-on: ubuntu-latest outputs: notes: ${{ steps.parse.outputs.notes }} steps: - name: Checkout the repository uses: actions/checkout@v3 - name: Parse release notes id: parse shell: bash run: | release_version=v${GITHUB_REF:11} r=$(./scripts/parse-changelog.sh "${release_version}") r="${r//'%'/'%25'}" r="${r//$'\n'/'%0A'}" r="${r//$'\r'/'%0D'}" echo "notes=$r" >> "$GITHUB_OUTPUT" publish-github: name: Publish on GitHub runs-on: ${{ matrix.config.OS }} needs: changelog strategy: fail-fast: false matrix: config: - { OS: ubuntu-latest, TARGET: "x86_64-unknown-linux-gnu" } - { OS: ubuntu-latest, TARGET: "x86_64-unknown-linux-musl" } - { OS: ubuntu-latest, TARGET: "i686-unknown-linux-gnu" } - { OS: ubuntu-latest, TARGET: "i686-unknown-linux-musl" } - { OS: ubuntu-latest, TARGET: "armv5te-unknown-linux-gnueabi" } - { OS: ubuntu-latest, TARGET: "armv7-unknown-linux-gnueabihf" } - { OS: ubuntu-latest, TARGET: "aarch64-unknown-linux-gnu" } - { OS: ubuntu-latest, TARGET: "aarch64-unknown-linux-musl" } - { OS: macos-latest, TARGET: "x86_64-apple-darwin" } - { OS: macos-latest, TARGET: "aarch64-apple-darwin" } - { OS: windows-latest, TARGET: "x86_64-pc-windows-msvc" } - { OS: windows-latest, TARGET: "i686-pc-windows-msvc" } steps: - name: Checkout the repository uses: actions/checkout@v4 - name: Set the release version shell: bash run: echo "RELEASE_VERSION=${GITHUB_REF:11}" >> $GITHUB_ENV - name: Build binary uses: houseabsolute/actions-rust-cross@a448c4b13769d56b63b035024fef8577e1d81915 with: command: build toolchain: 1.90.0 target: ${{ matrix.config.TARGET }} args: "--locked --release" - name: Prepare release assets shell: bash run: | mkdir release/ cp {LICENSE,README.md} release/ cp target/${{ matrix.config.TARGET }}/release/presenterm release/ mv release/ presenterm-${{ env.RELEASE_VERSION }}/ - name: Create release artifacts shell: bash run: | if [ "${{ matrix.config.OS }}" = "windows-latest" ]; then 7z a -tzip "presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.zip" \ presenterm-${{ env.RELEASE_VERSION }} sha512sum "presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.zip" \ > presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.zip.sha512 else tar -czvf presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.tar.gz \ presenterm-${{ env.RELEASE_VERSION }}/ shasum -a 512 presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.tar.gz \ > presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.tar.gz.sha512 fi - name: Upload the release uses: svenstaro/upload-release-action@e2a63377780a8bacc68dcac9b0979ee20ad5a791 with: repo_token: ${{ secrets.GITHUB_TOKEN }} file: presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.* file_glob: true overwrite: true release_name: v${{ env.RELEASE_VERSION }} tag: ${{ github.ref }} body: | ${{ needs.changelog.outputs.notes }} publish-crates-io: name: Publish on crates.io needs: publish-github runs-on: ubuntu-latest steps: - name: Checkout the repository uses: actions/checkout@v4 - name: Install rust toolchain uses: dtolnay/rust-toolchain@1.90.0 - name: Publish run: cargo publish --locked --token ${{ secrets.CARGO_TOKEN }} ================================================ FILE: .gitignore ================================================ /target ================================================ FILE: CHANGELOG.md ================================================ # v0.16.1 - 2026-02-19 ## New features * Allow `italic` to be used as well as `italics` in theme ([#847](https://github.com/mfontanini/presenterm/issues/847)). ## Fixes * Render modals at the center of the screen ([#848](https://github.com/mfontanini/presenterm/issues/848)). ## Docs * Add styling docs to `slide_title` ([#845](https://github.com/mfontanini/presenterm/issues/845)) - thanks @0atman. * Describe slide titles and headings better ([#846](https://github.com/mfontanini/presenterm/issues/846)). # v0.16.0 - 2026-02-15 ## Breaking changes * Sixel is now supported in all platform and enabled by default. The will break any build scripts that enable the `sixel` feature flag since that is now gone. Any maintainer that is building the package and enabling that flag should no longer do so ([#828](https://github.com/mfontanini/presenterm/issues/828)). ## New features * Allow [executing snippets inside a PTY](https://mfontanini.github.io/presenterm/features/code/execution.html#running-code-in-pseudo-terminal-pty). This allows you to run tools that move the cursor around and redraw the terminal inside a slide (e.g. `top`, `htop`, etc). ([#781](https://github.com/mfontanini/presenterm/issues/781)) ([#794](https://github.com/mfontanini/presenterm/issues/794)) ([#809](https://github.com/mfontanini/presenterm/issues/809)) ([#788](https://github.com/mfontanini/presenterm/issues/788)) ([#808](https://github.com/mfontanini/presenterm/issues/808)) ([#807](https://github.com/mfontanini/presenterm/issues/807)) ([#789](https://github.com/mfontanini/presenterm/issues/789)). * Use `icy_sixel` crate instead of `sixel-rs`. `icy_sixel` is a pure rust crate which simplifies code distribution and allows enabling `sixel` support by default ([#818](https://github.com/mfontanini/presenterm/issues/818)) - thanks @gcavelier. * Add support for [user comments](https://mfontanini.github.io/presenterm/features/commands.html#user-comments) in presentation rendering ([#773](https://github.com/mfontanini/presenterm/issues/773)). * Allow passing in a [mermaid config file](https://mfontanini.github.io/presenterm/features/commands.html#mermaid) ([#833](https://github.com/mfontanini/presenterm/issues/833)). * Allow specifying `mmdc` [puppeteer config path](https://mfontanini.github.io/presenterm/features/commands.html#mermaid) ([#830](https://github.com/mfontanini/presenterm/issues/830)) ([#842](https://github.com/mfontanini/presenterm/issues/842)). * Add [keymap](https://mfontanini.github.io/presenterm/features/introduction.html#toggle-visual-grid) to toggle layout grid ([#718](https://github.com/mfontanini/presenterm/issues/718)). * Add [`+auto_exec`](https://mfontanini.github.io/presenterm/features/code/execution.html#automatic-execution) attribute to snippets ([#732](https://github.com/mfontanini/presenterm/issues/732)). * Update bat themes/syntaxes to latest to support a few new themes ([#811](https://github.com/mfontanini/presenterm/issues/811)). * Add tokyonight moon/day/night themes ([#751](https://github.com/mfontanini/presenterm/issues/751)) - thanks @cloudlena. * Allow configuring a global alignment in theme ([#801](https://github.com/mfontanini/presenterm/issues/801)). * Add dynamic theme option (light/dark) based on terminal color ([#778](https://github.com/mfontanini/presenterm/issues/778)) - thanks @JOTSR. * Add common es executors and support jsx and ts(x) snippets ([#783](https://github.com/mfontanini/presenterm/issues/783)) - thanks @JOTSR. * Allow configuring code block line numbers at theme level ([#771](https://github.com/mfontanini/presenterm/issues/771)). * Allow setting prefix on slide titles ([#739](https://github.com/mfontanini/presenterm/issues/739)). * Allow configuring whether first h1 heading is slide title ([#738](https://github.com/mfontanini/presenterm/issues/738)) ([#756](https://github.com/mfontanini/presenterm/issues/756)). * Allow styling bold/italics ([#737](https://github.com/mfontanini/presenterm/issues/737)). * Respect `pause` in speaker notes ([#735](https://github.com/mfontanini/presenterm/issues/735)). * Add `--list-comment-commands` cli option ([#723](https://github.com/mfontanini/presenterm/issues/723)) - thanks @rochacbruno. * Allow setting headings to be bold/italics/underlined ([#721](https://github.com/mfontanini/presenterm/issues/721)). * Add `typescript-react/tsx` highlighting ([#777](https://github.com/mfontanini/presenterm/issues/777)) - thanks @JOTSR. * Add dart code highlighting #779 ([#780](https://github.com/mfontanini/presenterm/issues/780)) - thanks @alycda. * Add ms windows executors and highlight (`cmd`, `wsl`, `bat`, `pwsh`) ([#799](https://github.com/mfontanini/presenterm/issues/799)) - thanks @JOTSR. * Add Elixir to executors ([#709](https://github.com/mfontanini/presenterm/issues/709)) - thanks @kevinschweikert. * Support gdscript syntax highlighting ([#820](https://github.com/mfontanini/presenterm/issues/820)) - thanks @TitanNano. ## Fixes * Use right size for footer images ([#840](https://github.com/mfontanini/presenterm/issues/840)). * Don't crash when exporting `+image` snippets ([#827](https://github.com/mfontanini/presenterm/issues/827)). * Clippy useless conversion ([#805](https://github.com/mfontanini/presenterm/issues/805)) - thanks @JOTSR. * Preserve footnote definition location ([#803](https://github.com/mfontanini/presenterm/issues/803)). * Respect global alignment for lists ([#802](https://github.com/mfontanini/presenterm/issues/802)). * Don't crash sending event if presentation is in error state ([#800](https://github.com/mfontanini/presenterm/issues/800)). * Highlight php code even if it doesn't start with "this` syntaxes is supported when using the kitty terminal. For other terminals we try to use unicode half block characters which cover a portion of the ASCII charset. ([#606](https://github.com/mfontanini/presenterm/issues/606))([#617](https://github.com/mfontanini/presenterm/issues/617) ) ([#665](https://github.com/mfontanini/presenterm/issues/665)). * Allow [alternative snippet executors](https://mfontanini.github.io/presenterm/features/code/execution.html#alternative-executors) for languages that support execution. This allows, for example, runnig rust code via `rust-script` or python code via `pytest` ([#614](https://github.com/mfontanini/presenterm/issues/614)). * Allow using env var `PRESENTERM_CONFIG_FILE` to point to the config file ([#663](https://github.com/mfontanini/presenterm/issues/663)) - thanks @Silver-Golden. * Set background color via OSC 11 to avoid having a colored edge around the presentation ([#623](https://github.com/mfontanini/presenterm/issues/623)) ([#624](https://github.com/mfontanini/presenterm/issues/624)) ([#627](https://github.com/mfontanini/presenterm/issues/627)). * Add support for markdown footnotes ([#616](https://github.com/mfontanini/presenterm/issues/616)). * Runtime errors are now centered rather than being left aligned with some fixed margin ([#638](https://github.com/mfontanini/presenterm/issues/638)). * Allow [configuring number of newlines](https://mfontanini.github.io/presenterm/features/commands.html#number-of-lines-in-between-list-items) in between list items ([#628](https://github.com/mfontanini/presenterm/issues/628)). * Allow 3 digit hex colors ([#609](https://github.com/mfontanini/presenterm/issues/609)) - thanks @peterc-s. * Allow [configuring font](https://mfontanini.github.io/presenterm/configuration/settings.html#pdf-font) used in PDF export ([#608](https://github.com/mfontanini/presenterm/issues/608)). * Added `uv` as an alternative executor for python code ([#662](https://github.com/mfontanini/presenterm/issues/662)) - thanks @JanNeuendorf. * Allow multiline slide titles ([#679](https://github.com/mfontanini/presenterm/issues/679)). * Add support for multiline slide titles ([#682](https://github.com/mfontanini/presenterm/issues/682)) - thanks @barr-israel. * Add support for multiline subtitle ([#680](https://github.com/mfontanini/presenterm/issues/680)) - thanks @barr-israel. * Add support for syntax highlighting and execution for F# ([#650](https://github.com/mfontanini/presenterm/issues/650)) - thanks @mnebes. * Use text style/colors in rust-script errors ([#644](https://github.com/mfontanini/presenterm/issues/644)). * Added `rust-script-pedantic` alternative executor for rust ([#640](https://github.com/mfontanini/presenterm/issues/640)). ## Fixes * Consider rect start row when capping max terminal rows ([#656](https://github.com/mfontanini/presenterm/issues/656)). * Skip speaker notes slide on `skip_slide` ([#625](https://github.com/mfontanini/presenterm/issues/625)). * Don't loop on 0 bytes read when querying capabilities ([#620](https://github.com/mfontanini/presenterm/issues/620)). * Make code snippet language specifiers case insensitive ([#613](https://github.com/mfontanini/presenterm/issues/613)) - thanks @peterc-s. * Bump dependencies ([#681](https://github.com/mfontanini/presenterm/issues/681)) - thanks @barr-israel. ## Chore * Refactored code to make it more easily testeable, and added lots of tests to ensure markdown is rendered as expected. This will hopefully reduce the number of errors found after each release ([#660](https://github.com/mfontanini/presenterm/issues/660)) ([#659](https://github.com/mfontanini/presenterm/issues/659)) ([#655](https://github.com/mfontanini/presenterm/issues/655)) ([#647](https://github.com/mfontanini/presenterm/issues/647)). * Bump rust version to 1.82 ([#611](https://github.com/mfontanini/presenterm/issues/611)). * Perform better validation around matching HTML tags ([#668](https://github.com/mfontanini/presenterm/issues/668)). * Don't run nightly job if the git hash hasn't changed ([#667](https://github.com/mfontanini/presenterm/issues/667)) ([#675](https://github.com/mfontanini/presenterm/issues/675)) ([#669](https://github.com/mfontanini/presenterm/issues/669)). * Display an error when using http(s) urls in image tags ([#666](https://github.com/mfontanini/presenterm/issues/666)). * Update Catppuccin themes to use palettes ([#672](https://github.com/mfontanini/presenterm/issues/672)) - thanks @jmcharter. ## Docs * Add custom introduction slides example ([#633](https://github.com/mfontanini/presenterm/issues/633)). * Add mention of `winget` ([#621](https://github.com/mfontanini/presenterm/issues/621)) - thanks @DeveloperPaul123. * Fix incorrect note callout ([#610](https://github.com/mfontanini/presenterm/issues/610)) - thanks @Sacquer. * Add a note to export pdf using `uv` ([#646](https://github.com/mfontanini/presenterm/issues/646)) - thanks @PitiBouchon. * Clarify why no remote urls work with images ([#664](https://github.com/mfontanini/presenterm/issues/664)) - thanks @ryuheechul. ## ❤️ 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) * [@gwpl](https://github.com/gwpl) # v0.14.0 - 2025-05-17 ## New features * Add support for [exporting presentations as HTML files](https://mfontanini.github.io/presenterm/features/exports.html#html) ([#566](https://github.com/mfontanini/presenterm/issues/566)) ([#595](https://github.com/mfontanini/presenterm/issues/595)) ([#575](https://github.com/mfontanini/presenterm/issues/575)) ([#599](https://github.com/mfontanini/presenterm/issues/599)) - thanks @JustSimplyKyle. * Snippet execution output now contains configurable padding and built-in themes default to the same padding as snippets (2 spaces horizontally, one line vertically) ([#592](https://github.com/mfontanini/presenterm/issues/592)) ([#593](https://github.com/mfontanini/presenterm/issues/593)). * Add highlighting and execution support for Jsonnet ([#585](https://github.com/mfontanini/presenterm/issues/585)) - thanks @imobachgs. * Allow [configuring snippets](https://mfontanini.github.io/presenterm/configuration/settings.html#sequential-snippet-execution) to be executed sequentially during exports ([#584](https://github.com/mfontanini/presenterm/issues/584)). ## Fixes * Skip slides with pauses correctly ([#598](https://github.com/mfontanini/presenterm/issues/598)). * Avoid printing text if there's no vertical space for it, which otherwise looks bad particularly when using font size > 1 ([#594](https://github.com/mfontanini/presenterm/issues/594)). * Execute snippets only once during export ([#583](https://github.com/mfontanini/presenterm/issues/583)). * Don't add an extra pause after lists if there's nothing left ([#580](https://github.com/mfontanini/presenterm/issues/580)). * Allow interleaved spans and variables in footer ([#577](https://github.com/mfontanini/presenterm/issues/577)). * Truly center `+exec_replace` snippet output ([#572](https://github.com/mfontanini/presenterm/issues/572)). ## Docs * Added link to public presentation using presenterm ([#589](https://github.com/mfontanini/presenterm/issues/589)) - thanks @pwnwriter. * Rename parameter name to the correct one in docs ([#570](https://github.com/mfontanini/presenterm/issues/570)) - thanks @DzuWe. * Fix typo in highlighting.md ([#586](https://github.com/mfontanini/presenterm/issues/586)) - thanks @0atman. ## Chore * Bump dependencies ([#596](https://github.com/mfontanini/presenterm/issues/596)). ## ❤️ 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) # v0.13.0 - 2025-04-25 ## Breaking changes * The CLI parameter to generate the JSON schema for the config file (`--generate-config-file-schema`) is now hidden behind a `json-schema` feature flag. The JSON schema file for the latest version is already publicly available at `https://github.com/mfontanini/presenterm/blob/${VERSION}/config-file-schema.json`, so anyone can use it without having to generate it by hand. This allows cutting down the number of dependencies in this project quite a bit ([#563](https://github.com/mfontanini/presenterm/issues/563)). ## New features * Support for [slide transitions](https://mfontanini.github.io/presenterm/features/slide-transitions.html) is now available ([#530](https://github.com/mfontanini/presenterm/issues/530)): * Add fade slide transition ([#534](https://github.com/mfontanini/presenterm/issues/534)). * Add slide horizontally slide transition animation ([#528](https://github.com/mfontanini/presenterm/issues/528)). * Add `collapse_horizontal` slide transition ([#560](https://github.com/mfontanini/presenterm/issues/560)). * Add `--output` option to specify the path where the output file is written to during an export ([#526](https://github.com/mfontanini/presenterm/issues/526)) - thanks @marianozunino. * Allow specifying [start/end lines](https://mfontanini.github.io/presenterm/features/code/highlighting.html#including-external-code-snippets) in file snippet type ([#565](https://github.com/mfontanini/presenterm/issues/565)). * Allow letting [pauses become new slides](https://mfontanini.github.io/presenterm/configuration/settings.html#pause-behavior) when exporting ([#557](https://github.com/mfontanini/presenterm/issues/557)). * Allow [using images on right in footer](https://mfontanini.github.io/presenterm/features/themes/definition.html#footer-images) ([#554](https://github.com/mfontanini/presenterm/issues/554)). * Add [`max_rows` configuration](https://mfontanini.github.io/presenterm/configuration/settings.html#maximum-presentation-height) to cap vertical size ([#531](https://github.com/mfontanini/presenterm/issues/531)). * Add julia language highlighting and execution support ([#561](https://github.com/mfontanini/presenterm/issues/561)). ## Fixes * Center overflow lines when using centered text ([#546](https://github.com/mfontanini/presenterm/issues/546)). * Don't add extra space before heading if prefix in theme is empty ([#542](https://github.com/mfontanini/presenterm/issues/542)). * Use no typst background in terminal-* built in themes ([#535](https://github.com/mfontanini/presenterm/issues/535)). * Use `std::env::temp_dir` in the `external_snippet` test ([#533](https://github.com/mfontanini/presenterm/issues/533)) - thanks @Medovi. * Respect `extends` in a theme set via `path` in front matter ([#532](https://github.com/mfontanini/presenterm/issues/532)). ## Misc * Refactor async renders (e.g. mermaid/typst/latex `+render` blocks, `+exec` blocks, etc) to work truly asynchronously. This causes the output to be polled faster, and causes jumping to a slide that contains an async render to take a likely negligible (but maybe noticeable) amount of time to be jumped to. This was needed for slide transitions to work seemlessly ([#556](https://github.com/mfontanini/presenterm/issues/556)). * Get rid of `textproperties` ([#529](https://github.com/mfontanini/presenterm/issues/529)). * Add links to presentations using presenterm ([#544](https://github.com/mfontanini/presenterm/issues/544)) - thanks @orhun. ## Performance improvements * A few performance improvements had to be done for slide transitions to work seemlessly: * Pre-scale ASCII images when transitions are enabled ([#550](https://github.com/mfontanini/presenterm/issues/550)). * Pre-scale generated images ([#553](https://github.com/mfontanini/presenterm/issues/553)). * Cache resized ASCII images ([#547](https://github.com/mfontanini/presenterm/issues/547)). ## ❤️ Sponsors Thanks to the following users who supported _presenterm_ via a [github sponsorship](https://github.com/sponsors/mfontanini) in this release: * [@0atman](https://github.com/0atman) * [@orhun](https://github.com/orhun) * [@fipoac](https://github.com/fipoac) # v0.12.0 - 2025-03-24 ## Breaking changes * Using incremental lists now adds an extra pause before and after a list. Use the `defaults.incremental_lists` [configuration parameter](https://mfontanini.github.io/presenterm/features/commands.html#incremental-lists-behavior) to go back to the previous behavior ([#487](https://github.com/mfontanini/presenterm/issues/487)) ([#498](https://github.com/mfontanini/presenterm/issues/498)). ## New features * [PDF exports](https://mfontanini.github.io/presenterm/features/pdf-export.html) are now generated by invoking [weasyprint](https://pypi.org/project/weasyprint/) rather than by using the now deprecated _presenterm-export_. This gets rid of the need for _tmux_ and opens up the door for other export formats ([#509](https://github.com/mfontanini/presenterm/issues/509)) ([#517](https://github.com/mfontanini/presenterm/issues/517)). * PDF export dimensions can now also be [specified in the config file](https://mfontanini.github.io/presenterm/configuration/settings.html#pdf-export-size) rather than always having them inferred by the terminal size ([#511](https://github.com/mfontanini/presenterm/issues/511)). * Allow specifying path for temporary files generated during presentation export ([#518](https://github.com/mfontanini/presenterm/issues/518)). * Respect font sizes in generated PDF ([#510](https://github.com/mfontanini/presenterm/issues/510)). * Add [`skip_slide` comment command](https://mfontanini.github.io/presenterm/features/commands.html#skip-slide) to avoid including a slide in the final presentation ([#505](https://github.com/mfontanini/presenterm/issues/505)). * Add [`alignment` comment](https://mfontanini.github.io/presenterm/features/commands.html#text-alignment) command to specify text alignment for the remainder of a slide ([#493](https://github.com/mfontanini/presenterm/issues/493)) ([#522](https://github.com/mfontanini/presenterm/issues/522)). * Add `--current-theme` CLI parameter to display the theme being used ([#489](https://github.com/mfontanini/presenterm/issues/489)). * Add gruvbox dark theme ([#483](https://github.com/mfontanini/presenterm/issues/483)) - thanks @ret2src. ## Fixes * Fix broken ANSI escape code parsing which would cause command output to sometimes be incorrectly parsed and therefore led to its colors/attributes not being respected ([#500](https://github.com/mfontanini/presenterm/issues/500)). * Center lists correctly ([#512](https://github.com/mfontanini/presenterm/issues/512)) ([#520](https://github.com/mfontanini/presenterm/issues/520)). * Respect end slide shorthand in speaker notes mode ([#494](https://github.com/mfontanini/presenterm/issues/494)). * Use more visible colors in snippet execution output in terminal-light/dark themes ([#485](https://github.com/mfontanini/presenterm/issues/485)). * Show error if sixel mode is selected but disabled ([#525](https://github.com/mfontanini/presenterm/issues/525)). ## CI * Add nightly build job ([#496](https://github.com/mfontanini/presenterm/issues/496)). ## Docs * Fix typo in README.md ([#490](https://github.com/mfontanini/presenterm/issues/490)) - thanks @eltociear. * Correctly include layout pic ([#495](https://github.com/mfontanini/presenterm/issues/495)) - thanks @Tuxified. ## Misc * Cleanup text attributes ([#519](https://github.com/mfontanini/presenterm/issues/519)). * Refactor snippet processing ([#484](https://github.com/mfontanini/presenterm/issues/484)). ## Sponsors It is now possible to sponsor this project via [github sponsors](https://github.com/sponsors/mfontanini). Thanks to [@0atman](https://github.com/0atman) for being the first project sponsor! # v0.11.0 - 2025-03-08 ## Breaking changes * Footer templates are now sanitized, and any variables surrounded in braces that aren't supported (e.g. `{potato}`) will now cause _presenterm_ to display an error. If you'd like to use braces in contexts where you're not trying to reference a variable you can use double braces, e.g. `live at {{PotatoConf}}` ([#442](https://github.com/mfontanini/presenterm/issues/442)) ([#467](https://github.com/mfontanini/presenterm/issues/467)) ([#469](https://github.com/mfontanini/presenterm/issues/469)) ([#471](https://github.com/mfontanini/presenterm/issues/471)). ## New features * [Add support for kitty's font size protocol](https://mfontanini.github.io/presenterm/features/introduction.html#font-sizes). This is now used by default in built in themes in a few components such as the intro slide's title and slide titles. See the [example presentation gif](https://github.com/mfontanini/presenterm/blob/master/docs/src/assets/demo.gif) to check out how this looks like. Terminal suport for this feature is detected on startup and will be ignored if unsupported. This requires _kitty_ >= 0.40.0 ([#438](https://github.com/mfontanini/presenterm/issues/438)) ([#460](https://github.com/mfontanini/presenterm/issues/460)) ([#470](https://github.com/mfontanini/presenterm/issues/470)). * [Allow specifying font size in a comment command](https://mfontanini.github.io/presenterm/features/commands.html#font-size), which causes any subsequent text in a slide to use the specified font size. Just like the above, only supported in _kitty_ >= 0.40.0 for now ([#458](https://github.com/mfontanini/presenterm/issues/458)). * [Footers can now contain images](https://mfontanini.github.io/presenterm/features/themes/definition.html#footer-images) in the left and center components. This allows including some form of branding/company logo to your presentations ([#450](https://github.com/mfontanini/presenterm/issues/450)) ([#476](https://github.com/mfontanini/presenterm/issues/476)). * [Footers can now contain inline markdown](https://mfontanini.github.io/presenterm/features/themes/definition.html#template-footers), which allows using bold, italics, `` tags for colors, etc ([#466](https://github.com/mfontanini/presenterm/issues/466)). * [Presentation titles can now contain inline markdown](https://mfontanini.github.io/presenterm/features/introduction.html#introduction-slide) ([#464](https://github.com/mfontanini/presenterm/issues/464)). * [Introduce palette.classes in themes](https://mfontanini.github.io/presenterm/features/themes/definition.html#color-palette) to allow specifying combinations of foreground/background colors that can be referenced via the `class` attribute in `` tags ([#468](https://github.com/mfontanini/presenterm/issues/468)). * It's now possible to [configure the alignment](https://mfontanini.github.io/presenterm/configuration/settings.html#maximum-presentation-width) to use when `max_columns` is configured and the terminal width is larger than it ([#475](https://github.com/mfontanini/presenterm/issues/475)). * Add support for wikilinks ([#448](https://github.com/mfontanini/presenterm/issues/448)). ## Fixes * Don't get stuck if tmux doesn't passthrough ([#456](https://github.com/mfontanini/presenterm/issues/456)). * Don't squash image if terminal's font aspect ratio is not 2:1 ([#446](https://github.com/mfontanini/presenterm/issues/446)). * Fail if `--config-file` points to non existent file ([#474](https://github.com/mfontanini/presenterm/issues/474)). * Use right script name for kotlin files when executing ([#462](https://github.com/mfontanini/presenterm/issues/462)). * Respect lists that start at non 1 indexes ([#459](https://github.com/mfontanini/presenterm/issues/459)). * Jump to right slide on code attribute change ([#478](https://github.com/mfontanini/presenterm/issues/478)). ## Improvements * Remove `result` return type from builder fns that don't need it ([#465](https://github.com/mfontanini/presenterm/issues/465)). * Refactor theme code ([#463](https://github.com/mfontanini/presenterm/issues/463)). * Restructure `terminal` code and add test for margins/layouts ([#443](https://github.com/mfontanini/presenterm/issues/443)). * Use `fastrand` instead of `rand` ([#441](https://github.com/mfontanini/presenterm/issues/441)). * Avoid cloning strings when styling them ([#440](https://github.com/mfontanini/presenterm/issues/440)). # v0.10.1 - 2025-02-14 ## Fixes * Don't error out if `options` in front matter doesn't include `auto_render_languages` ([#454](https://github.com/mfontanini/presenterm/pull/454)). * Bump sixel-rs to 0.4.1 to fix build in aarch64 and riscv64 ([#452](https://github.com/mfontanini/presenterm/pull/452)) - thanks @Xeonacid. # v0.10.0 - 2025-02-02 ## New features * Support for presentation speaker notes ([#389](https://github.com/mfontanini/presenterm/issues/389)) ([#419](https://github.com/mfontanini/presenterm/issues/419)) ([#421](https://github.com/mfontanini/presenterm/issues/421)) ([#425](https://github.com/mfontanini/presenterm/issues/425)) - thanks @dmackdev. * Add support for colored text via inline `span` HTML tags ([#390](https://github.com/mfontanini/presenterm/issues/390)). * Add a color palette in themes to allow reusing colors across the theme and using predefined colors inside `span` tags ([#427](https://github.com/mfontanini/presenterm/issues/427)). * Add support for github/gitlab style markdown alerts ([#423](https://github.com/mfontanini/presenterm/issues/423)) ([#430](https://github.com/mfontanini/presenterm/issues/430)). * Allow using `+image` on code blocks to consume their output as an image ([#429](https://github.com/mfontanini/presenterm/issues/429)). * Allow multiline comment commands ([#424](https://github.com/mfontanini/presenterm/issues/424)). * Allow auto rendering mermaid/typst/latex code blocks ([#418](https://github.com/mfontanini/presenterm/issues/418)). * Allow capping max columns on presentation ([#417](https://github.com/mfontanini/presenterm/issues/417)). * Automatically detect kitty support, including when running inside tmux ([#406](https://github.com/mfontanini/presenterm/issues/406)). * Use kitty image protocol in ghostty ([#405](https://github.com/mfontanini/presenterm/issues/405)). * Force color output in rust, c, and c++ compiler executions ([#401](https://github.com/mfontanini/presenterm/issues/401)). * Add graphql code highlighting ([#385](https://github.com/mfontanini/presenterm/issues/385)) - thanks @GV14982. * Add tcl code highlighting ([#387](https://github.com/mfontanini/presenterm/issues/387)) - thanks @jtplaarj. * Add Haskell executor ([#414](https://github.com/mfontanini/presenterm/issues/414)) - thanks @feature-not-a-bug. * Add C# to code executors ([#399](https://github.com/mfontanini/presenterm/issues/399)) - thanks @giggio. * Add R to executors ([#393](https://github.com/mfontanini/presenterm/issues/393)) - thanks @jonocarroll. ## Fixes * Check for `term_program` before `term` to determine emulator ([#420](https://github.com/mfontanini/presenterm/issues/420)). * Allow jumping back to column in column layout ([#396](https://github.com/mfontanini/presenterm/issues/396)). * Ignore comments that start with `vim:` prefix ([#395](https://github.com/mfontanini/presenterm/issues/395)). * Respect `+no_background` on a `+exec_replace` block ([#383](https://github.com/mfontanini/presenterm/issues/383)). ## Docs * Document tmux active session bug ([#402](https://github.com/mfontanini/presenterm/issues/402)). * Add notes on running `bat` directly ([#397](https://github.com/mfontanini/presenterm/issues/397)). # v0.9.0 - 2024-10-06 ## Breaking changes * Default themes now no longer use a progress bar based footer. Instead they use indicator of the current page number and the total number of pages. If you'd like to preserve the old behavior, you can override the theme by using `footer.style = progress_bar` in [your theme](https://mfontanini.github.io/presenterm/guides/themes.html#setting-themes). * Links that include a title (e.g. `[my title](http://example.com)`) now have their title rendered as well. Removing a link's title will make it look the same as they used to. ## New features * Use "template" footer in built-in themes ([#358](https://github.com/mfontanini/presenterm/issues/358)). * Allow including external code snippets ([#328](https://github.com/mfontanini/presenterm/issues/328)) ([#372](https://github.com/mfontanini/presenterm/issues/372)). * Add `+no_background` property to remove background from code blocks ([#363](https://github.com/mfontanini/presenterm/issues/363)) ([#368](https://github.com/mfontanini/presenterm/issues/368)). * Show colored output from snippet execution output ([#316](https://github.com/mfontanini/presenterm/issues/316)). * Style markdown inside block quotes ([#350](https://github.com/mfontanini/presenterm/issues/350)) ([#351](https://github.com/mfontanini/presenterm/issues/351)). * Allow using all intro slide variables in footer template ([#338](https://github.com/mfontanini/presenterm/issues/338)). * Include hidden line prefix in executors file ([#337](https://github.com/mfontanini/presenterm/issues/337)). * Show link labels and titles ([#334](https://github.com/mfontanini/presenterm/issues/334)). * Add `+exec_replace` which executes snippets and replaces them with their output ([#330](https://github.com/mfontanini/presenterm/issues/330)) ([#371](https://github.com/mfontanini/presenterm/issues/371)). * Always show snippet execution bar ([#329](https://github.com/mfontanini/presenterm/issues/329)). * Handle suspend signal (SIGTSTP) ([#318](https://github.com/mfontanini/presenterm/issues/318)). * Allow closing with `q` ([#321](https://github.com/mfontanini/presenterm/issues/321)). * Add event, location, and date labels in intro slide ([#317](https://github.com/mfontanini/presenterm/issues/317)). * Use transparent background in mermaid charts ([#314](https://github.com/mfontanini/presenterm/issues/314)). * Add `+acquire_terminal` to acquire the terminal when running snippets ([#366](https://github.com/mfontanini/presenterm/issues/366)) ([#376](https://github.com/mfontanini/presenterm/pull/376)). * Add PHP executor ([#332](https://github.com/mfontanini/presenterm/issues/332)). * Add Racket syntax highlighting ([#367](https://github.com/mfontanini/presenterm/issues/367)). * Add TOML highlighting ([#361](https://github.com/mfontanini/presenterm/issues/361)). ## Fixes * Wrap code snippets if they don't fit in terminal ([#320](https://github.com/mfontanini/presenterm/issues/320)). * Allow list-themes/acknowledgements to run without path ([#359](https://github.com/mfontanini/presenterm/issues/359)). * Translate tabs in code snippets to 4 spaces ([#356](https://github.com/mfontanini/presenterm/issues/356)). * Add padding to right of code block wrapped lines ([#354](https://github.com/mfontanini/presenterm/issues/354)). * Don't wrap code snippet separator line ([#353](https://github.com/mfontanini/presenterm/issues/353)). * Show block quote prefix when wrapping ([#352](https://github.com/mfontanini/presenterm/issues/352)). * Don't crash on code block with only hidden-line-prefixed lines ([#347](https://github.com/mfontanini/presenterm/issues/347)). * Canonicalize resources path ([#333](https://github.com/mfontanini/presenterm/issues/333)). * Execute script relative to current working directory ([#323](https://github.com/mfontanini/presenterm/issues/323)). * Support rendering mermaid charts on windows ([#319](https://github.com/mfontanini/presenterm/issues/319)). ## Improvements * Add example on how column layouts and pauses interact ([#348](https://github.com/mfontanini/presenterm/issues/348)). * Rename `jump_to_vertical_center` -> `jump_to_middle` in docs ([#342](https://github.com/mfontanini/presenterm/issues/342)). * Document `all` snippet highlighting keyword ([#335](https://github.com/mfontanini/presenterm/issues/335)). # v0.8.0 - 2024-07-29 ## Breaking changes * Force users to explicitly enable snippet execution ([#276](https://github.com/mfontanini/presenterm/issues/276)) ([#281](https://github.com/mfontanini/presenterm/issues/281)). ## New features * Code snippet execution for various programming languages ([#253](https://github.com/mfontanini/presenterm/issues/253)) ([#255](https://github.com/mfontanini/presenterm/issues/255)) ([#256](https://github.com/mfontanini/presenterm/issues/256)) ([#258](https://github.com/mfontanini/presenterm/issues/258)) ([#282](https://github.com/mfontanini/presenterm/issues/282)). * Allow executing compiled snippets in windows ([#303](https://github.com/mfontanini/presenterm/issues/303)). * Add support for hidden lines in code snippets ([#283](https://github.com/mfontanini/presenterm/issues/283)) ([#254](https://github.com/mfontanini/presenterm/issues/254)) - thanks @dmackdev. * Support [mermaid](https://mermaid.js.org/) snippet rendering to image via `+render` attribute ([#268](https://github.com/mfontanini/presenterm/issues/268)). * Allow scaling images dynamically based on terminal size ([#288](https://github.com/mfontanini/presenterm/issues/288)) ([#291](https://github.com/mfontanini/presenterm/issues/291)). * Allow scaling images generated via `+render` code blocks (mermaid, typst, latex) ([#290](https://github.com/mfontanini/presenterm/issues/290)). * Show `stderr` output from code execution ([#252](https://github.com/mfontanini/presenterm/issues/252)) - thanks @dmackdev. * Wait for code execution process to exit completely ([#250](https://github.com/mfontanini/presenterm/issues/250)) - thanks @dmackdev. * Generate images in `+render` code snippets asynchronously ([#273](https://github.com/mfontanini/presenterm/issues/273)) ([#293](https://github.com/mfontanini/presenterm/issues/293)) ([#284](https://github.com/mfontanini/presenterm/issues/284)) ([#279](https://github.com/mfontanini/presenterm/issues/279)). * Dim non highlighted code snippet lines ([#287](https://github.com/mfontanini/presenterm/issues/287)). * Shrink snippet execution to match code block width ([#286](https://github.com/mfontanini/presenterm/issues/286)). * Include code snippet execution output in generated PDF ([#295](https://github.com/mfontanini/presenterm/issues/295)). * Cache `+render` block images ([#270](https://github.com/mfontanini/presenterm/issues/270)). * Add kotlin script executor ([#257](https://github.com/mfontanini/presenterm/issues/257)) - thanks @dmackdev. * Add nushell code execution ([#274](https://github.com/mfontanini/presenterm/issues/274)) ([#275](https://github.com/mfontanini/presenterm/issues/275)) - thanks @PitiBouchon. * Add rust-script as a new code executor ([#269](https://github.com/mfontanini/presenterm/issues/269)) - @ZhangHanDong. * Allow custom themes to extend others ([#265](https://github.com/mfontanini/presenterm/issues/265)). * Allow jumping fast between slides ([#244](https://github.com/mfontanini/presenterm/issues/244)). * Allow explicitly disabling footer in certain slides ([#239](https://github.com/mfontanini/presenterm/issues/239)). * Allow using image paths in typst ([#235](https://github.com/mfontanini/presenterm/issues/235)). * Add JSON schema for validation,completion,documentation ([#228](https://github.com/mfontanini/presenterm/issues/228)) ([#236](https://github.com/mfontanini/presenterm/issues/236)) - thanks @mikavilpas. * Allow having multiple authors ([#227](https://github.com/mfontanini/presenterm/issues/227)). ## Fixes * Avoid re-rendering code output and auto rendered blocks ([#280](https://github.com/mfontanini/presenterm/issues/280)). * Use unicode width to calculate execution output's line len ([#261](https://github.com/mfontanini/presenterm/issues/261)). * Display background color behind '\t' in code exec output ([#245](https://github.com/mfontanini/presenterm/issues/245)). * Close child process stdin by default ([#297](https://github.com/mfontanini/presenterm/issues/297)). ## Improvements * Update install instructions for Arch Linux ([#248](https://github.com/mfontanini/presenterm/issues/248)) - thanks @orhun. * Fix all clippy warnings ([#231](https://github.com/mfontanini/presenterm/issues/231)) - thanks @mikavilpas. * Include strict `_front_matter_parsing` in default config ([#229](https://github.com/mfontanini/presenterm/issues/229)) - thanks @mikavilpas. * `CHANGELOG.md` contains clickable links to issues ([#230](https://github.com/mfontanini/presenterm/issues/230)) - thanks @mikavilpas. * Add Support for Ruby Code Highlighting ([#226](https://github.com/mfontanini/presenterm/issues/226)) - thanks @pranavrao145. * Use ".presenterm" as prefix for tmp files ([#306](https://github.com/mfontanini/presenterm/issues/306)). * Add more descriptive error message when loading image fails ([#298](https://github.com/mfontanini/presenterm/issues/298)). * Align all error messages to left ([#301](https://github.com/mfontanini/presenterm/issues/301)). # v0.7.0 - 2024-03-02 ## New features * Add color to prefix in block quote ([#218](https://github.com/mfontanini/presenterm/issues/218)). * Allow having code blocks without background ([#215](https://github.com/mfontanini/presenterm/issues/215) [#216](https://github.com/mfontanini/presenterm/issues/216)). * Allow validating whether presentation overflows terminal ([#209](https://github.com/mfontanini/presenterm/issues/209) [#211](https://github.com/mfontanini/presenterm/issues/211)). * Add parameter to list themes ([#207](https://github.com/mfontanini/presenterm/issues/207)). * Add catppuccin themes ([#197](https://github.com/mfontanini/presenterm/issues/197) [#205](https://github.com/mfontanini/presenterm/issues/205) [#206](https://github.com/mfontanini/presenterm/issues/206)) - thanks @Mawdac. * Detect konsole terminal emulator ([#204](https://github.com/mfontanini/presenterm/issues/204)). * Allow customizing slide title style ([#201](https://github.com/mfontanini/presenterm/issues/201)). ## Fixes * Don't crash in present mode ([#210](https://github.com/mfontanini/presenterm/issues/210)). * Set colors properly before displaying an error ([#212](https://github.com/mfontanini/presenterm/issues/212)). ## Improvements * Suggest a tool is missing when spawning returns ENOTFOUND ([#221](https://github.com/mfontanini/presenterm/issues/221)). * Sort input file list ([#202](https://github.com/mfontanini/presenterm/issues/202)) - thanks @bmwiedemann. * Add more example presentations ([#217](https://github.com/mfontanini/presenterm/issues/217)). * Add Scoop to package managers ([#200](https://github.com/mfontanini/presenterm/issues/200)) - thanks @nagromc. * Remove support for uncommon image formats ([#208](https://github.com/mfontanini/presenterm/issues/208)). # v0.6.1 - 2024-02-11 ## Fixes * Don't escape symbols in block quotes ([#195](https://github.com/mfontanini/presenterm/issues/195)). * Respect `XDG_CONFIG_HOME` when loading configuration files and custom themes ([#193](https://github.com/mfontanini/presenterm/issues/193)). # v0.6.0 - 2024-02-09 ## Breaking changes * The default configuration file and custom themes paths have been changed in Windows and macOS to be compliant to where those platforms store these types of files. See the [configuration guide](https://mfontanini.github.io/presenterm/guides/configuration.html) to learn more. ## New features * Add `f` keys, tab, and backspace as possible bindings ([#188](https://github.com/mfontanini/presenterm/issues/188)). * Add support for multiline block quotes ([#184](https://github.com/mfontanini/presenterm/issues/184)). * Use theme color as background on ascii-blocks mode images ([#182](https://github.com/mfontanini/presenterm/issues/182)). * Blend ascii-blocks image semi-transparent borders ([#185](https://github.com/mfontanini/presenterm/issues/185)). * Respect Windows/macOS config paths for configuration ([#181](https://github.com/mfontanini/presenterm/issues/181)). * Allow making front matter strict parsing optional ([#190](https://github.com/mfontanini/presenterm/issues/190)). ## Fixes * Don't add an extra line after an end slide shorthand ([#187](https://github.com/mfontanini/presenterm/issues/187)). * Don't clear input state on key release event ([#183](https://github.com/mfontanini/presenterm/issues/183)). # v0.5.0 - 2024-01-26 ## New features * Support images on Windows ([#120](https://github.com/mfontanini/presenterm/issues/120)). * Support animated gifs on kitty terminal ([#157](https://github.com/mfontanini/presenterm/issues/157) [#161](https://github.com/mfontanini/presenterm/issues/161)). * Support images on tmux running in kitty terminal ([#166](https://github.com/mfontanini/presenterm/issues/166)). * Improve sixel support ([#169](https://github.com/mfontanini/presenterm/issues/169) [#172](https://github.com/mfontanini/presenterm/issues/172)). * Use synchronized updates to remove flickering when switching slides ([#156](https://github.com/mfontanini/presenterm/issues/156)). * Add newlines command ([#167](https://github.com/mfontanini/presenterm/issues/167)). * Detect image protocol instead of relying on viuer ([#160](https://github.com/mfontanini/presenterm/issues/160)). * Turn documentation into mdbook ([#141](https://github.com/mfontanini/presenterm/issues/141) [#147](https://github.com/mfontanini/presenterm/issues/147)) - thanks @pwnwriter. * Allow using thematic breaks to end slides ([#138](https://github.com/mfontanini/presenterm/issues/138)). * Allow specifying the preferred image protocol via `--image-protocol` / config file ([#136](https://github.com/mfontanini/presenterm/issues/136) [#170](https://github.com/mfontanini/presenterm/issues/170)). * Add slide index modal ([#128](https://github.com/mfontanini/presenterm/issues/128) [#139](https://github.com/mfontanini/presenterm/issues/139) [#133](https://github.com/mfontanini/presenterm/issues/133) [#158](https://github.com/mfontanini/presenterm/issues/158)). * Allow defining custom keybindings in config file ([#132](https://github.com/mfontanini/presenterm/issues/132) [#155](https://github.com/mfontanini/presenterm/issues/155)). * Add key bindings modal ([#152](https://github.com/mfontanini/presenterm/issues/152)). * Prioritize CLI args `--theme` over anything else ([#116](https://github.com/mfontanini/presenterm/issues/116)). * Allow enabling automatic list pauses ([#106](https://github.com/mfontanini/presenterm/issues/106) [#109](https://github.com/mfontanini/presenterm/issues/109) [#110](https://github.com/mfontanini/presenterm/issues/110)). * Allow passing in config file path via CLI arg ([#174](https://github.com/mfontanini/presenterm/issues/174)). ## Fixes * Shrink columns layout dimensions correctly when shrinking left ([#113](https://github.com/mfontanini/presenterm/issues/113)). * Explicitly set execution output foreground color in built-in themes ([#122](https://github.com/mfontanini/presenterm/issues/122)). * Detect sixel early and fallback to ascii blocks properly ([#135](https://github.com/mfontanini/presenterm/issues/135)). * Exit with a clap error on missing path ([#150](https://github.com/mfontanini/presenterm/issues/150)). * Don't blow up if presentation file temporarily disappears ([#154](https://github.com/mfontanini/presenterm/issues/154)). * Parse front matter properly in presence of \r\n ([#162](https://github.com/mfontanini/presenterm/issues/162)). * Don't preload graphics mode when generating pdf metadata ([#168](https://github.com/mfontanini/presenterm/issues/168)). * Ignore key release events ([#119](https://github.com/mfontanini/presenterm/issues/119)). ## Improvements * Validate that config file contains the right attributes ([#107](https://github.com/mfontanini/presenterm/issues/107)). * Display first presentation load error as any other ([#118](https://github.com/mfontanini/presenterm/issues/118)). * Add hashes for windows artifacts ([#126](https://github.com/mfontanini/presenterm/issues/126)). * Remove arch packaging files ([#111](https://github.com/mfontanini/presenterm/issues/111)). * Lower CPU and memory usage when displaying images ([#157](https://github.com/mfontanini/presenterm/issues/157)). # v0.4.1 - 2023-12-22 ## New features * Cause an error if an unknown field name is found on a theme, config file, or front matter ([#102](https://github.com/mfontanini/presenterm/issues/102)). ## Fixes * Explicitly disable kitty/iterm protocols when printing images in export PDF mode as this was causing PDF generation in macOS to fail ([#101](https://github.com/mfontanini/presenterm/issues/101)). # v0.4.0 - 2023-12-16 ## New features * Add support for all of [bat](https://github.com/sharkdp/bat)'s code highlighting themes ([#67](https://github.com/mfontanini/presenterm/issues/67)). * Add `terminal-dark` and `terminal-light` themes that preserve the terminal's colors and background ([#68](https://github.com/mfontanini/presenterm/issues/68) [#69](https://github.com/mfontanini/presenterm/issues/69)). * Allow placing themes in `$HOME/.config/presenterm/themes` to make them available automatically as if they were built-in themes ([#73](https://github.com/mfontanini/presenterm/issues/73)). * Allow configuring the default theme in `$HOME/.config/presenterm/config.yaml` ([#74](https://github.com/mfontanini/presenterm/issues/74)). * Add support for rendering _LaTeX_ and _typst_ code blocks automatically as images ([#75](https://github.com/mfontanini/presenterm/issues/75) [#76](https://github.com/mfontanini/presenterm/issues/76) [#79](https://github.com/mfontanini/presenterm/issues/79) [#81](https://github.com/mfontanini/presenterm/issues/81)). * Add syntax highlighting support for _nix_ and _diff_ ([#78](https://github.com/mfontanini/presenterm/issues/78) [#82](https://github.com/mfontanini/presenterm/issues/82)). * Add comment command to jump into the middle of a slide ([#86](https://github.com/mfontanini/presenterm/issues/86)). * Add configuration option to have implicit slide ends ([#87](https://github.com/mfontanini/presenterm/issues/87) [#89](https://github.com/mfontanini/presenterm/issues/89)). * Add configuration option to have custom comment-command prefix ([#91](https://github.com/mfontanini/presenterm/issues/91)). # v0.3.0 - 2023-11-24 ## New features * Support more languages in code blocks thanks to [bat](https://github.com/sharkdp/bat)'s syntax sets ([#21](https://github.com/mfontanini/presenterm/issues/21) [#53](https://github.com/mfontanini/presenterm/issues/53)). * Add shell script executable code blocks ([#17](https://github.com/mfontanini/presenterm/issues/17)). * Allow exporting presentation to PDF ([#43](https://github.com/mfontanini/presenterm/issues/43) [#60](https://github.com/mfontanini/presenterm/issues/60)). * Pauses no longer create new slides ([#18](https://github.com/mfontanini/presenterm/issues/18) [#25](https://github.com/mfontanini/presenterm/issues/25) [#34](https://github.com/mfontanini/presenterm/issues/34) [#42](https://github.com/mfontanini/presenterm/issues/42)). * Allow display code block line numbers ([#46](https://github.com/mfontanini/presenterm/issues/46)). * Allow code block selective line highlighting ([#48](https://github.com/mfontanini/presenterm/issues/48)). * Allow code block dynamic line highlighting ([#49](https://github.com/mfontanini/presenterm/issues/49)). * Support animated gifs when using the iterm2 image protocol ([#56](https://github.com/mfontanini/presenterm/issues/56)). * Nix flake packaging ([#11](https://github.com/mfontanini/presenterm/issues/11) [#27](https://github.com/mfontanini/presenterm/issues/27)). * Arch repo packaging ([#10](https://github.com/mfontanini/presenterm/issues/10)). * Ignore vim-like code folding tags in comments. * Add keybinding to refresh assets in presentation ([#38](https://github.com/mfontanini/presenterm/issues/38)). * Template style footer is now one row above bottom ([#39](https://github.com/mfontanini/presenterm/issues/39)). * Add `light` theme. ## Fixes * Don't crash on Windows when terminal window size can't be found ([#14](https://github.com/mfontanini/presenterm/issues/14)). * Don't reset numbers on ordered lists when using pauses in between ([#19](https://github.com/mfontanini/presenterm/issues/19)). * Show proper line number when parsing a comment command fails ([#29](https://github.com/mfontanini/presenterm/issues/29) [#40](https://github.com/mfontanini/presenterm/issues/40)). * Don't reset the default footer when overriding theme in presentation without setting footer ([#52](https://github.com/mfontanini/presenterm/issues/52)). * Don't let code blocks/block quotes that don't fit on the screen cause images to overlap with text ([#57](https://github.com/mfontanini/presenterm/issues/57)). # v0.2.1 - 2023-10-18 ## New features * Binary artifacts are now automatically generated when a new release is done ([#5](https://github.com/mfontanini/presenterm/issues/5)) - thanks @pwnwriter. # v0.2.0 - 2023-10-17 ## New features * [Column layouts](https://github.com/mfontanini/presenterm/blob/26e2eb28884675aac452f4c6e03f98413654240c/docs/layouts.md) that let you structure slides into columns. * Support for `percent` margin rather than only a fixed number of columns. * Spacebar now moves the presentation into the next slide. * Add support for `center` footer when using the `template` mode. * **Breaking**: themes now only use colors in hex format. ## Fixes * Allow using `sh` as language for code block ([#3](https://github.com/mfontanini/presenterm/issues/3)). * Minimum size for code blocks is now prioritized over minimum margin. * Overflowing lines in lists will now correctly be padded to align all text under the same starting column. * Running `cargo run` will now rebuild the tool if any of the built-in themes changed. * `alignment` was removed from certain elements (like `list`) as it didn't really make sense. * `default.alignment` is now no longer supported and by default we use left alignment. Use `default.margin` to specify the margins to use. # v0.1.0 - 2023-10-08 ## Features * Define your presentation in a single markdown file. * Image rendering support for iterm2, terminals that support the kitty graphics protocol, or sixel. * Customize your presentation's look by defining themes, including colors, margins, layout (left/center aligned content), footer for every slide, etc. * Code highlighting for a wide list of programming languages. * Support for an introduction slide that displays the presentation title and your name. * Support for slide titles. * Create pauses in between each slide so that it progressively renders for a more interactive presentation. * Text formatting support for **bold**, _italics_, ~strikethrough~, and `inline code`. * Automatically reload your presentation every time it changes for a fast development loop. ================================================ FILE: Cargo.toml ================================================ [package] name = "presenterm" authors = ["Matias Fontanini"] description = "A terminal slideshow presentation tool" repository = "https://github.com/mfontanini/presenterm" license = "BSD-2-Clause" version = "0.16.1" edition = "2021" [dependencies] anyhow = "1" base64 = "0.22" bincode = "1.3" clap = { version = "4.4", features = ["derive", "string", "env"] } comrak = { version = "0.48.0", default-features = false } crossterm = { version = "0.29", default-features = false, features = ["events", "windows"] } directories = "6.0" hex = "0.4" fastrand = "2.3" flate2 = "1.0" image = { version = "0.25", features = ["gif", "jpeg", "png"], default-features = false } icy_sixel = "0.5" merge-struct = "0.1.0" itertools = "0.14" once_cell = "1.19" portable-pty = "0.9" schemars = { version = "0.8", optional = true } serde = { version = "1.0", features = ["derive"] } serde_yaml = "0.9" serde_json = "1.0" syntect = { version = "5.2", features = ["parsing", "default-themes", "regex-onig", "plist-load"], default-features = false } socket2 = "0.6" strum = { version = "0.27", features = ["derive"] } tempfile = { version = "3.10", default-features = false } tl = "0.7" thiserror = "2" unicode-width = "0.2" os_pipe = "1.1.5" libc = "0.2" vte = "0.15" termbg = "0.6.2" vt100 = "0.16" [dev-dependencies] rstest = { version = "0.26", default-features = false } [features] default = [] json-schema = ["dep:schemars"] [profile.dev] opt-level = 0 debug = true panic = "abort" [profile.test] opt-level = 0 debug = true [profile.release] opt-level = 3 debug = false panic = "unwind" lto = true codegen-units = 1 [profile.bench] opt-level = 3 debug = false ================================================ FILE: LICENSE ================================================ BSD 2-Clause License Copyright (c) 2023, Matias Fontanini All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ================================================ FILE: README.md ================================================ presenterm === [![crates-badge]][crates-package] [![brew-badge]][brew-package] [![nix-badge]][nix-package] [![arch-badge]][arch-package] [![scoop-badge]][scoop-package] [![winget-badge]][winget-package] [brew-badge]: https://img.shields.io/homebrew/v/presenterm [brew-package]: https://formulae.brew.sh/formula/presenterm [nix-badge]: https://img.shields.io/badge/Packaged_for-Nix-5277C3.svg?logo=nixos&labelColor=73C3D5 [nix-package]: https://search.nixos.org/packages?size=1&show=presenterm [crates-badge]: https://img.shields.io/crates/v/presenterm [crates-package]: https://crates.io/crates/presenterm [arch-badge]: https://img.shields.io/archlinux/v/extra/x86_64/presenterm [arch-package]: https://archlinux.org/packages/extra/x86_64/presenterm/ [scoop-badge]: https://img.shields.io/scoop/v/presenterm [scoop-package]: https://scoop.sh/#/apps?q=presenterm&id=a462290f824b50f180afbaa6d8c7c1e6e0952e3a [winget-badge]: https://img.shields.io/winget/v/mfontanini.presenterm [winget-package]: https://winstall.app/apps/mfontanini.presenterm _presenterm_ lets you create presentations in markdown format and run them from your terminal, with support for image and animated gifs, highly customizable themes, code highlighting, exporting presentations into PDF format, and plenty of other features. This is how the [demo presentation](/examples/demo.md) looks like when running in the [kitty terminal](https://sw.kovidgoyal.net/kitty/): ![](/docs/src/assets/demo.gif) Check the rest of the example presentations in the [examples directory](/examples). # Documentation Visit the [documentation][docs-introduction] to get started. # Features * Presentations consist of one [or more][docs-include] markdown files. * [Images and animated gifs][docs-images] on terminals like _kitty_, _iterm2_, _wezterm_, _ghostty_ and _foot_. * [Customizable themes][docs-themes] including colors, margins, layout (left/center aligned content), footer for every slide, etc. Several [built-in themes][docs-builtin-themes] can give your presentation the look you want without having to define your own. * Code highlighting for a [wide list of programming languages][docs-code-highlight]. * [Font sizes][docs-font-sizes] for terminals that support them. * [Selective/dynamic][docs-selective-highlight] code highlighting that only highlights portions of code at a time. * [Column layouts][docs-layout]. * [mermaid graph rendering][docs-mermaid]. * [d2 graph rendering][docs-d2]. * [_LaTeX_ and _typst_ formula rendering][docs-latex]. * [Introduction slide][docs-intro-slide] that displays the presentation title and your name. * [Slide titles][docs-slide-titles]. * [Snippet execution][docs-code-execute] for various programming languages, including execution inside pseudo terminals. * [Export presentations to PDF and HTML][docs-exports]. * [Slide transitions][docs-slide-transitions]. * [Pause][docs-pauses] portions of your slides. * [Custom key bindings][docs-key-bindings]. * [Automatically reload your presentation][docs-hot-reload] every time it changes for a fast development loop. * [Define speaker notes][docs-speaker-notes] to aid you during presentations. See the [introduction page][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] - [Using Nix on Apple Silicon and declarative development environments][NiXOS-and-Dev] by [pwnwriter][pwnwriter-github] - [Hayasen: A Robust Embedded Rust Library which supports multiple sensors][hayasen] by [Vaishnav-Sabari-Girish][vaishnav] - [Using ratatui in Embedded sytems : Meet Mousefood][mousefood] by [Vaishnav-Sabari-Girish][vaishnav] Gave a talk using _presenterm_? We would love to feature it here! Open a PR or issue to get it added. [docs-introduction]: https://mfontanini.github.io/presenterm/ [docs-basics]: https://mfontanini.github.io/presenterm/features/introduction.html [docs-intro-slide]: https://mfontanini.github.io/presenterm/features/introduction.html#introduction-slide [docs-slide-titles]: https://mfontanini.github.io/presenterm/features/introduction.html#slide-titles [docs-font-sizes]: https://mfontanini.github.io/presenterm/features/introduction.html#font-sizes [docs-pauses]: https://mfontanini.github.io/presenterm/features/commands.html#pauses [docs-images]: https://mfontanini.github.io/presenterm/features/images.html [docs-include]: https://mfontanini.github.io/presenterm/features/commands.html#including-external-markdown-files [docs-themes]: https://mfontanini.github.io/presenterm/features/themes/introduction.html [docs-builtin-themes]: https://mfontanini.github.io/presenterm/features/themes/introduction.html#built-in-themes [docs-code-highlight]: https://mfontanini.github.io/presenterm/features/code/highlighting.html [docs-code-execute]: https://mfontanini.github.io/presenterm/features/code/execution.html [docs-selective-highlight]: https://mfontanini.github.io/presenterm/features/code/highlighting.html#selective-highlighting [docs-slide-transitions]: https://mfontanini.github.io/presenterm/features/slide-transitions.html [docs-layout]: https://mfontanini.github.io/presenterm/features/layout.html [docs-mermaid]: https://mfontanini.github.io/presenterm/features/code/mermaid.html [docs-d2]: https://mfontanini.github.io/presenterm/features/code/d2.html [docs-latex]: https://mfontanini.github.io/presenterm/features/code/latex.html [docs-exports]: https://mfontanini.github.io/presenterm/features/exports.html [docs-key-bindings]: https://mfontanini.github.io/presenterm/configuration/settings.html#key-bindings [docs-hot-reload]: https://mfontanini.github.io/presenterm/features/introduction.html#hot-reload [docs-speaker-notes]: https://mfontanini.github.io/presenterm/features/speaker-notes.html [bat]: https://github.com/sharkdp/bat [syntect]: https://github.com/trishume/syntect [bringing-terminal-aesthetics]: https://www.youtube.com/watch?v=iepbyYrF_YQ [rust-terminal-tools]: https://www.youtube.com/watch?v=ATiKwUiqnAU [renaissance-tui]: https://www.youtube.com/watch?v=hWG51Mc1DlM [orhun-github]: https://github.com/orhun [NiXOS-and-Dev]: https://github.com/pwnwriter/PTN11 [pwnwriter-github]: https://github.com/pwnwriter [hayasen]: https://github.com/Vaishnav-Sabari-Girish/rust_bangalore_oct_2025 [vaishnav]: https://github.com/Vaishnav-Sabari-Girish [mousefood]: https://github.com/Vaishnav-Sabari-Girish/rust_bangalore_december_2025/ ================================================ FILE: bat/bat.git-hash ================================================ 0e469634a3e8987fd7085520e1f90cd83d8fe51b ================================================ FILE: bat/syntaxes.git-hash ================================================ 3d87b25b190e0990e0e75a2ab8f994d6c277d263 ================================================ FILE: bat/update.sh ================================================ #!/usr/bin/env bash set -e if [ $# -ne 1 ]; then echo "Usage: $0 " exit 1 fi script_path=$(realpath "$0") script_dir=$(dirname "$script_path") git_hash=$1 clone_path=$(mktemp -d) echo "Cloning repo @ ${git_hash} into '$clone_path'" git clone https://github.com/sharkdp/bat.git "$clone_path" cd "$clone_path" git reset --hard "$git_hash" cp assets/syntaxes.bin "$script_dir" cp assets/themes.bin "$script_dir" acknowledgements_file="$script_dir/acknowledgements.txt" cp LICENSE-MIT "$acknowledgements_file" zlib-flate -uncompress >"$acknowledgements_file" echo "$git_hash" >"$script_dir/bat.git-hash" echo "syntaxes/themes updated" ================================================ FILE: bat/verify.sh ================================================ #!/usr/bin/env bash set -e script_path=$(realpath "$0") script_dir=$(dirname "$script_path") clone_path=$(mktemp -d) git_hash=$(cat "$script_dir/bat.git-hash") echo "Cloning repo @ ${git_hash} into '$clone_path'" git clone https://github.com/sharkdp/bat.git "$clone_path" cd "$clone_path" git reset --hard "$git_hash" for file in syntaxes.bin themes.bin; do our_hash=$(sha256sum "$script_dir/$file" | cut -d " " -f1) their_hash=$(sha256sum "$clone_path/assets/$file" | cut -d " " -f 1) if [ "$our_hash" != "$their_hash" ]; then echo "Unexpected hash for ${file}: should be ${their_hash}, is ${our_hash}" exit 1 fi done echo "All hashes match" ================================================ FILE: build.rs ================================================ use std::{ env, fs::{self, File}, io::{self, BufWriter, Write}, }; // Take all files under `themes` and turn them into a file that contains a hashmap with their // contents by name. This is pulled in theme.rs to construct themes. fn build_themes(out_dir: &str) -> io::Result<()> { let output_path = format!("{out_dir}/themes.rs"); let mut output_file = BufWriter::new(File::create(output_path)?); output_file.write_all(b"use std::collections::BTreeMap as Map;\n")?; output_file.write_all(b"use once_cell::sync::Lazy;\n")?; output_file.write_all(b"static THEMES: Lazy> = Lazy::new(|| Map::from([\n")?; let mut paths = fs::read_dir("themes")?.collect::>>()?; paths.sort_by_key(|e| e.path()); for theme_file in paths { let metadata = theme_file.metadata()?; if !metadata.is_file() { panic!("found non file in themes directory"); } let path = theme_file.path(); let contents = fs::read(&path)?; let file_name = path.file_name().unwrap().to_string_lossy(); let theme_name = file_name.split_once('.').unwrap().0; // TODO this wastes a bit of space output_file.write_all(format!("(\"{theme_name}\", {contents:?}.as_slice()),\n").as_bytes())?; } output_file.write_all(b"]));\n")?; // Rebuild if anything changes. println!("cargo:rerun-if-changed=themes"); Ok(()) } fn main() -> io::Result<()> { let out_dir = env::var("OUT_DIR").unwrap(); build_themes(&out_dir)?; Ok(()) } ================================================ FILE: config-file-schema.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "Config", "type": "object", "properties": { "bindings": { "$ref": "#/definitions/KeyBindingsConfig" }, "d2": { "$ref": "#/definitions/D2Config" }, "defaults": { "description": "The default configuration for the presentation.", "allOf": [ { "$ref": "#/definitions/DefaultsConfig" } ] }, "export": { "$ref": "#/definitions/ExportConfig" }, "mermaid": { "$ref": "#/definitions/MermaidConfig" }, "options": { "$ref": "#/definitions/OptionsConfig" }, "snippet": { "$ref": "#/definitions/SnippetConfig" }, "speaker_notes": { "$ref": "#/definitions/SpeakerNotesConfig" }, "transition": { "anyOf": [ { "$ref": "#/definitions/SlideTransitionConfig" }, { "type": "null" } ] }, "typst": { "$ref": "#/definitions/TypstConfig" } }, "additionalProperties": false, "definitions": { "D2Config": { "type": "object", "properties": { "scale": { "description": "The scaling parameter to be used in the d2 CLI.", "default": null, "type": [ "number", "null" ], "format": "float" } }, "additionalProperties": false }, "DefaultsConfig": { "type": "object", "properties": { "image_protocol": { "description": "The image protocol to use.", "allOf": [ { "$ref": "#/definitions/ImageProtocol" } ] }, "incremental_lists": { "description": "The configuration for lists when incremental lists are enabled.", "allOf": [ { "$ref": "#/definitions/IncrementalElementConfig" } ] }, "incremental_tables": { "description": "The configuration for tables when incremental tables are enabled.", "allOf": [ { "$ref": "#/definitions/IncrementalElementConfig" } ] }, "max_columns": { "description": "A max width in columns that the presentation must always be capped to.", "default": 65535, "type": "integer", "format": "uint16", "minimum": 0.0 }, "max_columns_alignment": { "description": "The alignment the presentation should have if `max_columns` is set and the terminal is larger than that.", "allOf": [ { "$ref": "#/definitions/MaxColumnsAlignment" } ] }, "max_rows": { "description": "A max height in rows that the presentation must always be capped to.", "default": 65535, "type": "integer", "format": "uint16", "minimum": 0.0 }, "max_rows_alignment": { "description": "The alignment the presentation should have if `max_rows` is set and the terminal is larger than that.", "allOf": [ { "$ref": "#/definitions/MaxRowsAlignment" } ] }, "terminal_font_size": { "description": "Override the terminal font size when in windows or when using sixel.", "default": 16, "type": "integer", "format": "uint8", "minimum": 1.0 }, "theme": { "description": "The theme to use by default in every presentation unless overridden.", "allOf": [ { "$ref": "#/definitions/ThemeConfig" } ] }, "validate_overflows": { "description": "Validate that the presentation does not overflow the terminal screen.", "allOf": [ { "$ref": "#/definitions/ValidateOverflows" } ] } }, "additionalProperties": false }, "ExportConfig": { "description": "The export configuration.", "type": "object", "properties": { "dimensions": { "description": "The dimensions to use for presentation exports.", "anyOf": [ { "$ref": "#/definitions/ExportDimensionsConfig" }, { "type": "null" } ] }, "pauses": { "description": "Whether pauses should create new slides.", "allOf": [ { "$ref": "#/definitions/PauseExportPolicy" } ] }, "pdf": { "description": "The PDF specific export configs.", "allOf": [ { "$ref": "#/definitions/PdfExportConfig" } ] }, "snippets": { "description": "The policy for executable snippets when exporting.", "allOf": [ { "$ref": "#/definitions/SnippetsExportPolicy" } ] } }, "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 }, "ExportFontsConfig": { "description": "The fonts used for exports.", "type": "object", "required": [ "normal" ], "properties": { "bold": { "description": "The path to the font file to be used for the \"bold\" variable of this font.", "type": [ "string", "null" ] }, "bold_italic": { "description": "The path to the font file to be used for the \"bold+italic\" variable of this font.", "type": [ "string", "null" ] }, "italic": { "description": "The path to the font file to be used for the \"italic\" variable of this font.", "type": [ "string", "null" ] }, "normal": { "description": "The path to the font file to be used for the \"normal\" variable of this font.", "type": "string" } }, "additionalProperties": false }, "ImageProtocol": { "oneOf": [ { "description": "Automatically detect the best image protocol to use.", "type": "string", "enum": [ "auto" ] }, { "description": "Use the iTerm2 image protocol.", "type": "string", "enum": [ "iterm2" ] }, { "description": "Use the iTerm2 image protocol in multipart mode.", "type": "string", "enum": [ "iterm2-multipart" ] }, { "description": "Use the kitty protocol in \"local\" mode, meaning both presenterm and the terminal run in the same host and can share the filesystem to communicate.", "type": "string", "enum": [ "kitty-local" ] }, { "description": "Use the kitty protocol in \"remote\" mode, meaning presenterm and the terminal run in different hosts and therefore can only communicate via terminal escape codes.", "type": "string", "enum": [ "kitty-remote" ] }, { "description": "Use the sixel protocol.", "type": "string", "enum": [ "sixel" ] }, { "description": "The default image protocol to use when no other is specified.", "type": "string", "enum": [ "ascii-blocks" ] } ] }, "IncrementalElementConfig": { "description": "The configuration for incrementally shown elements.", "type": "object", "properties": { "pause_after": { "description": "Whether to pause after.", "default": null, "type": [ "boolean", "null" ] }, "pause_before": { "description": "Whether to pause before.", "default": null, "type": [ "boolean", "null" ] } }, "additionalProperties": false }, "KeyBinding": { "type": "string" }, "KeyBindingsConfig": { "type": "object", "properties": { "close_modal": { "description": "The key binding to close the currently open modal.", "type": "array", "items": { "$ref": "#/definitions/KeyBinding" } }, "execute_code": { "description": "The key binding to execute a piece of shell code.", "type": "array", "items": { "$ref": "#/definitions/KeyBinding" } }, "exit": { "description": "The key binding to close the application.", "type": "array", "items": { "$ref": "#/definitions/KeyBinding" } }, "first_slide": { "description": "The key binding to jump to the first slide.", "type": "array", "items": { "$ref": "#/definitions/KeyBinding" } }, "go_to_slide": { "description": "The key binding to jump to a specific slide.", "type": "array", "items": { "$ref": "#/definitions/KeyBinding" } }, "last_slide": { "description": "The key binding to jump to the last slide.", "type": "array", "items": { "$ref": "#/definitions/KeyBinding" } }, "next": { "description": "The keys that cause the presentation to move forwards.", "type": "array", "items": { "$ref": "#/definitions/KeyBinding" } }, "next_fast": { "description": "The keys that cause the presentation to jump to the next slide \"fast\".\n\n\"fast\" means for slides that contain pauses, we will skip all pauses and jump straight to the next slide.", "type": "array", "items": { "$ref": "#/definitions/KeyBinding" } }, "previous": { "description": "The keys that cause the presentation to move backwards.", "type": "array", "items": { "$ref": "#/definitions/KeyBinding" } }, "previous_fast": { "description": "The keys that cause the presentation to move backwards \"fast\".\n\n\"fast\" means for slides that contain pauses, we will skip all pauses and jump straight to the previous slide.", "type": "array", "items": { "$ref": "#/definitions/KeyBinding" } }, "reload": { "description": "The key binding to reload the presentation.", "type": "array", "items": { "$ref": "#/definitions/KeyBinding" } }, "skip_pauses": { "description": "The key binding to show the entire slide, after skipping any pauses in it.", "type": "array", "items": { "$ref": "#/definitions/KeyBinding" } }, "suspend": { "description": "The key binding to suspend the application.", "type": "array", "items": { "$ref": "#/definitions/KeyBinding" } }, "toggle_bindings": { "description": "The key binding to toggle the key bindings modal.", "type": "array", "items": { "$ref": "#/definitions/KeyBinding" } }, "toggle_layout_grid": { "description": "The key binding to toggle the layout grid.", "type": "array", "items": { "$ref": "#/definitions/KeyBinding" } }, "toggle_slide_index": { "description": "The key binding to toggle the slide index modal.", "type": "array", "items": { "$ref": "#/definitions/KeyBinding" } } }, "additionalProperties": false }, "LanguageSnippetExecutionConfig": { "description": "The snippet execution configuration for a specific programming language.", "type": "object", "required": [ "commands", "filename" ], "properties": { "alternative": { "description": "Alternative executors for this language.", "type": "object", "additionalProperties": { "$ref": "#/definitions/SnippetExecutorConfig" } }, "commands": { "description": "The commands to be ran when executing snippets for this programming language.", "type": "array", "items": { "type": "array", "items": { "type": "string" } } }, "environment": { "description": "The environment variables to set before invoking every command.", "default": {}, "type": "object", "additionalProperties": { "type": "string" } }, "filename": { "description": "The filename to use for the snippet input file.", "type": "string" }, "hidden_line_prefix": { "description": "The prefix to use to hide lines visually but still execute them.", "type": [ "string", "null" ] } } }, "MaxColumnsAlignment": { "description": "The alignment to use when `defaults.max_columns` is set.", "oneOf": [ { "description": "Align the presentation to the left.", "type": "string", "enum": [ "left" ] }, { "description": "Align the presentation on the center.", "type": "string", "enum": [ "center" ] }, { "description": "Align the presentation to the right.", "type": "string", "enum": [ "right" ] } ] }, "MaxRowsAlignment": { "description": "The alignment to use when `defaults.max_rows` is set.", "oneOf": [ { "description": "Align the presentation to the top.", "type": "string", "enum": [ "top" ] }, { "description": "Align the presentation on the center.", "type": "string", "enum": [ "center" ] }, { "description": "Align the presentation to the bottom.", "type": "string", "enum": [ "bottom" ] } ] }, "MermaidConfig": { "type": "object", "properties": { "config_path": { "description": "A path to a mermaid JSON configuration file to be used by the `mmdc` tool.", "type": [ "string", "null" ] }, "puppeteer_config_path": { "description": "A path to a puppeteer JSON configuration file to be used by the `mmdc` tool.", "type": [ "string", "null" ] }, "scale": { "description": "The scaling parameter to be used in the mermaid CLI.", "default": 2, "type": "integer", "format": "uint32", "minimum": 0.0 } }, "additionalProperties": false }, "OptionsConfig": { "type": "object", "properties": { "auto_render_languages": { "description": "Assume snippets for these languages contain `+render` and render them automatically.", "type": "array", "items": { "$ref": "#/definitions/SnippetLanguage" } }, "command_prefix": { "description": "The prefix to use for commands.", "type": [ "string", "null" ] }, "end_slide_shorthand": { "description": "Whether to treat a thematic break as a slide end.", "type": [ "boolean", "null" ] }, "h1_slide_titles": { "description": "Whether the first `h1` header on a slide should be considered a slide title.", "type": [ "boolean", "null" ] }, "image_attributes_prefix": { "description": "The prefix to use for image attributes.", "type": [ "string", "null" ] }, "implicit_slide_ends": { "description": "Whether slides are automatically terminated when a slide title is found.", "type": [ "boolean", "null" ] }, "incremental_lists": { "description": "Show all lists incrementally, by implicitly adding pauses in between elements.", "type": [ "boolean", "null" ] }, "incremental_tables": { "description": "Show all tables incrementally, by implicitly adding pauses in between rows.", "type": [ "boolean", "null" ] }, "list_item_newlines": { "description": "The number of newlines in between list items.", "type": [ "integer", "null" ], "format": "uint8", "minimum": 1.0 }, "strict_front_matter_parsing": { "description": "Whether to be strict about parsing the presentation's front matter.", "type": [ "boolean", "null" ] } }, "additionalProperties": false }, "PauseExportPolicy": { "description": "The policy for pauses when exporting.", "oneOf": [ { "description": "Whether to ignore pauses.", "type": "string", "enum": [ "ignore" ] }, { "description": "Create a new slide when a pause is found.", "type": "string", "enum": [ "new_slide" ] } ] }, "PdfExportConfig": { "description": "The PDF export specific configs.", "type": "object", "properties": { "fonts": { "description": "The path to the font file to be used.", "anyOf": [ { "$ref": "#/definitions/ExportFontsConfig" }, { "type": "null" } ] } }, "additionalProperties": false }, "SlideTransitionConfig": { "type": "object", "required": [ "animation" ], "properties": { "animation": { "description": "The slide transition style.", "allOf": [ { "$ref": "#/definitions/SlideTransitionStyleConfig" } ] }, "duration_millis": { "description": "The amount of time to take to perform the transition.", "default": 1000, "type": "integer", "format": "uint16", "minimum": 0.0 }, "frames": { "description": "The number of frames in a transition.", "default": 30, "type": "integer", "format": "uint", "minimum": 0.0 } }, "additionalProperties": false }, "SlideTransitionStyleConfig": { "oneOf": [ { "description": "Slide horizontally.", "type": "object", "required": [ "style" ], "properties": { "style": { "type": "string", "enum": [ "slide_horizontal" ] } }, "additionalProperties": false }, { "description": "Fade the new slide into the previous one.", "type": "object", "required": [ "style" ], "properties": { "style": { "type": "string", "enum": [ "fade" ] } }, "additionalProperties": false }, { "description": "Collapse the current slide into the center of the screen.", "type": "object", "required": [ "style" ], "properties": { "style": { "type": "string", "enum": [ "collapse_horizontal" ] } }, "additionalProperties": false } ] }, "SnippetConfig": { "type": "object", "properties": { "exec": { "description": "The properties for snippet execution.", "allOf": [ { "$ref": "#/definitions/SnippetExecConfig" } ] }, "exec_replace": { "description": "The properties for snippet execution.", "allOf": [ { "$ref": "#/definitions/SnippetExecReplaceConfig" } ] }, "render": { "description": "The properties for snippet auto rendering.", "allOf": [ { "$ref": "#/definitions/SnippetRenderConfig" } ] }, "validate": { "description": "Whether to validate snippets.", "default": false, "type": "boolean" } }, "additionalProperties": false }, "SnippetExecConfig": { "type": "object", "properties": { "custom": { "description": "Custom snippet executors.", "type": "object", "additionalProperties": { "$ref": "#/definitions/LanguageSnippetExecutionConfig" } }, "enable": { "description": "Whether to enable snippet execution.", "default": false, "type": "boolean" } }, "additionalProperties": false }, "SnippetExecReplaceConfig": { "type": "object", "required": [ "enable" ], "properties": { "enable": { "description": "Whether to enable snippet replace-executions, which automatically run code snippets without the user's intervention.", "type": "boolean" } }, "additionalProperties": false }, "SnippetExecutorConfig": { "description": "A snippet executor configuration.", "type": "object", "required": [ "commands", "filename" ], "properties": { "commands": { "description": "The commands to be ran when executing snippets for this programming language.", "type": "array", "items": { "type": "array", "items": { "type": "string" } } }, "environment": { "description": "The environment variables to set before invoking every command.", "default": {}, "type": "object", "additionalProperties": { "type": "string" } }, "filename": { "description": "The filename to use for the snippet input file.", "type": "string" } } }, "SnippetLanguage": { "description": "The language of a code snippet.", "oneOf": [ { "type": "string", "enum": [ "Ada", "Asp", "Awk", "Bash", "BatchFile", "C", "CMake", "Crontab", "CSharp", "Clojure", "Cpp", "Css", "Dart", "D2", "DLang", "Diff", "Docker", "Dotenv", "Elixir", "Elm", "Erlang", "File", "Fish", "FSharp", "GdScript", "Go", "GraphQL", "Haskell", "Html", "Java", "JavaScript", "Json", "Jsonnet", "Julia", "Kotlin", "Latex", "Lua", "Makefile", "Mermaid", "Markdown", "Nix", "Nushell", "OCaml", "Perl", "Php", "PowerShell", "Protobuf", "Puppet", "Python", "R", "Racket", "Ruby", "Rust", "RustScript", "Scala", "Shell", "Sql", "Swift", "Svelte", "Tcl", "Terraform", "Toml", "TypeScript", "TypeScriptReact", "Typst", "Xml", "Yaml", "Verilog", "Vue", "Wsl", "Zig", "Zsh" ] }, { "type": "object", "required": [ "Unknown" ], "properties": { "Unknown": { "type": "string" } }, "additionalProperties": false } ] }, "SnippetRenderConfig": { "type": "object", "properties": { "threads": { "description": "The number of threads to use when rendering.", "default": 2, "type": "integer", "format": "uint", "minimum": 0.0 } }, "additionalProperties": false }, "SnippetsExportPolicy": { "description": "The policy for executable snippets when exporting.", "oneOf": [ { "description": "Render all executable snippets in parallel.", "type": "string", "enum": [ "parallel" ] }, { "description": "Render all executable snippets sequentially.", "type": "string", "enum": [ "sequential" ] } ] }, "SpeakerNotesConfig": { "type": "object", "properties": { "always_publish": { "description": "Whether to always publish speaker notes.", "default": false, "type": "boolean" }, "listen_address": { "description": "The address in which to listen for speaker note events.", "default": "127.255.255.255:59418", "type": "string" }, "publish_address": { "description": "The address in which to publish speaker notes events.", "default": "127.255.255.255:59418", "type": "string" } }, "additionalProperties": false }, "ThemeConfig": { "anyOf": [ { "type": "null" }, { "description": "Theme of the presentation.", "type": "string" }, { "description": "Automatic dark/light theme switch based on the terminal background luminance.", "type": "object", "required": [ "dark", "light" ], "properties": { "dark": { "description": "Dark theme of the presentation.", "type": "string" }, "light": { "description": "Light theme of the presentation.", "type": "string" }, "timeout": { "description": "Light/Dark detection timeout in ms.", "type": [ "integer", "null" ], "format": "uint64", "minimum": 1.0 } }, "additionalProperties": false } ] }, "TypstConfig": { "type": "object", "properties": { "ppi": { "description": "The pixels per inch when rendering latex/typst formulas.", "default": 300, "type": "integer", "format": "uint32", "minimum": 0.0 } }, "additionalProperties": false }, "ValidateOverflows": { "type": "string", "enum": [ "never", "always", "when_presenting", "when_developing" ] } } } ================================================ FILE: config.sample.yaml ================================================ --- # yaml-language-server: $schema=https://raw.githubusercontent.com/mfontanini/presenterm/master/config-file-schema.json defaults: # override the terminal font size when in windows or when using sixel. terminal_font_size: 16 # the theme to use by default in every presentation unless overridden. theme: dark # the image protocol to use. image_protocol: kitty-local typst: # the pixels per inch when rendering latex/typst formulas. ppi: 300 mermaid: # the scale parameter passed to the mermaid CLI (mmdc). scale: 2 options: # whether slides are automatically terminated when a slide title is found. implicit_slide_ends: false # the prefix to use for commands. command_prefix: "" # show all lists incrementally, by implicitly adding pauses in between elements. incremental_lists: false # this option tells presenterm you don't care about extra parameters in # presentation's front matter. This can be useful if you're trying to load a # presentation made for another tool strict_front_matter_parsing: true # whether to treat a thematic break as a slide end. end_slide_shorthand: false snippet: exec: # enable code snippet execution. Use at your own risk! enable: true exec_replace: # enable code snippet automatic execution + replacing the snippet with its output. Use at your own risk! enable: true render: # the number of threads to use when rendering `+render` code snippets. threads: 2 speaker_notes: # The endpoint to listen for speaker note events. listen_address: "127.0.0.1:59418" # The endpoint to publish speaker note events. publish_address: "127.0.0.1:59418" # Whether to always publish speaker notes even when `--publish-speaker-notes` is not set. always_publish: false bindings: # the keys that cause the presentation to move forwards. next: ["l", "j", "", "", "", " "] # the keys that cause the presentation to move forwards fast. next_fast: ["n"] # the keys that cause the presentation to move backwards. previous: ["h", "k", "", "", ""] # the keys that cause the presentation to move backwards fast previous_fast: ["p"] # the key binding to jump to the first slide. first_slide: ["gg"] # the key binding to jump to the last slide. last_slide: ["G"] # the key binding to jump to a specific slide. go_to_slide: ["G"] # the key binding to execute a piece of shell code. execute_code: [""] # the key binding to reload the presentation. reload: [""] # the key binding to toggle the slide index modal. toggle_slide_index: [""] # the key binding to toggle the key bindings modal. toggle_bindings: ["?"] # the key binding to close the currently open modal. close_modal: [""] # the key binding to close the application. exit: ["", "q"] # the key binding to suspend the application. suspend: [""] # the key binding to skip all pauses in the current slide. skip_pauses: ["s"] ================================================ FILE: docs/.gitignore ================================================ book/ ================================================ FILE: docs/book.toml ================================================ [book] authors = ["mfontanini"] language = "en" multilingual = false src = "src" title = "presenterm documentation" [preprocessor] [preprocessor.alerts] [output] [output.html] git-repository-url = "https://github.com/mfontanini/presenterm" default-theme = "navy" [output.html.redirect] # Redirects for broken links after 02/02/2025 restructuring. "/guides/basics.html" = "../features/introduction.html" "/guides/installation.html" = "../install.html" "/guides/code-highlight.html" = "../features/code/highlighting.html" "/guides/mermaid.html" = "../features/code/mermaid.html" # Redirects for HTML export changes on 05/17/2025. "/features/pdf-export.html" = "exports.html" ================================================ FILE: docs/src/SUMMARY.md ================================================ # Summary [Introduction](./introduction.md) # Docs - [Install](./install.md) - [Features](./features/introduction.md) - [Images](./features/images.md). - [Commands](./features/commands.md). - [Layout](./features/layout.md). - [Code](./features/code/highlighting.md) - [Execution](./features/code/execution.md) - [Mermaid diagrams](./features/code/mermaid.md) - [LaTeX and typst](./features/code/latex.md) - [D2](./features/code/d2.md) - [Themes](./features/themes/introduction.md) - [Definition](./features/themes/definition.md) - [Exports](./features/exports.md) - [Slide transitions](./features/slide-transitions.md) - [Speaker notes](./features/speaker-notes.md) - [Configuration](./configuration/introduction.md) - [Options](./configuration/options.md) - [Settings](./configuration/settings.md) # Internals - [Parse](./internals/parse.md) --- [Acknowledgements](./acknowledgements.md) ================================================ FILE: docs/src/acknowledgements.md ================================================ ## Acknowledgements This tool is heavily inspired by: * [slides][slides_url] * [lookatme][lookatme_url] * [sli.dev][slide_dev_url] Support for code highlighting on many languages is thanks to [bat][bat_url], which contains a custom set of syntaxes that extend [syntect][syntect_url]'s default set of supported languages. Run `presenterm --acknowledgements` to get a full list of all the licenses for the binary files being pulled in. ## Contributors Thanks to everyone who's contributed to _presenterm_ in one way or another! This is a list of the users who have contributed code to make _presenterm_ better in some way: [slides_url]: https://github.com/maaslalani/slides/ [lookatme_url]: https://github.com/d0c-s4vage/lookatme [slide_dev_url]: https://sli.dev/ [bat_url]: https://github.com/sharkdp/bat [syntect_url]: https://github.com/trishume/syntect ================================================ FILE: docs/src/configuration/introduction.md ================================================ # Configuration _presenterm_ allows you to customize its behavior via a configuration file. This file is stored, along with all of your custom themes, in the following directories: * `$XDG_CONFIG_HOME/presenterm/` if that environment variable is defined, otherwise: * `~/.config/presenterm/` in Linux. * `~/Library/Application Support/presenterm/` in macOS. * `~/AppData/Roaming/presenterm/config/` in Windows. The configuration file will be looked up automatically in the directories above under the name `config.yaml`. e.g. on Linux you should create it under `~/.config/presenterm/config.yaml`. You can also specify a custom path to this file when running _presenterm_ via the `--config-file` parameter or the `PRESENTERM_CONFIG_FILE` environment variable. A [sample configuration file](https://github.com/mfontanini/presenterm/blob/master/config.sample.yaml) is provided in the repository that you can use as a base. # Configuration schema A JSON schema that defines the configuration file's schema is available to be used with YAML language servers such as [yaml-language-server](https://github.com/redhat-developer/yaml-language-server). Include the following line at the beginning of your configuration file to have your editor pull in autocompletion suggestions and docs automatically: ```yaml # yaml-language-server: $schema=https://raw.githubusercontent.com/mfontanini/presenterm/master/config-file-schema.json ``` ================================================ FILE: docs/src/configuration/options.md ================================================ # Options Options are special configuration parameters that can be set either in the configuration file under the `options` key, or in a presentation's front matter under the same key. This last one allows you to customize a single presentation so that it acts in a particular way. This can also be useful if you'd like to share the source files for your presentation with other people. The supported configuration options are currently the following: ## implicit_slide_ends This option removes the need to use `` in between slides and instead assumes that if you use a slide title, then you're implying that the previous slide ended. For example, the following presentation: ```markdown --- options: implicit_slide_ends: true --- Tasty vegetables ================ * Potato Awful vegetables ================ * Lettuce ``` Is equivalent to this "vanilla" one that doesn't use implicit slide ends. ```markdown Tasty vegetables ================ * Potato Awful vegetables ================ * Lettuce ``` ## end_slide_shorthand This option allows using thematic breaks (`---`) as a delimiter between slides. When enabling this option, you can still use `` but any thematic break will also be considered a slide terminator. ``` --- options: end_slide_shorthand: true --- this is a slide --------------------- this is another slide ``` ## h1_slide_titles This options allows setting whether the first `h1` heading in a slide will automatically become the slide title: ``` --- options: h1_slide_titles: true --- # title # not the first, so no title ``` ## command_prefix Because _presenterm_ uses HTML comments to represent commands, it is necessary to make some assumptions on _what_ is a command and what isn't. The current heuristic is: * If an HTML comment is laid out on a single line, it is assumed to be a command. This means if you want to use a real HTML comment like ``, this will raise an error. * If an HTML comment is multi-line, then it is assumed to be a comment and it can have anything inside it. This means you can't have a multi-line comment that contains a command like `pause` inside. Depending on how you use HTML comments personally, this may be limiting to you: you cannot use any single line comments that are not commands. To get around this, the `command_prefix` option lets you configure a prefix that must be set in all commands for them to be configured as such. Any single line comment that doesn't start with this prefix will not be considered a command. For example: ``` --- options: command_prefix: "cmd:" --- Tasty vegetables ================ * Potato **That's it!** ``` In the example above, the first comment is ignored because it doesn't start with "cmd:" and the second one is processed because it does. ## incremental_lists If you'd like all bullet points in all lists to show up with pauses in between you can enable the `incremental_lists` option: ``` --- options: incremental_lists: true --- * pauses * in * between ``` Keep in mind if you only want specific bullet points to show up with pauses in between, you can use the [`incremental_lists` comment command](../features/commands.md#incremental-lists). ## strict_front_matter_parsing This option tells _presenterm_ you don't care about extra parameters in presentation's front matter. This can be useful if you're trying to load a presentation made for another tool. The following presentation would only be successfully loaded if you set `strict_front_matter_parsing` to `false` in your configuration file: ```markdown --- potato: 42 --- # Hi ``` ## image_attributes_prefix The [image size](../features/images.md#image-size) prefix (by default `image:`) can be configured to be anything you would want in case you don't like the default one. For example, if you'd like to set the image size by simply doing `![width:50%](path.png)` you would need to set: ```yaml --- options: image_attributes_prefix: "" --- ![width:50%](path.png) ``` ## auto_render_languages This option allows indicating a list of languages for which the `+render` attribute can be omitted in their code snippets and will be implicitly considered to be set. This can be used for languages like `mermaid` so that graphs are always automatically rendered without the need to specify `+render` everywhere. ```yaml --- options: auto_render_languages: - mermaid --- ``` ## list_item_newlines The option allows configuring the number of newlines in between list items, the default being `1`. This cam also be set via the `list_item_newlines` comment command. ```yaml --- options: list_item_newlines: 2 --- ``` ================================================ FILE: docs/src/configuration/settings.md ================================================ # Settings As opposed to options, the rest of these settings **can only be configured via the configuration file**. ## Default theme The default theme can be configured only via the config file. When this is set, every presentation that doesn't set a theme explicitly will use this one: ```yaml defaults: theme: light ``` You can also set a dark and light theme independently, _presenterm_ will detect terminal theme on launch by fetching foreground and background color and pick the right theme: ```yaml defaults: theme: light: light dark: dark ``` ## Terminal font size This is a parameter that lets you explicitly set the terminal font size in use. This should not be used unless you are in Windows, given there's no (easy) way to get the terminal window size so we use this to figure out how large the window is and resize images properly. Some terminals on other platforms may also have this issue, but that should not be as common. If you are on Windows or you notice images show up larger/smaller than they should, you can adjust this setting in your config file: ```yaml defaults: terminal_font_size: 16 ``` ## Preferred image protocol By default _presenterm_ will try to detect which image protocol to use based on the terminal you are using. In case detection for some reason fails in your setup or you'd like to force a different protocol to be used, you can explicitly set this via the `--image-protocol` parameter or the configuration key `defaults.image_protocol`: ```yaml defaults: image_protocol: kitty-local ``` Possible values are: * `auto`: try to detect it automatically (default). * `kitty-local`: use the kitty protocol in "local" mode, meaning both _presenterm_ and the terminal run in the same host and can share the filesystem to communicate. * `kitty-remote`: use the kitty protocol in "remote" mode, meaning _presenterm_ and the terminal run in different hosts and therefore can only communicate via terminal escape codes. * `iterm2`: use the iterm2 protocol. * `sixel`: use the sixel protocol. ## Maximum presentation width The `max_columns` property can be set to specify the maximum number of columns that the presentation will stretch to. If your terminal is larger than that, the presentation will stick to that size and will be centered, preventing it from looking too stretched. ```yaml defaults: max_columns: 100 ``` If you would like your presentation to be left or right aligned instead of centered when the terminal is too wide, you can use the `max_columns_alignment` key: ```yaml defaults: max_columns: 100 # Valid values: left, center, right max_columns_alignment: left ``` ## Maximum presentation height The `max_rows` and `max_rows_alignment` properties are analogous to `max_columns*` to allow capping the maximum number of rows: ```yaml defaults: max_rows: 100 # Valid values: top, center, bottom max_rows_alignment: left ``` ## Incremental lists behavior By default, [incremental lists](../features/commands.md) will pause before and after a list. If you would like to change this behavior, use the `defaults.incremental_lists` key: ```yaml defaults: incremental_lists: # The defaults, change to false if desired. pause_before: true pause_after: true ``` ## Validate terminal overflows The `validate_overflows` property allows configuring whether _presenterm_ should make sure your presentation fits in the current terminal screen. This allows knowing whether any lines are too long to fit in the screen without having to scroll through every slide and manually check for that. When the presentation is first loaded, after the presentation file is modified (if in development mode), and when you resize your terminal, _presenterm_ will make sure every slide in it fits. If any of them don't, an error will be displayed and you will need to resize your terminal until the error goes away or you exit the program. This parameter supports multiple options: * `never`: the default, where overflows aren't validated at all. * `always`: overflow validation will always happen when running _presenterm_. * `when_presenting`: only perform validation when in present mode. That is, when you're running `presenterm -p`. * `when_developing`: only perform validation when running in development mode. That is, any time you're not using `presenterm -p`. ```yaml defaults: validate_overflows: always ``` # 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: ``` See the [slide transitions page](../features/slide-transitions.md) for more information on which animation styles are supported. # Key bindings Key bindings that _presenterm_ uses can be manually configured in the config file via the `bindings` key. The following is the default configuration: ```yaml bindings: # the keys that cause the presentation to move forwards. next: ["l", "j", "", "", "", " "] # the keys that cause the presentation to move backwards. previous: ["h", "k", "", "", ""] # the keys that cause the presentation to move "fast" to the next slide. this will ignore: # # * Pauses. # * Dynamic code highlights. # * Slide transitions, if enabled. next_fast: ["n"] # same as `next_fast` but jumps fast to the previous slide. previous_fast: ["p"] # the key binding to jump to the first slide. first_slide: ["gg"] # the key binding to jump to the last slide. last_slide: ["G"] # the key binding to jump to a specific slide. go_to_slide: ["G"] # the key binding to execute a piece of shell code. execute_code: [""] # the key binding to reload the presentation. reload: [""] # the key binding to toggle the slide index modal. toggle_slide_index: [""] # the key binding to toggle the key bindings modal. toggle_bindings: ["?"] # the key binding to close the currently open modal. close_modal: [""] # the key binding to close the application. exit: ["", "q"] # the key binding to suspend the application. suspend: [""] ``` You can choose to override any of them. Keep in mind these are overrides so if for example you change `next`, the default won't apply anymore and only what you've defined will be used. # Snippet configurations The configurations that affect code snippets in presentations. ## Snippet execution [Snippet execution](../features/code/execution.md#executing-code-blocks) is disabled by default for security reasons. Besides passing in the `-x` command line parameter every time you run _presenterm_, you can also configure this globally for all presentations by setting: ```yaml snippet: exec: enable: true ``` **Use this at your own risk**, especially if you're running someone else's presentations! ## Snippet execution + replace [Snippet execution + replace](../features/code/execution.md#executing-and-replacing) is disabled by default for security reasons. Similar to `+exec`, this can be enabled by passing in the `-X` command line parameter or configuring it globally by setting: ```yaml snippet: exec_replace: enable: true ``` **Use this at your own risk**. This will cause _presenterm_ to execute code without user intervention so don't blindly enable this and open a presentation unless you trust its origin! ## Custom snippet executors If _presenterm_ doesn't support executing code snippets for your language of choice, please [create an issue](https://github.com/mfontanini/presenterm/issues/new)! Alternatively, you can configure this locally yourself by setting: ```yaml snippet: exec: custom: # The keys should be the language identifier you'd use in a code block. c++: # The name of the file that will be created with your snippet's contents. filename: "snippet.cpp" # A list of environment variables that should be set before building/running your code. environment: MY_FAVORITE_ENVIRONMENT_VAR: foo # A prefix that indicates a line that starts with it should not be visible but should be executed if the # snippet is marked with `+exec`. hidden_line_prefix: "/// " # A list of commands that will be ran one by one in the same directory as the snippet is in. commands: # Compile if first - ["g++", "-std=c++20", "snippet.cpp", "-o", "snippet"] # Now run it - ["./snippet"] ``` The output of all commands will be included in the code snippet execution output so if a command (like the `g++` invocation) was to emit any output, make sure to use whatever flags are needed to mute its output. Also note that you can override built-in executors in case you want to run them differently (e.g. use `c++23` in the example above). See more examples in the [executors.yaml](https://github.com/mfontanini/presenterm/blob/master/executors.yaml) file which defines all of the built-in executors. ## Snippet rendering threads Because some `+render` code blocks can take some time to be rendered into an image, especially if you're using [mermaid](https://mermaid.js.org/) charts, this is run asychronously. The number of threads used to render these, which defaults to 2, can be configured by setting: ```yaml snippet: render: threads: 2 ``` ## Mermaid The following configuration parameters can be set to alter the behavior when displaying [mermaid](https://mermaid.js.org/) diagrams. ### Config file A custom [mermaid config file](https://mermaid.ai/open-source/config/schema-docs/config.html) can be configured via the `mermaid.config_file` config parameter. This should point to a configuration file where you can set any configs you consider appropriate, such as the font family to use: ```yaml mermaid: config_file: /home/foo/my_config_file.yml ``` ### Puppeteer config file A custom puppeteer config file can be configured via the `mermaid.puppeteer_config_file` config parameter. This should point to a configuration file that will be given to puppeteer by the `mmdc` tool: ```yaml mermaid: puppeteer_config_file: /home/foo/puppeteer.json ``` ### Scaling mermaid graphs will use a default scaling of `2` when invoking the mermaid CLI. If you'd like to change this use: ```yaml mermaid: scale: 2 ``` ## D2 scaling [d2](https://d2lang.com/) graphs will use the default scaling when invoking the d2 CLI. If you'd like to change this use: ```yaml d2: scale: 2 ``` ## Enabling speaker note publishing If you don't want to run _presenterm_ with `--publish-speaker-notes` every time you want to publish speaker notes, you can set the `speaker_notes.always_publish` attribute to `true`. ```yaml speaker_notes: always_publish: true ``` # Presentation exports The configurations that affect PDF and HTML exports. ## Export size By default, the size of each page in the generated PDF and HTML files will depend on the size of your terminal. If you would like to instead configure the dimensions by hand, set the `export.dimensions` key: ```yaml export: dimensions: columns: 80 rows: 30 ``` ## 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 ``` ## Sequential snippet execution When generating exports, snippets are executed in parallel to make the process faster. If your snippets require being executed sequentially, you can use the `export.snippets` parameter: ```yaml export: snippets: sequential ``` ## PDF font The PDF export can be configured to use a specific font installed in your system. Use the following keys to do so: ```yaml export: pdf: fonts: normal: /usr/share/fonts/truetype/tlwg/TlwgMono.ttf italic: /usr/share/fonts/truetype/tlwg/TlwgMono-Oblique.ttf bold: /usr/share/fonts/truetype/tlwg/TlwgMono-Bold.ttf bold_italic: /usr/share/fonts/truetype/tlwg/TlwgMono-BoldOblique.ttf ``` ================================================ FILE: docs/src/features/code/d2.md ================================================ # D2 [D2](https://d2lang.com/) snippets can be converted into images automatically for any snippets tagged with the `d2` language and using a `+render` attribute: ~~~markdown ```d2 +render my_table: { shape: sql_table id: int {constraint: primary_key} last_updated: timestamp with time zone } ``` ~~~ **This requires having [d2](https://github.com/terrastruct/d2) installed**. Similar to [mermaid diagrams support](mermaid.md), d2 diagrams: * Will take some time because of how slow the d2 tool is. * Can be scaled by using a `+width:%` attribute in the snippet or by setting the `d2.scale` property in the config file, which is passed along to the `--scale` parameter to the d2 CLI. ## Theme The theme of the rendered d2 diagrams can be changed through the `d2.theme` [theme](../themes/introduction.md) parameter. See the available themes in the [d2 docs](https://d2lang.com/tour/themes/). ================================================ FILE: docs/src/features/code/execution.md ================================================ # Snippet execution ## Executing code blocks Annotating a code block with a `+exec` attribute will make it executable. Pressing `control+e` when viewing a slide that contains an executable block, the code in the snippet will be executed and the output of the execution will be displayed on a box below it. The code execution is stateful so if you switch to another slide and then go back, you will still see the output. ~~~markdown ```bash +exec echo hello world ``` ~~~ Code execution **must be explicitly enabled** by using either: * The `-x` command line parameter when running _presenterm_. * Setting the `snippet.exec.enable` property to `true` in your [_presenterm_ config file](../../configuration/settings.md#snippet-execution). Refer to [the table in the highlighting page](highlighting.md#code-highlighting) for the list of languages for which code execution is supported. --- [![asciicast](https://asciinema.org/a/BbAY817esxagCgPtnKUwgYnHr.svg)](https://asciinema.org/a/BbAY817esxagCgPtnKUwgYnHr) > [!warning] > Run code in presentations at your own risk! Especially if you're running someone else's presentation. Don't blindly > enable snippet execution! ### Output placing By default a snippet's output will always show up right below the snippet. However, if you wanted to show the output in a different place in a slide (e.g. another column) or even in another slide you can do this by: 1. Defining a snippet's identifier: ~~~markdown ```bash +exec +id:foo echo hellow world ``` ~~~ 2. Referencing that identifier where you want the output to appear by using the `snippet_output` comment command: ~~~markdown ~~~ A single snippet can be referenced multiple times in multiple slides, as long as the slide you're referencing it in comes after the snippet. The snippet will only be executed once, and every `snippet_output` command will display that single execution's output. ### Validating snippets While you're developing your presentation you probably want to make sure the executable snippets you write in it are correct and don't contain any syntax errors. While you can do this by constantly pressing `` every time you change a snippet, this is automatically done by _presenterm_ if you pass in the `--validate-snippets` flag. When you pass in this flag, _presenterm_ will: * Automatically run all `+exec`, `+exec_replace`, and `+validate` snippets as soon as your presentation starts. Note that the `+validate` flag is a special one that doesn't make a snippet executable but still validates it by running it during development. * Report an error if any of the snippets returns an exit code other than 0. * Re-run all snippets `+exec` and `+exec_replace` snippets every time the presentation is reloaded. In case you expect a snippet to return an exit code other than 0, you can use the `+expect:failure` flag. This will cause _presenterm_ to display an error if the snippet does not fail. For example, the following defines a snippet that's not executable but that will be validated if `--validate-snippets` is passed in and will display an error if the snippet does not fail. ```rust +validate +expect:failure fn main() { let q = 42; let w = q + "foo"; // oops } ``` ## Running code in pseudo terminal (PTY) By using the `+pty` attribute, you can run a code snippet inside a pseudo terminal. This allows running tools like `top` and `htop` which move the cursor around, clear the screen, etc, and have them behave how you'd expect. [![asciicast](https://asciinema.org/a/PWdPtehxI6xwM7Yt.svg)](https://asciinema.org/a/PWdPtehxI6xwM7Yt) Note that this can be used with snippets in any language, not necessarily only shell scripts. > [!note] > Support for sending keyboard input into running scripts is planned but not currently supported. ### Specifying PTY size The size of the PTY used to execute the code will be expanded to fit the screen. A custom size can be set by using the `+pty::` syntax, e.g. `+pty:80:30`. ### Standby mode A `+pty:standby` block indicates the area of the slide the PTY will be rendered on should always be visible, even before the code is executed. Note that if you're also configuring the PTY size, the syntax is `+pty:standby::` [![asciicast](https://asciinema.org/a/IrclITxMSkBZPPH3.svg)](https://asciinema.org/a/IrclITxMSkBZPPH3) ## Executing and replacing Similar to `+exec`, `+exec_replace` causes a snippet to be executable but: * Execution happens automatically without user intervention. * The snippet will be automatically replaced with its execution output. This can be useful to run programs that generate some form of ASCII art that you'd like to generate dynamically. [![asciicast](https://asciinema.org/a/hklQARZKb5sP5mavL4cGgbYXD.svg)](https://asciinema.org/a/hklQARZKb5sP5mavL4cGgbYXD) Because of the risk involved in `+exec_replace`, where code gets automatically executed when running a presentation, this requires users to explicitly opt in to it. This can be done by either passing in the `-X` command line parameter or setting the `snippet.exec_replace.enable` flag in your configuration file to `true`. ## Automatic execution The `+auto_exec` property behaves like a `+exec` code block but doesn't require pressing `` to execute it. ## Alternative executors Some languages support alternative executors. For example, `rust` code can be ran via [`rust-script`](https://rust-script.org/), which allows you to use external crates. These executors can be used by specifying `:` after `+exec` or `+exec_replace`. For example, the following `rust` snippet will be executed using `rust-script`: ~~~markdown ```rust +exec:rust-script # //! ```cargo # //! [dependencies] # //! time = "0.1.25" # //! ``` # // The lines above will be hidden fn main() { println!("the time is {}", time::now().rfc822z()); } ``` ~~~ The supported alternative executors are: * `rust-script` for `rust` snippets. * `pytest` and `uv` for `python` snippets. ## Code to image conversions The `+image` attribute behaves like `+exec_replace` but also assumes the output of the executed snippet will be an image, and it will render it as such. For this to work, the code **must only emit an image in jpg/png formats** and nothing else. For example, this would render the demo presentation's image: ~~~markdown ```bash +image cat examples/doge.png ``` ~~~ This attribute carries the same risks as `+exec_replace` and therefore needs to be enabled via the same flags. ## Executing snippets that need a TTY If you're trying to execute a program like `top` that needs to run on a TTY as it renders text, clears the screen, etc, you can use the `+acquire_terminal` modifier on a code already marked as executable with `+exec`. Executing snippets tagged with these two attributes will cause _presenterm_ to suspend execution, the snippet will be invoked giving it the raw terminal to do whatever it needs, and upon its completion _presenterm_ will resume its execution. [![asciicast](https://asciinema.org/a/AHfuJorCNRR8ZEnfwQSDR5vPT.svg)](https://asciinema.org/a/AHfuJorCNRR8ZEnfwQSDR5vPT) ## Styled execution output Snippets that generate output which contains escape codes that change the colors or styling of the text will be parsed and displayed respecting those styles. Do note that you may need to force certain tools to use colored output as they will likely not use it by default. For example, to get colored output when invoking `ls` you can use: ~~~markdown ```bash +exec ls /tmp --color=always ``` ~~~ The parameter or way to enable this will depend on the tool being invoked. ## Hiding code lines When you mark a code snippet as executable via the `+exec` flag, you may not be interested in showing _all the lines_ to your audience, as some of them may not be necessary to convey your point. For example, you may want to hide imports, non-essential functions, initialization of certain variables, etc. For this purpose, _presenterm_ supports a prefix under certain programming languages that let you indicate a line should be executed when running the code but should not be displayed in the presentation. For example, in the following code snippet only the print statement will be displayed but the entire snippet will be ran: ~~~markdown ```rust # fn main() { println!("Hello world!"); # } ``` ~~~ Rather than blindly relying on a prefix that may have a meaning in a language, prefixes are chosen on a per language basis. The languages that are supported and their prefix is: * rust: `# `. * python/bash/fish/shell/zsh/kotlin/java/javascript/typescript/c/c++/go: `/// `. This means that any line in a rust code snippet that starts with `# ` will be hidden, whereas all lines in, say, a golang code snippet that starts with a `/// ` will be hidden. ## Pre-rendering Some languages support pre-rendering. This means the code block is transformed into something else when the presentation is loaded. The languages that currently support this are _mermaid_, _LaTeX_, and _typst_ where the contents of the code block is transformed into an image, allowing you to define formulas as text in your presentation. This can be done by using the `+render` attribute on a code block. See the [LaTeX and typst](latex.md), [mermaid](mermaid.md), and [d2](d2.md) docs for more information. ================================================ FILE: docs/src/features/code/highlighting.md ================================================ # Code highlighting Code highlighting is supported for the following languages: | Language | Execution support | |------------|-------------------| | ada | | | asp | | | awk | | | bash | ✓ | | batchfile | | | C | ✓ | | cmake | | | crontab | | | C# | ✓ | | clojure | | | C++ | ✓ | | CSS | | | D | | | diff | | | docker | | | dotenv | | | elixir | | | elm | | | erlang | | | fish | ✓ | | F# | ✓ | | go | ✓ | | haskell | ✓ | | HTML | | | java | ✓ | | javascript | ✓ | | json | | | julia | ✓ | | kotlin | ✓ | | latex | | | lua | ✓ | | makefile | | | markdown | | | nix | | | ocaml | | | perl | ✓ | | php | ✓ | | protobuf | | | puppet | | | python | ✓ | | R | ✓ | | ruby | ✓ | | rust | ✓ | | scala | | | shell | ✓ | | sql | | | swift | | | svelte | | | tcl | | | toml | | | terraform | | | typescript | | | xml | | | yaml | | | vue | | | zig | | | zsh | ✓ | Other languages that are supported are: * nushell, for which highlighting isn't supported but execution is. If there's a language that is not in this list and you would like it to be supported, please [create an issue](https://github.com/mfontanini/presenterm/issues/new). If you'd also like code execution support, provide details on how to compile (if necessary) and run snippets for that language. You can also configure how to run code snippet for a language locally in your [config file](../../configuration/settings.md#custom-snippet-executors). ## Enabling line numbers If you would like line numbers to be shown on the left of a code block use the `+line_numbers` switch after specifying the language in a code block: ~~~markdown ```rust +line_numbers fn hello_world() { println!("Hello world"); } ``` ~~~ ## Selective highlighting By default, the entire code block will be syntax-highlighted. If instead you only wanted a subset of it to be highlighted, you can use braces and a list of either individual lines, or line ranges that you'd want to highlight. ~~~markdown ```rust {1,3,5-7} fn potato() -> u32 { // 1: highlighted // 2: not highlighted println!("Hello world"); // 3: highlighted let mut q = 42; // 4: not highlighted q = q * 1337; // 5: highlighted q // 6: highlighted } // 7: highlighted ``` ~~~ ## Dynamic highlighting Similar to the syntax used for selective highlighting, dynamic highlighting will change which lines of the code in a code block are highlighted every time you move to the next/previous slide. This is achieved by using the separator `|` to indicate what sections of the code will be highlighted at a given time. You can also use `all` to highlight all lines for a particular frame. ~~~markdown ```rust {1,3|5-7} fn potato() -> u32 { println!("Hello world"); let mut q = 42; q = q * 1337; q } ``` ~~~ In this example, lines 1 and 3 will be highlighted initially. Then once you press a key to move to the next slide, lines 1 and 3 will no longer be highlighted and instead lines 5 through 7 will. This allows you to create more dynamic presentations where you can display sections of the code to explain something specific about each of them. See this real example of how this looks like. [![asciicast](https://asciinema.org/a/iCf4f6how1Ux3H8GNzksFUczI.svg)](https://asciinema.org/a/iCf4f6how1Ux3H8GNzksFUczI) ## Including external code snippets The `file` snippet type can be used to specify an external code snippet that will be included and highlighted as usual. ~~~markdown ```file +exec +line_numbers path: snippet.rs language: rust ``` ~~~ If you'd like to include only a subset of the file, you can use the optional fields `start_line` and `end_line`: ~~~markdown ```file +exec +line_numbers path: snippet.rs language: rust # Only show lines 5-10 start_line: 5 end_line: 10 ``` ~~~ ## Showing a snippet without a background Using the `+no_background` flag will cause the snippet to have no background. This is useful when combining it with the `+exec_replace` flag described further down. ## Adding highlighting syntaxes for new languages _presenterm_ uses the syntaxes supported by [bat](https://github.com/sharkdp/bat) to highlight code snippets, so any languages supported by _bat_ natively can be added to _presenterm_ easily. Please create a ticket or use [this](https://github.com/mfontanini/presenterm/pull/385) as a reference to submit a pull request to make a syntax officially supported by _presenterm_ as well. If a language isn't natively supported by _bat_ but you'd like to use it, you can follow [this guide in the bat docs](https://github.com/sharkdp/bat#adding-new-syntaxes--language-definitions) and invoke _bat_ directly in a presentation: ~~~markdown ```bash +exec_replace bat --color always script.py ``` ~~~ > [!note] > Check the [code execution docs](execution.md#executing-and-replacing) for more details on how to allow the tool to run > `exec_replace` blocks. ================================================ FILE: docs/src/features/code/latex.md ================================================ # LaTeX and typst `latex` and `typst` code blocks can be marked with the `+render` attribute (see [highlighting](highlighting.md)) to have them rendered into images when the presentation is loaded. This allows you to define formulas in text rather than having to define them somewhere else, transform them into an image, and them embed it. For example, the following presentation: ~~~ # Formulas ```latex +render \[ \sum_{n=1}^{\infty} 2^{-n} = 1 \] ``` ~~~ Would be rendered like this: ![](../../assets/formula.png) ## Dependencies ### typst The engine used to render both of these languages is [typst](https://github.com/typst/typst). _typst_ is easy to install, lightweight, and boilerplate free as compared to _LaTeX_. ### pandoc For _LaTeX_ code rendering both _typst_ and [pandoc](https://github.com/jgm/pandoc) are required. How this works is the _LaTeX_ code you write gets transformed into _typst_ code via _pandoc_ and then rendered by using _typst_. This lets us: * Have the same look/feel on generated formulas for both languages. * Avoid having to write lots of boilerplate _LaTeX_ to make rendering for that language work. * Have the same logic to render formulas for both languages, except with a small preparation step for _LaTeX_. ## Controlling PPI _presenterm_ lets you define how many Pixels Per Inch (PPI) you want in the generated images. This is important because as opposed to images that you manually include in your presentation, where you control the exact dimensions, the images generated on the fly will have a fixed size. Configuring the PPI used during the conversion can let you adjust this: the higher the PPI, the larger the generated images will be. Because as opposed to most configurations this is a very environment-specific config, the PPI parameter is not part of the theme definition but is instead has to be set in _presenterm_'s [config file](../../configuration/introduction.md): ```yaml typst: ppi: 400 ``` The default is 300 so adjust it and see what works for you. ## Image paths If you're including an image inside a _typst_ snippet, you must: * Use absolute paths, e.g. `#image("/image1.png")`. * Place the image in the same or a sub path of the path where the presentation is. That is, if your presentation file is at `/tmp/foo/presentation.md`, you can place images in `/tmp/foo`, and `/tmp/foo/bar` but not under `/tmp/bar`. This is because of the absolute path rule above: the path will be considered to be relative to the presentation file's directory. ## Controlling the image size You can also set the generated image's size on a per code snippet basis by using the `+width` modifier which specifies the width of the image as a percentage of the terminal size. ~~~markdown ```typst +render +width:50% $f(x) = x + 1$ ``` ~~~ ## Customizations The colors and margin of the generated images can be defined in your theme: ```yaml typst: colors: background: ff0000 foreground: 00ff00 # In points horizontal_margin: 2 vertical_margin: 2 ``` ================================================ FILE: docs/src/features/code/mermaid.md ================================================ ## Mermaid [mermaid](https://mermaid.js.org/) snippets can be converted into images automatically in any code snippet tagged with the `mermaid` language and a `+render` tag: ~~~markdown ```mermaid +render sequenceDiagram Mark --> Bob: Hello! Bob --> Mark: Oh, hi mark! ``` ~~~ **This requires having [mermaid-cli](https://github.com/mermaid-js/mermaid-cli) installed**. Note that because the mermaid CLI will spin up a browser under the hood, this may not work in all environments and can also be a bit slow (e.g. ~2 seconds to generate every image). Mermaid graphs are rendered asynchronously by a number of threads that can be configured in the [configuration file](../../configuration/settings.md#snippet-rendering-threads). This configuration value currently defaults to 2. The size of the rendered image can be configured by changing: * The `mermaid.scale` [configuration parameter](../../configuration/settings.md#mermaid-scaling). * Using the `+width:%` attribute in the code snippet. For example, this diagram will take up 50% of the width of the window and will preserve its aspect ratio: ~~~markdown ```mermaid +render +width:50% sequenceDiagram Mark --> Bob: Hello! Bob --> Mark: Oh, hi mark! ``` ~~~ It is recommended to change the `mermaid.scale` parameter until images look big enough and then adjust on an image by image case if necessary using the `+width` attribute. Otherwise, using a small scale and then scaling via `+width` may cause the image to become blurry. ## Theme The theme of the rendered mermaid diagrams can be changed through the following [theme](../themes/introduction.md) parameters: * `mermaid.background` the background color passed to the CLI (e.g., `transparent`, `red`, `#F0F0F0`). * `mermaid.theme` the [mermaid theme](https://mermaid.js.org/config/theming.html#available-themes) to use. ## Always render diagrams If you don't want to use `+render` every time, you can configure which languages get this automatically via the [config file](../../configuration/settings.md#auto_render_languages). ================================================ FILE: docs/src/features/commands.md ================================================ # Comment commands _presenterm_ uses "comment commands" in the form of HTML comments to let the user specify certain behaviors that can't be specified by vanilla markdown. ## Pauses Pauses allow the sections of the content in your slide to only show up when you advance in your presentation. That is, only after you press, say, the right arrow will a section of the slide show up. This can be done by the `pause` comment command: ```html ``` ## Font size The font size can be changed by using the `font_size` command: ```html ``` This causes the remainder of the slide to use the font size specified. The font size can range from 1 to 7, 1 being the default. > [!note] > This is currently only supported in the [_kitty_](https://sw.kovidgoyal.net/kitty/) terminal and only as of version > 0.40.0. See the notes on font sizes on the [introduction page](introduction.md#font-sizes) for more information on > this. ## Jumping to the vertical center The command `jump_to_middle` lets you jump to the middle of the page vertically. This is useful in combination with slide titles to create separator slides: ```markdown blablabla Farming potatoes === ``` This will create a slide with the text "Farming potatoes" in the center, rendered using the slide title style. ## Explicit new lines The `newline`/`new_line` and `newlines`/`new_lines` commands allow you to explicitly create new lines. Because markdown ignores multiple line breaks in a row, this is useful to create some spacing where necessary: ```markdown hi mom bye ``` ## Incremental lists Using `` in between each bullet point a list is a bit tedious so instead you can use the `incremental_lists` command to tell _presenterm_ that **until the end of the current slide** you want each individual bullet point to appear only after you move to the next slide: ```markdown * this * appears * one after * the other * this appears * all at once ``` ## Number of lines in between list items The `list_item_newlines` option lets you configure the number of new lines in between list items in the remainder of a slide. This can be helpful to "unpack" a list that only has a few entries and you want it to take up more space in a slide. This can also be configured for all lists via the [`options.list_item_newlines` option](../configuration/options.md#list_item_newlines). ```markdown * this * is * more * spaced ``` ## Including external markdown files By using the `include` command you can include the contents of an external markdown file as if it was part of the original presentation file: ```markdown ``` Any files referenced by an included file will have their paths relative to that path. e.g. if you include `foo/bar.md` and that file contains an image `tar.png`, that image will be looked up in `foo/tar.png`. ## No footer If you don't want the footer to show up in some particular slide for some reason, you can use the `no_footer` command: ```html ``` ## Skip slide If you don't want a specific slide to be included in the presentation use the `skip_slide` command: ```html ``` ## 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 left alignment, the default centered right aligned ``` ## User comments User comments such as personal notes, TODOs, and other documentation that will be ignored during presentation rendering can be added using these formats: ```markdown ``` These are useful for: - Personal notes and reminders. - TODO items and planning notes. - Source references and attribution. ## Listing available comment commands The `--list-comment-commands` CLI option outputs all available comment commands to stdout, making it easy to discover and use them in external tools and editors. ### Purpose This feature is designed to: - Provide a machine-readable list of all comment commands - Enable editor integrations for autocompletion and snippets - Allow validation of comment commands in external tools - Serve as a quick reference without consulting documentation ### Usage ```bash # List all available comment commands presenterm --list-comment-commands # Use with fzf for interactive selection presenterm --list-comment-commands | fzf # Pipe to grep to filter specific commands presenterm --list-comment-commands | grep alignment ``` ### Output format Each command is output on a separate line with appropriate default values where applicable: ``` ``` ### Editor integration example: Vim For Vim users with fzf.vim installed, you can add this to your `.vimrc` to enable quick insertion of comment commands: ```vim " Presenterm comment command helper if executable('presenterm') && executable('fzf') inoremap fzf#vim#complete(fzf#wrap({ \ 'source': 'presenterm --list-comment-commands', \ 'options': '--header "Comment Command Selection" --no-hscroll', \ 'reducer': { lines -> lines[0] } })) endif ``` With this configuration, pressing `Ctrl+K` in insert mode will open an fzf picker with all available comment commands, allowing you to quickly select and insert them into your presentation. ================================================ FILE: docs/src/features/exports.md ================================================ # Exporting presentations Presentations can be exported to PDF and HTML, to allow easily sharing the slide deck at the end of a presentation. ## PDF Presentations can be converted into PDF by using [weasyprint](https://pypi.org/project/weasyprint/). Follow their [installation instructions](https://doc.courtbouillon.org/weasyprint/stable/first_steps.html) since it may require you to install extra dependencies for the tool to work. > [!note] > If you were using _presenterm-export_ before it was deprecated, that tool already required _weasyprint_ so it is > already installed in whatever virtual env you were using and there's nothing to be done. After you've installed _weasyprint_, run _presenterm_ with the `--export-pdf` parameter to generate the output PDF: ```bash presenterm --export-pdf examples/demo.md ``` The output PDF will be placed in `examples/demo.pdf`. Alternatively you can use the `--output` flag to specify where you want the output file to be written to. > [!note] > If you're using a separate virtual env to install _weasyprint_ just make sure you activate it before running > _presenterm_ with the `--export-pdf` parameter. > [!note] > If you have [uv](https://github.com/astral-sh/uv) installed you can simply run: > ```bash > uv run --with weasyprint presenterm --export-pdf examples/demo.md > ``` ## HTML Similarly, using the `--export-html` parameter allows generating a single self contained HTML file that contains all images and styles embedded in it. As opposed to PDF exports, this requires no extra dependencies: ```bash presenterm --export-html examples/demo.md ``` The output file will be placed in `examples/demo.html` but this behavior can be configured via the `--output` flag just like for PDF exports. # Configurable behavior See the [settings page](../configuration/settings.md#presentation-exports) to see all the configurable behavior around presentation exports. ================================================ FILE: docs/src/features/images.md ================================================ # Images Images are supported and will render in your terminal as long as it supports either the [iterm2 image protocol](https://iterm2.com/documentation-images.html), the [kitty graphics protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/), or [sixel](https://saitoha.github.io/libsixel/). Some of the terminals where at least one of these is supported are: * [kitty](https://sw.kovidgoyal.net/kitty/) * [iterm2](https://iterm2.com/) * [WezTerm](https://wezfurlong.org/wezterm/index.html) * [ghostty](https://ghostty.org/) * [foot](https://codeberg.org/dnkl/foot) --- Things you should know when using image tags in your presentation's markdown are: * Image paths are relative to your presentation path. That is a tag like `![](food/potato.png)` will be looked up at `$PRESENTATION_DIRECTORY/food/potato.png`. * Images will be rendered by default in their original size. That is, if your terminal is 300x200px and your image is 200x100px, it will take up 66% of your horizontal space and 50% of your vertical space. * The exception to the point above is if the image does not fit in your terminal, it will be resized accordingly while preserving the aspect ratio. * If your terminal does not support any of the graphics protocol above, images will be rendered using ascii blocks. It ain't great but it's something! * Remote images are not supported [by design](https://github.com/mfontanini/presenterm/issues/213#issuecomment-1950342423). ## tmux If you're using tmux, you will need to enable the [allow-passthrough option](https://github.com/tmux/tmux/wiki/FAQ#what-is-the-passthrough-escape-sequence-and-how-do-i-use-it) for images to work correctly. ## Image size The size of each image can be set by using the `image:width` or `image:w` attributes in the image tag. For example, the following will cause the image to take up 50% of the terminal width: ```markdown ![image:width:50%](image.png) ``` The image will always be scaled to preserve its aspect ratio and it will not be allowed to overflow vertically nor horizontally. ## Protocol detection By default the image protocol to be used will be automatically detected. In cases where this detection fails, you can set it manually via the `--image-protocol` parameter or by setting it in the [config file](../configuration/settings.md#preferred-image-protocol). ================================================ FILE: docs/src/features/introduction.md ================================================ # Introduction This guide teaches you how to use _presenterm_. At this point you should have already installed _presenterm_, otherwise visit the [installation](../install.md) guide to get started. ## Quick start Download the demo presentation and run it using: ```bash git clone https://github.com/mfontanini/presenterm.git cd presenterm presenterm examples/demo.md ``` # Presentations A presentation in _presenterm_ is a single markdown file. Every slide in the presentation file is delimited by a line that contains a single HTML comment: ```html ``` Presentations can contain most commonly used markdown elements such as ordered and unordered lists, headings, formatted text (**bold**, _italics_, ~strikethrough~, `inline code`, etc), code blocks, block quotes, tables, etc. ## Introduction slide By setting a front matter at the beginning of your presentation you can configure the title, sub title, author and other metadata about your presentation. Doing so will cause _presenterm_ to create an introduction slide: ```yaml --- title: "My _first_ **presentation**" sub_title: (in presenterm!) author: Myself --- ``` All of these attributes are optional and should be avoided if an introduction slide is not needed. Note that the `title` key can contain arbitrary markdown so you can use bold, italics, `` tags, etc. ### Multiple authors If you're creating a presentation in which there's multiple authors, you can use the `authors` key instead of `author` and list them all this way: ```yaml --- title: Our first presentation authors: - Me - You --- ``` ## Slide titles Any [setext header](https://spec.commonmark.org/0.30/#setext-headings) will be considered to be a slide title and will be rendered in a more slide-title-looking way. By default this means it will be centered, some vertical padding will be added and the text color will be different. ~~~markdown Hello === ~~~ > [!note] > See the [themes](themes/introduction.md) section on how to customize the looks of slide titles and any other element > in a presentation. ## Ending slides While other applications use a thematic break (`---`) to mark the end of a slide, _presenterm_ uses a special `end_slide` HTML comment: ```html ``` This makes the end of a slide more explicit and easy to spot while you're editing your presentation. See the [configuration](../configuration/options.md#implicit_slide_ends) if you want to customize this behavior. If you really would prefer to use thematic breaks (`---`) to delimit slides, you can do that by enabling the [`end_slide_shorthand`](../configuration/options.md#end_slide_shorthand) options. ## Colored text `span` HTML tags can be used to provide foreground and/or background colors to text. There's currently two ways to specify colors: * Via the `style` attribute, in which only the CSS attributes `color` and `background-color` can be used to set the foreground and background colors respectively. Colors used in both CSS attributes can refer to [theme palette colors](themes/definition.md#color-palette) by using the `palette:` or `p:colored text! ``` Alternatively, can you can define a class that contains a foreground/background color combination in your theme's palette and use it: ```markdown colored text! ``` > [!note] > Keep in mind **only `span` tags are supported**. ## Font sizes The [_kitty_](https://sw.kovidgoyal.net/kitty/) terminal added in version 0.40.0 support for a new protocol that allows TUIs to specify the font size to be used when printing text. _presenterm_ is one of the first applications supports this protocol in various places: * Themes can specify it in the presentation title in the introduction slide, in slide titles, and in headers by using the `font_size` property. All built in themes currently set font size to 2 (1 is the default) for these elements. * Explicitly by using the `font_size` comment command: ```markdown # Normal text # Larger text ``` Terminal support for this feature is verified when _presenterm_ starts and any attempt to change the font size, be it via the theme or via the comment command, will be ignored if it's not supported. # Key bindings Navigation within a presentation should be intuitive: jumping to the next/previous slide can be done by using the arrow keys, _hjkl_, and page up/down keys. Besides this: * Jumping to the first slide: `gg`. * Jumping to the last slide: `G`. * Jumping to a specific slide: `G`. * Exit the presentation: `c`. You can check all the configured keybindings by pressing `?` while running _presenterm_. ## Configuring key bindings If you don't like the default key bindings, you can override them in the [configuration file](../configuration/settings.md#key-bindings). # Modals _presenterm_ currently has 2 modals that can provide some information while running the application. Modals can be toggled using some key combination and can be hidden using the escape key by default, but these can be configured via the [configuration file key bindings](../configuration/settings.md#key-bindings). ## Slide index modal This modal can be toggled by default using `control+p` and lets you see an index that contains a row for every slide in the presentation, including its title and slide index. This allows you to find a slide you're trying to jump to quicklier rather than scanning through each of them. [![asciicast](https://asciinema.org/a/1VgRxVIEyLrMmq6OZ3oKx4PGi.svg)](https://asciinema.org/a/1VgRxVIEyLrMmq6OZ3oKx4PGi) ## Key bindings modal The key bindings modal displays the key bindings for each of the supported actions and can be opened by pressing `?`. ## Toggle visual grid Press uppercase `T` by default to toggle the layout grid. This is useful when using a column layout and trying to understand how wide each column is. See [this PR](https://github.com/mfontanini/presenterm/pull/718) for more details. # Hot reload Unless you run in presentation mode by passing in the `--present` parameter, _presenterm_ will automatically reload your presentation file every time you save it. _presenterm_ will also automatically detect which specific slide was modified and jump to it so you don't have to be jumping back and forth between the source markdown and the presentation to see how the changes look like. [![asciicast](https://asciinema.org/a/bu9ITs8KhaQK5OdDWnPwUYKu3.svg)](https://asciinema.org/a/bu9ITs8KhaQK5OdDWnPwUYKu3) ================================================ FILE: docs/src/features/layout.md ================================================ # Layouts _presenterm_ currently supports a column layout that lets you split parts of your slides into column. This allows you to put text on one side, and code/images on the other, or really organize markdown into columns in any way you want. This is done by using commands, just like `pause` and `end_slide`, in the form of HTML comments. This section describes how to use those. ## Wait, why not HTML? While markdown _can_ contain HTML tags (beyond comments!) and we _could_ represent this using divs with alignment, I don't really want to: 1. Deal with HTML and all the implications this would have. e.g. nesting many divs together and all the chaos that would bring to the rendering code. 2. Require people to write HTML when we have such a narrow use-case for it here: we only want column layouts. Because of this, _presenterm_ doesn't let you use HTML and instead has a custom way of specifying column layouts. ## Column layout The way to specify column layouts is by first creating a layout, and then telling _presenterm_ you want to enter each of the column in it as you write your presentation. ### Defining layouts Defining a layout is done via the `column_layout` command, in the form of an HTML comment: ```html ``` This defines a layout with 2 columns where: * The total number of "size units" is `3 + 2 = 5`. You can think of this as the terminal screen being split into 5 pieces vertically. * The first column takes 3 out of those 5 pieces/units, or in other words 60% of the terminal screen. * The second column takes 2 out of those 5 pieces/units, or in other words 40% of the terminal screen. You can use any number of columns and with as many units you want on each of them. This lets you decide how to structure the presentation in a fairly straightforward way. ### Using columns Once a layout is defined, you just need to specify that you want to enter a column before writing any text to it by using the `column` command: ```html ``` Now all the markdown you write will be placed on the first column until you either: * Reset the layout by using the `reset_layout` command. * The slide ends. * You jump into another column by using the `column` command again. ## Example The following example puts all of this together by defining 2 columns, one with some code and bullet points, another one with an image, and some extra text at the bottom that's not tied to any columns. ~~~markdown Layout example ============== This is some code I like: ```rust fn potato() -> u32 { 42 } ``` Things I like about it: 1. Potato 2. Rust 3. The image on the right ![](examples/doge.png) _Picture by Alexis Bailey / CC BY-NC 4.0_ Because we just reset the layout, this text is now below both of the columns. ~~~ This would render the following way: ![](../assets/layouts.png) ## Other uses Besides organizing your slides into columns, you can use column layouts to center a piece of your slide. For example, if you want a certain portion of your slide to be centered, you could define a column layout like `[1, 3, 1]` and then only write content into the middle column. This would make your content take up the center 60% of the screen. ================================================ FILE: docs/src/features/slide-transitions.md ================================================ # 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) ================================================ FILE: docs/src/features/speaker-notes.md ================================================ ## Speaker notes Starting on version 0.10.0, _presenterm_ allows presentations to define speaker notes. The way this works is: * You start an instance of _presenterm_ using the `--publish-speaker-notes` parameter. This will be the main instance in which you will present like you usually do. * Another instance should be started using the `--listen-speaker-notes` parameter. This instance will only display speaker notes in the presentation and will automatically change slides whenever the main instance does so. For example: ```bash # Start the main instance presenterm demo.md --publish-speaker-notes # In another shell: start the speaker notes instance presenterm demo.md --listen-speaker-notes ``` [![asciicast](https://asciinema.org/a/ETusvlmHuHrcLKzwa0CMQRX2J.svg)](https://asciinema.org/a/ETusvlmHuHrcLKzwa0CMQRX2J) See the [speaker notes example](https://github.com/mfontanini/presenterm/blob/master/examples/speaker-notes.md) for more information. ### Defining speaker notes In order to define speaker notes you can use the `speaker_notes` comment command: ```markdown Normal text More text ``` When running this two instance setup, the main one will show "normal text" and "more text", whereas the second one will only show "this is a speaker note" on that slide. ### Multiline speaker notes You can use multiline speaker notes by using the appropriate YAML syntax: ```yaml ``` ### Multiple instances On Linux and Windows, you can run multiple instances in publish mode and multiple instances in listen mode at the same time. Each instance will only listen to events for the presentation it was started on. On Mac this is not supported and only a single listener can be used at a time. ### Enabling publishing by default You can use the `speaker_notes.always_publish` key in your config file to always publish speaker notes. This means you will only ever need to use `--listen-speaker-notes` and you will never need to use `--publish-speaker-notes`: ```yaml speaker_notes: always_publish: true ``` ### Internals This uses UDP sockets on localhost to communicate between instances. The main instance sends events every time a slide is shown and the listener instances listen to them and displays the speaker notes for that specific slide. ================================================ FILE: docs/src/features/themes/definition.md ================================================ # Theme definition This section goes through the structure of the theme files. Have a look at some of the [existing themes](https://github.com/mfontanini/presenterm/tree/master/themes) to have an idea of how to structure themes. ## Root elements The root attributes on the theme yaml files specify either: * A specific type of element in the input markdown or rendered presentation. That is, the slide title, headings, footer, etc. * A default to be applied as a fallback if no specific style is specified for a particular element. ## Alignment _presenterm_ uses the notion of alignment, just like you would have in a GUI editor, to align text to the left, center, or right. You probably want most elements to be aligned left, _some_ to be aligned on the center, and probably none to the right (but hey, you're free to do so!). The following elements support alignment: * Code blocks. * Slide titles. * The title, subtitle, and author elements in the intro slide. * Tables. ### Left/right alignment Left and right alignments take a margin property which specifies the number of columns to keep between the text and the left/right terminal screen borders. The margin can be specified in two ways: #### Fixed A specific number of characters regardless of the terminal size. ```yaml alignment: left margin: fixed: 5 ``` #### Percent A percentage over the total number of columns in the terminal. ```yaml alignment: left margin: percent: 8 ``` Percent alignment tends to look a bit nicer as it won't change the presentation's look as much when the terminal size changes. ### Center alignment Center alignment has 2 properties: * `minimum_size` which specifies the minimum size you want that element to have. This is normally useful for code blocks as they have a predefined background which you likely want to extend slightly beyond the end of the code on the right. * `minimum_margin` which specifies the minimum margin you want, using the same structure as `margin` for left/right alignment. This doesn't play very well with `minimum_size` but in isolation it specifies the minimum number of columns you want to the left and right of your text. ## Colors Every element can have its own background/foreground color using hex notation: ```yaml default: colors: foreground: ff0000 background: 00ff00 ``` ## Default style The default style specifies: * The margin to be applied to all slides. * The colors to be used for all text. ```yaml default: margin: percent: 8 colors: foreground: "e6e6e6" background: "040312" ``` ## Intro slide The introductory slide will be rendered if you specify a title, subtitle, or author in the presentation's front matter. This lets you have a less markdown-looking introductory slide that stands out so that it doesn't end up looking too monotonous: ```yaml --- title: Presenting from my terminal sub_title: Like it's 1990 author: John Doe --- ``` The theme can specify: * For the title and subtitle, the alignment and colors. * For the author, the alignment, colors, and positioning (`page_bottom` and `below_title`). The first one will push it to the bottom of the screen while the second one will put it right below the title (or subtitle if there is one) For example: ```yaml intro_slide: title: alignment: left margin: percent: 8 author: colors: foreground: black positioning: below_title ``` ## Footer The footer currently comes in 3 flavors: ### Template footers A template footer lets you put text on the left, center and/or right of the screen. The template strings can reference `{current_slide}` and `{total_slides}` which will be replaced with the current and total number of slides. Besides those special variables, any of the attributes defined in the front matter can also be used: * `title`. * `sub_title`. * `event`. * `location`. * `date`. * `author`. Strings used in template footers can contain arbitrary markdown, including `span` tags that let you use colored text. A `height` attribute allows specifying how tall, in terminal rows, the footer is. The text in the footer will always be placed at the center of the footer area. The default footer height is 2. ```yaml footer: style: template left: "My **name** is {author}" center: "_@myhandle_" right: "{current_slide} / {total_slides}" height: 3 ``` Do note that: * Only existing attributes in the front matter can be referenced. That is, if you use `{date}` but the `date` isn't set, an error will be shown. * Similarly, referencing unsupported variables (e.g. `{potato}`) will cause an error to be displayed. If you'd like the `{}` characters to be used in contexts where you don't want to reference a variable, you will need to escape them by using another brace. e.g. `{{potato}} farms` will be displayed as `{potato} farms`. #### Footer images Besides text, images can also be used in the left/center/right positions. This can be done by specifying an `image` key under each of those attributes: ```yaml footer: style: template left: image: potato.png center: image: banana.png right: image: apple.png # The height of the footer to adjust image sizes height: 5 ``` Images will be looked up: * First, relative to the presentation file just like any other image. * If the image is not found, it will be looked up relative to the themes directory. e.g. `~/.config/presenterm/themes`. This allows you to define a custom theme in your themes directory that points to a local image within that same location. Images will preserve their aspect ratio and expand vertically to take up as many terminal rows as `footer.height` specifies. This parameter should be adjusted accordingly if taller-than-wider images are used in a footer. See the [footer example](https://github.com/mfontanini/presenterm/blob/master/examples/footer.md) as a showcase of how a footer can contain images and colored text. ![](../../assets/example-footer.png) ### Progress bar footers A progress bar that will advance as you move in your presentation. This will by default use a block-looking character to draw the progress bar but you can customize it: ```yaml footer: style: progress_bar # Optional! character: 🚀 ``` ### None No footer at all! ```yaml footer: style: empty ``` ## Slide title Slide titles, as specified by using a setext header, can be styled the following way: ```yaml slide_title: # The prefix to use for the slide title. prefix: "██" # The font size to use. font_size: 2 # The vertical padding added before the title. padding_top: 1 # The vertical padding added after the title. padding_bottom: 1 # Whether to use a horizontal separator line after the title. separator: true # Whether to style for the title using bold text. bold: true # Whether to style for the title using underlined text. underlined: true # Whether to style for the title using italics text. italics: true # The colors to use. colors: foreground: beeeff background: feeedd ``` ## Headings Every header type (h1 through h6) can have its own style. Each of them can be styled using the following attributes: ```yaml headings: # H1 style. h1: # The prefix to use for the heading prefix: "██" # The colors to use. colors: foreground: beeeff background: feeedd # Whether to style for the title using bold text. bold: true # Whether to style for the title using underlined text. underlined: true # Whether to style for the title using italics text. italics: true # H2 style, same as the keys for H1. h2: prefix: "▓▓▓" colors: foreground: feeedd ``` ## Code blocks The syntax highlighting for code blocks is done via the [syntect](https://github.com/trishume/syntect) crate. The list of all the supported themes is the following: * base16-ocean.dark * base16-eighties.dark * base16-mocha.dark * base16-ocean.light * Catppuccin * Coldark * DarkNeon * InspiredGitHub * Nord-sublime * Solarized * Solarized (dark) * Solarized (light) * TwoDark * dracula-sublime * github-sublime-theme * gruvbox * onehalf * sublime-monokai-extended * sublime-snazzy * visual-studio-dark-plus * zenburn Most of these are taken from the [bat tool](https://github.com/sharkdp/bat), thanks to the people behind `bat` for implementing them! Code blocks can also have a few additional properties: ```yaml code: # The code theme. theme_name: base16-eighties.dark # The padding to be applied, in cells, around a code snippet. padding: horizontal: 2 vertical: 1 # Whether the theme's background color should be used around the code block. background: false # Whether to set line numbers in all snippets by default. line_numbers: false ``` #### Custom highlighting themes Besides the built-in highlighting themes, you can drop any `.tmTheme` theme in the `themes/highlighting` directory under your [configuration directory](../../configuration/introduction.md) (e.g. `~/.config/presenterm/themes/highlighting` in Linux) and they will be loaded automatically when _presenterm_ starts. ## Block quotes For block quotes you can specify a string to use as a prefix in every line of quoted text: ```yaml block_quote: prefix: "▍ " ``` ## Mermaid The [mermaid](https://mermaid.js.org/) graphs can be customized using the following parameters: * `mermaid.background` the background color passed to the CLI (e.g., `transparent`, `red`, `#F0F0F0`). * `mermaid.theme` the [mermaid theme](https://mermaid.js.org/config/theming.html#available-themes) to use. ```yaml mermaid: background: transparent theme: dark ``` ## Alerts GitHub style markdown alerts can be styled by setting the `alert` key: ```yaml alert: # the base colors used in all text in an alert base_colors: foreground: red background: black # the prefix used in every line in the alert prefix: "▍ " # the style for each alert type styles: note: color: blue title: Note icon: I tip: color: green title: Tip icon: T important: color: cyan title: Important icon: I warning: color: orange title: Warning icon: W caution: color: red title: Caution icon: C ``` ## Extending themes Custom themes can extend other custom or built in themes. This means it will inherit all the properties of the theme being extended by default. For example: ```yaml extends: dark default: colors: background: "000000" ``` This theme extends the built in _dark_ theme and overrides the background color. This is useful if you find yourself _almost_ liking a built in theme but there's only some properties you don't like. ## Color palette Every theme can define a color palette, which includes a list of pre-defined colors and a list of background/foreground pairs called "classes". Colors and classes can be used when styling text via `` HTML tags, whereas colors can also be used inside themes to avoid duplicating the same colors all over the theme definition. A palette can de defined as follows: ```yaml palette: colors: red: "f78ca2" purple: "986ee2" classes: foo: foreground: "ff0000" background: "00ff00" ``` Any palette color can be referenced using either `palette:` or `p:`. This means now any part of the theme can use `p:red` and `p:purple` where a color is required. Similarly, these colors can be used in `span` tags like: ```html this is red this is foo-colored ``` These colors can used anywhere in your presentation as well as in other places such as in [template footers](#template-footers) and [introduction slides](../introduction.md#introduction-slide). ## Bold/italics styling Bold and italics text is not given any colors by default. The `bold` and `italics` top level keys can be used to define a set of colors to use for them: ```yaml bold: colors: foreground: red italics: colors: background: blue ``` ================================================ FILE: docs/src/features/themes/introduction.md ================================================ # Themes _presenterm_ tries to be as configurable as possible, allowing users to create presentations that look exactly how they want them to look like. The tool ships with a set of [built-in themes](https://github.com/mfontanini/presenterm/tree/master/themes) but users can be created by users in their local setup and imported in their presentations. ## Setting themes There's various ways of setting the theme you want in your presentation: ### CLI Passing in the `--theme` parameter when running _presenterm_ to select one of the built-in themes. ### Within the presentation The presentation's markdown file can contain a front matter that specifies the theme to use. This comes in 3 flavors: #### By name Using a built-in theme name makes your presentation use that one regardless of what the default or what the `--theme` option specifies: ```yaml --- theme: name: dark --- ``` Built-in light/dark theme detection allows you to define a different theme for each variant: ```yaml --- theme: # The theme used if your terminal is using light colors. light: light # The theme used if your terminal is using dark colors. dark: dark --- ``` #### By path You can define a theme file in yaml format somewhere in your filesystem and reference it within the presentation: ```yaml --- theme: path: /home/me/Documents/epic-theme.yaml --- ``` #### Overrides You can partially/completely override the theme in use from within the presentation: ```yaml --- theme: override: default: colors: foreground: "beeeff" --- ``` This lets you: 1. Create a unique style for your presentation without having to go through the process of taking an existing theme, copying somewhere, and changing it when you only expect to use it for that one presentation. 2. Iterate quickly on styles given overrides are reloaded whenever you save your presentation file. # Built-in themes A few built-in themes are bundled with the application binary, meaning you don't need to have any external files available to use them. These are packed as part of the [build process](https://github.com/mfontanini/presenterm/blob/master/build.rs) as a binary blob and are decoded on demand only when used. Currently, the following themes are supported: * A set of themes based on the [catppuccin](https://github.com/catppuccin/catppuccin) color palette: * `catppuccin-latte` * `catppuccin-frappe` * `catppuccin-macchiato` * `catppuccin-mocha` * `dark`: A dark theme. * `gruvbox-dark`: A theme inspired by the colors used in [gruvbox](https://github.com/morhetz/gruvbox). * `light`: A light theme. * `terminal-dark`: A theme that uses your terminals color and looks best if your terminal uses a dark color scheme. This means if your terminal background is e.g. transparent, or uses an image, the presentation will inherit that. * `terminal-light`: The same as `terminal-dark` but works best if your terminal uses a light color scheme. * A set of themes based on the [toyonight](https://github.com/folke/tokyonight.nvim) color palette: * `tokyonight-moon` * `tokyonight-day` * `tokyonight-night` * `tokyonight-storm` ## Trying out built-in themes All built-in themes can be tested by using the `--list-themes` parameter: ```bash presenterm --list-themes ``` This will run a presentation where the same content is rendered using a different theme in each slide: [![asciicast](https://asciinema.org/a/zeV1QloyrLkfBp6rNltvX7Lle.svg)](https://asciinema.org/a/zeV1QloyrLkfBp6rNltvX7Lle) # Loading custom themes On startup, _presenterm_ will look into the `themes` directory under the [configuration directory](../../configuration/introduction.md) (e.g. `~/.config/presenterm/themes` in Linux) and will load any `.yaml` file as a theme and make it available as if it was a built-in theme. This means you can use it as an argument to the `--theme` parameter, use it in the `theme.name` property in a presentation's front matter, etc. ================================================ FILE: docs/src/install.md ================================================ # Installing _presenterm_ _presenterm_ works on Linux, macOS, and Windows and can be installed in different ways: #### Binary The recommended way to install _presenterm_ is to download the latest pre-built version for your system from the [releases page](https://github.com/mfontanini/presenterm/releases). #### cargo-binstall If you're a [cargo-binstall](https://github.com/cargo-bins/cargo-binstall) user: ```bash cargo binstall presenterm ``` #### From source Alternatively, build from source by downloading [rust](https://www.rust-lang.org/) and running: ```bash cargo install --locked presenterm ``` ## Latest unreleased version The latest unreleased version can be installed either in binary form or by building it from source. #### Binary The nightly pre-build binary can be downloaded from [github](https://github.com/mfontanini/presenterm/releases/tag/nightly). Keep in mind this is built once a day at midnight UTC so if you need code that has been recently merged you may have to wait a few hours. #### From source ```bash cargo install --locked --git https://github.com/mfontanini/presenterm ``` # Community maintained packages The community maintains packages for various operating systems and linux distributions and can be installed in the following ways: ## macOS Install the latest version in macOS via [brew](https://formulae.brew.sh/formula/presenterm) by running: ```bash brew install presenterm ``` The latest unreleased version can be built via brew by running: ```bash brew install --head presenterm ``` ## Nix To install _presenterm_ using the Nix package manager run: ```bash nix-env -iA nixos.presenterm # for nixos nix-env -iA nixpkgs.presenterm # for non-nixos ``` #### NixOS Add the following to your `configuration.nix` if you are on NixOS ```nix environment.systemPackages = [ pkgs.presenterm ]; ``` #### Flakes Alternatively if you're a Nix user using flakes you can run: ```shell nix run nixpkgs#presenterm # to run from nixpkgs nix run github:mfontanini/presenterm # to run from github repo ``` For more information see [nixpkgs](https://search.nixos.org/packages?channel=unstable&show=presenterm&from=0&size=50&sort=relevance&type=packages&query=presenterm). ## Arch Linux _presenterm_ is available in the [official repositories](https://archlinux.org/packages/extra/x86_64/presenterm/). You can use [pacman](https://wiki.archlinux.org/title/pacman) to install as follows: ```bash pacman -S presenterm ``` #### Binary Alternatively, you can use any AUR helper to install the upstream binaries: ```bash paru/yay -S presenterm-bin ``` #### From source ```bash paru/yay -S presenterm-git ``` ## Windows #### Scoop Install the [latest version](https://scoop.sh/#/apps?q=presenterm&id=a462289f824b50f180afbaa6d8c7c1e6e0952e3a) via scoop by running: ```powershell scoop install main/presenterm ``` #### Winget Alternatively, you can install via [WinGet](https://github.com/microsoft/winget-cli) by running: ```powershell winget install --id=mfontanini.presenterm -e ``` ================================================ FILE: docs/src/internals/parse.md ================================================ # Parsing and rendering This document goes through the internals of how we take a markdown file and finish rendering it into the terminal screen. ## Parsing Markdown file parsing is done via the [comrak](https://github.com/kivikakk/comrak) crate. This crate parses the markdown file and gives you back an AST that contains the contents and structure of the input file. ASTs are a logical way of representing the markdown file but this structure makes it a bit hard to process. Given our ultimate goal is to render this input, we want it to be represented in a way that facilitates that. Because of this we first do a pass on this AST and construct a list of `MarkdownElement`s. This enum represents each of the markdown elements in a flattened, non-recursive, way: * Inline text is flattened so that instead of having a recursive structure you have chunks of text, each with their own style. So for example the text "**hello _my name is ~bob~_**" which would look like a 3 level tree (I think?) in the AST, gets transformed to something like `[Bold(hello), ItalicsBold(my name is), ItalicsBoldStrikethrough(bob)]` (names are completely not what they are in the code, this is just to illustrate flattening). This makes it much easier to render text because we don't need to walk the tree and keep the state between levels. * Lists are flattened into a single `MarkdownElement::List` element that contains a list of items that contain their text, prefix ("\*" for bullet lists), and nesting depth. This also simplifies processing as list elements can also contain formatted text so we would otherwise have the same problem as above. This first step then produces a list of elements that can easily be processed. ## Building the presentation The format above is _nicer_ than an AST but it's still not great to be used as the input to the code that renders the presentation for various reasons: * The presentation needs to be styled, which means we need to apply a theme on top of it to transform it. Putting this responsibility in the render code creates too much coupling: now the render needs to understand markdown _and_ how themes work. * The render code tends to be a bit annoying: we need to jump around in the screen, print text, change colors, etc. If we add the responsibility of transforming the markdown into visible text to the render code itself, we end up having a mess of UI code mixed with the markdown element processing. * Some elements can't be printed as-is. For example, a list item has text and a prefix, so we don't want the render code to be in charge of understanding and executing those transformations. Because of this, we introduce a step in between parsing and rendering where we build a presentation. A presentation is made up of a list of slides and each slide is made up of render operations. Render operations are the primitives that the render code understands to print text on the screen. These can be the following, among others: * Render text. * Clear the screen. * Set the default colors to be used. * Render a line break. * Jump to the middle of the screen. This allows us to have a simple model where the logic that takes markdown elements and a theme and chooses _how_ it will be rendered is in one place, and the logic that takes those instructions and executes them is elsewhere. So for example, this step will take a bullet point and concatenate is suffix ("\*" for bullet points for example), turn that into a single string and generate a "render text" operation. This has the nice added bonus that the rendering code doesn't have to be fiddling around with string concatenation or other operations that could take up CPU cycles: it just takes these render operations and executes them. Not that performance matters here but it's nice to get better performance for free. ## Render a slide The rendering code is straightforward and simply takes the current slide, iterates all of its rendering operations, and executes those one by one. This is done via the [crossterm](https://github.com/crossterm-rs/crossterm) crate. The only really complicated part is fitting text into the screen. Because we apply our own margins, we perform word splitting and wrapping around manually, so there's some logic that takes the text to be printed and the width of the terminal and splits it accordingly. Note that this piece of code is the only one aware of the current screen size. This lets us forget in previous steps about how large the screen is and simply delegate that responsibility to this piece. ## Entire flow ![](../assets/parse-flow.png) ================================================ FILE: docs/src/introduction.md ================================================ # presenterm [presenterm][github] lets you create presentations in markdown format and run them from your terminal, with support for image and animated gif support, highly customizable themes, code highlighting, exporting presentations into PDF format, and plenty of other features. ## Demo This is how the [demo presentation][demo-source] looks like: ![demo] **A few other example presentations can be found [here][examples]**. [github]: https://github.com/mfontanini/presenterm/ [demo]: ./assets/demo.gif [demo-source]: https://github.com/mfontanini/presenterm/blob/master/examples/demo.md [examples]: https://github.com/mfontanini/presenterm/tree/master/examples ================================================ FILE: examples/README.md ================================================ Examples === This section contains a few example presentations that display different features and styles you can use in your own. In order to run the presentations locally, first [install presenterm](https://mfontanini.github.io/presenterm/guides/installation.html), clone this repo, and finally run: ```shell presenterm examples/.md ``` # Demo [Source](/examples/demo.md) This is the main demo presentation, which showcases most features and uses the default dark theme. This is how it looks like when rendered: ![](/docs/src/assets/demo.gif) # Code [Source](/examples/code.md) This example contains some piece of code and showcases some different styling properties to make it look a bit different than how it looks like by default by using: * Use left alignment for code blocks. * No background for code blocks. [![asciicast](https://asciinema.org/a/irNPKwEkPZzFbQP6jIKfVL30b.svg)](https://asciinema.org/a/irNPKwEkPZzFbQP6jIKfVL30b) # Footer [Source](/examples/footer.md) This example uses a template-style footer, which lets you place some text on the left, center, and right of every slide. A few template variables, such as `current_slide` and `total_slides` can be used to reference properties of the presentation. ![](../docs/src/assets/example-footer.png) # Columns [Source](/examples/columns.md) This example shows how column layouts and pauses interact with each other. Note that the image shows up as pixels because asciinema doesn't support these and it will otherwise look like a normal image if your terminal supports images. [![asciicast](https://asciinema.org/a/x2tTDt0BIesvOXeal3UpdzMHp.svg)](https://asciinema.org/a/x2tTDt0BIesvOXeal3UpdzMHp) # Speaker notes [Source](/examples/speaker-notes.md) This example shows how to use speaker notes. [![asciicast](https://asciinema.org/a/ETusvlmHuHrcLKzwa0CMQRX2J.svg)](https://asciinema.org/a/ETusvlmHuHrcLKzwa0CMQRX2J) # Custom introduction slides [Source](/examples/custom-intro-slides.md) This example various custom introduction slides that contain images placed in different layouts. Note that the images looks pixelated because of asciinema but they will otherwise look normal in your terminal. [![asciicast](https://asciinema.org/a/sBeAMJbpBxqKA2gF2RI3MmLT7.svg)](https://asciinema.org/a/sBeAMJbpBxqKA2gF2RI3MmLT7) ================================================ FILE: examples/code.md ================================================ --- theme: override: code: alignment: left background: false --- Code styling === This presentation shows how to: * Left-align code blocks. * Have code blocks without background. * Execute code snippets. ```rust pub struct Greeter { prefix: &'static str, } impl Greeter { /// Greet someone. pub fn greet(&self, name: &str) -> String { let prefix = self.prefix; format!("{prefix} {name}!") } } fn main() { let greeter = Greeter { prefix: "Oh, hi" }; let greeting = greeter.greet("Mark"); println!("{greeting}"); } ``` Column layouts === The same code as the one before but split into two columns to split the API definition with its usage: # The `Greeter` type ```rust pub struct Greeter { prefix: &'static str, } impl Greeter { /// Greet someone. pub fn greet(&self, name: &str) -> String { let prefix = self.prefix; format!("{prefix} {name}!") } } ``` # Using the `Greeter` ```rust fn main() { let greeter = Greeter { prefix: "Oh, hi" }; let greeting = greeter.greet("Mark"); println!("{greeting}"); } ``` Snippet execution === Run code snippets from the presentation and display their output dynamically. ```python +exec /// import time for i in range(0, 5): print(f"count is {i}") time.sleep(0.5) ``` Snippet execution - `stderr` === Output from `stderr` will also be shown as output. ```bash +exec echo "This is a successful command" sleep 0.5 echo "This message redirects to stderr" >&2 sleep 0.5 echo "This is a successful command again" sleep 0.5 man # Missing argument ``` ================================================ FILE: examples/columns.md ================================================ # Columns and pauses Columns and pauses can interact with each other in useful ways: ![](../examples/doge.png) After this pause, the text on the left will show up This is useful for various things: * Lorem. * Ipsum. * Etcetera. ================================================ FILE: examples/custom-intro-slides.md ================================================ ![](doge.png) Custom introduction slides ==== John Doe ![](doge.png) Custom introduction slides ==== John Doe Custom introduction slides ==== John Doe ![](doge.png) ================================================ FILE: examples/demo.md ================================================ --- title: Introducing _presenterm_ author: Matias --- Customizability --- _presenterm_ allows configuring almost anything about your presentation: * The colors used. * Layouts. * Footers, including images in the footer. This is an example on how to configure a footer: ```yaml footer: style: template left: image: doge.png center: 'Colored _footer_' right: "{current_slide} / {total_slides}" height: 5 palette: classes: noice: foreground: red ``` Headers --- Markdown headers can be used to set slide titles like: ```markdown Headers ------- ``` # Headers Each header type can be styled differently. ## Subheaders ### And more Code highlighting --- Highlight code in 50+ programming languages: ```rust // Rust fn greet() -> &'static str { "hi mom" } ``` ```python # Python def greet() -> str: return "hi mom" ``` ------- Code snippets can have different styles including no background: ```cpp +no_background +line_numbers // C++ string greet() { return "hi mom"; } ``` Dynamic code highlighting --- Dynamically highlight different subsets of lines: ```rust {1-4|6-10|all} +line_numbers #[derive(Clone, Debug)] struct Person { name: String, } impl Person { fn say_hello(&self) { println!("hello, I'm {}", self.name) } } ``` Snippet execution --- Code snippets can be executed on demand: * For 20+ languages, including compiled ones. * Display their output in real time. * Comment out unimportant lines to hide them. ```rust +exec # use std::thread::sleep; # use std::time::Duration; fn main() { let names = ["Alice", "Bob", "Eve", "Mallory", "Trent"]; for name in names { println!("Hi {name}!"); sleep(Duration::from_millis(500)); } } ``` Images --- Images and animated gifs are supported in terminals such as: * kitty * iterm2 * wezterm * ghostty * foot * Any sixel enabled terminal ![](doge.png) _Picture by Alexis Bailey / CC BY-NC 4.0_ Column layouts --- Use column layouts to structure your presentation: * Define the number of columns. * Adjust column widths as needed. * Write content into every column. ```rust fn potato() -> u32 { 42 } ``` ![](doge.png) --- Layouts can be reset at any time. ```python print("Hello world!") ``` Text formatting --- Text formatting works including: * **Bold text**. * _Italics_. * **_Bold and italic_**. * ~Strikethrough~. * `Inline code`. * Links [](https://example.com/) * Colored text. * Background color can be changed too. More markdown --- Other markdown elements supported are: # Block quotes > Lorem ipsum dolor sit amet. Eos laudantium animi ut ipsam beataeet > et exercitationem deleniti et quia maiores a cumque enim et > aspernatur nesciunt sed adipisci quis. # Alerts > [!caution] > Github style alerts # Tables | Name | Taste | | ------ | ------ | | Potato | Great | | Carrot | Yuck | The end --- ================================================ FILE: examples/footer.md ================================================ --- theme: override: footer: style: template left: image: doge.png center: '**Introduction** to footer _styling_' right: "{current_slide} / {total_slides}" height: 5 palette: classes: noice: foreground: red --- First slide === The important bit in this presentation is the **footer at the bottom**. Second slide === _nothing to see here_ ================================================ FILE: examples/speaker-notes.md ================================================ Speaker Notes === `presenterm` supports speaker notes. You can use the following HTML comment throughout your presentation markdown file: ```markdown ``` And you can run a separate instance of `presenterm` to view them. Usage === Run the following two commands in separate terminals. The `--publish-speaker-notes` argument will render your actual presentation as normal, without speaker notes: ``` presenterm --publish-speaker-notes examples/speaker-notes.md ``` The `--listen-speaker-notes` argument will render only the speaker notes for the current slide being shown in the actual presentation: ``` presenterm --listen-speaker-notes examples/speaker-notes.md ``` As you change slides in your actual presentation, the speaker notes presentation slide will automatically navigate to the correct slide. ================================================ FILE: executors.yaml ================================================ --- bash: filename: script.sh commands: - ["bash", "$pwd/script.sh"] hidden_line_prefix: "/// " c++: filename: snippet.cpp commands: - [ "g++", "-std=c++20", "-fdiagnostics-color=always", "$pwd/snippet.cpp", "-o", "$pwd/snippet", ] - ["$pwd/snippet"] hidden_line_prefix: "/// " c: filename: snippet.c commands: - [ "gcc", "$pwd/snippet.c", "-fdiagnostics-color=always", "-o", "$pwd/snippet", ] - ["$pwd/snippet"] hidden_line_prefix: "/// " elixir: filename: snippet.exs commands: - ["elixir", "$pwd/snippet.exs"] hidden_line_prefix: "## " fish: filename: script.fish commands: - ["fish", "$pwd/script.fish"] hidden_line_prefix: "/// " go: filename: snippet.go environment: GO11MODULE: off commands: - ["go", "run", "$pwd/snippet.go"] hidden_line_prefix: "/// " haskell: filename: snippet.hs commands: - ["runhaskell", "-w", "$pwd/snippet.hs"] java: filename: Snippet.java commands: - ["java", "$pwd/Snippet.java"] hidden_line_prefix: "/// " js: filename: snippet.js environment: FORCE_COLOR: "true" commands: - ["node", "$pwd/snippet.js"] hidden_line_prefix: "/// " alternative: deno: filename: "snippet.js" environment: FORCE_COLOR: "true" commands: - ["deno", "run", "-A", "$pwd/snippet.js"] bun: filename: "snippet.js" environment: FORCE_COLOR: "true" commands: - ["bun", "run", "$pwd/snippet.js"] jsx: filename: snippet.jsx environment: FORCE_COLOR: "true" commands: - ["node", "$pwd/snippet.jsx"] hidden_line_prefix: "/// " alternative: deno: filename: "snippet.jsx" environment: FORCE_COLOR: "true" commands: - ["deno", "run", "-A", "$pwd/snippet.jsx"] bun: filename: "snippet.jsx" environment: FORCE_COLOR: "true" commands: - ["bun", "run", "$pwd/snippet.jsx"] ts: filename: snippet.ts environment: FORCE_COLOR: "true" commands: - ["node", "$pwd/snippet.ts"] hidden_line_prefix: "/// " alternative: deno: filename: "snippet.ts" environment: FORCE_COLOR: "true" commands: - ["deno", "run", "-A", "$pwd/snippet.ts"] bun: filename: "snippet.ts" environment: FORCE_COLOR: "true" commands: - ["bun", "run", "$pwd/snippet.ts"] tsx: filename: snippet.tsx environment: FORCE_COLOR: "true" commands: - ["node", "$pwd/snippet.tsx"] hidden_line_prefix: "/// " alternative: deno: filename: "snippet.tsx" environment: FORCE_COLOR: "true" commands: - ["deno", "run", "-A", "$pwd/snippet.tsx"] bun: filename: "snippet.tsx" environment: FORCE_COLOR: "true" commands: - ["bun", "run", "$pwd/snippet.tsx"] julia: filename: snippet.jl commands: - ["julia", "$pwd/snippet.jl"] hidden_line_prefix: "/// " jsonnet: filename: snippet.jsonnet commands: - ["jsonnet", "$pwd/snippet.jsonnet"] hidden_line_prefix: "## " kotlin: filename: snippet.kts commands: - ["kotlinc", "-script", "$pwd/snippet.kts"] hidden_line_prefix: "/// " lua: filename: snippet.lua commands: - ["lua", "$pwd/snippet.lua"] nushell: filename: snippet.nu commands: - ["nu", "$pwd/snippet.nu"] perl: filename: snippet.pl commands: - ["perl", "$pwd/snippet.pl"] php: filename: snippet.php commands: - ["php", "-f", "$pwd/snippet.php"] hidden_line_prefix: "/// " python: filename: snippet.py commands: - ["python", "-u", "$pwd/snippet.py"] hidden_line_prefix: "/// " alternative: uv: filename: "snippet.py" commands: - ["uv", "run", "--script", "-q", "$pwd/snippet.py"] r: filename: snippet.R commands: - ["Rscript", "$pwd/snippet.R"] ruby: filename: snippet.rb commands: - ["ruby", "$pwd/snippet.rb"] rust-script: filename: snippet.rs environment: CARGO_TERM_COLOR: "always" commands: - ["rust-script", "--debug", "$pwd/snippet.rs"] hidden_line_prefix: "# " rust: filename: snippet.rs commands: - [ "rustc", "--crate-name", "presenterm_snippet", "$pwd/snippet.rs", "-o", "$pwd/snippet", "--color", "always", ] - ["$pwd/snippet"] hidden_line_prefix: "# " alternative: rust-script: filename: snippet.rs environment: CARGO_TERM_COLOR: "always" commands: - ["rust-script", "--debug", "$pwd/snippet.rs"] rust-script-pedantic: filename: snippet.rs environment: RUSTFLAGS: "--deny warnings" CARGO_TERM_COLOR: "always" commands: - ["rust-script", "--debug", "$pwd/snippet.rs"] sh: filename: script.sh commands: - ["sh", "$pwd/script.sh"] hidden_line_prefix: "/// " zsh: filename: script.sh commands: - ["zsh", "$pwd/script.sh"] hidden_line_prefix: "/// " pwsh: filename: script.ps1 commands: - ["pwsh", "$pwd/script.ps1"] hidden_line_prefix: "/// " cmd: filename: script.cmd commands: - ["cmd", "/q", "/c", "$pwd/script.cmd"] hidden_line_prefix: "/// " wsl: filename: script.sh commands: - ["wsl", "$(wslpath -u '$pwd')/script.sh"] hidden_line_prefix: "/// " csharp: filename: snippet.cs commands: - ["dotnet-script", "$pwd/snippet.cs"] hidden_line_prefix: "/// " fsharp: filename: snippet.fsx commands: - ["dotnet", "fsi", "$pwd/snippet.fsx"] hidden_line_prefix: "/// " ================================================ FILE: flake.nix ================================================ { description = "A terminal slideshow tool"; inputs = { flakebox = { url = "github:rustshop/flakebox?rev=62af969ab344229d2a0d585a482293b3f186b221"; }; flake-utils.url = "github:numtide/flake-utils"; }; outputs = { self, flake-utils, flakebox }: flake-utils.lib.eachDefaultSystem (system: let projectName = "presenterm"; pkgs = flakebox.inputs.nixpkgs.legacyPackages.${system}; flakeboxLib = flakebox.lib.mkLib pkgs { config = { github.ci.buildOutputs = [ ".#ci.${projectName}" ]; }; }; buildPaths = [ "build.rs" "Cargo.toml" "Cargo.lock" ".cargo" "src" "themes" "bat" "executors.yaml" ]; buildSrc = flakeboxLib.filterSubPaths { root = builtins.path { name = projectName; path = ./.; }; paths = buildPaths; }; multiBuild = (flakeboxLib.craneMultiBuild { }) (craneLib': let craneLib = (craneLib'.overrideArgs { pname = projectName; src = buildSrc; nativeBuildInputs = [ ]; }); in { ${projectName} = craneLib.buildPackage { }; }); in { packages.default = multiBuild.${projectName}; legacyPackages = multiBuild; devShells = flakeboxLib.mkShells { }; } ); } ================================================ FILE: rustfmt.toml ================================================ version = "Two" unstable_features = true use_small_heuristics = "Max" max_width = 120 imports_granularity = "Crate" normalize_comments = true ================================================ FILE: scripts/generate-config-file-schema.sh ================================================ #!/bin/bash set -euo pipefail script_dir=$(dirname "$0") root_dir="${script_dir}/../" current_schema=$(mktemp) docker run \ --rm \ -v "${root_dir}:/tmp/workspace" \ -w "/tmp/workspace" \ rust:1.90 \ cargo run --features json-schema -q -- --generate-config-file-schema >"${current_schema}" cp "$current_schema" "${root_dir}/config-file-schema.json" rm "$current_schema" ================================================ FILE: scripts/parse-changelog.sh ================================================ #!/usr/bin/env bash set -e script_dir=$(dirname "$0") root_dir="${script_dir}/../" if [ $# -ne 1 ]; then echo "Usage: $0 " exit 1 fi version=$1 changelog="${root_dir}/CHANGELOG.md" if ! grep "^# ${version}" "$changelog" >/dev/null; then echo "Version ${version} not found in changelog" exit 1 fi releases=$(grep -e "^# " -n "$changelog") version_line=$(echo "$releases" | grep "$version" | cut -d : -f 1) next_line=$(echo "$releases" | grep "$version" -A 1 -m 1 | tail -n 1 | cut -d : -f 1) let next_line=("$next_line" - 1) sed -n "${version_line},${next_line}p" "$changelog" | tail -n +3 ================================================ FILE: scripts/test-pdf-generation.sh ================================================ #!/bin/bash set -e script_dir=$(dirname "$0") root_dir=$(realpath "${script_dir}/../") echo "Creating python env" env_dir=$(mktemp -d) trap 'rm -rf "${env_dir}"' EXIT python -mvenv "${env_dir}/pyenv" source "${env_dir}/pyenv/bin/activate" echo "Installing presenterm-export==0.2.0" pip install presenterm-export echo "Running presenterm..." rm -f "${root_dir}/examples/demo.pdf" cargo run -q -- --export-pdf "${root_dir}/examples/demo.md" if test -f "${root_dir}/examples/demo.pdf"; then echo "PDF file created successfully" rm -f "${root_dir}/examples/demo.pdf" else echo "PDF file does not exist" exit 1 fi ================================================ FILE: scripts/validate-config-file-schema.sh ================================================ #!/bin/bash set -euo pipefail script_dir=$(dirname "$0") root_dir="${script_dir}/../" current_schema=$(mktemp) cargo run --features json-schema -q -- --generate-config-file-schema >"$current_schema" diff=$(diff --color=always -u "${root_dir}/config-file-schema.json" "$current_schema") if [ $? -ne 0 ]; then echo "Config file JSON schema differs:" echo "$diff" exit 1 else echo "Config file JSON schema is up to date" fi ================================================ FILE: src/code/execute.rs ================================================ //! Code execution. use super::snippet::SnippetExecutorSpec; use crate::{ code::snippet::{Snippet, SnippetExecution, SnippetLanguage, SnippetRepr}, config::{LanguageSnippetExecutionConfig, SnippetExecutorConfig}, }; use once_cell::sync::Lazy; use os_pipe::PipeReader; use std::{ collections::{BTreeMap, HashMap}, fmt::{self, Debug}, fs::File, io::{self, BufRead, BufReader, Read, Write}, path::{Path, PathBuf}, process::{self, Child, Stdio}, sync::{Arc, Mutex}, thread, }; use tempfile::TempDir; static EXECUTORS: Lazy> = Lazy::new(|| serde_yaml::from_slice(include_bytes!("../../executors.yaml")).expect("executors.yaml is broken")); /// Strip verbatim UNC prefix when drive letters #[cfg(windows)] fn strip_drive_unc_prefix(path: &Path) -> String { // Convert to string (lossy if needed) let path_str = path.to_string_lossy(); // If it starts with \\?\ and the next part looks like a drive letter, strip it if let Some(rest) = path_str.strip_prefix(r"\\?\") { if rest.len() >= 2 && rest.as_bytes()[1] == b':' { // Example: \\?\C:\foo -> C:\foo return rest.to_string(); } } // Otherwise, return the original string unchanged path_str.into_owned() } /// Allows executing code. pub struct SnippetExecutor { executors: BTreeMap, cwd: PathBuf, } impl SnippetExecutor { pub fn new( custom_executors: BTreeMap, cwd: PathBuf, ) -> Result { let mut executors = EXECUTORS.clone(); executors.extend(custom_executors); for (language, config) in &executors { Self::validate_executor_config(language, &config.executor)?; for alternative in config.alternative.values() { Self::validate_executor_config(language, alternative)?; } } Ok(Self { executors, cwd }) } pub(crate) fn language_executor( &self, language: &SnippetLanguage, spec: &SnippetExecutorSpec, ) -> Result { let language_config = self .executors .get(language) .ok_or_else(|| UnsupportedExecution(language.clone(), "no executors found".into()))?; let config = match spec { SnippetExecutorSpec::Default => language_config.executor.clone(), SnippetExecutorSpec::Alternative(name) => { language_config.alternative.get(name).cloned().ok_or_else(|| { UnsupportedExecution(language.clone(), format!("alternative executor '{name}' is not defined")) })? } }; Ok(LanguageSnippetExecutor { hidden_line_prefix: language_config.hidden_line_prefix.clone(), config, cwd: self.cwd.clone(), }) } pub(crate) fn hidden_line_prefix(&self, language: &SnippetLanguage) -> Option<&str> { self.executors.get(language).and_then(|lang| lang.hidden_line_prefix.as_deref()) } fn validate_executor_config( language: &SnippetLanguage, executor: &SnippetExecutorConfig, ) -> Result<(), InvalidSnippetConfig> { if executor.filename.is_empty() { return Err(InvalidSnippetConfig(language.clone(), "filename is empty")); } if executor.commands.is_empty() { return Err(InvalidSnippetConfig(language.clone(), "no commands given")); } for command in &executor.commands { if command.is_empty() { return Err(InvalidSnippetConfig(language.clone(), "empty command given")); } } Ok(()) } } impl Default for SnippetExecutor { fn default() -> Self { Self::new(Default::default(), PathBuf::from("./")).expect("initialization failed") } } impl Debug for SnippetExecutor { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "SnippetExecutor {{ .. }}") } } #[derive(Clone, Debug)] pub(crate) struct LanguageSnippetExecutor { hidden_line_prefix: Option, config: SnippetExecutorConfig, cwd: PathBuf, } impl LanguageSnippetExecutor { /// Execute a piece of code asynchronously. pub(crate) fn execute_async(&self, snippet: &Snippet) -> Result { let script_dir = self.write_snippet(snippet)?; let state: Arc> = Default::default(); let output_type = match &snippet.attributes.execution { SnippetExecution::Exec(args) if matches!(args.repr, SnippetRepr::Image) => OutputType::Binary, _ => OutputType::Lines, }; let reader_handle = CommandsRunner::spawn( state.clone(), script_dir, self.config.commands.clone(), self.config.environment.clone(), self.cwd.clone(), output_type, ); let handle = ExecutionHandle { state, reader_handle }; Ok(handle) } /// Executes a piece of code synchronously. pub(crate) fn execute_sync(&self, snippet: &Snippet) -> Result<(), CodeExecuteError> { let script_dir = self.write_snippet(snippet)?; let script_dir_path = script_dir.path().to_string_lossy(); for commands in self.config.commands.clone() { self.execute_command(commands, &script_dir_path)?; } Ok(()) } /// Creates the necessary context to run this snippet in a PTY. pub(crate) fn pty_execution_context(&self, snippet: &Snippet) -> Result { let script_dir = self.write_snippet(snippet)?; let script_dir_path = script_dir.path().to_string_lossy(); // Run the first N-1 commands normally and assume the last one is the one that actually // invokes the thing (e.g. rust snippet compilation happens here, snippet execution in PTY) for commands in self.config.commands.iter().take(self.config.commands.len() - 1).cloned() { self.execute_command(commands, &script_dir_path)?; } let mut commands = self.config.commands.last().cloned().unwrap(); for command in &mut commands { *command = command.replace("$pwd", &script_dir_path); } let (command, args) = commands.split_first().expect("no commands"); let mut command = portable_pty::CommandBuilder::new(command); command.args(args); command.cwd(&self.cwd); for (key, value) in &self.config.environment { command.env(key, value); } Ok(PtySnippetContext { command, _temp: script_dir }) } fn execute_command(&self, mut commands: Vec, script_dir_path: &str) -> Result<(), CodeExecuteError> { for command in &mut commands { *command = command.replace("$pwd", script_dir_path); } let (command, args) = commands.split_first().expect("no commands"); let child = process::Command::new(command) .args(args) .envs(&self.config.environment) .current_dir(&self.cwd) .stderr(Stdio::piped()) .spawn() .map_err(|e| CodeExecuteError::SpawnProcess(command.clone(), e))?; let output = child.wait_with_output().map_err(CodeExecuteError::Waiting)?; if output.status.success() { Ok(()) } else { let error = String::from_utf8_lossy(&output.stderr).to_string(); Err(CodeExecuteError::Running(error)) } } fn write_snippet(&self, snippet: &Snippet) -> Result { let hide_prefix = self.hidden_line_prefix.as_deref(); let code = snippet.executable_contents(hide_prefix); let script_dir = tempfile::Builder::default().prefix(".presenterm").tempdir().map_err(CodeExecuteError::TempDir)?; let snippet_path = script_dir.path().join(&self.config.filename); let mut snippet_file = File::create(snippet_path).map_err(CodeExecuteError::TempDir)?; snippet_file.write_all(code.as_bytes()).map_err(CodeExecuteError::TempDir)?; Ok(script_dir) } } pub(crate) struct PtySnippetContext { pub(crate) command: portable_pty::CommandBuilder, _temp: TempDir, } /// An invalid executor was found. #[derive(thiserror::Error, Debug)] #[error("invalid snippet execution for '{0:?}': {1}")] pub struct InvalidSnippetConfig(SnippetLanguage, &'static str); /// Execution for a language is unsupported. #[derive(thiserror::Error, Debug)] #[error("cannot execute code for '{0:?}': {1}")] pub struct UnsupportedExecution(SnippetLanguage, String); /// An error during the execution of some code. #[derive(thiserror::Error, Debug)] pub(crate) enum CodeExecuteError { #[error("error creating temporary directory: {0}")] TempDir(io::Error), #[error("error spawning process '{0}': {1}")] SpawnProcess(String, io::Error), #[error("error creating pipe: {0}")] Pipe(io::Error), #[error("error waiting for process to run: {0}")] Waiting(io::Error), #[error("error running process: {0}")] Running(String), } /// A handle for the execution of a piece of code. #[derive(Debug)] pub(crate) struct ExecutionHandle { pub(crate) state: Arc>, #[allow(dead_code)] reader_handle: thread::JoinHandle<()>, } /// Consumes the output of a process and stores it in a shared state. struct CommandsRunner { state: Arc>, script_directory: TempDir, } impl CommandsRunner { fn spawn( state: Arc>, script_directory: TempDir, commands: Vec>, env: HashMap, cwd: PathBuf, output_type: OutputType, ) -> thread::JoinHandle<()> { let reader = Self { state, script_directory }; thread::spawn(move || reader.run(commands, env, cwd, output_type)) } fn run(self, commands: Vec>, env: HashMap, cwd: PathBuf, output_type: OutputType) { let mut last_result = true; for command in commands { last_result = self.run_command(command, &env, &cwd, output_type); if !last_result { break; } } let status = match last_result { true => ProcessStatus::Success, false => ProcessStatus::Failure, }; self.state.lock().unwrap().status = status; } fn run_command( &self, command: Vec, env: &HashMap, cwd: &Path, output_type: OutputType, ) -> bool { let (mut child, reader) = match self.launch_process(command, env, cwd) { Ok(inner) => inner, Err(e) => { let mut state = self.state.lock().unwrap(); state.status = ProcessStatus::Failure; state.output.extend(e.to_string().into_bytes()); return false; } }; let _ = Self::process_output(self.state.clone(), reader, output_type); match child.wait() { Ok(code) => code.success(), _ => false, } } fn launch_process( &self, mut commands: Vec, env: &HashMap, cwd: &Path, ) -> Result<(Child, PipeReader), CodeExecuteError> { let (reader, writer) = os_pipe::pipe().map_err(CodeExecuteError::Pipe)?; let writer_clone = writer.try_clone().map_err(CodeExecuteError::Pipe)?; let script_dir = self.script_directory.path().to_string_lossy(); #[cfg(windows)] let cwd = strip_drive_unc_prefix(cwd); for command in &mut commands { *command = command.replace("$pwd", &script_dir); } let (command, args) = commands.split_first().expect("no commands"); let child = process::Command::new(command) .args(args) .envs(env) .current_dir(cwd) .stdin(Stdio::null()) .stdout(writer) .stderr(writer_clone) .spawn() .map_err(|e| CodeExecuteError::SpawnProcess(command.clone(), e))?; Ok((child, reader)) } fn process_output( state: Arc>, mut reader: os_pipe::PipeReader, output_type: OutputType, ) -> io::Result<()> { match output_type { OutputType::Lines => { let reader = BufReader::new(reader); for line in reader.lines() { let mut state = state.lock().unwrap(); state.output.extend(line?.into_bytes()); state.output.push(b'\n'); } Ok(()) } OutputType::Binary => { let mut buffer = Vec::new(); reader.read_to_end(&mut buffer)?; state.lock().unwrap().output.extend(buffer); Ok(()) } } } } #[derive(Clone, Copy)] enum OutputType { Lines, Binary, } /// The state of the execution of a process. #[derive(Clone, Default, Debug)] pub(crate) struct ExecutionState { pub(crate) output: Vec, pub(crate) status: ProcessStatus, } /// The status of a process. #[derive(Clone, Copy, Debug, Default)] pub(crate) enum ProcessStatus { #[default] Running, Success, Failure, } impl ProcessStatus { /// Check whether the underlying process is finished. pub(crate) fn is_finished(&self) -> bool { matches!(self, ProcessStatus::Success | ProcessStatus::Failure) } } #[cfg(test)] mod test { use super::*; use crate::code::snippet::{SnippetAttributes, SnippetExecution}; #[test] fn shell_code_execution() { let contents = r" echo 'hello world' echo 'bye'" .into(); let snippet = Snippet { contents, language: SnippetLanguage::Shell, attributes: SnippetAttributes { execution: SnippetExecution::Exec(Default::default()), ..Default::default() }, }; let executor = SnippetExecutor::default().language_executor(&snippet.language, &Default::default()).unwrap(); let handle = executor.execute_async(&snippet).expect("execution failed"); let state = loop { let state = handle.state.lock().unwrap(); if state.status.is_finished() { break state; } }; let expected = b"hello world\nbye\n"; assert_eq!(state.output, expected); } #[test] fn shell_code_execution_captures_stderr() { let contents = r" echo 'This message redirects to stderr' >&2 echo 'hello world' " .into(); let snippet = Snippet { contents, language: SnippetLanguage::Shell, attributes: SnippetAttributes { execution: SnippetExecution::Exec(Default::default()), ..Default::default() }, }; let executor = SnippetExecutor::default().language_executor(&snippet.language, &Default::default()).unwrap(); let handle = executor.execute_async(&snippet).expect("execution failed"); let state = loop { let state = handle.state.lock().unwrap(); if state.status.is_finished() { break state; } }; let expected = b"This message redirects to stderr\nhello world\n"; assert_eq!(state.output, expected); } #[test] fn shell_code_execution_executes_hidden_lines() { let contents = r" /// echo 'this line was hidden' /// echo 'this line was hidden and contains another prefix /// ' echo 'hello world' " .into(); let snippet = Snippet { contents, language: SnippetLanguage::Shell, attributes: SnippetAttributes { execution: SnippetExecution::Exec(Default::default()), ..Default::default() }, }; let executor = SnippetExecutor::default().language_executor(&snippet.language, &Default::default()).unwrap(); let handle = executor.execute_async(&snippet).expect("execution failed"); let state = loop { let state = handle.state.lock().unwrap(); if state.status.is_finished() { break state; } }; let expected = b"this line was hidden\nthis line was hidden and contains another prefix /// \nhello world\n"; assert_eq!(state.output, expected); } #[test] fn built_in_executors() { SnippetExecutor::new(Default::default(), PathBuf::from("./")).expect("invalid default executors"); } } ================================================ FILE: src/code/highlighting.rs ================================================ use crate::{ code::snippet::SnippetLanguage, markdown::{ elements::{Line, Text}, text_style::{Color, TextStyle}, }, theme::CodeBlockStyle, }; use flate2::read::ZlibDecoder; use once_cell::sync::Lazy; use serde::Deserialize; use std::{cell::RefCell, collections::BTreeMap, fs, path::Path, rc::Rc}; use syntect::{ LoadingError, easy::HighlightLines, highlighting::{Style, Theme, ThemeSet}, parsing::SyntaxSet, }; static SYNTAX_SET: Lazy = Lazy::new(|| { let contents = include_bytes!("../../bat/syntaxes.bin"); bincode::deserialize(contents).expect("syntaxes are broken") }); static BAT_THEMES: Lazy = Lazy::new(|| { let contents = include_bytes!("../../bat/themes.bin"); let theme_set: LazyThemeSet = bincode::deserialize(contents).expect("syntaxes are broken"); theme_set }); // This structure mimic's `bat`'s serialized theme set's. #[derive(Debug, Deserialize)] struct LazyThemeSet { serialized_themes: BTreeMap>, } pub struct HighlightThemeSet { themes: RefCell>>, } impl HighlightThemeSet { /// Construct a new highlighter using the given [syntect] theme name. pub fn load_by_name(&self, name: &str) -> Option { let mut themes = self.themes.borrow_mut(); // Check if we already loaded this one. if let Some(theme) = themes.get(name).cloned() { Some(SnippetHighlighter { theme }) } // Otherwise try to deserialize it from bat's themes else if let Some(theme) = self.deserialize_bat_theme(name) { themes.insert(name.into(), theme.clone()); Some(SnippetHighlighter { theme }) } else { None } } /// Register all highlighting themes in the given directory. pub fn register_from_directory>(&mut self, path: P) -> Result<(), LoadingError> { let Ok(metadata) = fs::metadata(&path) else { return Ok(()); }; if !metadata.is_dir() { return Ok(()); } let themes = ThemeSet::load_from_folder(path)?; let themes = themes.themes.into_iter().map(|(name, theme)| (name, Rc::new(theme))); self.themes.borrow_mut().extend(themes); Ok(()) } fn deserialize_bat_theme(&self, name: &str) -> Option> { let serialized = BAT_THEMES.serialized_themes.get(name)?; let decoded: Theme = bincode::deserialize_from(ZlibDecoder::new(serialized.as_slice())).ok()?; let decoded = Rc::new(decoded); Some(decoded) } } impl Default for HighlightThemeSet { fn default() -> Self { let themes = ThemeSet::load_defaults(); let themes = themes.themes.into_iter().map(|(name, theme)| (name, Rc::new(theme))).collect(); Self { themes: RefCell::new(themes) } } } /// A snippet highlighter. #[derive(Clone)] pub(crate) struct SnippetHighlighter { theme: Rc, } impl SnippetHighlighter { /// Create a highlighter for a specific language. pub(crate) fn language_highlighter(&self, language: &SnippetLanguage) -> LanguageHighlighter<'_> { let extension = Self::language_extension(language); let syntax = SYNTAX_SET.find_syntax_by_extension(extension).unwrap(); let highlighter = HighlightLines::new(syntax, &self.theme); LanguageHighlighter::new(language.clone(), highlighter) } fn language_extension(language: &SnippetLanguage) -> &'static str { use SnippetLanguage::*; match language { Ada => "adb", Asp => "asa", Awk => "awk", Bash => "sh", BatchFile => "cmd", C => "c", CMake => "cmake", CSharp => "cs", Clojure => "clj", Cpp => "cpp", Crontab => "crontab", Css => "css", Dart => "dart", D2 => "txt", DLang => "d", Diff => "diff", Docker => "Dockerfile", Dotenv => "env", Elixir => "ex", Elm => "elm", Erlang => "erl", File => "txt", Fish => "fish", FSharp => "fsx", GdScript => "gd", Go => "go", GraphQL => "graphql", Haskell => "hs", Html => "html", Java => "java", JavaScript => "js", Json => "json", Jsonnet => "jsonnet", Julia => "jl", Kotlin => "kt", Latex => "tex", Lua => "lua", Makefile => "make", Markdown => "md", Mermaid => "txt", Nix => "nix", Nushell => "txt", OCaml => "ml", Perl => "pl", Php => "php", PowerShell => "ps1", Protobuf => "proto", Puppet => "pp", Python => "py", R => "r", Racket => "rkt", Ruby => "rb", Rust => "rs", RustScript => "rs", Scala => "scala", Shell => "sh", Sql => "sql", Swift => "swift", Svelte => "svelte", Tcl => "tcl", Terraform => "tf", Toml => "toml", TypeScript => "ts", TypeScriptReact => "tsx", Typst => "txt", // default to plain text so we get the same look&feel Unknown(_) => "txt", Verilog => "v", Vue => "vue", Wsl => "sh", Xml => "xml", Yaml => "yaml", Zsh => "sh", Zig => "zig", } } } impl Default for SnippetHighlighter { fn default() -> Self { let themes = HighlightThemeSet::default(); themes.load_by_name("base16-eighties.dark").expect("default theme not found") } } pub(crate) struct LanguageHighlighter<'a> { language: SnippetLanguage, highlighter: HighlightLines<'a>, parse_started: bool, } impl<'a> LanguageHighlighter<'a> { fn new(language: SnippetLanguage, highlighter: HighlightLines<'a>) -> Self { Self { language, highlighter, parse_started: false } } pub(crate) fn style_line(&mut self, line: &str, block_style: &CodeBlockStyle) -> Line { if !self.parse_started { let line = line.trim(); if !line.is_empty() { self.parse_started = true; // Parse a fake " = self .highlighter .highlight_line(line, &SYNTAX_SET) .unwrap() .into_iter() .map(|(style, tokens)| StyledTokens::new(style, tokens, block_style).apply_style()) .collect(); Line(texts) } } pub(crate) struct StyledTokens<'a> { pub(crate) style: TextStyle, pub(crate) tokens: &'a str, } impl<'a> StyledTokens<'a> { pub(crate) fn new(style: Style, tokens: &'a str, block_style: &CodeBlockStyle) -> Self { let has_background = block_style.background; let background = has_background.then_some(parse_color(style.background)).flatten(); let foreground = parse_color(style.foreground); let mut style = TextStyle::default(); style.colors.background = background; style.colors.foreground = foreground; Self { style, tokens } } pub(crate) fn apply_style(&self) -> Text { let text: String = self.tokens.split('\n').collect(); Text::new(text, self.style) } } // This code has been adapted from bat's: https://github.com/sharkdp/bat fn parse_color(color: syntect::highlighting::Color) -> Option { if color.a == 0 { Some(match color.r { 0x00 => Color::Black, 0x01 => Color::DarkRed, 0x02 => Color::DarkGreen, 0x03 => Color::DarkYellow, 0x04 => Color::DarkBlue, 0x05 => Color::DarkMagenta, 0x06 => Color::DarkCyan, 0x07 => Color::Grey, 0x08 => Color::DarkGrey, 0x09 => Color::Red, 0x0a => Color::Green, 0x0b => Color::Yellow, 0x0c => Color::Blue, 0x0d => Color::Magenta, 0x0e => Color::Cyan, 0x0f => Color::White, n => Color::from_ansi(n)?, }) } else if color.a == 1 { None } else { Some(Color::new(color.r, color.g, color.b)) } } #[cfg(test)] mod test { use super::*; use strum::IntoEnumIterator; use tempfile::tempdir; #[test] fn language_extensions_exist() { for language in SnippetLanguage::iter() { let extension = SnippetHighlighter::language_extension(&language); let syntax = SYNTAX_SET.find_syntax_by_extension(extension); assert!(syntax.is_some(), "extension {extension} for {language:?} not found"); } } #[test] fn default_highlighter() { SnippetHighlighter::default(); } #[test] fn load_custom() { let directory = tempdir().expect("creating tempdir"); // A minimalistic .tmTheme theme. let theme = r#" potato Example Color Scheme settings settings "#; fs::write(directory.path().join("potato.tmTheme"), theme).expect("writing theme"); let mut themes = HighlightThemeSet::default(); themes.register_from_directory(directory.path()).expect("loading themes"); assert!(themes.load_by_name("potato").is_some()); } #[test] fn register_from_missing_directory() { let mut themes = HighlightThemeSet::default(); let result = themes.register_from_directory("/tmp/presenterm/8ee2027983915ec78acc45027d874316"); result.expect("loading failed"); } #[test] fn default_themes() { let themes = HighlightThemeSet::default(); // This is a bat theme assert!(themes.load_by_name("GitHub").is_some()); // This is a default syntect theme assert!(themes.load_by_name("InspiredGitHub").is_some()); } } ================================================ FILE: src/code/mod.rs ================================================ pub(crate) mod execute; pub(crate) mod highlighting; pub(crate) mod padding; pub(crate) mod snippet; ================================================ FILE: src/code/padding.rs ================================================ use std::iter; pub(crate) struct NumberPadder { width: usize, } impl NumberPadder { pub(crate) fn new(upper_bound: usize) -> Self { let width = upper_bound.checked_ilog10().map(|log| log as usize + 1).unwrap_or_default(); Self { width } } pub(crate) fn pad_right(&self, number: usize) -> String { let line_number_width = number.ilog10() as usize + 1; let number_padding = self.width - line_number_width; let mut output = String::with_capacity(self.width); output.extend(iter::repeat_n(' ', number_padding)); output.push_str(&number.to_string()); output } } #[cfg(test)] mod test { use super::*; use rstest::rstest; #[rstest] #[case(&[1, 2], &["1", "2"])] #[case(&[1, 9], &["1", "9"])] #[case(&[1, 10], &[" 1", "10"])] #[case(&[1, 10, 100], &[" 1", " 10", "100"])] fn right_padding(#[case] numbers: &[usize], #[case] expected: &[&str]) { let max = numbers.iter().max().expect("no numbers"); let padder = NumberPadder::new(*max); let rendered: Vec<_> = numbers.iter().map(|n| padder.pad_right(*n)).collect(); assert_eq!(rendered, expected); } #[test] fn zero_count() { NumberPadder::new(0); } } ================================================ FILE: src/code/snippet.rs ================================================ use super::{ highlighting::{LanguageHighlighter, StyledTokens}, padding::NumberPadder, }; use crate::{ markdown::{ elements::{Percent, PercentParseError}, text::{WeightedLine, WeightedText}, text_style::{Color, TextStyle}, }, presentation::ChunkMutator, render::{ operation::{AsRenderOperations, BlockLine, RenderOperation}, properties::WindowSize, }, theme::{Alignment, CodeBlockStyle}, }; use serde::Deserialize; use std::{cell::RefCell, convert::Infallible, fmt::Write, ops::Range, path::PathBuf, rc::Rc, str::FromStr}; use strum::{EnumDiscriminants, EnumIter}; use unicode_width::UnicodeWidthStr; pub(crate) struct SnippetSplitter<'a> { style: &'a CodeBlockStyle, hidden_line_prefix: Option<&'a str>, } impl<'a> SnippetSplitter<'a> { pub(crate) fn new(style: &'a CodeBlockStyle, hidden_line_prefix: Option<&'a str>) -> Self { Self { style, hidden_line_prefix } } pub(crate) fn split(&self, code: &Snippet) -> Vec { let mut lines = Vec::new(); let horizontal_padding = self.style.padding.horizontal; let vertical_padding = self.style.padding.vertical; if vertical_padding > 0 { lines.push(SnippetLine::empty()); } self.push_lines(code, horizontal_padding, &mut lines); if vertical_padding > 0 { lines.push(SnippetLine::empty()); } lines } fn push_lines(&self, code: &Snippet, horizontal_padding: u8, lines: &mut Vec) { if code.contents.is_empty() { return; } let padding = " ".repeat(horizontal_padding as usize); let padder = NumberPadder::new(code.visible_lines(self.hidden_line_prefix).count()); for (index, line) in code.visible_lines(self.hidden_line_prefix).enumerate() { let mut line = line.replace('\t', " "); let mut prefix = padding.clone(); if code.attributes.line_numbers { let line_number = index + 1; prefix.push_str(&padder.pad_right(line_number)); prefix.push(' '); } line.push('\n'); let line_number = Some(index as u16 + 1); lines.push(SnippetLine { prefix, code: line, right_padding_length: padding.len() as u16, line_number }); } } } pub(crate) struct SnippetLine { pub(crate) prefix: String, pub(crate) code: String, pub(crate) right_padding_length: u16, pub(crate) line_number: Option, } impl SnippetLine { pub(crate) fn empty() -> Self { Self { prefix: String::new(), code: "\n".into(), right_padding_length: 0, line_number: None } } pub(crate) fn width(&self) -> usize { self.prefix.width() + self.code.width() + self.right_padding_length as usize } pub(crate) fn highlight( &self, code_highlighter: &mut LanguageHighlighter, block_style: &CodeBlockStyle, font_size: u8, ) -> WeightedLine { let mut line = code_highlighter.style_line(&self.code, block_style); line.apply_style(&TextStyle::default().size(font_size)); line.into() } pub(crate) fn dim(&self, dim_style: &TextStyle) -> WeightedLine { let output = vec![StyledTokens { style: *dim_style, tokens: &self.code }.apply_style()]; output.into() } pub(crate) fn dim_prefix(&self, dim_style: &TextStyle) -> WeightedText { let text = StyledTokens { style: *dim_style, tokens: &self.prefix }.apply_style(); text.into() } } #[derive(Debug)] pub(crate) struct HighlightContext { pub(crate) groups: Vec, pub(crate) current: usize, pub(crate) block_length: u16, pub(crate) alignment: Alignment, } #[derive(Debug)] pub(crate) struct HighlightedLine { pub(crate) prefix: WeightedText, pub(crate) right_padding_length: u16, pub(crate) highlighted: WeightedLine, pub(crate) not_highlighted: WeightedLine, pub(crate) line_number: Option, pub(crate) context: Rc>, pub(crate) block_color: Option, } impl AsRenderOperations for HighlightedLine { fn as_render_operations(&self, _: &WindowSize) -> Vec { let context = self.context.borrow(); let group = &context.groups[context.current]; let needs_highlight = self.line_number.map(|number| group.contains(number)).unwrap_or_default(); // TODO: Cow? let text = match needs_highlight { true => self.highlighted.clone(), false => self.not_highlighted.clone(), }; vec![ RenderOperation::RenderBlockLine(BlockLine { prefix: self.prefix.clone(), right_padding_length: self.right_padding_length, repeat_prefix_on_wrap: false, text, block_length: context.block_length, alignment: context.alignment, block_color: self.block_color, }), RenderOperation::RenderLineBreak, ] } } #[derive(Debug)] pub(crate) struct HighlightMutator { context: Rc>, } impl HighlightMutator { pub(crate) fn new(context: Rc>) -> Self { Self { context } } } impl ChunkMutator for HighlightMutator { fn mutate_next(&self) -> bool { let mut context = self.context.borrow_mut(); if context.current == context.groups.len() - 1 { false } else { context.current += 1; true } } fn mutate_previous(&self) -> bool { let mut context = self.context.borrow_mut(); if context.current == 0 { false } else { context.current -= 1; true } } fn reset_mutations(&self) { self.context.borrow_mut().current = 0; } fn apply_all_mutations(&self) { let mut context = self.context.borrow_mut(); context.current = context.groups.len() - 1; } fn mutations(&self) -> (usize, usize) { let context = self.context.borrow(); (context.current, context.groups.len()) } } pub(crate) type ParseResult = Result; pub(crate) struct SnippetParser; impl SnippetParser { pub(crate) fn parse(info: String, code: String) -> ParseResult { let (language, attributes) = Self::parse_block_info(&info)?; let code = Snippet { contents: code, language, attributes }; Ok(code) } fn parse_block_info(input: &str) -> ParseResult<(SnippetLanguage, SnippetAttributes)> { let (language, input) = Self::parse_language(input); let attributes = Self::parse_attributes(input)?; if attributes.width.is_some() && !matches!(attributes.execution, SnippetExecution::Render) { return Err(SnippetBlockParseError::NotRenderSnippet("width")); } Ok((language, attributes)) } fn parse_language(input: &str) -> (SnippetLanguage, &str) { let token = Self::next_identifier(input); // this always returns `Ok` given we fall back to `Unknown` if we don't know the language. let language = token.parse().expect("language parsing"); let rest = &input[token.len()..]; (language, rest) } fn parse_attributes(mut input: &str) -> ParseResult { let mut attributes = SnippetAttributes::default(); let mut processed_attributes = Vec::new(); while let (Some(attribute), rest) = Self::parse_attribute(input)? { let discriminant = SnippetAttributeDiscriminants::from(&attribute); if processed_attributes.contains(&discriminant) { return Err(SnippetBlockParseError::DuplicateAttribute("duplicate attribute")); } use SnippetAttribute::*; match attribute { LineNumbers => attributes.line_numbers = true, Exec(spec) => { attributes.execution = attributes .execution .try_merge(SnippetExecution::Exec(SnippetExecArgs { spec, ..Default::default() }))?; } AutoExec(spec) => { attributes.execution = attributes.execution.try_merge(SnippetExecution::Exec(SnippetExecArgs { spec, auto: true, ..Default::default() }))?; } ExecPty(spec, args) => { attributes.execution = attributes.execution.try_merge(SnippetExecution::Exec(SnippetExecArgs { spec, pty: Some(args), ..Default::default() }))?; } ExecReplace(spec) => { attributes.execution = attributes.execution.try_merge(SnippetExecution::Exec(SnippetExecArgs { spec, repr: SnippetRepr::ExecReplace, ..Default::default() }))?; } Id(id) => { attributes.id = Some(id); } Validate(spec) => { if attributes.validate.is_some() { return Err(SnippetBlockParseError::DuplicateAttribute("+validate")); } attributes.validate = Some(spec); } Image => { attributes.execution = attributes.execution.try_merge(SnippetExecution::Exec(SnippetExecArgs { repr: SnippetRepr::Image, ..Default::default() }))?; } Render => { attributes.execution = attributes.execution.try_merge(SnippetExecution::Render)?; } AcquireTerminal(spec) => { attributes.execution = attributes.execution.try_merge(SnippetExecution::Exec(SnippetExecArgs { spec, repr: SnippetRepr::AcquireTerminal, ..Default::default() }))?; } NoBackground => attributes.no_background = true, HighlightedLines(lines) => attributes.highlight_groups = lines, Width(width) => attributes.width = Some(width), ExpectedExecutionResult(result) => attributes.expected_execution_result = result, }; processed_attributes.push(discriminant); input = rest; } if attributes.highlight_groups.is_empty() { attributes.highlight_groups.push(HighlightGroup::new(vec![Highlight::All])); } Ok(attributes) } fn parse_attribute(input: &str) -> ParseResult<(Option, &str)> { let input = Self::skip_whitespace(input); let (attribute, input) = match input.chars().next() { Some('+') => { let token = Self::next_identifier(&input[1..]); let attribute = match token { "line_numbers" => SnippetAttribute::LineNumbers, "exec" => SnippetAttribute::Exec(SnippetExecutorSpec::default()), "auto_exec" => SnippetAttribute::AutoExec(SnippetExecutorSpec::default()), "exec_replace" => SnippetAttribute::ExecReplace(SnippetExecutorSpec::default()), "validate" => SnippetAttribute::Validate(SnippetExecutorSpec::default()), "image" => SnippetAttribute::Image, "render" => SnippetAttribute::Render, "no_background" => SnippetAttribute::NoBackground, "acquire_terminal" => SnippetAttribute::AcquireTerminal(SnippetExecutorSpec::default()), "pty" => SnippetAttribute::ExecPty(SnippetExecutorSpec::default(), Default::default()), other => { let (attribute, parameter) = other .split_once(':') .ok_or_else(|| SnippetBlockParseError::InvalidToken(Self::next_identifier(input).into()))?; match attribute { "exec" => SnippetAttribute::Exec(SnippetExecutorSpec::Alternative(parameter.to_string())), "auto_exec" => { SnippetAttribute::AutoExec(SnippetExecutorSpec::Alternative(parameter.to_string())) } "exec_replace" => { SnippetAttribute::ExecReplace(SnippetExecutorSpec::Alternative(parameter.to_string())) } "id" => SnippetAttribute::Id(parameter.to_string()), "validate" => { SnippetAttribute::Validate(SnippetExecutorSpec::Alternative(parameter.to_string())) } "acquire_terminal" => SnippetAttribute::AcquireTerminal(SnippetExecutorSpec::Alternative( parameter.to_string(), )), "width" => { let width = parameter.parse().map_err(SnippetBlockParseError::InvalidWidth)?; SnippetAttribute::Width(width) } "expect" => match parameter { "success" => { SnippetAttribute::ExpectedExecutionResult(ExpectedSnippetExecutionResult::Success) } "failure" | "fail" => { SnippetAttribute::ExpectedExecutionResult(ExpectedSnippetExecutionResult::Failure) } _ => { return Err(SnippetBlockParseError::InvalidToken( Self::next_identifier(input).into(), )); } }, "pty" => SnippetAttribute::ExecPty(SnippetExecutorSpec::default(), parameter.parse()?), _ => return Err(SnippetBlockParseError::InvalidToken(Self::next_identifier(input).into())), } } }; (Some(attribute), &input[token.len() + 1..]) } Some('{') => { let (lines, input) = Self::parse_highlight_groups(&input[1..])?; (Some(SnippetAttribute::HighlightedLines(lines)), input) } Some(_) => return Err(SnippetBlockParseError::InvalidToken(Self::next_identifier(input).into())), None => (None, input), }; Ok((attribute, input)) } fn parse_highlight_groups(input: &str) -> ParseResult<(Vec, &str)> { use SnippetBlockParseError::InvalidHighlightedLines; let Some((head, tail)) = input.split_once('}') else { return Err(InvalidHighlightedLines("no enclosing '}'".into())); }; let head = head.trim(); if head.is_empty() { return Ok((Vec::new(), tail)); } let mut highlight_groups = Vec::new(); for group in head.split('|') { let group = Self::parse_highlight_group(group)?; highlight_groups.push(group); } Ok((highlight_groups, tail)) } fn parse_highlight_group(input: &str) -> ParseResult { let mut highlights = Vec::new(); for piece in input.split(',') { let piece = piece.trim(); if piece == "all" { highlights.push(Highlight::All); continue; } match piece.split_once('-') { Some((left, right)) => { let left = Self::parse_number(left)?; let right = Self::parse_number(right)?; let right = right.checked_add(1).ok_or_else(|| { SnippetBlockParseError::InvalidHighlightedLines(format!("{right} is too large")) })?; highlights.push(Highlight::Range(left..right)); } None => { let number = Self::parse_number(piece)?; highlights.push(Highlight::Single(number)); } } } Ok(HighlightGroup::new(highlights)) } fn parse_number(input: &str) -> ParseResult { input .trim() .parse() .map_err(|_| SnippetBlockParseError::InvalidHighlightedLines(format!("not a number: '{input}'"))) } fn skip_whitespace(input: &str) -> &str { input.trim_start_matches(' ') } fn next_identifier(input: &str) -> &str { match input.split_once(' ') { Some((token, _)) => token, None => input, } } } #[derive(thiserror::Error, Debug)] pub enum SnippetBlockParseError { #[error("invalid code attribute: {0}")] InvalidToken(String), #[error("invalid highlighted lines: {0}")] InvalidHighlightedLines(String), #[error("invalid width: {0}")] InvalidWidth(PercentParseError), #[error("invalid pty args, expected '[standby:]:'")] InvalidPtyArgs, #[error("duplicate attribute: {0}")] DuplicateAttribute(&'static str), #[error("+exec_replace +image and +render can't be used together ")] MultipleRepresentation, #[error("attribute {0} can only be set in +render blocks")] NotRenderSnippet(&'static str), } #[derive(EnumDiscriminants)] enum SnippetAttribute { LineNumbers, Exec(SnippetExecutorSpec), AutoExec(SnippetExecutorSpec), ExecReplace(SnippetExecutorSpec), ExecPty(SnippetExecutorSpec, PtyArgs), Validate(SnippetExecutorSpec), Image, Render, HighlightedLines(Vec), Width(Percent), NoBackground, AcquireTerminal(SnippetExecutorSpec), ExpectedExecutionResult(ExpectedSnippetExecutionResult), Id(String), } #[derive(Clone, Debug, Default, PartialEq, Eq)] pub(crate) enum SnippetExecutorSpec { #[default] Default, Alternative(String), } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(crate) enum ExpectedSnippetExecutionResult { #[default] Success, Failure, } /// A code snippet. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct Snippet { /// The snippet itself. pub(crate) contents: String, /// The programming language this snippet is written in. pub(crate) language: SnippetLanguage, /// The attributes used for snippet. pub(crate) attributes: SnippetAttributes, } impl Snippet { pub(crate) fn visible_lines<'a, 'b>( &'a self, hidden_line_prefix: Option<&'b str>, ) -> impl Iterator + 'b where 'a: 'b, { self.contents.lines().filter(move |line| !hidden_line_prefix.is_some_and(|prefix| line.starts_with(prefix))) } pub(crate) fn executable_contents(&self, hidden_line_prefix: Option<&str>) -> String { if let Some(prefix) = hidden_line_prefix { self.contents.lines().fold(String::new(), |mut output, line| { let line = line.strip_prefix(prefix).unwrap_or(line); let _ = writeln!(output, "{line}"); output }) } else { self.contents.to_owned() } } } /// The language of a code snippet. #[derive(Clone, Debug, PartialEq, Eq, EnumIter, PartialOrd, Ord)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub enum SnippetLanguage { Ada, Asp, Awk, Bash, BatchFile, C, CMake, Crontab, CSharp, Clojure, Cpp, Css, Dart, D2, DLang, Diff, Docker, Dotenv, Elixir, Elm, Erlang, File, Fish, FSharp, GdScript, Go, GraphQL, Haskell, Html, Java, JavaScript, Json, Jsonnet, Julia, Kotlin, Latex, Lua, Makefile, Mermaid, Markdown, Nix, Nushell, OCaml, Perl, Php, PowerShell, Protobuf, Puppet, Python, R, Racket, Ruby, Rust, RustScript, Scala, Shell, Sql, Swift, Svelte, Tcl, Terraform, Toml, TypeScript, TypeScriptReact, Typst, Unknown(String), Xml, Yaml, Verilog, Vue, Wsl, Zig, Zsh, } crate::utils::impl_deserialize_from_str!(SnippetLanguage); impl FromStr for SnippetLanguage { type Err = Infallible; fn from_str(s: &str) -> Result { use SnippetLanguage::*; let language = match s.to_lowercase().as_str() { "ada" => Ada, "asp" => Asp, "awk" => Awk, "bash" => Bash, "bat" | "cmd" => BatchFile, "c" => C, "cmake" => CMake, "crontab" => Crontab, "csharp" => CSharp, "clojure" => Clojure, "cpp" | "c++" => Cpp, "css" => Css, "dart" => Dart, "d2" => D2, "d" => DLang, "diff" => Diff, "docker" => Docker, "dotenv" => Dotenv, "elixir" => Elixir, "elm" => Elm, "erlang" => Erlang, "file" => File, "fish" => Fish, "fsharp" => FSharp, "go" => Go, "graphql" => GraphQL, "gdscript" => GdScript, "haskell" => Haskell, "html" => Html, "java" => Java, "javascript" | "js" => JavaScript, "json" => Json, "jsonnet" => Jsonnet, "julia" => Julia, "kotlin" => Kotlin, "latex" => Latex, "lua" => Lua, "make" => Makefile, "markdown" => Markdown, "mermaid" => Mermaid, "nix" => Nix, "nushell" | "nu" => Nushell, "ocaml" => OCaml, "perl" => Perl, "php" => Php, "powershell" | "pwsh" => PowerShell, "protobuf" => Protobuf, "puppet" => Puppet, "python" => Python, "r" => R, "racket" => Racket, "ruby" => Ruby, "rust" => Rust, "rust-script" => RustScript, "scala" => Scala, "shell" | "sh" => Shell, "sql" => Sql, "svelte" => Svelte, "swift" => Swift, "tcl" => Tcl, "terraform" => Terraform, "toml" => Toml, "typescript" | "ts" => TypeScript, "tsx" => TypeScriptReact, "typst" => Typst, "xml" => Xml, "yaml" => Yaml, "verilog" => Verilog, "vue" => Vue, "wsl" => Wsl, "zig" => Zig, "zsh" => Zsh, other => Unknown(other.to_string()), }; Ok(language) } } /// Attributes for code snippets. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub(crate) struct SnippetAttributes { /// The way the snippet should be executed, if any. pub(crate) execution: SnippetExecution, /// Whether the snippet should show line numbers. pub(crate) line_numbers: bool, /// The groups of lines to highlight. pub(crate) highlight_groups: Vec, /// The width of the generated image. /// /// Only valid for +render snippets. pub(crate) width: Option, /// Whether to add no background to a snippet. pub(crate) no_background: bool, /// The spec to use to validate this snippet. pub(crate) validate: Option, /// The expected execution result for a snippet. pub(crate) expected_execution_result: ExpectedSnippetExecutionResult, /// The identifier for a snippet. pub(crate) id: Option, } #[derive(Clone, Debug, Default, PartialEq, Eq)] pub(crate) enum SnippetRepr { #[default] SnippetOutput, Image, ExecReplace, AcquireTerminal, } #[derive(Clone, Debug, Default, PartialEq, Eq)] pub(crate) struct PtyArgs { pub(crate) columns: Option, pub(crate) rows: Option, pub(crate) standby: bool, } impl FromStr for PtyArgs { type Err = SnippetBlockParseError; fn from_str(s: &str) -> Result { let mut standby = false; let s = match s.strip_prefix("standby") { Some(rest) => { let rest = match rest.get(..1) { Some(":") => &rest[1..], Some(_) => return Err(SnippetBlockParseError::InvalidPtyArgs), None => rest, }; standby = true; rest } None => s, }; if s.is_empty() { return Ok(Self { standby, ..Default::default() }); } let Some((columns, rows)) = s.split_once(':') else { return Err(SnippetBlockParseError::InvalidPtyArgs); }; let columns = columns.parse().map_err(|_| SnippetBlockParseError::InvalidPtyArgs)?; let rows = rows.parse().map_err(|_| SnippetBlockParseError::InvalidPtyArgs)?; Ok(Self { columns: Some(columns), rows: Some(rows), standby }) } } #[derive(Clone, Debug, Default, PartialEq, Eq)] pub(crate) struct SnippetExecArgs { pub(crate) spec: SnippetExecutorSpec, pub(crate) auto: bool, pub(crate) pty: Option, pub(crate) repr: SnippetRepr, } #[derive(Clone, Debug, Default, PartialEq, Eq)] pub(crate) enum SnippetExecution { #[default] None, Render, Exec(SnippetExecArgs), } impl SnippetExecution { fn try_merge(self, other: SnippetExecution) -> ParseResult { match (self, other) { (Self::None, other) => Ok(other), (Self::Render, Self::None) => Ok(Self::Render), (Self::Render, Self::Render) => Err(SnippetBlockParseError::DuplicateAttribute("+render")), (Self::Render, Self::Exec(_)) | (Self::Exec(_), Self::Render) => { Err(SnippetBlockParseError::MultipleRepresentation) } (Self::Exec(mut ours), Self::Exec(theirs)) => { let SnippetExecArgs { spec, auto, pty, repr } = theirs; ours.auto = ours.auto || auto; ours.pty = pty.or(ours.pty); ours.spec = match ours.spec { SnippetExecutorSpec::Default => spec, SnippetExecutorSpec::Alternative(spec) => SnippetExecutorSpec::Alternative(spec), }; ours.repr = match (ours.repr, repr) { (SnippetRepr::SnippetOutput, other) => other, (ours, SnippetRepr::SnippetOutput) => ours, _ => return Err(SnippetBlockParseError::MultipleRepresentation), }; Ok(Self::Exec(ours)) } (Self::Exec(args), Self::None) => Ok(Self::Exec(args)), } } } #[derive(Clone, Debug, Default, PartialEq, Eq)] pub(crate) struct HighlightGroup(Vec); impl HighlightGroup { pub(crate) fn new(highlights: Vec) -> Self { Self(highlights) } pub(crate) fn contains(&self, line_number: u16) -> bool { for higlight in &self.0 { match higlight { Highlight::All => return true, Highlight::Single(number) if number == &line_number => return true, Highlight::Range(range) if range.contains(&line_number) => return true, _ => continue, }; } false } } /// A highlighted set of lines #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum Highlight { All, Single(u16), Range(Range), } #[derive(Debug, Deserialize)] pub(crate) struct ExternalFile { pub(crate) path: PathBuf, pub(crate) language: SnippetLanguage, pub(crate) start_line: Option, pub(crate) end_line: Option, } #[cfg(test)] mod test { use super::*; use Highlight::*; use rstest::rstest; fn parse_language(input: &str) -> SnippetLanguage { let (language, _) = SnippetParser::parse_block_info(input).expect("parse failed"); language } fn try_parse_attributes(input: &str) -> Result { let (_, attributes) = SnippetParser::parse_block_info(input)?; Ok(attributes) } fn parse_attributes(input: &str) -> SnippetAttributes { try_parse_attributes(input).expect("parse failed") } #[test] fn code_with_line_numbers() { let total_lines = 11; let input_lines = "hi\n".repeat(total_lines); let code = Snippet { contents: input_lines, language: SnippetLanguage::Unknown("".to_string()), attributes: SnippetAttributes { line_numbers: true, ..Default::default() }, }; let lines = SnippetSplitter::new(&Default::default(), None).split(&code); assert_eq!(lines.len(), total_lines); let mut lines = lines.into_iter().enumerate(); // 0..=9 for (index, line) in lines.by_ref().take(9) { let line_number = index + 1; assert_eq!(&line.prefix, &format!(" {line_number} ")); } // 10.. for (index, line) in lines { let line_number = index + 1; assert_eq!(&line.prefix, &format!("{line_number} ")); } } #[test] fn unknown_language() { assert_eq!(parse_language("potato"), SnippetLanguage::Unknown("potato".to_string())); } #[test] fn no_attributes() { assert_eq!(parse_language("rust"), SnippetLanguage::Rust); } #[test] fn one_attribute() { let attributes = parse_attributes("bash +exec"); assert_eq!(attributes.execution, SnippetExecution::Exec(Default::default())); assert!(!attributes.line_numbers); } #[test] fn two_attributes() { let attributes = parse_attributes("bash +exec +line_numbers"); assert_eq!(attributes.execution, SnippetExecution::Exec(Default::default())); assert!(attributes.line_numbers); } #[test] fn acquire_terminal() { let attributes = parse_attributes("bash +acquire_terminal +exec"); assert_eq!( attributes.execution, SnippetExecution::Exec(SnippetExecArgs { repr: SnippetRepr::AcquireTerminal, ..Default::default() }) ); assert!(!attributes.line_numbers); } #[test] fn image() { let attributes = parse_attributes("bash +image +exec"); assert_eq!( attributes.execution, SnippetExecution::Exec(SnippetExecArgs { repr: SnippetRepr::Image, ..Default::default() }) ); assert!(!attributes.line_numbers); } #[test] fn invalid_attributes() { SnippetParser::parse_block_info("bash +potato").unwrap_err(); SnippetParser::parse_block_info("bash potato").unwrap_err(); } #[rstest] #[case::no_end("{")] #[case::number_no_end("{42")] #[case::comma_nothing("{42,")] #[case::brace_comma("{,}")] #[case::range_no_end("{42-")] #[case::range_end("{42-}")] #[case::too_many_ranges("{42-3-5}")] #[case::range_comma("{42-,")] #[case::too_large("{65536}")] #[case::too_large_end("{1-65536}")] fn invalid_line_highlights(#[case] input: &str) { let input = format!("bash {input}"); SnippetParser::parse_block_info(&input).expect_err("parsed successfully"); } #[test] fn highlight_none() { let attributes = parse_attributes("bash {}"); assert_eq!(attributes.highlight_groups, &[HighlightGroup::new(vec![Highlight::All])]); } #[test] fn highlight_specific_lines() { let attributes = parse_attributes("bash { 1, 2 , 3 }"); assert_eq!(attributes.highlight_groups, &[HighlightGroup::new(vec![Single(1), Single(2), Single(3)])]); } #[test] fn highlight_line_range() { let attributes = parse_attributes("bash { 1, 2-4,6 , all , 10 - 12 }"); assert_eq!( attributes.highlight_groups, &[HighlightGroup::new(vec![Single(1), Range(2..5), Single(6), All, Range(10..13)])] ); } #[test] fn multiple_groups() { let attributes = parse_attributes("bash {1-3,5 |6-9}"); assert_eq!(attributes.highlight_groups.len(), 2); assert_eq!(attributes.highlight_groups[0], HighlightGroup::new(vec![Range(1..4), Single(5)])); assert_eq!(attributes.highlight_groups[1], HighlightGroup::new(vec![Range(6..10)])); } #[test] fn parse_width() { let attributes = parse_attributes("mermaid +width:50% +render"); assert_eq!(attributes.execution, SnippetExecution::Render); assert_eq!(attributes.width, Some(Percent(50))); } #[test] fn invalid_width() { try_parse_attributes("mermaid +width:50%% +render").expect_err("parse succeeded"); try_parse_attributes("mermaid +width: +render").expect_err("parse succeeded"); try_parse_attributes("mermaid +width:50%").expect_err("parse succeeded"); } #[test] fn code_visible_lines() { let contents = r##"# fn main() { println!("Hello world"); # // The prefix is # . # } "## .to_string(); let expected = vec!["println!(\"Hello world\");"]; let code = Snippet { contents, language: SnippetLanguage::Rust, attributes: Default::default() }; assert_eq!(expected, code.visible_lines(Some("# ")).collect::>()); } #[test] fn code_executable_contents() { let contents = r##"# fn main() { println!("Hello world"); # // The prefix is # . # } "## .to_string(); let expected = r##"fn main() { println!("Hello world"); // The prefix is # . } "## .to_string(); let code = Snippet { contents, language: SnippetLanguage::Rust, attributes: Default::default() }; assert_eq!(expected, code.executable_contents(Some("# "))); } #[test] fn tabs_in_snippet() { let snippet = Snippet { contents: "\thi".into(), language: SnippetLanguage::C, attributes: Default::default() }; let lines = SnippetSplitter::new(&Default::default(), None).split(&snippet); assert_eq!(lines[0].code, " hi\n"); } #[rstest] #[case::exec("bash +exec:foo", SnippetExecutorSpec::Alternative("foo".to_string()))] #[case::exec_and_more("bash +exec:foo +line_numbers", SnippetExecutorSpec::Alternative("foo".to_string()))] #[case::exec_replace("bash +exec_replace:foo", SnippetExecutorSpec::Alternative("foo".to_string()))] #[case::exec_replace_and_more("bash +exec_replace:foo +line_numbers", SnippetExecutorSpec::Alternative("foo".into()))] fn alternative_executor(#[case] input: &str, #[case] spec: SnippetExecutorSpec) { let attributes = parse_attributes(input); let SnippetExecution::Exec(args) = attributes.execution else { panic!("not an exec snippet") }; assert_eq!(args.spec, spec); } #[test] fn acquire_terminal_alternative() { let attributes = parse_attributes("bash +acquire_terminal:foo"); assert_eq!( attributes.execution, SnippetExecution::Exec(SnippetExecArgs { spec: SnippetExecutorSpec::Alternative("foo".into()), repr: SnippetRepr::AcquireTerminal, ..Default::default() }) ); } #[rstest] #[case::success("expect:success", ExpectedSnippetExecutionResult::Success)] #[case::failure("expect:failure", ExpectedSnippetExecutionResult::Failure)] #[case::fail("expect:fail", ExpectedSnippetExecutionResult::Failure)] fn parse_expect(#[case] input: &str, #[case] expected: ExpectedSnippetExecutionResult) { let attributes = parse_attributes(&format!("bash +{input}")); assert_eq!(attributes.expected_execution_result, expected); } } ================================================ FILE: src/commands/keyboard.rs ================================================ use super::listener::{Command, CommandDiscriminants}; use crate::config::KeyBindingsConfig; use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, poll, read}; use std::{fmt, io, iter, mem, str::FromStr, time::Duration}; /// A keyboard command listener. pub struct KeyboardListener { bindings: CommandKeyBindings, events: Vec, } impl KeyboardListener { pub fn new(bindings: CommandKeyBindings) -> Self { Self { bindings, events: Vec::new() } } /// Polls for the next input command coming from the keyboard. pub(crate) fn poll_next_command(&mut self, timeout: Duration) -> io::Result> { if poll(timeout)? { self.next_command() } else { Ok(None) } } /// Blocks waiting for the next command. pub(crate) fn next_command(&mut self) -> io::Result> { let mut events = mem::take(&mut self.events); let (command, events) = match read()? { // Ignore release events Event::Key(event) if event.kind == KeyEventKind::Release => (None, events), Event::Key(event) => { events.push(event); self.match_events(events) } Event::Resize(..) => (Some(Command::Redraw), events), _ => (None, vec![]), }; self.events = events; Ok(command) } fn match_events(&self, events: Vec) -> (Option, Vec) { match self.bindings.apply(&events) { InputAction::Emit(command) => (Some(command), Vec::new()), InputAction::Buffer => (None, events), InputAction::Reset => (None, Vec::new()), } } } enum InputAction { Buffer, Reset, Emit(Command), } pub struct CommandKeyBindings { bindings: Vec<(KeyBinding, CommandDiscriminants)>, } impl CommandKeyBindings { fn apply(&self, events: &[KeyEvent]) -> InputAction { let mut any_partials = false; for (binding, identifier) in &self.bindings { match binding.match_events(events) { BindingMatch::Full(context) => return Self::instantiate(identifier, context), BindingMatch::Partial => any_partials = true, BindingMatch::None => (), } } if any_partials { InputAction::Buffer } else { InputAction::Reset } } fn instantiate(discriminant: &CommandDiscriminants, context: MatchContext) -> InputAction { use CommandDiscriminants::*; let command = match discriminant { Redraw => Command::Redraw, Next => Command::Next, NextFast => Command::NextFast, Previous => Command::Previous, PreviousFast => Command::PreviousFast, FirstSlide => Command::FirstSlide, LastSlide => Command::LastSlide, GoToSlide => { match context { // this means the command is malformed and this should have been caught earlier // on. MatchContext::None => return InputAction::Reset, MatchContext::Number(number) => Command::GoToSlide(number), } } RenderAsyncOperations => Command::RenderAsyncOperations, Exit => Command::Exit, Suspend => Command::Suspend, Reload => Command::Reload, HardReload => Command::HardReload, ToggleSlideIndex => Command::ToggleSlideIndex, ToggleKeyBindingsConfig => Command::ToggleKeyBindingsConfig, ToggleLayoutGrid => Command::ToggleLayoutGrid, CloseModal => Command::CloseModal, SkipPauses => Command::SkipPauses, GoToSlideChunk => panic!("go to slide chunk is not configurable"), }; InputAction::Emit(command) } fn validate_conflicts<'a>( bindings: impl Iterator, ) -> Result<(), KeyBindingsValidationError> { let mut bindings: Vec<_> = bindings.map(|binding| &binding.0).collect(); bindings.sort_by(|a, b| a.partial_cmp(b).unwrap()); for window in bindings.windows(2) { if window[0].iter().eq(window[1].iter().take(window[0].len())) { return Err(KeyBindingsValidationError::Conflict( KeyBinding(window[0].clone()), KeyBinding(window[1].clone()), )); } } Ok(()) } } impl TryFrom for CommandKeyBindings { type Error = KeyBindingsValidationError; fn try_from(config: KeyBindingsConfig) -> Result { let zip = |discriminant, bindings: Vec| bindings.into_iter().zip(iter::repeat(discriminant)); if !config.go_to_slide.iter().all(|k| k.expects_number()) { return Err(KeyBindingsValidationError::Invalid("go_to_slide", " matcher required")); } let KeyBindingsConfig { next, next_fast, previous, previous_fast, first_slide, last_slide, go_to_slide, execute_code, reload, toggle_slide_index, toggle_bindings, toggle_layout_grid, close_modal, exit, suspend, skip_pauses, } = config; let bindings: Vec<_> = iter::empty() .chain(zip(CommandDiscriminants::Next, next)) .chain(zip(CommandDiscriminants::NextFast, next_fast)) .chain(zip(CommandDiscriminants::Previous, previous)) .chain(zip(CommandDiscriminants::PreviousFast, previous_fast)) .chain(zip(CommandDiscriminants::FirstSlide, first_slide)) .chain(zip(CommandDiscriminants::LastSlide, last_slide)) .chain(zip(CommandDiscriminants::GoToSlide, go_to_slide)) .chain(zip(CommandDiscriminants::Exit, exit)) .chain(zip(CommandDiscriminants::Suspend, suspend)) .chain(zip(CommandDiscriminants::HardReload, reload)) .chain(zip(CommandDiscriminants::ToggleSlideIndex, toggle_slide_index)) .chain(zip(CommandDiscriminants::ToggleKeyBindingsConfig, toggle_bindings)) .chain(zip(CommandDiscriminants::ToggleLayoutGrid, toggle_layout_grid)) .chain(zip(CommandDiscriminants::RenderAsyncOperations, execute_code)) .chain(zip(CommandDiscriminants::CloseModal, close_modal)) .chain(zip(CommandDiscriminants::SkipPauses, skip_pauses)) .collect(); Self::validate_conflicts(bindings.iter().map(|binding| &binding.0))?; Ok(Self { bindings }) } } #[derive(Debug, thiserror::Error)] pub enum KeyBindingsValidationError { #[error("invalid binding for {0}: {1}")] Invalid(&'static str, &'static str), #[error("conflicting keybindings: {0} and {1}")] Conflict(KeyBinding, KeyBinding), } #[derive(Clone, Debug, PartialEq, Eq)] enum BindingMatch { Full(MatchContext), Partial, 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); crate::utils::impl_deserialize_from_str!(KeyBinding); impl KeyBinding { fn match_events(&self, mut events: &[KeyEvent]) -> BindingMatch { let mut output_context = MatchContext::None; for (index, matcher) in self.0.iter().enumerate() { let Some((context, rest)) = matcher.try_match_events(events) else { return BindingMatch::None; }; if !matches!(context, MatchContext::None) { output_context = context; } events = rest; // We ran all matchers but we have no events left; this is a partial match. if index != self.0.len() - 1 && events.is_empty() { return BindingMatch::Partial; } } // If there's more events than we need, this is an issue on the caller side. BindingMatch::Full(output_context) } fn expects_number(&self) -> bool { self.0.iter().any(|m| matches!(m, KeyMatcher::Number)) } } impl FromStr for KeyBinding { type Err = KeyBindingParseError; fn from_str(mut input: &str) -> Result { let mut matchers = Vec::new(); let mut has_numbers = false; while !input.is_empty() { let (matcher, rest) = KeyMatcher::parse(input)?; let is_number = matches!(matcher, KeyMatcher::Number); // We don't want more than one matcher if has_numbers && is_number { return Err(KeyBindingParseError::TooManyNumbers); } has_numbers = has_numbers || is_number; matchers.push(matcher); input = rest; } Ok(Self(matchers)) } } impl fmt::Display for KeyBinding { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { for matcher in &self.0 { write!(f, "{matcher}")?; } Ok(()) } } #[derive(Debug, thiserror::Error)] pub enum KeyBindingParseError { #[error("no input")] NoInput, #[error("not a valid key: {0}")] InvalidKey(char), #[error("too many number placeholders")] TooManyNumbers, #[error("invalid control sequence")] InvalidControlSequence, } #[derive(Clone, Debug, PartialEq, Eq, PartialOrd)] enum KeyMatcher { Key(KeyCombination), Number, } impl KeyMatcher { fn try_match_events<'a>(&self, events: &'a [KeyEvent]) -> Option<(MatchContext, &'a [KeyEvent])> { match self { Self::Key(combo) => Self::try_match_key(combo, events), Self::Number => Self::try_match_number(events), } } fn try_match_key<'a>(combo: &KeyCombination, events: &'a [KeyEvent]) -> Option<(MatchContext, &'a [KeyEvent])> { let event = events.first()?; let is_control = event.modifiers == KeyModifiers::CONTROL; if combo.key == event.code && combo.control == is_control { let rest = &events[1..]; Some((MatchContext::None, rest)) } else { None } } fn try_match_number(mut events: &[KeyEvent]) -> Option<(MatchContext, &[KeyEvent])> { let mut number = None; while let Some((head, rest)) = events.split_first() { let digit = match head.code { KeyCode::Char(c) if c.is_ascii_digit() => c.to_digit(10).expect("not a digit"), _ => break, }; let next = number.unwrap_or(0u32).checked_mul(10).and_then(|number| number.checked_add(digit)); match next { Some(n) => { number = Some(n); events = rest; } // if we overflow we're done None => return None, } } number.map(|number| (MatchContext::Number(number), events)) } fn parse(input: &str) -> Result<(Self, &str), KeyBindingParseError> { if let Some(input) = input.strip_prefix("") { Ok((Self::Number, input)) } else if let Some(input) = Self::try_match_input(input, &["') else { return Err(KeyBindingParseError::InvalidControlSequence); }; let matcher = Self::Key(KeyCombination { key, control: true }); Ok((matcher, input)) } else { let (key, input) = Self::parse_key_code(input)?; let matcher = Self::Key(KeyCombination { key, control: false }); Ok((matcher, input)) } } fn parse_key_code(input: &str) -> Result<(KeyCode, &str), KeyBindingParseError> { if let Some(input) = Self::try_match_input(input, &["", ""]) { Ok((KeyCode::PageUp, input)) } else if let Some(input) = Self::try_match_input(input, &["", ""]) { Ok((KeyCode::PageDown, input)) } else if let Some(input) = Self::try_match_input(input, &["", "", "", ""]) { Ok((KeyCode::Enter, input)) } else if let Some(input) = Self::try_match_input(input, &["", ""]) { Ok((KeyCode::Home, input)) } else if let Some(input) = Self::try_match_input(input, &["", ""]) { Ok((KeyCode::End, input)) } else if let Some(input) = Self::try_match_input(input, &["", ""]) { Ok((KeyCode::Left, input)) } else if let Some(input) = Self::try_match_input(input, &["", ""]) { Ok((KeyCode::Right, input)) } else if let Some(input) = Self::try_match_input(input, &["", ""]) { Ok((KeyCode::Up, input)) } else if let Some(input) = Self::try_match_input(input, &["", ""]) { Ok((KeyCode::Down, input)) } else if let Some(input) = Self::try_match_input(input, &["", ""]) { Ok((KeyCode::Esc, input)) } else if let Some(input) = Self::try_match_input(input, &["", ""]) { Ok((KeyCode::Tab, input)) } else if let Some(input) = Self::try_match_input(input, &["", ""]) { Ok((KeyCode::Backspace, input)) } else if let Some(input) = Self::try_match_input(input, &["').ok_or(KeyBindingParseError::InvalidControlSequence)?; let number: u8 = number.parse().map_err(|_| KeyBindingParseError::InvalidControlSequence)?; if number > 12 { Err(KeyBindingParseError::InvalidControlSequence) } else { Ok((KeyCode::F(number), rest)) } } else { let next = input.chars().next().ok_or(KeyBindingParseError::NoInput)?; // don't allow these as they create ambiguity if next == '<' || next == '>' { Err(KeyBindingParseError::InvalidKey(next)) } else if next.is_alphanumeric() || next.is_ascii_punctuation() || next == ' ' { let key = KeyCode::Char(next); Ok((key, &input[next.len_utf8()..])) } else { Err(KeyBindingParseError::InvalidKey(next)) } } } fn try_match_input<'a>(input: &'a str, aliases: &[&str]) -> Option<&'a str> { for alias in aliases { if let Some(input) = input.strip_prefix(alias) { return Some(input); } } None } } impl fmt::Display for KeyMatcher { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Number => write!(f, ""), Self::Key(combo) => { if combo.control { write!(f, " write!(f, "' '")?, KeyCode::Char(c) => write!(f, "{}", c)?, other => write!(f, "<{other:?}>")?, }; if combo.control { write!(f, ">")?; } Ok(()) } } } } #[derive(Clone, Debug, PartialEq, Eq)] enum MatchContext { Number(u32), None, } #[derive(Clone, Debug, PartialEq, Eq, PartialOrd)] struct KeyCombination { key: KeyCode, control: bool, } impl KeyCombination { #[cfg(test)] fn char(c: char) -> Self { Self { key: KeyCode::Char(c), control: false } } #[cfg(test)] fn control_char(c: char) -> Self { Self { key: KeyCode::Char(c), control: true } } } impl From for KeyCombination { fn from(key: KeyCode) -> Self { Self { key, control: false } } } #[cfg(test)] mod test { use super::*; use crossterm::event::KeyEventState; use rstest::rstest; trait KeyEventSource { fn into_event(self) -> KeyEvent; } impl KeyEventSource for KeyCode { fn into_event(self) -> KeyEvent { KeyEvent { code: self, modifiers: KeyModifiers::empty(), kind: KeyEventKind::Press, state: KeyEventState::NONE, } } } impl KeyEventSource for char { fn into_event(self) -> KeyEvent { KeyCode::Char(self).into_event() } } trait KeyEventExt { fn with_control(self) -> Self; } impl KeyEventExt for KeyEvent { fn with_control(mut self) -> Self { self.modifiers = KeyModifiers::CONTROL; self } } #[rstest] #[case::number("", vec![KeyMatcher::Number])] #[case::char("w", vec![KeyMatcher::Key(KeyCombination::char('w'))])] #[case::ctrl_char1("", vec![KeyMatcher::Key(KeyCombination::control_char('w'))])] #[case::ctrl_char2("", vec![KeyMatcher::Key(KeyCombination::control_char('w'))])] #[case::dot(".", vec![KeyMatcher::Key(KeyCombination::char('.'))])] #[case::dot(" ", vec![KeyMatcher::Key(KeyCombination::char(' '))])] #[case::multi("hi", vec![KeyMatcher::Key(KeyCombination::char('h')), KeyMatcher::Key(KeyCombination::char('i'))])] #[case::page_up1("", vec![KeyMatcher::Key(KeyCode::PageUp.into())])] #[case::page_up2("", vec![KeyMatcher::Key(KeyCode::PageUp.into())])] #[case::page_down1("", vec![KeyMatcher::Key(KeyCode::PageDown.into())])] #[case::page_down2("", vec![KeyMatcher::Key(KeyCode::PageDown.into())])] #[case::enter1("", vec![KeyMatcher::Key(KeyCode::Enter.into())])] #[case::enter2("", vec![KeyMatcher::Key(KeyCode::Enter.into())])] #[case::enter3("", vec![KeyMatcher::Key(KeyCode::Enter.into())])] #[case::home1("", vec![KeyMatcher::Key(KeyCode::Home.into())])] #[case::home2("", vec![KeyMatcher::Key(KeyCode::Home.into())])] #[case::end1("", vec![KeyMatcher::Key(KeyCode::End.into())])] #[case::end2("", vec![KeyMatcher::Key(KeyCode::End.into())])] #[case::left1("", vec![KeyMatcher::Key(KeyCode::Left.into())])] #[case::left2("", vec![KeyMatcher::Key(KeyCode::Left.into())])] #[case::right1("", vec![KeyMatcher::Key(KeyCode::Right.into())])] #[case::right2("", vec![KeyMatcher::Key(KeyCode::Right.into())])] #[case::up1("", vec![KeyMatcher::Key(KeyCode::Up.into())])] #[case::up2("", vec![KeyMatcher::Key(KeyCode::Up.into())])] #[case::down1("", vec![KeyMatcher::Key(KeyCode::Down.into())])] #[case::down2("", vec![KeyMatcher::Key(KeyCode::Down.into())])] #[case::esc1("", vec![KeyMatcher::Key(KeyCode::Esc.into())])] #[case::esc2("", vec![KeyMatcher::Key(KeyCode::Esc.into())])] #[case::f1("", vec![KeyMatcher::Key(KeyCode::F(1).into())])] #[case::f12("", vec![KeyMatcher::Key(KeyCode::F(12).into())])] #[case::backspace1("", vec![KeyMatcher::Key(KeyCode::Backspace.into())])] #[case::backspace2("", vec![KeyMatcher::Key(KeyCode::Backspace.into())])] #[case::tab1("", vec![KeyMatcher::Key(KeyCode::Tab.into())])] #[case::tab2("", vec![KeyMatcher::Key(KeyCode::Tab.into())])] fn parse_key_binding(#[case] pattern: &str, #[case] matchers: Vec) { let binding = KeyBinding::from_str(pattern).expect("failed to parse"); let expected = KeyBinding(matchers); assert_eq!(binding, expected); } #[rstest] #[case::invalid_tag("")] #[case::invalid_char("🚀")] #[case::too_many_numbers("")] #[case::control_sequence("")] #[case::unfinished_f("", &['w'.into_event().with_control()])] #[case::page_up("", &[KeyCode::PageUp.into_event()])] #[case::page_down("", &[KeyCode::PageDown.into_event()])] #[case::enter("", &[KeyCode::Enter.into_event()])] #[case::home("", &[KeyCode::Home.into_event()])] #[case::end("", &[KeyCode::End.into_event()])] fn matching(#[case] pattern: &str, #[case] events: &[KeyEvent]) { let binding = KeyBinding::from_str(pattern).expect("failed to parse"); let result = binding.match_events(events); assert!(matches!(result, BindingMatch::Full(_)), "not full match: {result:?}"); } #[rstest] #[case::fewer("gg", &['g'.into_event()])] #[case::number_something1("G", &['4'.into_event()])] #[case::number_something2("G", &['4'.into_event(), '2'.into_event()])] #[case::number_something3(":", &[':'.into_event(), '4'.into_event()])] fn partial_matching(#[case] pattern: &str, #[case] events: &[KeyEvent]) { let binding = KeyBinding::from_str(pattern).expect("failed to parse"); let result = binding.match_events(events); assert!(matches!(result, BindingMatch::Partial), "not partial match: {result:?}"); } #[rstest] #[case::number_something("G", &['4'.into_event(), 'K'.into_event()])] fn no_matching(#[case] pattern: &str, #[case] events: &[KeyEvent]) { let binding = KeyBinding::from_str(pattern).expect("failed to parse"); let result = binding.match_events(events); assert!(matches!(result, BindingMatch::None), "some match: {result:?}"); } #[rstest] #[case::number_something("G", &['4'.into_event(), '2'.into_event(), 'G'.into_event()])] #[case::number_something( ":", &[':'.into_event(), '4'.into_event(), '2'.into_event(), KeyCode::Enter.into_event()] )] fn match_number(#[case] pattern: &str, #[case] events: &[KeyEvent]) { let binding = KeyBinding::from_str(pattern).expect("failed to parse"); let result = binding.match_events(events); let BindingMatch::Full(MatchContext::Number(number)) = result else { panic!("unexpected match: {result:?}"); }; assert_eq!(number, 42); } #[rstest] #[case(&["G", "other", "Go"])] #[case(&["", "something", ""])] #[case(&["", ""])] #[case(&["", "a"])] #[case(&["", ""])] #[case(&["", ""])] fn conflicts(#[case] patterns: &[&str]) { let bindings: Vec<_> = patterns.iter().map(|p| KeyBinding::from_str(p).unwrap()).collect(); let result = CommandKeyBindings::validate_conflicts(bindings.iter()); assert!(result.is_err(), "not an error: {result:?}"); } #[rstest] #[case(&["Ga", "Go"])] #[case(&["", "hi"])] fn no_conflicts(#[case] patterns: &[&str]) { let bindings: Vec<_> = patterns.iter().map(|p| KeyBinding::from_str(p).unwrap()).collect(); let result = CommandKeyBindings::validate_conflicts(bindings.iter()); assert!(result.is_ok(), "got error: {result:?}"); } #[rstest] #[case("G")] #[case("potato")] #[case("")] fn display(#[case] pattern: &str) { let binding = KeyBinding::from_str(pattern).expect("invalid pattern"); let rendered = binding.to_string(); assert_eq!(rendered, pattern); } } ================================================ FILE: src/commands/listener.rs ================================================ use super::{ keyboard::{CommandKeyBindings, KeyBindingsValidationError, KeyboardListener}, speaker_notes::{SpeakerNotesEvent, SpeakerNotesEventListener}, }; use crate::{config::KeyBindingsConfig, presenter::PresentationError}; use serde::Deserialize; use std::time::Duration; use strum::EnumDiscriminants; /// A command listener that allows polling all command sources in a single place. pub struct CommandListener { keyboard: KeyboardListener, speaker_notes_event_listener: Option, } impl CommandListener { /// Create a new command source over the given presentation path. pub fn new( config: KeyBindingsConfig, speaker_notes_event_listener: Option, ) -> Result { let bindings = CommandKeyBindings::try_from(config)?; Ok(Self { keyboard: KeyboardListener::new(bindings), speaker_notes_event_listener }) } /// Try to get the next command. /// /// This attempts to get a command and returns `Ok(None)` on timeout. pub(crate) fn try_next_command(&mut self) -> Result, PresentationError> { if let Some(receiver) = &self.speaker_notes_event_listener { if let Some(msg) = receiver.try_recv()? { let command = match msg { SpeakerNotesEvent::GoTo { slide, chunk } => Command::GoToSlideChunk { slide, chunk }, SpeakerNotesEvent::Exit => Command::Exit, }; return Ok(Some(command)); } } match self.keyboard.poll_next_command(Duration::from_millis(20))? { Some(command) => Ok(Some(command)), None => Ok(None), } } } /// A command. #[derive(Clone, Debug, PartialEq, Eq, EnumDiscriminants)] #[strum_discriminants(derive(Deserialize))] pub(crate) enum Command { /// Redraw the presentation. /// /// This can happen on terminal resize. Redraw, /// Move forward in the presentation. Next, /// Move to the next slide fast. NextFast, /// Move backwards in the presentation. Previous, /// Move to the previous slide fast. PreviousFast, /// Go to the first slide. FirstSlide, /// Go to the last slide. LastSlide, /// Go to one particular slide. GoToSlide(u32), /// Go to one particular slide + chunk. GoToSlideChunk { slide: u32, chunk: u32 }, /// Render any async render operations in the current slide. RenderAsyncOperations, /// Exit the presentation. Exit, /// Suspend the presentation. Suspend, /// The presentation has changed and needs to be reloaded. Reload, /// Hard reload the presentation. /// /// Like [Command::Reload] but also reloads any external resources like images and themes. HardReload, /// Toggle the slide index view. ToggleSlideIndex, /// Toggle the key bindings config view. ToggleKeyBindingsConfig, /// Toggle layout grid. ToggleLayoutGrid, /// Hide the currently open modal, if any. CloseModal, /// Skip pauses in the current slide. SkipPauses, } ================================================ FILE: src/commands/mod.rs ================================================ pub(crate) mod keyboard; pub(crate) mod listener; pub(crate) mod speaker_notes; ================================================ FILE: src/commands/speaker_notes.rs ================================================ use serde::{Deserialize, Serialize}; use socket2::{Domain, Protocol, Socket, Type}; use std::{ io, net::{SocketAddr, UdpSocket}, path::PathBuf, }; pub struct SpeakerNotesEventPublisher { socket: UdpSocket, presentation_path: PathBuf, } impl SpeakerNotesEventPublisher { pub fn new(address: SocketAddr, presentation_path: PathBuf) -> io::Result { let socket = UdpSocket::bind("127.0.0.1:0")?; socket.set_broadcast(true)?; socket.connect(address)?; Ok(Self { socket, presentation_path }) } pub(crate) fn send(&self, event: SpeakerNotesEvent) -> io::Result<()> { // Wrap this event in an envelope that contains the presentation path so listeners can // ignore unrelated events. let envelope = SpeakerNotesEventEnvelope { event, presentation_path: self.presentation_path.clone() }; let data = serde_json::to_string(&envelope).expect("serialization failed"); match self.socket.send(data.as_bytes()) { Ok(_) => Ok(()), Err(e) if e.kind() == io::ErrorKind::ConnectionRefused => Ok(()), Err(e) => Err(e), } } } pub struct SpeakerNotesEventListener { socket: UdpSocket, presentation_path: PathBuf, } impl SpeakerNotesEventListener { pub fn new(address: SocketAddr, presentation_path: PathBuf) -> io::Result { let s = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP))?; // Use SO_REUSEADDR so we can have multiple listeners on the same port. #[cfg(not(target_os = "macos"))] s.set_reuse_address(true)?; // Don't block so we can listen to the keyboard and this socket at the same time. s.set_nonblocking(true)?; s.bind(&address.into())?; Ok(Self { socket: s.into(), presentation_path }) } pub(crate) fn try_recv(&self) -> io::Result> { let mut buffer = [0; 1024]; let bytes_read = match self.socket.recv(&mut buffer) { Ok(bytes_read) => bytes_read, Err(e) if e.kind() == io::ErrorKind::WouldBlock => return Ok(None), Err(e) => return Err(e), }; // Ignore garbage. Odds are this is someone else sending garbage rather than presenterm // itself. let Ok(envelope) = serde_json::from_slice::(&buffer[0..bytes_read]) else { return Ok(None); }; if envelope.presentation_path == self.presentation_path { Ok(Some(envelope.event)) } else { Ok(None) } } } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(tag = "command")] pub(crate) enum SpeakerNotesEvent { GoTo { slide: u32, chunk: u32 }, Exit, } #[derive(Clone, Debug, Serialize, Deserialize)] struct SpeakerNotesEventEnvelope { presentation_path: PathBuf, event: SpeakerNotesEvent, } #[cfg(not(target_os = "macos"))] #[cfg(test)] mod tests { use super::*; use crate::config::{default_speaker_notes_listen_address, default_speaker_notes_publish_address}; use std::{thread::sleep, time::Duration}; fn make_listener(path: PathBuf) -> SpeakerNotesEventListener { SpeakerNotesEventListener::new(default_speaker_notes_listen_address(), path).expect("building listener") } fn make_publisher(path: PathBuf) -> SpeakerNotesEventPublisher { SpeakerNotesEventPublisher::new(default_speaker_notes_publish_address(), path).expect("building publisher") } #[test] fn bind_multiple() { let _l1 = make_listener("".into()); let _l2 = make_listener("".into()); } #[test] fn multicast() { let path = PathBuf::from("/tmp/test.md"); let l1 = make_listener(path.clone()); let l2 = make_listener(path.clone()); let publisher = make_publisher(path); let event = SpeakerNotesEvent::Exit; publisher.send(event.clone()).expect("send failed"); sleep(Duration::from_millis(100)); assert_eq!(l1.try_recv().expect("recv first failed"), Some(event.clone())); assert_eq!(l2.try_recv().expect("recv second failed"), Some(event)); } } ================================================ FILE: src/config.rs ================================================ use crate::{ code::snippet::SnippetLanguage, commands::keyboard::KeyBinding, terminal::{GraphicsMode, emulator::TerminalEmulator, image::protocols::kitty::KittyMode}, }; use clap::ValueEnum; use serde::Deserialize; use std::{ collections::{BTreeMap, HashMap}, fs, io, net::{IpAddr, Ipv4Addr, SocketAddr}, num::NonZeroU8, path::{Path, PathBuf}, }; #[derive(Clone, Debug, Default, Deserialize)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(deny_unknown_fields)] pub struct Config { /// The default configuration for the presentation. #[serde(default)] pub defaults: DefaultsConfig, #[serde(default)] pub typst: TypstConfig, #[serde(default)] pub mermaid: MermaidConfig, #[serde(default)] pub d2: D2Config, #[serde(default)] pub options: OptionsConfig, #[serde(default)] pub bindings: KeyBindingsConfig, #[serde(default)] pub snippet: SnippetConfig, #[serde(default)] pub speaker_notes: SpeakerNotesConfig, #[serde(default)] pub export: ExportConfig, #[serde(default)] pub transition: Option, } impl Config { /// Load the config from a path. pub fn load(path: &Path) -> Result { let contents = match fs::read_to_string(path) { Ok(contents) => contents, Err(e) if e.kind() == io::ErrorKind::NotFound => return Err(ConfigLoadError::NotFound), Err(e) => return Err(e.into()), }; let config = serde_yaml::from_str(&contents)?; Ok(config) } } #[derive(Debug, thiserror::Error)] pub enum ConfigLoadError { #[error("io: {0}")] Io(#[from] io::Error), #[error("config file not found")] NotFound, #[error("invalid configuration: {0}")] Invalid(#[from] serde_yaml::Error), } #[derive(Clone, Debug, Deserialize, Default)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(deny_unknown_fields)] #[serde(untagged)] #[cfg_attr(feature = "json-schema", schemars(with = "ThemeConfigSchema"))] pub enum ThemeConfig { #[default] None, /// Theme of the presentation. Some(String), /// Automatic dark/light theme switch based on the terminal background luminance. Dynamic { /// Dark theme of the presentation. dark: String, /// Light theme of the presentation. light: String, /// Light/Dark detection timeout in ms. #[cfg_attr(feature = "json-schema", validate(range(min = 1)))] timeout: Option, }, } #[derive(Clone, Debug, Deserialize)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(deny_unknown_fields)] pub struct DefaultsConfig { /// The theme to use by default in every presentation unless overridden. #[serde(default)] pub theme: ThemeConfig, /// Override the terminal font size when in windows or when using sixel. #[serde(default = "default_terminal_font_size")] #[cfg_attr(feature = "json-schema", validate(range(min = 1)))] pub terminal_font_size: u8, /// The image protocol to use. #[serde(default)] pub image_protocol: ImageProtocol, /// Validate that the presentation does not overflow the terminal screen. #[serde(default)] pub validate_overflows: ValidateOverflows, /// A max width in columns that the presentation must always be capped to. #[serde(default = "default_u16_max")] pub max_columns: u16, /// The alignment the presentation should have if `max_columns` is set and the terminal is /// larger than that. #[serde(default)] pub max_columns_alignment: MaxColumnsAlignment, /// A max height in rows that the presentation must always be capped to. #[serde(default = "default_u16_max")] pub max_rows: u16, /// The alignment the presentation should have if `max_rows` is set and the terminal is /// larger than that. #[serde(default)] pub max_rows_alignment: MaxRowsAlignment, /// The configuration for lists when incremental lists are enabled. #[serde(default)] pub incremental_lists: IncrementalElementConfig, /// The configuration for tables when incremental tables are enabled. #[serde(default)] pub incremental_tables: IncrementalElementConfig, } impl Default for DefaultsConfig { fn default() -> Self { Self { theme: Default::default(), terminal_font_size: default_terminal_font_size(), image_protocol: Default::default(), validate_overflows: Default::default(), max_columns: default_u16_max(), max_columns_alignment: Default::default(), max_rows: default_u16_max(), max_rows_alignment: Default::default(), incremental_lists: Default::default(), incremental_tables: Default::default(), } } } /// The configuration for incrementally shown elements. #[derive(Clone, Debug, Default, Deserialize)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(deny_unknown_fields)] pub struct IncrementalElementConfig { /// Whether to pause before. #[serde(default)] pub pause_before: Option, /// Whether to pause after. #[serde(default)] pub pause_after: Option, } fn default_terminal_font_size() -> u8 { 16 } /// The alignment to use when `defaults.max_columns` is set. #[derive(Clone, Copy, Debug, Default, Deserialize)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum MaxColumnsAlignment { /// Align the presentation to the left. Left, /// Align the presentation on the center. #[default] Center, /// Align the presentation to the right. Right, } /// The alignment to use when `defaults.max_rows` is set. #[derive(Clone, Copy, Debug, Default, Deserialize)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum MaxRowsAlignment { /// Align the presentation to the top. Top, /// Align the presentation on the center. #[default] Center, /// Align the presentation to the bottom. Bottom, } #[derive(Clone, Debug, Default, Deserialize)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum ValidateOverflows { #[default] Never, Always, WhenPresenting, WhenDeveloping, } #[derive(Clone, Debug, Default, Deserialize)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(deny_unknown_fields)] pub struct OptionsConfig { /// Whether slides are automatically terminated when a slide title is found. pub implicit_slide_ends: Option, /// The prefix to use for commands. pub command_prefix: Option, /// The prefix to use for image attributes. pub image_attributes_prefix: Option, /// Show all lists incrementally, by implicitly adding pauses in between elements. pub incremental_lists: Option, /// Show all tables incrementally, by implicitly adding pauses in between rows. pub incremental_tables: Option, /// The number of newlines in between list items. pub list_item_newlines: Option, /// Whether to treat a thematic break as a slide end. pub end_slide_shorthand: Option, /// Whether to be strict about parsing the presentation's front matter. pub strict_front_matter_parsing: Option, /// Assume snippets for these languages contain `+render` and render them automatically. #[serde(default)] pub auto_render_languages: Vec, /// Whether the first `h1` header on a slide should be considered a slide title. pub h1_slide_titles: Option, } #[derive(Clone, Debug, Default, Deserialize)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(deny_unknown_fields)] pub struct SnippetConfig { /// The properties for snippet execution. #[serde(default)] pub exec: SnippetExecConfig, /// The properties for snippet execution. #[serde(default)] pub exec_replace: SnippetExecReplaceConfig, /// The properties for snippet auto rendering. #[serde(default)] pub render: SnippetRenderConfig, /// Whether to validate snippets. #[serde(default)] pub validate: bool, } #[derive(Clone, Debug, Default, Deserialize)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(deny_unknown_fields)] pub struct SnippetExecConfig { /// Whether to enable snippet execution. #[serde(default)] pub enable: bool, /// Custom snippet executors. #[serde(default)] pub custom: BTreeMap, } #[derive(Clone, Debug, Default, Deserialize)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(deny_unknown_fields)] pub struct SnippetExecReplaceConfig { /// Whether to enable snippet replace-executions, which automatically run code snippets without /// the user's intervention. pub enable: bool, } #[derive(Clone, Debug, Deserialize)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(deny_unknown_fields)] pub struct SnippetRenderConfig { /// The number of threads to use when rendering. #[serde(default = "default_snippet_render_threads")] pub threads: usize, } impl Default for SnippetRenderConfig { fn default() -> Self { Self { threads: default_snippet_render_threads() } } } pub(crate) fn default_snippet_render_threads() -> usize { 2 } #[derive(Clone, Debug, Deserialize)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(deny_unknown_fields)] pub struct TypstConfig { /// The pixels per inch when rendering latex/typst formulas. #[serde(default = "default_typst_ppi")] pub ppi: u32, } impl Default for TypstConfig { fn default() -> Self { Self { ppi: default_typst_ppi() } } } pub(crate) fn default_typst_ppi() -> u32 { 300 } #[derive(Clone, Debug, Deserialize)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(deny_unknown_fields)] pub struct MermaidConfig { /// The scaling parameter to be used in the mermaid CLI. #[serde(default = "default_mermaid_scale")] pub scale: u32, /// A path to a puppeteer JSON configuration file to be used by the `mmdc` tool. pub puppeteer_config_path: Option, /// A path to a mermaid JSON configuration file to be used by the `mmdc` tool. pub config_path: Option, } impl Default for MermaidConfig { fn default() -> Self { Self { scale: default_mermaid_scale(), puppeteer_config_path: None, config_path: None } } } pub(crate) fn default_mermaid_scale() -> u32 { 2 } #[derive(Clone, Debug, Default, Deserialize)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(deny_unknown_fields)] pub struct D2Config { /// The scaling parameter to be used in the d2 CLI. #[serde(default)] pub scale: Option, } pub(crate) fn default_u16_max() -> u16 { u16::MAX } /// The snippet execution configuration for a specific programming language. #[derive(Clone, Debug, Deserialize)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct LanguageSnippetExecutionConfig { #[serde(flatten)] pub executor: SnippetExecutorConfig, /// The prefix to use to hide lines visually but still execute them. pub hidden_line_prefix: Option, /// Alternative executors for this language. #[serde(default)] pub alternative: HashMap, } /// A snippet executor configuration. #[derive(Clone, Debug, Deserialize)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct SnippetExecutorConfig { /// The filename to use for the snippet input file. pub filename: String, /// The environment variables to set before invoking every command. #[serde(default)] pub environment: HashMap, /// The commands to be ran when executing snippets for this programming language. pub commands: Vec>, } #[derive(Clone, Debug, Default, Deserialize, ValueEnum)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "kebab-case")] pub enum ImageProtocol { /// Automatically detect the best image protocol to use. #[default] Auto, /// Use the iTerm2 image protocol. Iterm2, /// Use the iTerm2 image protocol in multipart mode. Iterm2Multipart, /// Use the kitty protocol in "local" mode, meaning both presenterm and the terminal run in the /// same host and can share the filesystem to communicate. KittyLocal, /// Use the kitty protocol in "remote" mode, meaning presenterm and the terminal run in /// different hosts and therefore can only communicate via terminal escape codes. KittyRemote, /// Use the sixel protocol. Sixel, /// The default image protocol to use when no other is specified. AsciiBlocks, } impl From<&ImageProtocol> for GraphicsMode { fn from(protocol: &ImageProtocol) -> Self { match protocol { ImageProtocol::Auto => { let emulator = TerminalEmulator::detect(); emulator.preferred_protocol() } ImageProtocol::Iterm2 => GraphicsMode::Iterm2, ImageProtocol::Iterm2Multipart => GraphicsMode::Iterm2Multipart, ImageProtocol::KittyLocal => GraphicsMode::Kitty { mode: KittyMode::Local }, ImageProtocol::KittyRemote => GraphicsMode::Kitty { mode: KittyMode::Remote }, ImageProtocol::AsciiBlocks => GraphicsMode::AsciiBlocks, ImageProtocol::Sixel => GraphicsMode::Sixel, } } } #[derive(Clone, Debug, Deserialize)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(deny_unknown_fields)] pub struct KeyBindingsConfig { /// The keys that cause the presentation to move forwards. #[serde(default = "default_next_bindings")] pub(crate) next: Vec, /// The keys that cause the presentation to jump to the next slide "fast". /// /// "fast" means for slides that contain pauses, we will skip all pauses and jump straight to /// the next slide. #[serde(default = "default_next_fast_bindings")] pub(crate) next_fast: Vec, /// The keys that cause the presentation to move backwards. #[serde(default = "default_previous_bindings")] pub(crate) previous: Vec, /// The keys that cause the presentation to move backwards "fast". /// /// "fast" means for slides that contain pauses, we will skip all pauses and jump straight to /// the previous slide. #[serde(default = "default_previous_fast_bindings")] pub(crate) previous_fast: Vec, /// The key binding to jump to the first slide. #[serde(default = "default_first_slide_bindings")] pub(crate) first_slide: Vec, /// The key binding to jump to the last slide. #[serde(default = "default_last_slide_bindings")] pub(crate) last_slide: Vec, /// The key binding to jump to a specific slide. #[serde(default = "default_go_to_slide_bindings")] pub(crate) go_to_slide: Vec, /// The key binding to execute a piece of shell code. #[serde(default = "default_execute_code_bindings")] pub(crate) execute_code: Vec, /// The key binding to reload the presentation. #[serde(default = "default_reload_bindings")] pub(crate) reload: Vec, /// The key binding to toggle the slide index modal. #[serde(default = "default_toggle_index_bindings")] pub(crate) toggle_slide_index: Vec, /// The key binding to toggle the key bindings modal. #[serde(default = "default_toggle_bindings_modal_bindings")] pub(crate) toggle_bindings: Vec, /// The key binding to toggle the layout grid. #[serde(default = "default_toggle_layout_grid")] pub(crate) toggle_layout_grid: Vec, /// The key binding to close the currently open modal. #[serde(default = "default_close_modal_bindings")] pub(crate) close_modal: Vec, /// The key binding to close the application. #[serde(default = "default_exit_bindings")] pub(crate) exit: Vec, /// The key binding to suspend the application. #[serde(default = "default_suspend_bindings")] pub(crate) suspend: Vec, /// The key binding to show the entire slide, after skipping any pauses in it. #[serde(default = "default_skip_pauses")] pub(crate) skip_pauses: Vec, } impl Default for KeyBindingsConfig { fn default() -> Self { Self { next: default_next_bindings(), next_fast: default_next_fast_bindings(), previous: default_previous_bindings(), previous_fast: default_previous_fast_bindings(), first_slide: default_first_slide_bindings(), last_slide: default_last_slide_bindings(), go_to_slide: default_go_to_slide_bindings(), execute_code: default_execute_code_bindings(), reload: default_reload_bindings(), toggle_slide_index: default_toggle_index_bindings(), toggle_bindings: default_toggle_bindings_modal_bindings(), toggle_layout_grid: default_toggle_layout_grid(), close_modal: default_close_modal_bindings(), exit: default_exit_bindings(), suspend: default_suspend_bindings(), skip_pauses: default_skip_pauses(), } } } #[derive(Clone, Debug, Deserialize)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(deny_unknown_fields)] pub struct SpeakerNotesConfig { /// The address in which to listen for speaker note events. #[serde(default = "default_speaker_notes_listen_address")] pub listen_address: SocketAddr, /// The address in which to publish speaker notes events. #[serde(default = "default_speaker_notes_publish_address")] pub publish_address: SocketAddr, /// Whether to always publish speaker notes. #[serde(default)] pub always_publish: bool, } impl Default for SpeakerNotesConfig { fn default() -> Self { Self { listen_address: default_speaker_notes_listen_address(), publish_address: default_speaker_notes_publish_address(), always_publish: false, } } } /// The export configuration. #[derive(Clone, Debug, Default, Deserialize)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(deny_unknown_fields)] pub struct ExportConfig { /// The dimensions to use for presentation exports. pub dimensions: Option, /// Whether pauses should create new slides. #[serde(default)] pub pauses: PauseExportPolicy, /// The policy for executable snippets when exporting. #[serde(default)] pub snippets: SnippetsExportPolicy, /// The PDF specific export configs. #[serde(default)] pub pdf: PdfExportConfig, } /// 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 policy for executable snippets 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 SnippetsExportPolicy { /// Render all executable snippets in parallel. #[default] Parallel, /// Render all executable snippets sequentially. Sequential, } /// 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 PDF export specific configs. #[derive(Clone, Debug, Default, Deserialize)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(deny_unknown_fields, rename_all = "snake_case")] pub struct PdfExportConfig { /// The path to the font file to be used. pub fonts: Option, } /// The fonts used for exports. #[derive(Clone, Debug, Default, Deserialize)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(deny_unknown_fields, rename_all = "snake_case")] pub struct ExportFontsConfig { /// The path to the font file to be used for the "normal" variable of this font. pub normal: PathBuf, /// The path to the font file to be used for the "bold" variable of this font. pub bold: Option, /// The path to the font file to be used for the "italic" variable of this font. pub italic: Option, /// The path to the font file to be used for the "bold+italic" variable of this font. pub bold_italic: Option, } // 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(raw_bindings: [&str; N]) -> Vec { let mut bindings = Vec::new(); for binding in raw_bindings { bindings.push(binding.parse().expect("invalid binding")); } bindings } fn default_next_bindings() -> Vec { make_keybindings(["l", "j", "", "", "", " "]) } fn default_next_fast_bindings() -> Vec { make_keybindings(["n"]) } fn default_previous_bindings() -> Vec { make_keybindings(["h", "k", "", "", ""]) } fn default_previous_fast_bindings() -> Vec { make_keybindings(["p"]) } fn default_first_slide_bindings() -> Vec { make_keybindings(["gg"]) } fn default_last_slide_bindings() -> Vec { make_keybindings(["G"]) } fn default_go_to_slide_bindings() -> Vec { make_keybindings(["G"]) } fn default_execute_code_bindings() -> Vec { make_keybindings([""]) } fn default_reload_bindings() -> Vec { make_keybindings([""]) } fn default_toggle_index_bindings() -> Vec { make_keybindings([""]) } fn default_toggle_bindings_modal_bindings() -> Vec { make_keybindings(["?"]) } fn default_toggle_layout_grid() -> Vec { make_keybindings(["T"]) } fn default_close_modal_bindings() -> Vec { make_keybindings([""]) } fn default_exit_bindings() -> Vec { make_keybindings(["", "q"]) } fn default_suspend_bindings() -> Vec { make_keybindings([""]) } fn default_skip_pauses() -> Vec { make_keybindings(["s"]) } fn default_transition_duration_millis() -> u16 { 1000 } fn default_transition_frames() -> usize { 30 } #[cfg(target_os = "linux")] pub(crate) fn default_speaker_notes_listen_address() -> SocketAddr { SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 255, 255, 255)), 59418) } #[cfg(not(target_os = "linux"))] pub(crate) fn default_speaker_notes_listen_address() -> SocketAddr { SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 59418) } #[cfg(not(target_os = "macos"))] pub(crate) fn default_speaker_notes_publish_address() -> SocketAddr { SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 255, 255, 255)), 59418) } #[cfg(target_os = "macos")] pub(crate) fn default_speaker_notes_publish_address() -> SocketAddr { SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 59418) } #[cfg(test)] mod test { use super::*; use crate::commands::keyboard::CommandKeyBindings; #[test] fn default_bindings() { let config = KeyBindingsConfig::default(); CommandKeyBindings::try_from(config).expect("construction failed"); } #[test] fn default_options_serde() { serde_yaml::from_str::<'_, OptionsConfig>("implicit_slide_ends: true").expect("failed to parse"); } } ================================================ FILE: src/demo.rs ================================================ use crate::{ ImageRegistry, MarkdownParser, PresentationBuilderOptions, Resources, ThemeOptions, Themes, ThirdPartyRender, code::execute::SnippetExecutor, commands::{ keyboard::{CommandKeyBindings, KeyboardListener}, listener::Command, }, markdown::elements::MarkdownElement, presentation::{ Presentation, builder::{PresentationBuilder, error::BuildError}, }, render::TerminalDrawer, terminal::emulator::TerminalEmulator, theme::raw::PresentationTheme, }; use std::{io, sync::Arc}; const PRESENTATION: &str = r#" # Header 1 ## Header 2 ### Header 3 #### Header 4 ##### Header 5 ###### Header 6 ```rust fn greet(name: &str) -> String { format!("hi {name}") } ```` * **bold text** * _italics_ * `some inline code` * ~strikethrough~ > a block quote "#; pub struct ThemesDemo { themes: Themes, input: KeyboardListener, drawer: TerminalDrawer, } impl ThemesDemo { pub fn new(themes: Themes, bindings: CommandKeyBindings) -> io::Result { let input = KeyboardListener::new(bindings); let drawer = TerminalDrawer::new(Default::default(), Default::default())?; Ok(Self { themes, input, drawer }) } pub fn run(mut self) -> Result<(), Box> { let arena = Default::default(); let parser = MarkdownParser::new(&arena); let elements = parser.parse(PRESENTATION).expect("broken demo presentation"); let mut presentations = Vec::new(); for theme_name in self.themes.presentation.theme_names() { let theme = self.themes.presentation.load_by_name(&theme_name).expect("theme not found"); let presentation = self.build(&elements, &theme_name, &theme)?; presentations.push(presentation); } let mut current = 0; loop { self.drawer.render_operations(presentations[current].current_slide().iter_visible_operations())?; let command = self.next_command()?; match command { DemoCommand::Next => current = (current + 1).min(presentations.len() - 1), DemoCommand::Previous => current = current.saturating_sub(1), DemoCommand::First => current = 0, DemoCommand::Last => current = presentations.len() - 1, DemoCommand::Exit => return Ok(()), }; } } fn next_command(&mut self) -> io::Result { loop { let mut command = self.input.next_command()?; while command.is_none() { command = self.input.next_command()?; } match command.unwrap() { Command::Next => return Ok(DemoCommand::Next), Command::Previous => return Ok(DemoCommand::Previous), Command::FirstSlide => return Ok(DemoCommand::First), Command::LastSlide => return Ok(DemoCommand::Last), Command::Exit => return Ok(DemoCommand::Exit), _ => continue, } } } fn build( &self, base_elements: &[MarkdownElement], theme_name: &str, theme: &PresentationTheme, ) -> Result { let image_registry = ImageRegistry::default(); let resources = Resources::new("non_existent", "non_existent", image_registry.clone()); let mut third_party = ThirdPartyRender::default(); let options = PresentationBuilderOptions { theme_options: ThemeOptions { font_size_supported: TerminalEmulator::capabilities().font_size }, ..Default::default() }; let executer = Arc::new(SnippetExecutor::default()); let bindings_config = Default::default(); let arena = Default::default(); let parser = MarkdownParser::new(&arena); let builder = PresentationBuilder::new( theme, resources, &mut third_party, executer, &self.themes, image_registry, bindings_config, &parser, options, )?; let mut elements = vec![MarkdownElement::SetexHeading { text: vec![format!("theme: {theme_name}").into()] }]; elements.extend(base_elements.iter().cloned()); builder.build_from_parsed(elements) } } enum DemoCommand { Next, Previous, First, Last, Exit, } #[cfg(test)] mod test { use super::*; #[test] fn demo_presentation() { let arena = Default::default(); let parser = MarkdownParser::new(&arena); parser.parse(PRESENTATION).expect("broken demo presentation"); } } ================================================ FILE: src/export/exporter.rs ================================================ use crate::{ MarkdownParser, Resources, code::execute::SnippetExecutor, config::{KeyBindingsConfig, PauseExportPolicy, PdfExportConfig, SnippetsExportPolicy}, export::output::{ExportRenderer, OutputFormat}, markdown::text_style::Color, presentation::{ Presentation, builder::{PresentationBuilder, PresentationBuilderOptions, Themes, error::BuildError}, poller::{Poller, PollerCommand}, }, render::{ RenderError, operation::{AsRenderOperations, PollableState, RenderOperation}, properties::WindowSize, }, terminal::image::printer::{ImagePrinter, ImageRegistry}, theme::{ProcessingThemeError, raw::PresentationTheme}, third_party::ThirdPartyRender, tools::{ExecutionError, ThirdPartyTools}, }; use crossterm::{ cursor::{MoveToColumn, MoveToNextLine, MoveUp}, execute, style::{Print, PrintStyledContent, Stylize}, terminal::{Clear, ClearType}, }; use image::ImageError; use std::{ fs, io, path::{Path, PathBuf}, rc::Rc, sync::Arc, }; use tempfile::TempDir; pub enum OutputDirectory { Temporary(TempDir), External(PathBuf), } impl OutputDirectory { pub fn temporary() -> io::Result { let dir = TempDir::with_suffix("presenterm")?; Ok(Self::Temporary(dir)) } pub fn external(path: PathBuf) -> io::Result { fs::create_dir_all(&path)?; Ok(Self::External(path)) } pub(crate) fn path(&self) -> &Path { match self { Self::Temporary(temp) => temp.path(), Self::External(path) => path, } } } /// Allows exporting presentations into PDF. pub struct Exporter<'a> { parser: MarkdownParser<'a>, default_theme: &'a PresentationTheme, resources: Resources, third_party: ThirdPartyRender, code_executor: Arc, image_printer: Arc, themes: Themes, dimensions: WindowSize, options: PresentationBuilderOptions, snippet_policy: SnippetsExportPolicy, } impl<'a> Exporter<'a> { /// Construct a new exporter. #[allow(clippy::too_many_arguments)] pub fn new( parser: MarkdownParser<'a>, default_theme: &'a PresentationTheme, resources: Resources, third_party: ThirdPartyRender, code_executor: Arc, image_printer: Arc, themes: Themes, mut options: PresentationBuilderOptions, mut dimensions: WindowSize, pause_policy: PauseExportPolicy, snippet_policy: SnippetsExportPolicy, ) -> Self { // We don't want dynamically highlighted code blocks. options.allow_mutations = false; options.theme_options.font_size_supported = true; options.pause_create_new_slide = match pause_policy { PauseExportPolicy::Ignore => false, PauseExportPolicy::NewSlide => true, }; // Make sure we have a 1:2 aspect ratio. let width = (0.5 * dimensions.columns as f64) / (dimensions.rows as f64 / dimensions.height as f64); dimensions.width = width as u16; Self { parser, default_theme, resources, third_party, code_executor, image_printer, themes, options, dimensions, snippet_policy, } } fn build_renderer( &mut self, presentation_path: &Path, output_directory: OutputDirectory, renderer: OutputFormat, ) -> Result { let mut presentation = PresentationBuilder::new( self.default_theme, self.resources.clone(), &mut self.third_party, self.code_executor.clone(), &self.themes, ImageRegistry::new(self.image_printer.clone()), KeyBindingsConfig::default(), &self.parser, self.options.clone(), )? .build(presentation_path)?; Self::validate_theme_colors(&presentation)?; let mut render = ExportRenderer::new(self.dimensions, output_directory, renderer); Self::log("waiting for images to be generated and code to be executed, if any...")?; match self.snippet_policy { SnippetsExportPolicy::Parallel => Self::wait_async_renders_parallel(&mut presentation), SnippetsExportPolicy::Sequential => Self::wait_async_renders_sequential(&mut presentation), }; for (index, slide) in presentation.into_slides().into_iter().enumerate() { let index = index + 1; Self::log(&format!("processing slide {index}..."))?; render.process_slide(slide)?; } Self::log("invoking weasyprint...")?; Ok(render) } /// Export the given presentation into PDF. pub fn export_pdf( mut self, presentation_path: &Path, output_directory: OutputDirectory, output_path: Option<&Path>, config: PdfExportConfig, ) -> Result<(), ExportError> { println!( "exporting using rows={}, columns={}, width={}, height={}", self.dimensions.rows, self.dimensions.columns, self.dimensions.width, self.dimensions.height ); println!("checking for weasyprint..."); Self::validate_weasyprint_exists()?; Self::log("weasyprint installation found")?; let render = self.build_renderer(presentation_path, output_directory, OutputFormat::Pdf)?; let pdf_path = match output_path { Some(path) => path.to_path_buf(), None => presentation_path.with_extension("pdf"), }; render.generate(&pdf_path, &config.fonts)?; execute!( io::stdout(), PrintStyledContent( format!("output file is at {}\n", pdf_path.display()).stylize().with(Color::Green.into()) ) )?; Ok(()) } /// Export the given presentation into HTML. pub fn export_html( mut self, presentation_path: &Path, output_directory: OutputDirectory, output_path: Option<&Path>, ) -> Result<(), ExportError> { println!( "exporting using rows={}, columns={}, width={}, height={}", self.dimensions.rows, self.dimensions.columns, self.dimensions.width, self.dimensions.height ); let render = self.build_renderer(presentation_path, output_directory, OutputFormat::Html)?; let output_path = match output_path { Some(path) => path.to_path_buf(), None => presentation_path.with_extension("html"), }; render.generate(&output_path, &None)?; execute!( io::stdout(), PrintStyledContent( format!("output file is at {}\n", output_path.display()).stylize().with(Color::Green.into()) ) )?; Ok(()) } fn wait_async_renders_parallel(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 wait_async_renders_sequential(presentation: &mut Presentation) { let poller = Poller::launch(); for (index, slide) in presentation.iter_slides_mut().enumerate() { for op in slide.iter_operations_mut() { if let RenderOperation::RenderAsync(inner) = op { // Send a pollable to the poller poller.send(PollerCommand::Poll { pollable: inner.pollable(), slide: index }); // Poll until it's done let mut pollable = inner.pollable(); while let PollableState::Unmodified | PollableState::Modified = pollable.poll() {} // Replace it with its contents let window_size = WindowSize { rows: 0, columns: 0, width: 0, height: 0 }; let new_operations = inner.as_render_operations(&window_size); *op = RenderOperation::RenderDynamic(Rc::new(RenderMany(new_operations))); } } } } fn validate_weasyprint_exists() -> Result<(), ExportError> { let result = ThirdPartyTools::weasyprint(&["--version"]).run_and_capture_stdout(); match result { Ok(_) => Ok(()), Err(ExecutionError::Execution { .. }) => Err(ExportError::WeasyprintMissing), Err(e) => Err(e.into()), } } fn validate_theme_colors(presentation: &Presentation) -> Result<(), ExportError> { for slide in presentation.iter_slides() { for operation in slide.iter_visible_operations() { let RenderOperation::SetColors(colors) = operation else { continue; }; // The PDF requires a specific theme to be set, as "no background" means "what the // browser uses" which is likely white and it will probably look terrible. It's // better to err early and let you choose a theme that contains _some_ color. if colors.background.is_none() { return Err(ExportError::UnsupportedColor("background")); } if colors.foreground.is_none() { return Err(ExportError::UnsupportedColor("foreground")); } } } Ok(()) } fn log(text: &str) -> io::Result<()> { execute!( io::stdout(), MoveUp(1), Clear(ClearType::CurrentLine), MoveToColumn(0), Print(text), MoveToNextLine(1) ) } } #[derive(thiserror::Error, Debug)] pub enum ExportError { #[error("failed to build presentation: {0}")] BuildPresentation(#[from] BuildError), #[error("unsupported {0} color in theme")] UnsupportedColor(&'static str), #[error("generating images: {0}")] GeneratingImages(#[from] ImageError), #[error(transparent)] Execution(#[from] ExecutionError), #[error("weasyprint not found")] WeasyprintMissing, #[error("processing theme: {0}")] ProcessingTheme(#[from] ProcessingThemeError), #[error("io: {0}")] Io(#[from] io::Error), #[error("render: {0}")] Render(#[from] RenderError), } #[derive(Debug)] struct RenderMany(Vec); impl AsRenderOperations for RenderMany { fn as_render_operations(&self, _: &WindowSize) -> Vec { self.0.clone() } } ================================================ FILE: src/export/html.rs ================================================ 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 { let mut text = text.to_string(); if style == &TextStyle::default() { return Self::Plain(text); } 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::Superscript => text = format!("{text}"), 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, 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, "{text}"), } } } 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, "hi"); } } ================================================ FILE: src/export/mod.rs ================================================ pub mod exporter; pub(crate) mod html; pub(crate) mod output; ================================================ FILE: src/export/output.rs ================================================ use super::{ exporter::{ExportError, OutputDirectory}, html::{FontSize, color_to_html}, }; use crate::{ config::ExportFontsConfig, 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}, }; const FONT_NAME: &str = "presenterm-font"; // 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, background_color: Option, } impl HtmlSlide { fn new(grid: TerminalGrid) -> Result { let mut rows = Vec::new(); rows.push(String::from("
")); for (y, row) in grid.rows.into_iter().enumerate() { let mut finalized_row = "
".to_string();
            let mut current_style = row.first().map(|c| c.style).unwrap_or_default();
            let mut current_string = String::new();
            let mut x = 0;
            while x < row.len() {
                let c = row[x];
                if c.style != current_style {
                    finalized_row.push_str(&Self::finalize_string(¤t_string, ¤t_style));
                    current_string = String::new();
                    current_style = c.style;
                }
                match c.character {
                    '<' => current_string.push_str("<"),
                    '>' => current_string.push_str(">"),
                    other => current_string.push(other),
                }
                if let Some(image) = grid.images.get(&(y as u16, x as u16)) {
                    let TerminalImage::Raw(raw_image) = image.image.image() else { panic!("not in raw image mode") };
                    let image_contents = raw_image.to_inline_html();
                    let width_pixels = (image.width_columns as f64 * FONT_SIZE as f64 * FONT_SIZE_WIDTH).ceil();
                    let image_tag = format!(
                        ""
                    );
                    current_string.push_str(&image_tag);
                }
                x += c.style.size as usize;
            }
            if !current_string.is_empty() {
                finalized_row.push_str(&Self::finalize_string(¤t_string, ¤t_style));
            }
            finalized_row.push_str("
"); rows.push(finalized_row); } rows.push(String::from("
")); 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 { 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, } 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, Default::default()); let engine = RenderEngine::new(&mut terminal, self.dimensions, 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, fonts: &Option) -> 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 FontConfig { font_face, font_family } = fonts.as_ref().map(Self::font_configs).unwrap_or_default(); let css = format!( r" pre {{ margin: 0; padding: 0; {font_family} }} span {{ display: inline-block; }} {font_face} 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!( " " ) } }; let style = match self.output_format { OutputFormat::Pdf => String::new(), OutputFormat::Html => format!( " " ), }; let html = format!( r" {style} {html_body} {html_script} " ); 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(()) } fn font_configs(config: &ExportFontsConfig) -> FontConfig { let mut font_face = Self::make_font_face(&config.normal, "normal", "normal"); if let Some(path) = &config.bold { font_face.push_str(&Self::make_font_face(path, "bold", "normal")); } if let Some(path) = &config.italic { font_face.push_str(&Self::make_font_face(path, "normal", "italic")); } if let Some(path) = &config.bold_italic { font_face.push_str(&Self::make_font_face(path, "bold", "italic")); } let font_family = format!("font-family: {FONT_NAME}"); FontConfig { font_face, font_family } } fn make_font_face(path: &Path, weight: &str, style: &str) -> String { let path = path.display(); format!( r" @font-face {{ font-family: {FONT_NAME}; src: url(file://{path}); font-weight: {weight}; font-style: {style}; }}" ) } } #[derive(Default)] struct FontConfig { font_face: String, font_family: String, } ================================================ FILE: src/export/script.js ================================================ 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(); }); ================================================ FILE: src/main.rs ================================================ use crate::{ code::{execute::SnippetExecutor, highlighting::HighlightThemeSet}, commands::listener::CommandListener, config::{Config, ImageProtocol, ValidateOverflows}, demo::ThemesDemo, export::exporter::Exporter, markdown::parse::MarkdownParser, presentation::builder::{CommentCommand, PresentationBuilderOptions, Themes}, presenter::{PresentMode, Presenter, PresenterOptions}, resource::Resources, terminal::{ GraphicsMode, image::printer::{ImagePrinter, ImageRegistry}, }, theme::{raw::PresentationTheme, registry::PresentationThemeRegistry}, third_party::{ThirdPartyConfigs, ThirdPartyRender}, }; use anyhow::anyhow; use clap::{CommandFactory, Parser, error::ErrorKind}; use commands::speaker_notes::{SpeakerNotesEventListener, SpeakerNotesEventPublisher}; use comrak::Arena; use config::ConfigLoadError; use crossterm::{ execute, style::{PrintStyledContent, Stylize}, }; use directories::ProjectDirs; use export::exporter::OutputDirectory; use render::{engine::MaxSize, properties::WindowSize}; use std::{ env::{self, current_dir}, io, path::{Path, PathBuf}, sync::Arc, time::Duration, }; use terminal::emulator::TerminalEmulator; use theme::ThemeOptions; mod code; mod commands; mod config; mod demo; mod export; mod markdown; mod presentation; mod presenter; mod render; mod resource; mod terminal; mod theme; mod third_party; mod tools; mod transitions; mod ui; mod utils; const DEFAULT_THEME: &str = "dark"; const DEFAULT_THEME_DYNAMIC_DETECTION_TIMEOUT: u64 = 100; 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)] #[command()] #[command(author, version, about = create_splash(), arg_required_else_help = true)] struct Cli { /// The path to the markdown file that contains the presentation. #[clap(group = "target")] path: Option, /// Export the presentation as a PDF rather than displaying it. #[clap(short, long, group = "export")] export_pdf: bool, /// Export the presentation as a HTML rather than displaying it. #[clap(short = 'E', 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, /// The output path for the exported PDF. #[clap(short = 'o', long = "output", requires = "export")] export_output: Option, /// Generate a JSON schema for the configuration file. #[clap(long)] #[cfg(feature = "json-schema")] generate_config_file_schema: bool, /// Use presentation mode. #[clap(short, long, default_value_t = false)] present: bool, /// The theme to use. #[clap(short, long)] theme: Option, /// List all supported themes. #[clap(long, group = "target")] list_themes: bool, /// Print the theme in use. #[clap(long, group = "target")] current_theme: bool, /// Display acknowledgements. #[clap(long, group = "target")] acknowledgements: bool, /// The image protocol to use. #[clap(long)] image_protocol: Option, /// Validate that the presentation does not overflow the terminal screen. #[clap(long)] validate_overflows: bool, /// Enable code snippet execution. #[clap(short = 'x', long)] enable_snippet_execution: bool, /// Enable code snippet auto execution via `+exec_replace` blocks. #[clap(short = 'X', long)] enable_snippet_execution_replace: bool, /// The path to the configuration file. #[clap(short, long, env = "PRESENTERM_CONFIG_FILE")] config_file: Option, /// Whether to publish speaker notes to local listeners. #[clap(short = 'P', long, group = "speaker-notes")] publish_speaker_notes: bool, /// Whether to listen for speaker notes. #[clap(short, long, group = "speaker-notes")] listen_speaker_notes: bool, /// Whether to validate snippets. #[clap(long)] validate_snippets: bool, /// List all available comment commands. #[clap(long, group = "target")] list_comment_commands: bool, } fn create_splash() -> String { let crate_version = env!("CARGO_PKG_VERSION"); format!( r#" ┌─┐┬─┐┌─┐┌─┐┌─┐┌┐┌┌┬┐┌─┐┬─┐┌┬┐ ├─┘├┬┘├┤ └─┐├┤ │││ │ ├┤ ├┬┘│││ ┴ ┴└─└─┘└─┘└─┘┘└┘ ┴ └─┘┴└─┴ ┴ v{crate_version} A terminal slideshow tool @mfontanini/presenterm "#, ) } #[derive(Default)] struct Customizations { config: Config, themes: Themes, themes_path: Option, code_executor: SnippetExecutor, } impl Customizations { fn load(config_file_path: Option, cwd: &Path) -> Result> { let configs_path: PathBuf = match env::var("XDG_CONFIG_HOME") { Ok(path) => Path::new(&path).join("presenterm"), Err(_) => { let Some(project_dirs) = ProjectDirs::from("", "", "presenterm") else { return Ok(Default::default()); }; project_dirs.config_dir().into() } }; let themes_path = configs_path.join("themes"); let themes = Self::load_themes(&themes_path)?; let require_config_file = config_file_path.is_some(); let config_file_path = config_file_path.unwrap_or_else(|| configs_path.join("config.yaml")); let config = match Config::load(&config_file_path) { Ok(config) => config, Err(ConfigLoadError::NotFound) if !require_config_file => Default::default(), Err(e) => return Err(e.into()), }; let code_executor = SnippetExecutor::new(config.snippet.exec.custom.clone(), cwd.to_path_buf())?; Ok(Customizations { config, themes, themes_path: Some(themes_path), code_executor }) } fn load_themes(themes_path: &Path) -> Result> { let mut highlight_themes = HighlightThemeSet::default(); highlight_themes.register_from_directory(themes_path.join("highlighting"))?; let mut presentation_themes = PresentationThemeRegistry::default(); presentation_themes.register_from_directory(themes_path)?; let themes = Themes { presentation: presentation_themes, highlight: highlight_themes }; Ok(themes) } } struct CoreComponents { third_party: ThirdPartyRender, code_executor: Arc, resources: Resources, printer: Arc, builder_options: PresentationBuilderOptions, themes: Themes, default_theme: PresentationTheme, config: Config, present_mode: PresentMode, graphics_mode: GraphicsMode, } impl CoreComponents { fn new(cli: &Cli, path: &Path) -> Result> { let mut resources_path = path.parent().unwrap_or(Path::new("./")).to_path_buf(); if resources_path == Path::new("") { resources_path = "./".into(); } let resources_path = resources_path.canonicalize().unwrap_or(resources_path); let Customizations { config, themes, code_executor, themes_path } = Customizations::load(cli.config_file.clone().map(PathBuf::from), &resources_path)?; let default_theme = Self::load_default_theme(&config, &themes, cli); let force_default_theme = cli.theme.is_some(); let present_mode = match (cli.present, cli.export_pdf) { (true, _) | (_, true) => PresentMode::Presentation, (false, false) => PresentMode::Development, }; let mut builder_options = Self::make_builder_options(&config, force_default_theme, cli.listen_speaker_notes); if cli.enable_snippet_execution { builder_options.enable_snippet_execution = true; } if cli.enable_snippet_execution_replace { builder_options.enable_snippet_execution_replace = true; } let graphics_mode = Self::select_graphics_mode(cli, &config); let printer = Arc::new(ImagePrinter::new(graphics_mode.clone())?); let registry = ImageRegistry::new(printer.clone()); let resources = Resources::new( resources_path.clone(), themes_path.unwrap_or_else(|| resources_path.clone()), registry.clone(), ); let third_party_config = ThirdPartyConfigs { typst_ppi: config.typst.ppi.to_string(), mermaid_scale: config.mermaid.scale.to_string(), mermaid_puppeteer_file: config.mermaid.puppeteer_config_path.clone(), mermaid_config_file: config.mermaid.config_path.clone(), d2_scale: config.d2.scale.map(|s| s.to_string()).unwrap_or_else(|| "-1".to_string()), threads: config.snippet.render.threads, }; let third_party = ThirdPartyRender::new(third_party_config, registry, &resources_path); let code_executor = Arc::new(code_executor); Ok(Self { third_party, code_executor, resources, printer, builder_options, themes, default_theme, config, present_mode, graphics_mode, }) } fn make_builder_options( config: &Config, force_default_theme: bool, render_speaker_notes_only: bool, ) -> PresentationBuilderOptions { let options = &config.options; PresentationBuilderOptions { allow_mutations: true, implicit_slide_ends: options.implicit_slide_ends.unwrap_or_default(), command_prefix: options.command_prefix.clone().unwrap_or_default(), image_attribute_prefix: options.image_attributes_prefix.clone().unwrap_or_else(|| "image:".to_string()), incremental_lists: options.incremental_lists.unwrap_or_default(), incremental_tables: options.incremental_tables.unwrap_or_default(), force_default_theme, end_slide_shorthand: options.end_slide_shorthand.unwrap_or_default(), print_modal_background: false, strict_front_matter_parsing: options.strict_front_matter_parsing.unwrap_or(true), enable_snippet_execution: config.snippet.exec.enable, enable_snippet_execution_replace: config.snippet.exec_replace.enable, render_speaker_notes_only, auto_render_languages: options.auto_render_languages.clone(), theme_options: ThemeOptions { font_size_supported: TerminalEmulator::capabilities().font_size }, pause_before_incremental_lists: config.defaults.incremental_lists.pause_before.unwrap_or(true), pause_after_incremental_lists: config.defaults.incremental_lists.pause_after.unwrap_or(true), pause_before_incremental_tables: config.defaults.incremental_tables.pause_before.unwrap_or(true), pause_after_incremental_tables: config.defaults.incremental_tables.pause_after.unwrap_or(true), pause_create_new_slide: false, list_item_newlines: options.list_item_newlines.map(Into::into).unwrap_or(1), validate_snippets: config.snippet.validate, layout_grid: false, h1_slide_titles: options.h1_slide_titles.unwrap_or_default(), } } fn select_graphics_mode(cli: &Cli, config: &Config) -> GraphicsMode { if cli.export_pdf | cli.export_html { GraphicsMode::Raw } else { cli.image_protocol.as_ref().unwrap_or(&config.defaults.image_protocol).into() } } fn theme_name(config: &Config, cli: &Cli) -> String { if let Some(name) = cli.theme.as_ref() { name.clone() } else { match &config.defaults.theme { config::ThemeConfig::None => DEFAULT_THEME.into(), config::ThemeConfig::Some(theme_name) => theme_name.clone(), config::ThemeConfig::Dynamic { dark, light, timeout } => { let default_timeout = timeout.unwrap_or(DEFAULT_THEME_DYNAMIC_DETECTION_TIMEOUT); let timeout_duration = Duration::from_millis(default_timeout); if let Ok(theme) = termbg::theme(timeout_duration) { if theme == termbg::Theme::Dark { dark.clone() } else { light.clone() } } else { Cli::command() .error( ErrorKind::Io, "terminal theme detection failed, unsupported terminal or timeout exceeded", ) .exit(); } } } } } fn load_default_theme(config: &Config, themes: &Themes, cli: &Cli) -> PresentationTheme { let default_theme_name = Self::theme_name(config, cli); let Some(default_theme) = themes.presentation.load_by_name(default_theme_name.as_str()) else { let valid_themes = themes.presentation.theme_names().join(", "); let error_message = format!("invalid theme name, valid themes are: {valid_themes}"); Cli::command().error(ErrorKind::InvalidValue, error_message).exit(); }; default_theme } } struct SpeakerNotesComponents { events_listener: Option, events_publisher: Option, } impl SpeakerNotesComponents { fn new(cli: &Cli, config: &Config, path: &Path) -> anyhow::Result { let full_presentation_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); let publish_speaker_notes = cli.publish_speaker_notes || (config.speaker_notes.always_publish && !cli.listen_speaker_notes); let events_publisher = publish_speaker_notes .then(|| { SpeakerNotesEventPublisher::new(config.speaker_notes.publish_address, full_presentation_path.clone()) }) .transpose() .map_err(|e| anyhow!("failed to create speaker notes publisher: {e}"))?; let events_listener = cli .listen_speaker_notes .then(|| SpeakerNotesEventListener::new(config.speaker_notes.listen_address, full_presentation_path)) .transpose() .map_err(|e| anyhow!("failed to create speaker notes listener: {e}"))?; Ok(Self { events_listener, events_publisher }) } } fn overflow_validation_enabled(mode: &PresentMode, config: &ValidateOverflows) -> bool { match (config, mode) { (ValidateOverflows::Always, _) => true, (ValidateOverflows::Never, _) => false, (ValidateOverflows::WhenPresenting, PresentMode::Presentation) => true, (ValidateOverflows::WhenDeveloping, PresentMode::Development) => true, _ => false, } } fn run(cli: Cli) -> Result<(), Box> { #[cfg(feature = "json-schema")] if cli.generate_config_file_schema { let schema = schemars::schema_for!(Config); serde_json::to_writer_pretty(io::stdout(), &schema).map_err(|e| format!("failed to write schema: {e}"))?; return Ok(()); } if cli.acknowledgements { let acknowledgements = include_bytes!("../bat/acknowledgements.txt"); println!("{}", String::from_utf8_lossy(acknowledgements)); return Ok(()); } else if cli.list_themes { // Load this ahead of time so we don't do it when we're already in raw mode. TerminalEmulator::capabilities(); let Customizations { config, themes, .. } = Customizations::load(cli.config_file.clone().map(PathBuf::from), ¤t_dir()?)?; let bindings = config.bindings.try_into()?; let demo = ThemesDemo::new(themes, bindings)?; demo.run()?; return Ok(()); } else if cli.current_theme { let Customizations { config, .. } = Customizations::load(cli.config_file.clone().map(PathBuf::from), ¤t_dir()?)?; let theme_name = CoreComponents::theme_name(&config, &cli); println!("{theme_name}"); return Ok(()); } else if cli.list_comment_commands { let samples = CommentCommand::generate_samples(); for sample in samples { println!("{}", sample); } return Ok(()); } // Disable this so we don't mess things up when generating PDFs if cli.export_pdf { TerminalEmulator::disable_capability_detection(); } let Some(path) = cli.path.clone() else { Cli::command().error(ErrorKind::MissingRequiredArgument, "no path specified").exit(); }; let CoreComponents { third_party, code_executor, resources, printer, mut builder_options, themes, default_theme, config, present_mode, graphics_mode, } = CoreComponents::new(&cli, &path)?; let arena = Arena::new(); let parser = MarkdownParser::new(&arena); let validate_overflows = overflow_validation_enabled(&present_mode, &config.defaults.validate_overflows) || cli.validate_overflows; if cli.validate_snippets { builder_options.validate_snippets = cli.validate_snippets; } if cli.export_pdf || cli.export_html { let dimensions = match config.export.dimensions { Some(dimensions) => WindowSize { rows: dimensions.rows, columns: dimensions.columns, height: dimensions.rows * DEFAULT_EXPORT_PIXELS_PER_ROW, width: dimensions.columns * DEFAULT_EXPORT_PIXELS_PER_COLUMN, }, None => WindowSize::current(config.defaults.terminal_font_size)?, }; let exporter = Exporter::new( parser, &default_theme, resources, third_party, code_executor, printer, themes, builder_options, dimensions, config.export.pauses, config.export.snippets, ); 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(), config.export.pdf)?; } else { exporter.export_html(&path, output_directory, cli.export_output.as_deref())?; } } else { let SpeakerNotesComponents { events_listener, events_publisher } = SpeakerNotesComponents::new(&cli, &config, &path)?; let command_listener = CommandListener::new(config.bindings.clone(), events_listener)?; builder_options.print_modal_background = matches!(graphics_mode, GraphicsMode::Kitty { .. }); let options = PresenterOptions { builder_options, mode: present_mode, font_size_fallback: config.defaults.terminal_font_size, bindings: config.bindings, validate_overflows, max_size: MaxSize { max_columns: config.defaults.max_columns, max_columns_alignment: config.defaults.max_columns_alignment, max_rows: config.defaults.max_rows, max_rows_alignment: config.defaults.max_rows_alignment, }, transition: config.transition, }; let presenter = Presenter::new( &default_theme, command_listener, parser, resources, third_party, code_executor, themes, printer, options, events_publisher, ); presenter.present(&path)?; } Ok(()) } fn main() { let cli = Cli::parse(); if let Err(e) = run(cli) { let _ = execute!(io::stdout(), PrintStyledContent(format!("{e}\n").stylize().with(crossterm::style::Color::Red))); std::process::exit(1); } } ================================================ FILE: src/markdown/elements.rs ================================================ use super::text_style::{Color, TextStyle, UndefinedPaletteColorError}; use crate::theme::{ColorPalette, raw::RawColor}; use comrak::nodes::AlertType; use std::{fmt, iter, path::PathBuf, str::FromStr}; use unicode_width::UnicodeWidthStr; /// A markdown element. /// /// This represents each of the supported markdown elements. The structure here differs a bit from /// the spec, mostly in how inlines are handled, to simplify its processing. #[derive(Clone, Debug)] pub(crate) enum MarkdownElement { /// The front matter that optionally shows up at the beginning of the file. FrontMatter(String), /// A setex heading. SetexHeading { text: Vec> }, /// A normal heading. Heading { level: u8, text: Line }, /// A paragraph composed by a list of lines. Paragraph(Vec>), /// An image. Image { path: PathBuf, title: String, source_position: SourcePosition }, /// A list. /// /// All contiguous list items are merged into a single one, regardless of levels of nesting. List(Vec), /// A code snippet. Snippet { /// The information line that specifies this code's language, attributes, etc. info: String, /// The code in this snippet. code: String, /// The position in the source file this snippet came from. source_position: SourcePosition, }, /// A table. Table(Table), /// A thematic break. ThematicBreak, /// An HTML comment. Comment { comment: String, source_position: SourcePosition }, /// A block quote containing a list of lines. BlockQuote(Vec>), /// An alert. Alert { /// The alert's type. alert_type: AlertType, /// The optional title. title: Option, /// The content lines in this alert. lines: Vec>, }, /// A footnote definition. Footnote(Line), } #[derive(Clone, Copy, Debug, Default)] pub struct SourcePosition { pub(crate) start: LineColumn, } impl fmt::Display for SourcePosition { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}:{}", self.start.line, self.start.column) } } impl From for SourcePosition { fn from(position: comrak::nodes::Sourcepos) -> Self { Self { start: position.start.into() } } } #[derive(Clone, Copy, Debug, Default)] pub(crate) struct LineColumn { pub(crate) line: usize, pub(crate) column: usize, } impl From for LineColumn { fn from(position: comrak::nodes::LineColumn) -> Self { Self { line: position.line, column: position.column } } } /// A text line. /// /// Text is represented as a series of chunks, each with their own formatting. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct Line(pub(crate) Vec>); impl Default for Line { fn default() -> Self { Self(vec![]) } } impl Line { /// Get the total width for this text. pub(crate) fn width(&self) -> usize { self.0.iter().map(|text| text.content.width()).sum() } } impl Line { /// Applies the given style to this text. pub(crate) fn apply_style(&mut self, style: &TextStyle) { for text in &mut self.0 { text.style.merge(style); } } } impl Line { /// Resolve the colors in this line. pub(crate) fn resolve(self, palette: &ColorPalette) -> Result, UndefinedPaletteColorError> { let mut output = Vec::with_capacity(self.0.len()); for text in self.0 { let style = text.style.resolve(palette)?; output.push(Text::new(text.content, style)); } Ok(Line(output)) } } impl>> From for Line { fn from(text: T) -> Self { Self(vec![text.into()]) } } /// A styled piece of text. /// /// This is the most granular text representation: a `String` and a style. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct Text { pub(crate) content: String, pub(crate) style: TextStyle, } impl Default for Text { fn default() -> Self { Self { content: Default::default(), style: TextStyle::default() } } } impl Text { /// Construct a new styled text. pub(crate) fn new>(content: S, style: TextStyle) -> Self { Self { content: content.into(), style } } /// Get the width of this text. pub(crate) fn width(&self) -> usize { self.content.width() } } impl From for Text { fn from(text: String) -> Self { Self { content: text, style: TextStyle::default() } } } impl From<&str> for Text { fn from(text: &str) -> Self { Self { content: text.into(), style: TextStyle::default() } } } /// A list item. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct ListItem { /// The depth of this item. /// /// This increases by one for every nested list level. pub(crate) depth: u8, /// The contents of this list item. pub(crate) contents: Line, /// The type of list item. pub(crate) item_type: ListItemType, } /// The type of a list item. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum ListItemType { /// A list item for an unordered list. Unordered, /// A list item for an ordered list that uses parenthesis after the list item number. OrderedParens(usize), /// A list item for an ordered list that uses a period after the list item number. OrderedPeriod(usize), } /// A table. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct Table { /// The table's header. pub(crate) header: TableRow, /// All of the rows in this table, excluding the header. pub(crate) rows: Vec, } impl Table { /// gets the number of columns in this table. pub(crate) fn columns(&self) -> usize { self.header.0.len() } /// Iterates all the text entries in a column. /// /// This includes the header. pub(crate) fn iter_column(&self, column: usize) -> impl Iterator> { let header_element = &self.header.0[column]; let row_elements = self.rows.iter().map(move |row| &row.0[column]); iter::once(header_element).chain(row_elements) } } /// A table row. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct TableRow(pub(crate) Vec>); /// A percentage. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct Percent(pub(crate) u8); impl Percent { pub(crate) fn as_ratio(&self) -> f64 { self.0 as f64 / 100.0 } } impl FromStr for Percent { type Err = PercentParseError; fn from_str(input: &str) -> Result { let (prefix, suffix) = input.split_once('%').ok_or(PercentParseError::Unit)?; let value: u8 = prefix.parse().map_err(|_| PercentParseError::Value)?; if !(1..=100).contains(&value) { return Err(PercentParseError::Value); } if !suffix.is_empty() { return Err(PercentParseError::Trailer(suffix.into())); } Ok(Percent(value)) } } #[derive(thiserror::Error, Debug)] pub enum PercentParseError { #[error("value must be a number between 1-100")] Value, #[error("no unit provided")] Unit, #[error("unexpected: '{0}'")] Trailer(String), } ================================================ FILE: src/markdown/html.rs ================================================ use super::text_style::{Color, TextStyle}; use crate::theme::raw::{ParseColorError, RawColor}; use std::{borrow::Cow, str, str::Utf8Error}; use tl::Attributes; pub(crate) struct HtmlParseOptions { pub(crate) strict: bool, } impl Default for HtmlParseOptions { fn default() -> Self { Self { strict: true } } } #[derive(Default)] pub(crate) struct HtmlParser { options: HtmlParseOptions, } impl HtmlParser { pub(crate) fn parse(self, input: &str) -> Result { if input.starts_with(" (HtmlTag::Span, TextStyle::default()), b"sup" => (HtmlTag::Sup, TextStyle::default().superscript()), _ => return Err(ParseHtmlError::UnsupportedHtml), }; let style = self.parse_attributes(tag.attributes())?; Ok(HtmlInline::OpenTag { style: style.merged(&base_style), tag: output_tag }) } fn parse_attributes(&self, attributes: &Attributes) -> Result, ParseHtmlError> { let mut style = TextStyle::default(); for (name, value) in attributes.iter() { let value = value.unwrap_or(Cow::Borrowed("")); match name.as_ref() { "style" => self.parse_css_attribute(&value, &mut style)?, "class" => { style = style.fg_color(RawColor::ForegroundClass(value.to_string())); style = style.bg_color(RawColor::BackgroundClass(value.to_string())); } _ => { if self.options.strict { return Err(ParseHtmlError::UnsupportedTagAttribute(name.to_string())); } } } } Ok(style) } fn parse_css_attribute(&self, attribute: &str, style: &mut TextStyle) -> Result<(), ParseHtmlError> { for attribute in attribute.split(';') { let attribute = attribute.trim(); if attribute.is_empty() { continue; } let (key, value) = attribute.split_once(':').ok_or(ParseHtmlError::NoColonInAttribute)?; let key = key.trim(); let value = value.trim(); match key { "color" => style.colors.foreground = Some(Self::parse_color(value)?), "background-color" => style.colors.background = Some(Self::parse_color(value)?), _ => { if self.options.strict { return Err(ParseHtmlError::UnsupportedCssAttribute(key.into())); } } } } Ok(()) } fn parse_color(input: &str) -> Result { if input.starts_with('#') { let color = input.strip_prefix('#').unwrap().parse()?; if matches!(color, RawColor::Color(Color::Rgb { .. })) { Ok(color) } else { Ok(input.parse()?) } } else { let color = input.parse::()?; if matches!(color, RawColor::Color(Color::Rgb { .. })) { Err(ParseHtmlError::InvalidColor("missing '#' in rgb color".into())) } else { Ok(color) } } } } #[derive(Debug, PartialEq)] pub(crate) enum HtmlInline { OpenTag { style: TextStyle, tag: HtmlTag }, CloseTag { tag: HtmlTag }, } #[derive(Clone, Debug, PartialEq)] pub(crate) enum HtmlTag { Span, Sup, } #[derive(Debug, thiserror::Error)] pub(crate) enum ParseHtmlError { #[error("parsing html failed: {0}")] ParsingHtml(#[from] tl::ParseError), #[error("no html tags found")] NoTags, #[error("non utf8 content: {0}")] NotUtf8(#[from] Utf8Error), #[error("attribute has no ':'")] NoColonInAttribute, #[error("invalid color: {0}")] InvalidColor(String), #[error("invalid css attribute: {0}")] UnsupportedCssAttribute(String), #[error("HTML can only contain span and sup tags")] UnsupportedHtml, #[error("unsupported tag attribute: {0}")] UnsupportedTagAttribute(String), #[error("unsupported closing tag: {0}")] UnsupportedClosingTag(String), } impl From for ParseHtmlError { fn from(e: ParseColorError) -> Self { Self::InvalidColor(e.to_string()) } } #[cfg(test)] mod tests { use super::*; use rstest::rstest; #[test] fn parse_style() { let tag = HtmlParser::default().parse(r#""#).expect("parse failed"); let HtmlInline::OpenTag { style, tag: HtmlTag::Span } = tag else { panic!("not an open tag") }; assert_eq!(style, TextStyle::default().bg_color(Color::Black).fg_color(Color::Red)); } #[test] fn parse_sup() { let tag = HtmlParser::default().parse(r#""#).expect("parse failed"); let HtmlInline::OpenTag { style, tag: HtmlTag::Sup } = tag else { panic!("not an open tag") }; assert_eq!(style, TextStyle::default().superscript()); } #[test] fn parse_class() { let tag = HtmlParser::default().parse(r#""#).expect("parse failed"); let HtmlInline::OpenTag { style, tag: HtmlTag::Span } = tag else { panic!("not an open tag") }; assert_eq!( style, TextStyle::default() .bg_color(RawColor::BackgroundClass("foo".into())) .fg_color(RawColor::ForegroundClass("foo".into())) ); } #[rstest] #[case::span("", HtmlTag::Span)] #[case::sup("", HtmlTag::Sup)] fn parse_end_tag(#[case] input: &str, #[case] tag: HtmlTag) { let inline = HtmlParser::default().parse(input).expect("parse failed"); assert_eq!(inline, HtmlInline::CloseTag { tag }); } #[rstest] #[case::invalid_start_tag("
")] #[case::invalid_end_tag("
")] #[case::invalid_attribute("")] #[case::invalid_attribute(" = Result; struct ParserOptions(comrak::Options<'static>); impl Default for ParserOptions { fn default() -> Self { let mut options = Options::default(); options.extension.front_matter_delimiter = Some("---".into()); options.extension.table = true; options.extension.strikethrough = true; options.extension.multiline_block_quotes = true; options.extension.alerts = true; options.extension.wikilinks_title_before_pipe = true; options.extension.superscript = true; options.extension.footnotes = true; options.parse.leave_footnote_definitions = true; Self(options) } } /// A markdown parser. /// /// This takes the contents of a markdown file and parses it into a list of [MarkdownElement]. pub struct MarkdownParser<'a> { arena: &'a Arena<'a>, options: comrak::Options<'static>, } impl<'a> MarkdownParser<'a> { /// Construct a new markdown parser. pub fn new(arena: &'a Arena<'a>) -> Self { Self { arena, options: ParserOptions::default().0 } } /// Parse the contents of a markdown file. pub(crate) fn parse(&self, contents: &str) -> ParseResult> { let node = parse_document(self.arena, contents, &self.options); let mut elements = Vec::new(); for node in node.children() { let parsed_elements = self.parse_node(node).map_err(|e| ParseError::new(e.kind, e.sourcepos))?; elements.extend(parsed_elements); } Ok(elements) } /// Parse inlines in a markdown input. pub(crate) fn parse_inlines(&self, line: &str) -> Result, ParseInlinesError> { let node = parse_document(self.arena, line, &self.options); if node.children().count() == 0 { return Ok(Default::default()); } if node.children().count() > 1 { return Err(ParseInlinesError("inline must be simple text".into())); } let node = node.first_child().expect("must have one child"); let data = node.data.borrow(); let NodeValue::Paragraph = &data.value else { return Err(ParseInlinesError("inline must be simple text".into())); }; let parser = InlinesParser::new(self.arena, SoftBreak::Space, StringifyImages::No); let inlines = parser.parse(node).map_err(|e| ParseInlinesError(e.to_string()))?; let mut output = Line::default(); for inline in inlines { match inline { Inline::Text(line) => { output.0.extend(line.0); } Inline::Image { .. } => return Err(ParseInlinesError("images not supported".into())), Inline::LineBreak => return Err(ParseInlinesError("line breaks not supported".into())), }; } Ok(output) } fn parse_node(&self, node: &'a AstNode<'a>) -> ParseResult> { let data = node.data.borrow(); let element = match &data.value { // Paragraphs are the only ones that can actually yield more than one. NodeValue::Paragraph => return self.parse_paragraph(node), NodeValue::FrontMatter(contents) => Self::parse_front_matter(contents)?, NodeValue::Heading(heading) => self.parse_heading(heading, node)?, NodeValue::List(list) => { let items = self.parse_list(node, list.marker_offset as u8 / 2)?; MarkdownElement::List(items) } NodeValue::Table(_) => self.parse_table(node)?, NodeValue::CodeBlock(block) => Self::parse_code_block(block, data.sourcepos)?, NodeValue::ThematicBreak => MarkdownElement::ThematicBreak, NodeValue::HtmlBlock(block) => self.parse_html_block(block, data.sourcepos)?, NodeValue::BlockQuote | NodeValue::MultilineBlockQuote(_) => self.parse_block_quote(node)?, NodeValue::Alert(alert) => self.parse_alert(alert, node)?, NodeValue::FootnoteDefinition(definition) => self.parse_footnote_definition(definition, node)?, other => return Err(ParseErrorKind::UnsupportedElement(other.identifier()).with_sourcepos(data.sourcepos)), }; Ok(vec![element]) } fn parse_front_matter(contents: &str) -> ParseResult { // Remove leading and trailing delimiters before parsing. This is quite poopy but hey, it // works. let contents = contents.strip_prefix("---\n").unwrap_or(contents); let contents = contents.strip_prefix("---\r\n").unwrap_or(contents); let contents = contents.strip_suffix("---\n").unwrap_or(contents); let contents = contents.strip_suffix("---\r\n").unwrap_or(contents); let contents = contents.strip_suffix("---\n\n").unwrap_or(contents); let contents = contents.strip_suffix("---\r\n\r\n").unwrap_or(contents); Ok(MarkdownElement::FrontMatter(contents.into())) } fn parse_html_block(&self, block: &NodeHtmlBlock, sourcepos: Sourcepos) -> ParseResult { let block = block.literal.trim(); let start_tag = ""; if !block.starts_with(start_tag) || !block.ends_with(end_tag) { return Err(ParseErrorKind::UnsupportedElement("html block").with_sourcepos(sourcepos)); } let block = &block[start_tag.len()..]; let block = &block[0..block.len() - end_tag.len()]; Ok(MarkdownElement::Comment { comment: block.into(), source_position: sourcepos.into() }) } fn parse_block_quote(&self, node: &'a AstNode<'a>) -> ParseResult { let mut lines = Vec::new(); let inlines = InlinesParser::new(self.arena, SoftBreak::Newline, StringifyImages::Yes).parse(node)?; for inline in inlines { match inline { Inline::Text(text) => lines.push(text), Inline::LineBreak => lines.push(Line::from("")), Inline::Image { .. } => {} } } if lines.last() == Some(&Line::::from("")) { lines.pop(); } Ok(MarkdownElement::BlockQuote(lines)) } fn parse_code_block(block: &NodeCodeBlock, sourcepos: Sourcepos) -> ParseResult { if !block.fenced { return Err(ParseErrorKind::UnfencedCodeBlock.with_sourcepos(sourcepos)); } Ok(MarkdownElement::Snippet { info: block.info.clone(), code: block.literal.clone(), source_position: sourcepos.into(), }) } fn parse_alert(&self, alert: &NodeAlert, node: &'a AstNode<'a>) -> ParseResult { let MarkdownElement::BlockQuote(lines) = self.parse_block_quote(node)? else { panic!("not a block quote") }; Ok(MarkdownElement::Alert { alert_type: alert.alert_type, title: alert.title.clone(), lines }) } fn parse_footnote_definition( &self, definition: &NodeFootnoteDefinition, node: &'a AstNode<'a>, ) -> ParseResult { let mut line = vec![Text::new(definition.name.clone(), TextStyle::default().superscript())]; let inlines = InlinesParser::new(self.arena, SoftBreak::Space, StringifyImages::Yes).parse(node)?; for inline in inlines { match inline { Inline::Text(text) => line.extend(text.0), Inline::LineBreak | Inline::Image { .. } => {} } } Ok(MarkdownElement::Footnote(Line(line))) } fn parse_heading(&self, heading: &NodeHeading, node: &'a AstNode<'a>) -> ParseResult { if heading.setext { let text = self.parse_exheading(node)?; Ok(MarkdownElement::SetexHeading { text }) } else { let text = self.parse_text(node)?; Ok(MarkdownElement::Heading { text, level: heading.level }) } } fn parse_paragraph(&self, node: &'a AstNode<'a>) -> ParseResult> { let mut elements = Vec::new(); let inlines = InlinesParser::new(self.arena, SoftBreak::Space, StringifyImages::No).parse(node)?; let mut paragraph_elements = Vec::new(); for inline in inlines { match inline { Inline::Text(text) => paragraph_elements.push(text), Inline::LineBreak => (), Inline::Image { path, title } => { if !paragraph_elements.is_empty() { elements.push(MarkdownElement::Paragraph(mem::take(&mut paragraph_elements))); } elements.push(MarkdownElement::Image { path: path.into(), title, source_position: node.data.borrow().sourcepos.into(), }); } } } if !paragraph_elements.is_empty() { elements.push(MarkdownElement::Paragraph(mem::take(&mut paragraph_elements))); } Ok(elements) } fn parse_exheading(&self, node: &'a AstNode<'a>) -> ParseResult>> { let inlines = InlinesParser::new(self.arena, SoftBreak::Space, StringifyImages::No).parse(node)?; let mut lines = Vec::new(); let mut chunks = Vec::new(); for inline in inlines { match inline { Inline::Text(text) => chunks.extend(text.0), Inline::LineBreak => { lines.push(Line(chunks)); chunks = Vec::new(); } other => { return Err(ParseErrorKind::UnsupportedStructure { container: "text", element: other.kind() } .with_sourcepos(node.data.borrow().sourcepos)); } }; } lines.push(Line(chunks)); Ok(lines) } fn parse_text(&self, node: &'a AstNode<'a>) -> ParseResult> { let inlines = InlinesParser::new(self.arena, SoftBreak::Space, StringifyImages::No).parse(node)?; let mut chunks = Vec::new(); for inline in inlines { match inline { Inline::Text(text) => chunks.extend(text.0), other => { return Err(ParseErrorKind::UnsupportedStructure { container: "text", element: other.kind() } .with_sourcepos(node.data.borrow().sourcepos)); } }; } Ok(Line(chunks)) } fn parse_list(&self, root: &'a AstNode<'a>, depth: u8) -> ParseResult> { let mut elements = Vec::new(); for node in root.children() { let data = node.data.borrow(); match &data.value { NodeValue::Item(item) => { elements.extend(self.parse_list_item(item, node, depth)?); } other => { return Err(ParseErrorKind::UnsupportedStructure { container: "list", element: other.identifier(), } .with_sourcepos(data.sourcepos)); } }; } Ok(elements) } fn parse_list_item(&self, item: &NodeList, root: &'a AstNode<'a>, depth: u8) -> ParseResult> { let item_type = match (item.list_type, item.delimiter) { (ListType::Bullet, _) => ListItemType::Unordered, (ListType::Ordered, ListDelimType::Paren) => ListItemType::OrderedParens(item.start), (ListType::Ordered, ListDelimType::Period) => ListItemType::OrderedPeriod(item.start), }; let mut elements = Vec::new(); for node in root.children() { let data = node.data.borrow(); match &data.value { NodeValue::Paragraph => { let contents = self.parse_text(node)?; elements.push(ListItem { contents, depth, item_type: item_type.clone() }); } NodeValue::List(_) => { elements.extend(self.parse_list(node, depth + 1)?); } other => { return Err(ParseErrorKind::UnsupportedStructure { container: "list", element: other.identifier(), } .with_sourcepos(data.sourcepos)); } } } Ok(elements) } fn parse_table(&self, node: &'a AstNode<'a>) -> ParseResult { let mut header = TableRow(Vec::new()); let mut rows = Vec::new(); for node in node.children() { let data = node.data.borrow(); let NodeValue::TableRow(_) = &data.value else { return Err(ParseErrorKind::UnsupportedStructure { container: "table", element: data.value.identifier(), } .with_sourcepos(data.sourcepos)); }; let row = self.parse_table_row(node)?; if header.0.is_empty() { header = row; } else { rows.push(row) } } Ok(MarkdownElement::Table(Table { header, rows })) } fn parse_table_row(&self, node: &'a AstNode<'a>) -> ParseResult { let mut cells = Vec::new(); for node in node.children() { let data = node.data.borrow(); let NodeValue::TableCell = &data.value else { return Err(ParseErrorKind::UnsupportedStructure { container: "table", element: data.value.identifier(), } .with_sourcepos(data.sourcepos)); }; let text = self.parse_text(node)?; cells.push(text); } Ok(TableRow(cells)) } } enum SoftBreak { Newline, Space, } enum StringifyImages { Yes, No, } struct InlinesParser<'a> { inlines: Vec, pending_text: Vec>, arena: &'a Arena<'a>, soft_break: SoftBreak, stringify_images: StringifyImages, } impl<'a> InlinesParser<'a> { fn new(arena: &'a Arena<'a>, soft_break: SoftBreak, stringify_images: StringifyImages) -> Self { Self { inlines: Vec::new(), pending_text: Vec::new(), arena, soft_break, stringify_images } } fn parse(mut self, node: &'a AstNode<'a>) -> ParseResult> { self.process_children(node, TextStyle::default())?; self.store_pending_text(); Ok(self.inlines) } fn store_pending_text(&mut self) { let chunks = mem::take(&mut self.pending_text); if !chunks.is_empty() { self.inlines.push(Inline::Text(Line(chunks))); } } fn process_node( &mut self, node: &'a AstNode<'a>, parent: &'a AstNode<'a>, style: TextStyle, ) -> ParseResult> { let data = node.data.borrow(); match &data.value { NodeValue::Text(text) => { self.pending_text.push(Text::new(text.clone(), style)); } NodeValue::Code(code) => { self.pending_text.push(Text::new(code.literal.clone(), TextStyle::default().code())); } NodeValue::Strong => self.process_children(node, style.bold())?, NodeValue::Emph => self.process_children(node, style.italics())?, NodeValue::Strikethrough => self.process_children(node, style.strikethrough())?, NodeValue::Superscript => self.process_children(node, style.superscript())?, NodeValue::SoftBreak => { match self.soft_break { SoftBreak::Newline => { self.store_pending_text(); } SoftBreak::Space => self.pending_text.push(Text::new(" ", style)), }; } NodeValue::Link(link) => { let has_label = node.first_child().is_some(); if has_label { self.process_children(node, TextStyle::default().link_label())?; self.pending_text.push(Text::from(" (")); } self.pending_text.push(Text::new(link.url.clone(), TextStyle::default().link_url())); if !link.title.is_empty() { self.pending_text.push(Text::from(" \"")); self.pending_text.push(Text::new(link.title.clone(), TextStyle::default().link_title())); self.pending_text.push(Text::from("\"")); } if has_label { self.pending_text.push(Text::from(")")); } } NodeValue::WikiLink(link) => { self.pending_text.push(Text::new(link.url.clone(), TextStyle::default().link_url())); } NodeValue::LineBreak => { self.store_pending_text(); self.inlines.push(Inline::LineBreak); } NodeValue::Image(link) => { if link.url.starts_with("http://") || link.url.starts_with("https://") { return Err(ParseErrorKind::ExternalImageUrl.with_sourcepos(data.sourcepos)); } if matches!(self.stringify_images, StringifyImages::Yes) { self.pending_text.push(Text::from(format!("![{}]({})", link.title, link.url))); return Ok(None); } self.store_pending_text(); // The image "title" contains inlines so we create a dummy paragraph node that // contains it so we can flatten it back into text. We could walk the tree but this // is good enough. let mut buffer = String::new(); let paragraph = self.arena.alloc(Node::new(RefCell::new(Ast::new(NodeValue::Paragraph, data.sourcepos.start)))); for child in node.children() { paragraph.append(child); } format_commonmark(paragraph, &ParserOptions::default().0, &mut buffer) .map_err(|e| ParseErrorKind::Internal(e.to_string()).with_sourcepos(data.sourcepos))?; let title = buffer.trim_end().to_string(); self.inlines.push(Inline::Image { path: link.url.clone(), title }); } NodeValue::Paragraph => { self.process_children(node, style)?; self.store_pending_text(); if matches!(parent.data.borrow().value, NodeValue::BlockQuote | NodeValue::MultilineBlockQuote(_)) { self.inlines.push(Inline::LineBreak); } } NodeValue::List(_) => { self.process_children(node, style)?; self.store_pending_text(); self.inlines.push(Inline::LineBreak); } NodeValue::Item(item) => { match (item.list_type, item.delimiter) { (ListType::Bullet, _) => self.pending_text.push(Text::from("* ")), (ListType::Ordered, ListDelimType::Period) => { self.pending_text.push(Text::from(format!("{}. ", item.start))) } (ListType::Ordered, ListDelimType::Paren) => { self.pending_text.push(Text::from(format!("{}) ", item.start))) } }; self.process_children(node, style)?; } NodeValue::HtmlInline(html) => { let html_inline = HtmlParser::default() .parse(html) .map_err(|e| ParseErrorKind::InvalidHtml(e).with_sourcepos(data.sourcepos))?; match html_inline { HtmlInline::OpenTag { style, tag } => return Ok(Some(HtmlStyle::Add(style, tag))), HtmlInline::CloseTag { tag } => return Ok(Some(HtmlStyle::Remove(tag))), }; } NodeValue::FootnoteReference(reference) => { // Keep only colors here, we don't care about e.g. italics footnotes. let style = TextStyle::colored(style.colors).superscript(); self.pending_text.push(Text::new(reference.name.clone(), style)); } other => { return Err(ParseErrorKind::UnsupportedStructure { container: "text", element: other.identifier() } .with_sourcepos(data.sourcepos)); } }; Ok(None) } fn process_children(&mut self, root: &'a AstNode<'a>, base_style: TextStyle) -> ParseResult<()> { let mut html_styles = Vec::new(); let mut style = base_style.clone(); for node in root.children() { if let Some(html_style) = self.process_node(node, root, style.clone())? { match html_style { HtmlStyle::Add(style, tag) => html_styles.push((style, tag)), HtmlStyle::Remove(tag) => { let popped_tag = html_styles .pop() .ok_or_else(|| ParseErrorKind::NoOpenTag.with_sourcepos(node.data.borrow().sourcepos))? .1; if popped_tag != tag { return Err(ParseErrorKind::CloseTagMismatch.with_sourcepos(node.data.borrow().sourcepos)); } } }; style = base_style.clone(); for html_style in html_styles.iter().rev() { style.merge(&html_style.0); } } } Ok(()) } } enum HtmlStyle { Add(TextStyle, HtmlTag), Remove(HtmlTag), } enum Inline { Text(Line), Image { path: String, title: String }, LineBreak, } impl Inline { fn kind(&self) -> &'static str { match self { Self::Text(_) => "text", Self::Image { .. } => "image", Self::LineBreak => "line break", } } } /// A parsing error. #[derive(thiserror::Error, Debug)] pub struct ParseError { /// The kind of error. pub(crate) kind: ParseErrorKind, /// The position in the source file this error originated from. pub(crate) sourcepos: SourcePosition, } impl Display for ParseError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "parse error at {}: {}", self.sourcepos, self.kind) } } impl ParseError { fn new>(kind: ParseErrorKind, sourcepos: S) -> Self { Self { kind, sourcepos: sourcepos.into() } } } /// The kind of error. #[derive(Debug)] pub(crate) enum ParseErrorKind { /// We don't support parsing this element. UnsupportedElement(&'static str), /// We don't support parsing an element in a specific container. UnsupportedStructure { container: &'static str, element: &'static str }, /// We don't support unfenced code blocks. UnfencedCodeBlock, /// We don't support external URLs in images. ExternalImageUrl, /// Invalid HTML was found. InvalidHtml(ParseHtmlError), /// HTML tag closed without having an open one. NoOpenTag, /// HTML tag closed for a different opened one. CloseTagMismatch, /// An internal parsing error. Internal(String), } impl Display for ParseErrorKind { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::UnsupportedElement(element) => write!(f, "unsupported element: {element}"), Self::UnsupportedStructure { container, element } => { write!(f, "unsupported structure in {container}: {element}") } Self::ExternalImageUrl => write!(f, "external URLs are not supported in image tags"), Self::UnfencedCodeBlock => write!(f, "only fenced code blocks are supported"), Self::InvalidHtml(inner) => write!(f, "invalid HTML: {inner}"), Self::NoOpenTag => write!(f, "closing tag without an open one"), Self::CloseTagMismatch => write!(f, "closing tag does not match last open one"), Self::Internal(message) => write!(f, "internal error: {message}"), } } } impl ParseErrorKind { fn with_sourcepos>(self, sourcepos: S) -> ParseError { ParseError::new(self, sourcepos) } } trait Identifier { fn identifier(&self) -> &'static str; } impl Identifier for NodeValue { fn identifier(&self) -> &'static str { match self { NodeValue::Document => "document", NodeValue::FrontMatter(_) => "front matter", NodeValue::BlockQuote => "block quote", NodeValue::List(_) => "list", NodeValue::Item(_) => "item", NodeValue::DescriptionList => "description list", NodeValue::DescriptionItem(_) => "description item", NodeValue::DescriptionTerm => "description term", NodeValue::DescriptionDetails => "description details", NodeValue::CodeBlock(_) => "code block", NodeValue::HtmlBlock(_) => "html block", NodeValue::Paragraph => "paragraph", NodeValue::Heading(_) => "heading", NodeValue::ThematicBreak => "thematic break", NodeValue::FootnoteDefinition(_) => "footnote definition", NodeValue::Table(_) => "table", NodeValue::TableRow(_) => "table row", NodeValue::TableCell => "table cell", NodeValue::Text(_) => "text", NodeValue::TaskItem(_) => "task item", NodeValue::SoftBreak => "soft break", NodeValue::LineBreak => "line break", NodeValue::Code(_) => "code", NodeValue::HtmlInline(_) => "inline html", NodeValue::Emph => "emph", NodeValue::Strong => "strong", NodeValue::Strikethrough => "strikethrough", NodeValue::Superscript => "superscript", NodeValue::Link(_) => "link", NodeValue::Image(_) => "image", NodeValue::FootnoteReference(_) => "footnote reference", NodeValue::MultilineBlockQuote(_) => "multiline block quote", NodeValue::Math(_) => "math", NodeValue::Escaped => "escaped", NodeValue::WikiLink(_) => "wiki link", NodeValue::Underline => "underline", NodeValue::SpoileredText => "spoilered text", NodeValue::EscapedTag(_) => "escaped tag", NodeValue::Subscript => "subscript", NodeValue::Raw(_) => "raw", NodeValue::Alert(_) => "alert", NodeValue::Subtext => "subtext", NodeValue::Highlight => "highlight", } } } #[derive(Debug, thiserror::Error)] #[error("invalid markdown line: {0}")] pub(crate) struct ParseInlinesError(String); #[cfg(test)] mod test { use super::*; use crate::markdown::text_style::Color; use rstest::rstest; use std::path::Path; fn try_parse(input: &str) -> Result, ParseError> { let arena = Arena::new(); MarkdownParser::new(&arena).parse(input) } fn parse_single(input: &str) -> MarkdownElement { let elements = try_parse(input).expect("failed to parse"); assert_eq!(elements.len(), 1, "more than one element: {elements:?}"); elements.into_iter().next().unwrap() } fn parse_all(input: &str) -> Vec { try_parse(input).expect("parsing failed") } #[test] fn slide_metadata() { let parsed = parse_single( r"--- beep boop --- ", ); let MarkdownElement::FrontMatter(contents) = parsed else { panic!("not a front matter: {parsed:?}") }; assert_eq!(contents, "beep\nboop\n"); } #[test] fn paragraph() { let parsed = parse_single("some **bold text**, _italics_, *italics*, **nested _italics_**, ~strikethrough~, ^super^"); let MarkdownElement::Paragraph(elements) = parsed else { panic!("not a paragraph: {parsed:?}") }; let expected_chunks = vec![ Text::from("some "), Text::new("bold text", TextStyle::default().bold()), Text::from(", "), Text::new("italics", TextStyle::default().italics()), Text::from(", "), Text::new("italics", TextStyle::default().italics()), Text::from(", "), Text::new("nested ", TextStyle::default().bold()), Text::new("italics", TextStyle::default().italics().bold()), Text::from(", "), Text::new("strikethrough", TextStyle::default().strikethrough()), Text::from(", "), Text::new("super", TextStyle::default().superscript()), ]; let expected_elements = &[Line(expected_chunks)]; assert_eq!(elements, expected_elements); } #[test] fn html_inlines() { let parsed = parse_single( "hiredblueyellow", ); let MarkdownElement::Paragraph(elements) = parsed else { panic!("not a paragraph: {parsed:?}") }; let expected_chunks = vec![ Text::from("hi"), Text::new("red", TextStyle::default().fg_color(Color::Red)), Text::new("blue", TextStyle::default().fg_color(Color::Red).bg_color(Color::Blue)), Text::new("yell", TextStyle::default().fg_color(Color::Yellow).bg_color(Color::Blue)), Text::new("ow", TextStyle::default().fg_color(Color::Yellow).bg_color(Color::Blue).superscript()), ]; let expected_elements = &[Line(expected_chunks)]; assert_eq!(elements, expected_elements); } #[rstest] #[case::closed_no_open("", ParseErrorKind::NoOpenTag)] #[case::mismatch_open1("", ParseErrorKind::CloseTagMismatch)] #[case::mismatch_open2("", ParseErrorKind::CloseTagMismatch)] #[case::mismatch_open3("", ParseErrorKind::CloseTagMismatch)] fn invalid_html_inlines(#[case] input: &str, #[case] expected_error: ParseErrorKind) { let ParseError { kind, .. } = try_parse(input).expect_err("no failure"); assert_eq!(kind.to_string(), expected_error.to_string()); } #[test] fn link_wo_label_wo_title() { let parsed = parse_single("my [](https://example.com)"); let MarkdownElement::Paragraph(elements) = parsed else { panic!("not a paragraph: {parsed:?}") }; let expected_chunks = vec![Text::from("my "), Text::new("https://example.com", TextStyle::default().link_url())]; let expected_elements = &[Line(expected_chunks)]; assert_eq!(elements, expected_elements); } #[test] fn link_w_label_wo_title() { let parsed = parse_single("my [website](https://example.com)"); let MarkdownElement::Paragraph(elements) = parsed else { panic!("not a paragraph: {parsed:?}") }; let expected_chunks = vec![ Text::from("my "), Text::new("website", TextStyle::default().link_label()), Text::from(" ("), Text::new("https://example.com", TextStyle::default().link_url()), Text::from(")"), ]; let expected_elements = &[Line(expected_chunks)]; assert_eq!(elements, expected_elements); } #[test] fn link_wo_label_w_title() { let parsed = parse_single("my [](https://example.com \"Example\")"); let MarkdownElement::Paragraph(elements) = parsed else { panic!("not a paragraph: {parsed:?}") }; let expected_chunks = vec![ Text::from("my "), Text::new("https://example.com", TextStyle::default().link_url()), Text::from(" \""), Text::new("Example", TextStyle::default().link_title()), Text::from("\""), ]; let expected_elements = &[Line(expected_chunks)]; assert_eq!(elements, expected_elements); } #[test] fn link_w_label_w_title() { let parsed = parse_single("my [website](https://example.com \"Example\")"); let MarkdownElement::Paragraph(elements) = parsed else { panic!("not a paragraph: {parsed:?}") }; let expected_chunks = vec![ Text::from("my "), Text::new("website", TextStyle::default().link_label()), Text::from(" ("), Text::new("https://example.com", TextStyle::default().link_url()), Text::from(" \""), Text::new("Example", TextStyle::default().link_title()), Text::from("\""), Text::from(")"), ]; let expected_elements = &[Line(expected_chunks)]; assert_eq!(elements, expected_elements); } #[test] fn wikilink_wo_title() { let parsed = parse_single("[[https://example.com]]"); let MarkdownElement::Paragraph(elements) = parsed else { panic!("not a paragraph: {parsed:?}") }; let expected_chunks = vec![Text::new("https://example.com", TextStyle::default().link_url())]; let expected_elements = &[Line(expected_chunks)]; assert_eq!(elements, expected_elements); } #[test] fn image() { let parsed = parse_single("![](potato.png)"); let MarkdownElement::Image { path, .. } = parsed else { panic!("not an image: {parsed:?}") }; assert_eq!(path, Path::new("potato.png")); } #[test] fn image_within_text() { let parsed = parse_all( r" picture of potato: ![](potato.png) ", ); assert_eq!(parsed.len(), 2); } #[test] fn external_image() { let result = try_parse("![](https://example.com/potato.png)"); let Err(ParseError { kind: ParseErrorKind::ExternalImageUrl, .. }) = result else { panic!("not the expected error: {result:?}") }; } #[test] fn setex_heading() { let parsed = parse_single( r" Title === ", ); let MarkdownElement::SetexHeading { text } = parsed else { panic!("not a slide title: {parsed:?}") }; let expected_chunks = [Text::from("Title")]; assert_eq!(text[0].0, expected_chunks); } #[test] fn heading() { let parsed = parse_single("# Title **with bold**"); let MarkdownElement::Heading { text, level } = parsed else { panic!("not a heading: {parsed:?}") }; let expected_chunks = vec![Text::from("Title "), Text::new("with bold", TextStyle::default().bold())]; assert_eq!(level, 1); assert_eq!(text.0, expected_chunks); } #[test] fn unordered_list() { let parsed = parse_single( r" * One * Sub1 * Sub2 * Two * Three", ); let MarkdownElement::List(items) = parsed else { panic!("not a list: {parsed:?}") }; let mut items = items.into_iter(); let mut next = || items.next().expect("list ended prematurely"); assert_eq!(next().depth, 0); assert_eq!(next().depth, 1); assert_eq!(next().depth, 1); assert_eq!(next().depth, 0); assert_eq!(next().depth, 0); } #[test] fn ordered_list_starting_non_one() { let parsed = parse_single( r" 4. One 1. Sub1 2. Sub2 5. Two 6. Three", ); let MarkdownElement::List(items) = parsed else { panic!("not a list: {parsed:?}") }; let mut items = items.into_iter(); let mut next = || items.next().expect("list ended prematurely"); assert_eq!(next().item_type, ListItemType::OrderedPeriod(4)); assert_eq!(next().item_type, ListItemType::OrderedPeriod(1)); assert_eq!(next().item_type, ListItemType::OrderedPeriod(2)); assert_eq!(next().item_type, ListItemType::OrderedPeriod(5)); assert_eq!(next().item_type, ListItemType::OrderedPeriod(6)); } #[test] fn line_breaks() { let parsed = parse_all( r" some text with line breaks a hard break another", ); // note that "with line breaks" also has a hard break (" ") at the end, hence the 3. assert_eq!(parsed.len(), 2); let MarkdownElement::Paragraph(elements) = &parsed[0] else { panic!("not a line break: {parsed:?}") }; assert_eq!(elements.len(), 2); let expected_chunks = &[Text::from("some text"), Text::from(" "), Text::from("with line breaks")]; let text = &elements[0]; assert_eq!(text.0, expected_chunks); } #[test] fn code_block() { let parsed = parse_single( r" ```rust +exec let q = 42; ```` ", ); let MarkdownElement::Snippet { info, code, .. } = parsed else { panic!("not a code block: {parsed:?}") }; assert_eq!(info, "rust +exec"); assert_eq!(code, "let q = 42;\n"); } #[test] fn inline_code() { let parsed = parse_single("some `inline code`"); let MarkdownElement::Paragraph(elements) = parsed else { panic!("not a paragraph: {parsed:?}") }; let expected_chunks = &[Text::from("some "), Text::new("inline code", TextStyle::default().code())]; assert_eq!(elements.len(), 1); let text = &elements[0]; assert_eq!(text.0, expected_chunks); } #[test] fn table() { let parsed = parse_single( r" | Name | Taste | | ------ | ------ | | Potato | Great | | Carrot | Yuck | ", ); let MarkdownElement::Table(Table { header, rows }) = parsed else { panic!("not a table: {parsed:?}") }; assert_eq!(header.0.len(), 2); assert_eq!(rows.len(), 2); assert_eq!(rows[0].0.len(), 2); assert_eq!(rows[1].0.len(), 2); } #[test] fn comment() { let parsed = parse_single( r" ", ); let MarkdownElement::Comment { comment, .. } = parsed else { panic!("not a comment: {parsed:?}") }; assert_eq!(comment, " foo "); } #[test] fn list_comment_in_between() { let parsed = parse_all( r" * A * B ", ); assert_eq!(parsed.len(), 3); let MarkdownElement::List(items) = &parsed[2] else { panic!("not a list item: {parsed:?}") }; assert_eq!(items[0].depth, 1); } #[test] fn block_quote() { let parsed = parse_single( r#" > foo **is not** bar > ![](hehe.png) test ![](potato.png) > > * a > * b > > 1. a > 2. b > > 1) a > 2) b "#, ); let MarkdownElement::BlockQuote(lines) = parsed else { panic!("not a block quote: {parsed:?}") }; assert_eq!(lines.len(), 11); assert_eq!( lines[0], Line(vec![Text::from("foo "), Text::new("is not", TextStyle::default().bold()), Text::from(" bar")]) ); assert_eq!( lines[1], Line(vec![Text::from("![](hehe.png)"), Text::from(" test "), Text::from("![](potato.png)")]) ); assert_eq!(lines[2], Line::from("")); assert_eq!(lines[3], Line(vec![Text::from("* "), Text::from("a")])); assert_eq!(lines[4], Line(vec![Text::from("* "), Text::from("b")])); assert_eq!(lines[5], Line::from("")); assert_eq!(lines[6], Line(vec![Text::from("1. "), Text::from("a")])); assert_eq!(lines[7], Line(vec![Text::from("2. "), Text::from("b")])); assert_eq!(lines[8], Line::from("")); assert_eq!(lines[9], Line(vec![Text::from("1) "), Text::from("a")])); assert_eq!(lines[10], Line(vec![Text::from("2) "), Text::from("b")])); } #[test] fn multiline_block_quote() { let parsed = parse_single( r" >>> bar foo * a * b >>>", ); let MarkdownElement::BlockQuote(lines) = parsed else { panic!("not a block quote: {parsed:?}") }; assert_eq!(lines.len(), 5); assert_eq!(lines[0], Line::from("bar")); assert_eq!(lines[1], Line::from("foo")); assert_eq!(lines[2], Line::from("")); assert_eq!(lines[3], Line(vec![Text::from("* "), Text::from("a")])); assert_eq!(lines[4], Line(vec![Text::from("* "), Text::from("b")])); } #[test] fn thematic_break() { let parsed = parse_all( r" hello --- bye ", ); assert_eq!(parsed.len(), 3); assert!(matches!(parsed[1], MarkdownElement::ThematicBreak)); } #[test] fn error_lines_offset_by_front_matter() { let input = r"--- hi mom --- * ![](potato.png) "; let arena = Arena::new(); let result = MarkdownParser::new(&arena).parse(input); let Err(e) = result else { panic!("parsing didn't fail"); }; assert_eq!(e.sourcepos.start.line, 6); assert_eq!(e.sourcepos.start.column, 3); } #[test] fn comment_lines_offset_by_front_matter() { let parsed = parse_all( r"--- hi mom --- ", ); let MarkdownElement::Comment { source_position, .. } = &parsed[1] else { panic!("not a comment") }; assert_eq!(source_position.start.line, 6); assert_eq!(source_position.start.column, 1); } #[rstest] #[case::lf("\n")] #[case::crlf("\r\n")] fn front_matter_newlines(#[case] nl: &str) { let input = format!("---{nl}hi{nl}mom{nl}---{nl}"); let parsed = parse_single(&input); let MarkdownElement::FrontMatter(contents) = &parsed else { panic!("not a front matter") }; let expected = format!("hi{nl}mom{nl}"); assert_eq!(contents, &expected); } #[test] fn parse_alert() { let input = r" > [!note] > hi mom > bye **mom** "; let MarkdownElement::Alert { lines, .. } = parse_single(input) else { panic!("not an alert"); }; assert_eq!(lines.len(), 2); } #[test] fn parse_inlines() { let arena = Arena::new(); let input = "hello **mom** how _are you_?"; let parsed = MarkdownParser::new(&arena).parse_inlines(input).expect("parse failed"); let expected = &[ "hello ".into(), Text::new("mom", TextStyle::default().bold()), " how ".into(), Text::new("are you", TextStyle::default().italics()), "?".into(), ]; assert_eq!(parsed.0, expected); } #[test] fn footnote() { let input = r" this[^1] [^1]: ref abc "; let elements = parse_all(input); assert_eq!(elements.len(), 3); let MarkdownElement::Paragraph(line) = &elements[0] else { panic!("not a paragraph") }; assert_eq!(line, &[Line(vec![Text::from("this"), Text::new("1", TextStyle::default().superscript())])]); let MarkdownElement::Footnote(line) = &elements[1] else { panic!("not a footnote") }; assert_eq!(line, &Line(vec![Text::new("1", TextStyle::default().superscript()), Text::from("ref")])); } } ================================================ FILE: src/markdown/text.rs ================================================ use super::{ elements::{Line, Text}, text_style::TextStyle, }; use std::{fmt, mem}; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; /// A weighted line of text. /// /// The weight of a character is its given by its width in unicode. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub(crate) struct WeightedLine { text: Vec, width: usize, font_size: u8, } impl WeightedLine { /// Split this line into chunks of at most `max_length` width. pub(crate) fn split(&self, max_length: usize) -> SplitTextIter<'_> { SplitTextIter::new(&self.text, max_length) } /// The total width of this line. pub(crate) fn width(&self) -> usize { self.width } /// The height of this line. pub(crate) fn font_size(&self) -> u8 { self.font_size } } impl From for WeightedLine { fn from(block: Line) -> Self { block.0.into() } } impl From> for WeightedLine { fn from(mut texts: Vec) -> Self { let mut output = Vec::new(); let mut index = 0; let mut width = 0; let mut font_size = 1; // Compact chunks so any consecutive chunk with the same style is merged into the same block. while index < texts.len() { let mut target = mem::replace(&mut texts[index], Text::from("")); let mut current = index + 1; while current < texts.len() && texts[current].style == target.style { let current_content = mem::take(&mut texts[current].content); target.content.push_str(¤t_content); current += 1; } let size = target.style.size.max(1); width += target.content.width() * size as usize; output.push(target.into()); index = current; font_size = font_size.max(size); } Self { text: output, width, font_size } } } impl From for WeightedLine { fn from(text: String) -> Self { let width = text.width(); let text = vec![WeightedText::from(text)]; Self { text, width, font_size: 1 } } } impl From<&str> for WeightedLine { fn from(text: &str) -> Self { Self::from(text.to_string()) } } #[derive(Clone, Debug, PartialEq, Eq)] struct CharAccumulator { width: usize, bytes: usize, } /// A piece of weighted text. #[derive(Clone, PartialEq, Eq)] pub(crate) struct WeightedText { text: Text, accumulators: Vec, } impl WeightedText { fn to_ref(&self) -> WeightedTextRef<'_> { WeightedTextRef { text: &self.text.content, accumulators: &self.accumulators, style: self.text.style } } pub(crate) fn width(&self) -> usize { self.to_ref().width() } pub(crate) fn text(&self) -> &Text { &self.text } } impl> From for WeightedText { fn from(text: S) -> Self { Self::from(Text::from(text.into())) } } impl From for WeightedText { fn from(text: Text) -> Self { let mut accumulators = Vec::new(); let mut width = 0; let mut bytes = 0; for c in text.content.chars() { accumulators.push(CharAccumulator { width, bytes }); width += c.width().unwrap_or(0); bytes += c.len_utf8(); } accumulators.push(CharAccumulator { width, bytes }); Self { text, accumulators } } } impl fmt::Debug for WeightedText { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("WeightedText").field("text", &self.text).finish() } } /// An iterator over the chunks in a [WeightedLine]. pub(crate) struct SplitTextIter<'a> { texts: &'a [WeightedText], max_length: usize, current: Option>, } impl<'a> SplitTextIter<'a> { fn new(texts: &'a [WeightedText], max_length: usize) -> Self { Self { texts, max_length, current: texts.first().map(WeightedText::to_ref) } } } impl<'a> Iterator for SplitTextIter<'a> { type Item = Vec>; fn next(&mut self) -> Option { self.current.as_ref()?; let mut elements = Vec::new(); let mut remaining = self.max_length as i64; while let Some(current) = self.current.take() { let (head, rest) = current.word_split_at_length(remaining as usize); // Prevent splitting a word partially. We do allow this on the first chunk as otherwise // a word longer than `max_length` would never be split. if !rest.text.is_empty() && !rest.text.starts_with(' ') && !elements.is_empty() { self.current = Some(current); break; } let head_width = head.width(); remaining -= head_width as i64; elements.push(head); // The moment we hit a chunk we couldn't fully split, we're done. if !rest.text.is_empty() { self.current = Some(rest.trim_start()); break; } // Consume the first one and point to the next one, if any. self.texts = &self.texts[1..]; self.current = self.texts.first().map(WeightedText::to_ref); } Some(elements) } } /// A reference of a piece of a [WeightedText]. #[derive(Clone, Debug)] pub(crate) struct WeightedTextRef<'a> { text: &'a str, accumulators: &'a [CharAccumulator], style: TextStyle, } impl<'a> WeightedTextRef<'a> { /// Decompose this into its parts. pub(crate) fn into_parts(self) -> (&'a str, TextStyle) { (self.text, self.style) } // Attempts to split this at a word boundary. // // This will try to consume as many words as possible up to the given maximum length, and // return the text before and after that split point. fn word_split_at_length(&self, max_length: usize) -> (Self, Self) { if self.width() <= max_length { return (self.make_ref(0, self.text.len()), self.make_ref(0, 0)); } let max_length = (max_length / self.style.size as usize).max(1); let target_chunk = self.substr(max_length + 1); let output_chunk = match target_chunk.rsplit_once(' ') { Some((before, _)) => before, None => self.substr(max_length), }; (self.make_ref(0, output_chunk.len()), self.make_ref(output_chunk.len(), self.text.len())) } fn substr(&self, max_length: usize) -> &'a str { let last_index = self.bytes_until(max_length); &self.text[0..last_index] } fn make_ref(&self, from: usize, to: usize) -> Self { let text = &self.text[from..to]; let leading_char_count = self.text[0..from].chars().count(); let output_char_count = text.chars().count(); let character_lengths = &self.accumulators[leading_char_count..leading_char_count + output_char_count + 1]; WeightedTextRef { text, accumulators: character_lengths, style: self.style } } fn trim_start(self) -> Self { let text = self.text.trim_start(); let trimmed = self.text.chars().count() - text.chars().count(); let accumulators = &self.accumulators[trimmed..]; Self { text, accumulators, style: self.style } } pub(crate) fn width(&self) -> usize { let last_width = self.accumulators.last().map(|a| a.width).unwrap_or(0); let first_width = self.accumulators.first().map(|a| a.width).unwrap_or(0); (last_width - first_width) * self.style.size as usize } fn bytes_until(&self, index: usize) -> usize { let last_bytes = self.accumulators.get(index).or_else(|| self.accumulators.last()).map(|a| a.bytes).unwrap_or(0); let first_bytes = self.accumulators.first().map(|a| a.bytes).unwrap_or(0); last_bytes - first_bytes } } #[cfg(test)] mod test { use super::*; use rstest::rstest; fn join_lines<'a>(lines: impl Iterator>>) -> Vec { lines.map(|l| l.iter().map(|weighted| weighted.text).collect::>().join(" ")).collect() } #[test] fn text_creation() { let text = WeightedText::from("hello world"); let text_ref = text.to_ref(); assert_eq!(text_ref.width(), 11); } #[test] fn text_creation_utf8() { let text = WeightedText::from("█████"); let text_ref = text.to_ref(); assert_eq!(text_ref.width(), 5); assert_eq!(text_ref.bytes_until(0), 0); assert_eq!(text_ref.bytes_until(1), 3); assert_eq!(text_ref.bytes_until(2), 6); assert_eq!(text_ref.bytes_until(3), 9); assert_eq!(text_ref.bytes_until(4), 12); let text_ref = text_ref.make_ref(3, 12); assert_eq!(text_ref.width(), 3); assert_eq!(text_ref.bytes_until(0), 0); assert_eq!(text_ref.bytes_until(1), 3); assert_eq!(text_ref.bytes_until(2), 6); let text_ref = text_ref.make_ref(0, 9); assert_eq!(text_ref.width(), 3); assert_eq!(text_ref.bytes_until(0), 0); assert_eq!(text_ref.bytes_until(1), 3); assert_eq!(text_ref.bytes_until(2), 6); } #[test] fn minimal_split() { let text = WeightedText::from("█████"); let text_ref = text.to_ref(); let (head, rest) = text_ref.word_split_at_length(1); assert_eq!(head.width(), 1); assert_eq!(rest.width(), 4); } #[test] fn no_spaces_split() { let text = WeightedText::from("█████"); let text_ref = text.to_ref(); let (head, rest) = text_ref.word_split_at_length(2); assert_eq!(head.width(), 2); assert_eq!(rest.width(), 3); } #[test] fn font_size_split() { let text = WeightedText::from(Text::new("█████", TextStyle::default().size(2))); let text_ref = text.to_ref(); let (head, rest) = text_ref.word_split_at_length(3); assert_eq!(head.width(), 2); assert_eq!(rest.width(), 8); } #[test] fn make_ref() { let text = WeightedText::from("hello world"); let text_ref = text.to_ref(); let head = text_ref.make_ref(0, 1); assert_eq!(head.text, "h"); assert_eq!(head.width(), 1); let rest = text_ref.make_ref(1, 11); assert_eq!(rest.text, "ello world"); assert_eq!(rest.width(), 10); } #[test] fn word_split() { let text = WeightedText::from("short string"); let (head, rest) = text.to_ref().word_split_at_length(7); assert_eq!(head.text, "short"); assert_eq!(rest.text, " string"); } #[test] fn split_at_full_length() { let text = WeightedLine::from("hello world"); let lines = join_lines(text.split(11)); let expected = vec!["hello world"]; assert_eq!(lines, expected); } #[test] fn no_split_necessary() { let text = WeightedLine { text: vec![WeightedText::from("short"), WeightedText::from("text")], width: 0, font_size: 1, }; let lines = join_lines(text.split(50)); let expected = vec!["short text"]; assert_eq!(lines, expected); } #[test] fn split_lines_single() { let text = WeightedLine { text: vec![WeightedText::from("this is a slightly long line")], width: 0, font_size: 1 }; let lines = join_lines(text.split(6)); let expected = vec!["this", "is a", "slight", "ly", "long", "line"]; assert_eq!(lines, expected); } #[test] fn split_lines_multi() { let text = WeightedLine { text: vec![ WeightedText::from("this is a slightly long line"), WeightedText::from("another chunk"), WeightedText::from("yet some other piece"), ], width: 0, font_size: 1, }; let lines = join_lines(text.split(10)); let expected = vec!["this is a", "slightly", "long line", "another", "chunk yet", "some other", "piece"]; assert_eq!(lines, expected); } #[test] fn long_splits() { let text = WeightedLine { text: vec![ WeightedText::from("this is a slightly long line"), WeightedText::from("another chunk"), WeightedText::from("yet some other piece"), ], width: 0, font_size: 1, }; let lines = join_lines(text.split(50)); let expected = vec!["this is a slightly long line another chunk yet some", "other piece"]; assert_eq!(lines, expected); } #[test] fn prefixed_by_whitespace() { let text = WeightedLine::from(" * bullet"); let lines = join_lines(text.split(50)); let expected = vec![" * bullet"]; assert_eq!(lines, expected); } #[test] fn utf8_character() { let text = WeightedLine::from("• A"); let lines = join_lines(text.split(50)); let expected = vec!["• A"]; assert_eq!(lines, expected); } #[test] fn many_utf8_characters() { let content = "█████ ██"; let text = WeightedLine::from(content); let lines = join_lines(text.split(3)); let expected = vec!["███", "██", "██"]; assert_eq!(lines, expected); } #[test] fn no_whitespaces_ascii() { let content = "X".repeat(10); let text = WeightedLine::from(content); let lines = join_lines(text.split(3)); let expected = vec!["XXX", "XXX", "XXX", "X"]; assert_eq!(lines, expected); } #[test] fn no_whitespaces_utf8() { let content = "─".repeat(10); let text = WeightedLine::from(content); let lines = join_lines(text.split(3)); let expected = vec!["───", "───", "───", "─"]; assert_eq!(lines, expected); } #[test] fn wide_characters() { let content = "Hello world"; let text = WeightedLine::from(content); let lines = join_lines(text.split(10)); // Each word is 10 characters long let expected = vec!["Hello", "world"]; assert_eq!(lines, expected); } #[rstest] #[case::single(&["hello".into()], 1)] #[case::two(&["hello".into(), " world".into()], 1)] #[case::three(&["hello".into(), " ".into(), "world".into()], 1)] #[case::split(&["hello".into(), Text::new(" ", TextStyle::default().bold()), "world".into()], 3)] #[case::split_merged(&["hello".into(), Text::new(" ", TextStyle::default().bold()), Text::new("w", TextStyle::default().bold()), "orld".into()], 3)] fn compaction(#[case] texts: &[Text], #[case] expected: usize) { let block = WeightedLine::from(texts.to_vec()); assert_eq!(block.text.len(), expected); } } ================================================ FILE: src/markdown/text_style.rs ================================================ use crate::{ terminal::capabilities::TerminalCapabilities, theme::{ColorPalette, raw::RawColor}, }; use crossterm::style::{ContentStyle, StyledContent, Stylize}; use serde::{Deserialize, Serialize}; use std::{ borrow::Cow, fmt::{self, Display}, }; /// The style of a piece of text. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct TextStyle { flags: u8, pub(crate) colors: Colors, pub(crate) size: u8, } impl Default for TextStyle { fn default() -> Self { Self { flags: Default::default(), colors: Default::default(), size: 1 } } } impl TextStyle where C: Clone, { pub(crate) fn colored(colors: Colors) -> Self { Self { colors, ..Default::default() } } pub(crate) fn size(mut self, size: u8) -> Self { self.size = size.min(16); self } /// Add bold to this style. pub(crate) fn bold(self) -> Self { self.add_flag(TextFormatFlags::Bold) } /// Add italics to this style. pub(crate) fn italics(self) -> Self { self.add_flag(TextFormatFlags::Italics) } /// Indicate this text is a piece of inline code. pub(crate) fn code(self) -> Self { self.add_flag(TextFormatFlags::Code) } /// Add strikethrough to this style. pub(crate) fn strikethrough(self) -> Self { self.add_flag(TextFormatFlags::Strikethrough) } /// Add underline to this style. pub(crate) fn underlined(self) -> Self { self.add_flag(TextFormatFlags::Underlined) } /// Indicate this is a link label. pub(crate) fn link_label(self) -> Self { self.bold() } /// Indicate this is a link title. pub(crate) fn link_title(self) -> Self { self.italics() } /// Indicate this is a link url. pub(crate) fn link_url(self) -> Self { self.italics().underlined() } /// Indicate this is a superscript. pub(crate) fn superscript(self) -> Self { self.add_flag(TextFormatFlags::Superscript) } /// Set the background color for this text style. pub(crate) fn bg_color>(mut self, color: U) -> Self { self.colors.background = Some(color.into()); self } /// Set the foreground color for this text style. pub(crate) fn fg_color>(mut self, color: U) -> Self { self.colors.foreground = Some(color.into()); self } /// Set the colors on this style. pub(crate) fn colors(mut self, colors: Colors) -> Self { self.colors = colors; self } /// Check whether this text is code. pub(crate) fn is_code(&self) -> bool { self.has_flag(TextFormatFlags::Code) } /// Check whether this text is bold. pub(crate) fn is_bold(&self) -> bool { self.has_flag(TextFormatFlags::Bold) } /// Check whether this text is italics. pub(crate) fn is_italics(&self) -> bool { self.has_flag(TextFormatFlags::Italics) } /// Merge this style with another one. pub(crate) fn merge(&mut self, other: &TextStyle) { self.flags |= other.flags; self.size = self.size.max(other.size); self.colors.background = self.colors.background.clone().or(other.colors.background.clone()); self.colors.foreground = self.colors.foreground.clone().or(other.colors.foreground.clone()); } /// Return a new style merged with the one passed in. pub(crate) fn merged(mut self, other: &TextStyle) -> Self { self.merge(other); self } fn add_flag(mut self, flag: TextFormatFlags) -> Self { self.flags |= flag as u8; self } fn has_flag(&self, flag: TextFormatFlags) -> bool { self.flags & flag as u8 != 0 } } impl TextStyle { /// Apply this style to a piece of text. pub(crate) fn apply<'a>( &self, text: &'a str, capabilities: &TerminalCapabilities, ) -> StyledContent { let mut contents = Cow::Borrowed(text); let mut font_size = FontSize::Scaled(self.size); let mut style = ContentStyle::default(); for attr in self.iter_attributes() { style = match attr { TextAttribute::Bold => style.bold(), TextAttribute::Italics => style.italic(), TextAttribute::Strikethrough => style.crossed_out(), TextAttribute::Underlined => style.underlined(), TextAttribute::Superscript => { if capabilities.fractional_font_size { font_size = FontSize::Fractional { numerator: self.size, denominator: 2 } } else if let Some(t) = text.try_into_superscript() { contents = Cow::Owned(t); } style } TextAttribute::ForegroundColor(color) => style.with(color.into()), TextAttribute::BackgroundColor(color) => style.on(color.into()), } } let text = FontSizedStr { contents, font_size }; StyledContent::new(style, text) } pub(crate) fn into_raw(self) -> TextStyle { let colors = Colors { background: self.colors.background.map(Into::into), foreground: self.colors.foreground.map(Into::into), }; TextStyle { flags: self.flags, colors, size: self.size } } /// Iterate all attributes in this style. pub(crate) fn iter_attributes(&self) -> AttributeIterator { AttributeIterator { flags: self.flags, next_mask: Some(TextFormatFlags::Bold), background_color: self.colors.background, foreground_color: self.colors.foreground, } } } impl TextStyle { pub(crate) fn resolve(&self, palette: &ColorPalette) -> Result { let colors = self.colors.resolve(palette)?; Ok(TextStyle { flags: self.flags, colors, size: self.size }) } } pub(crate) struct AttributeIterator { flags: u8, next_mask: Option, background_color: Option, foreground_color: Option, } impl Iterator for AttributeIterator { type Item = TextAttribute; fn next(&mut self) -> Option { 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(Superscript), Superscript => 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, Superscript => TextAttribute::Superscript, Underlined => TextAttribute::Underlined, }; return Some(attr); } } } } #[derive(Clone, Copy, Debug, PartialEq)] pub(crate) enum TextAttribute { Bold, Italics, Strikethrough, Underlined, Superscript, ForegroundColor(Color), BackgroundColor(Color), } #[derive(Clone, Debug)] struct FontSizedStr<'a> { contents: Cow<'a, str>, font_size: FontSize, } impl fmt::Display for FontSizedStr<'_> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let contents = &self.contents; match self.font_size { FontSize::Scaled(0 | 1) => write!(f, "{contents}"), FontSize::Scaled(size) => write!(f, "\x1b]66;s={size};{contents}\x1b\\"), FontSize::Fractional { numerator, denominator } => { write!(f, "\x1b]66;n={numerator}:d={denominator};{contents}\x1b\\") } } } } #[derive(Clone, Debug)] enum FontSize { Scaled(u8), Fractional { numerator: u8, denominator: u8 }, } #[derive(Clone, Copy, Debug)] enum TextFormatFlags { Bold = 1, Italics = 2, Code = 4, Strikethrough = 8, Underlined = 16, Superscript = 32, } #[derive(Debug, Copy, Clone, PartialEq, Eq)] pub(crate) enum Color { Black, DarkGrey, Red, DarkRed, Green, DarkGreen, Yellow, DarkYellow, Blue, DarkBlue, Magenta, DarkMagenta, Cyan, DarkCyan, White, Grey, Rgb { r: u8, g: u8, b: u8 }, } impl Color { pub(crate) fn new(r: u8, g: u8, b: u8) -> Self { Self::Rgb { r, g, b } } pub(crate) fn from_8bit(color: u8) -> Option { match color { 0 => Self::Black.into(), 1 => Self::DarkRed.into(), 2 => Self::DarkGreen.into(), 3 => Self::DarkYellow.into(), 4 => Self::DarkBlue.into(), 5 => Self::DarkMagenta.into(), 6 => Self::DarkCyan.into(), 7 => Self::Grey.into(), 8 => Self::DarkGrey.into(), 9 => Self::Red.into(), 10 => Self::Green.into(), 11 => Self::Yellow.into(), 12 => Self::Blue.into(), 13 => Self::Magenta.into(), 14 => Self::Cyan.into(), 15 => Self::White.into(), 16..=231 => { let mapping = [0, 95, 95 + 40, 95 + 80, 95 + 120, 95 + 160]; let mut value = color - 16; let b = (value % 6) as usize; value /= 6; let g = (value % 6) as usize; value /= 6; let r = (value % 6) as usize; Some(Self::new(mapping[r], mapping[g], mapping[b])) } _ => None, } } pub(crate) fn as_rgb(&self) -> Option<(u8, u8, u8)> { match self { Self::Rgb { r, g, b } => Some((*r, *g, *b)), _ => None, } } pub(crate) fn from_ansi(color: u8) -> Option { let color = match color { 30 | 40 => Color::Black, 31 | 41 => Color::Red, 32 | 42 => Color::Green, 33 | 43 => Color::Yellow, 34 | 44 => Color::Blue, 35 | 45 => Color::Magenta, 36 | 46 => Color::Cyan, 37 | 47 => Color::White, _ => return None, }; Some(color) } } impl From for crossterm::style::Color { fn from(value: Color) -> Self { use crossterm::style::Color as C; match value { Color::Black => C::Black, Color::DarkGrey => C::DarkGrey, Color::Red => C::Red, Color::DarkRed => C::DarkRed, Color::Green => C::Green, Color::DarkGreen => C::DarkGreen, Color::Yellow => C::Yellow, Color::DarkYellow => C::DarkYellow, Color::Blue => C::Blue, Color::DarkBlue => C::DarkBlue, Color::Magenta => C::Magenta, Color::DarkMagenta => C::DarkMagenta, Color::Cyan => C::Cyan, Color::DarkCyan => C::DarkCyan, Color::White => C::White, Color::Grey => C::Grey, Color::Rgb { r, g, b } => C::Rgb { r, g, b }, } } } #[derive(Debug, thiserror::Error)] #[error("unresolved palette color: {0}")] pub(crate) struct PaletteColorError(String); #[derive(Debug, thiserror::Error)] #[error("undefined palette color: {0}")] pub(crate) struct UndefinedPaletteColorError(pub(crate) String); /// Text colors. #[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)] pub(crate) struct Colors { /// The background color. pub(crate) background: Option, /// The foreground color. pub(crate) foreground: Option, } impl Default for Colors { fn default() -> Self { Self { background: None, foreground: None } } } impl Colors { pub(crate) fn resolve(&self, palette: &ColorPalette) -> Result, UndefinedPaletteColorError> { let background = self.background.clone().map(|c| c.resolve(palette)).transpose()?.flatten(); let foreground = self.foreground.clone().map(|c| c.resolve(palette)).transpose()?.flatten(); Ok(Colors { foreground, background }) } } impl From for crossterm::style::Colors { fn from(value: Colors) -> Self { let foreground = value.foreground.map(Color::into); let background = value.background.map(Color::into); Self { foreground, background } } } trait TryIntoSuperscript { fn try_into_superscript(&self) -> Option; } impl TryIntoSuperscript for &'_ str { fn try_into_superscript(&self) -> Option { let mut output = String::new(); for from in self.chars() { let to = match from { '0' => '⁰', '1' => '¹', '2' => '²', '3' => '³', '4' => '⁴', '5' => '⁵', '6' => '⁶', '7' => '⁷', '8' => '⁸', '9' => '⁹', '+' => '⁺', '-' => '⁻', '=' => '⁼', '(' => '⁽', ')' => '⁾', 'a' => 'ᵃ', 'b' => 'ᵇ', 'c' => 'ᶜ', 'd' => 'ᵈ', 'e' => 'ᵉ', 'f' => 'ᶠ', 'g' => 'ᵍ', 'h' => 'ʰ', 'i' => 'ⁱ', 'j' => 'ʲ', 'k' => 'ᵏ', 'l' => 'ˡ', 'm' => 'ᵐ', 'n' => 'ⁿ', 'o' => 'ᵒ', 'p' => 'ᵖ', 'q' => '𐞥', 'r' => 'ʳ', 's' => 'ˢ', 't' => 'ᵗ', 'u' => 'ᵘ', 'v' => 'ᵛ', 'w' => 'ʷ', 'x' => 'ˣ', 'y' => 'ʸ', 'z' => 'ᶻ', _ => return None, }; output.push(to); } Some(output) } } #[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); } } ================================================ FILE: src/presentation/builder/comment.rs ================================================ use crate::{ markdown::elements::{MarkdownElement, SourcePosition}, presentation::builder::{BuildResult, LayoutState, PresentationBuilder, error::InvalidPresentation}, render::operation::{LayoutGrid, RenderOperation}, theme::{Alignment, ElementType}, }; use serde::Deserialize; use std::{fmt, num::NonZeroU8, path::PathBuf, str::FromStr}; use strum::{EnumDiscriminants, EnumIter}; impl PresentationBuilder<'_, '_> { pub(crate) fn process_comment(&mut self, comment: String, source_position: SourcePosition) -> BuildResult { let comment = comment.trim(); let trimmed_comment = comment.trim_start_matches(&self.options.command_prefix); let command = match trimmed_comment.parse::() { Ok(comment) => comment, Err(error) => { // If we failed to parse this, make sure we shouldn't have ignored it if self.should_ignore_comment(comment) { // Ignored comments should not add line breaks self.slide_state.ignore_element_line_break = true; return Ok(()); } return Err(self.invalid_presentation(source_position, error)); } }; if self.options.render_speaker_notes_only { self.process_comment_command_speaker_notes_mode(command); } else { self.process_comment_command_presentation_mode(command, source_position)?; } Ok(()) } fn process_comment_command_presentation_mode( &mut self, command: CommentCommand, source_position: SourcePosition, ) -> BuildResult { match command { CommentCommand::Pause => self.push_pause(), CommentCommand::EndSlide => self.terminate_slide(), CommentCommand::NewLine => self.push_line_breaks(self.slide_font_size() as usize), CommentCommand::NewLines(count) => { self.push_line_breaks(count as usize * self.slide_font_size() as usize); } CommentCommand::Comment(_) => {} CommentCommand::JumpToMiddle => self.chunk_operations.push(RenderOperation::JumpToVerticalCenter), CommentCommand::InitColumnLayout(columns) => { self.validate_column_layout(&columns, source_position)?; let resolved_position = self.sources.resolve_source_position(source_position); self.slide_state.last_layout_comment = Some(resolved_position); self.slide_state.layout = LayoutState::InLayout { columns_count: columns.len() }; let grid = if self.options.layout_grid { LayoutGrid::Draw(self.theme.layout_grid.style) } else { LayoutGrid::None }; self.chunk_operations.push(RenderOperation::InitColumnLayout { columns, grid, margin: self.theme.column_layout.margin, }); self.slide_state.needs_enter_column = true; } CommentCommand::ResetLayout => { self.slide_state.layout = LayoutState::Default; self.chunk_operations.push(RenderOperation::ExitLayout); } CommentCommand::Column(column) => { let (current_column, columns_count) = match self.slide_state.layout { LayoutState::InColumn { column, columns_count } => (Some(column), columns_count), LayoutState::InLayout { columns_count } => (None, columns_count), LayoutState::Default => { return Err(self.invalid_presentation(source_position, InvalidPresentation::NoLayout)); } }; if current_column == Some(column) { return Err(self.invalid_presentation(source_position, InvalidPresentation::AlreadyInColumn)); } else if column >= columns_count { return Err(self.invalid_presentation(source_position, InvalidPresentation::ColumnIndexTooLarge)); } self.slide_state.layout = LayoutState::InColumn { column, columns_count }; self.chunk_operations.push(RenderOperation::EnterColumn { column }); } CommentCommand::IncrementalLists(value) => { self.slide_state.incremental_lists = Some(value); } CommentCommand::IncrementalTables(value) => { self.slide_state.incremental_tables = Some(value); } CommentCommand::NoFooter => { self.slide_state.ignore_footer = true; } CommentCommand::SpeakerNote(_) => {} CommentCommand::FontSize(size) => { if size == 0 || size > 7 { return Err(self.invalid_presentation(source_position, InvalidPresentation::InvalidFontSize)); } self.slide_state.font_size = Some(size) } CommentCommand::Alignment(alignment) => { let alignment = match alignment { CommentCommandAlignment::Left => Alignment::Left { margin: Default::default() }, CommentCommandAlignment::Center => { Alignment::Center { minimum_margin: Default::default(), minimum_size: Default::default() } } CommentCommandAlignment::Right => Alignment::Right { margin: Default::default() }, }; self.slide_state.alignment = Some(alignment); } CommentCommand::SkipSlide => { self.slide_state.skip_slide = true; } CommentCommand::ListItemNewlines(count) => { self.slide_state.list_item_newlines = Some(count.into()); } CommentCommand::Include(path) => { self.process_include(path, source_position)?; return Ok(()); } CommentCommand::SnippetOutput(id) => { let handle = self.executable_snippets.get(&id).cloned().ok_or_else(|| { self.invalid_presentation(source_position, InvalidPresentation::UndefinedSnippetId(id)) })?; self.push_detached_code_execution(handle)?; return Ok(()); } }; // Don't push line breaks for any comments. self.slide_state.ignore_element_line_break = true; Ok(()) } fn process_comment_command_speaker_notes_mode(&mut self, comment_command: CommentCommand) { match comment_command { CommentCommand::SpeakerNote(note) => { for line in note.lines() { self.push_text(line.into(), ElementType::Paragraph); self.push_line_break(); } self.push_line_break(); } CommentCommand::EndSlide => self.terminate_slide(), CommentCommand::Pause => self.push_pause(), CommentCommand::SkipSlide => self.slide_state.skip_slide = true, _ => {} } } fn should_ignore_comment(&self, comment: &str) -> bool { if comment.contains('\n') || !comment.starts_with(&self.options.command_prefix) { // Ignore any multi line comment; those are assumed to be user comments // Ignore any line that doesn't start with the selected prefix. true } else if comment.trim().starts_with("vim:") { // ignore vim: commands true } else { // Ignore vim-like code folding tags let comment = comment.trim(); comment == "{{{" || comment == "}}}" || comment.starts_with("//") } } fn process_include(&mut self, path: PathBuf, source_position: SourcePosition) -> BuildResult { let base = self.resource_base_path(); let resolved_path = self.resources.resolve_path(&path, &base); let contents = self.resources.external_text_file(&path, &base).map_err(|e| { self.invalid_presentation( source_position, InvalidPresentation::IncludeMarkdown { path: path.clone(), error: e }, ) })?; let elements = self.markdown_parser.parse(&contents).map_err(|e| { self.invalid_presentation( source_position, InvalidPresentation::ParseInclude { path: path.clone(), error: e }, ) })?; let _guard = self .sources .enter(resolved_path) .map_err(|e| self.invalid_presentation(source_position, InvalidPresentation::Import { path, error: e }))?; for element in elements { if let MarkdownElement::FrontMatter(_) = element { return Err(self.invalid_presentation(source_position, InvalidPresentation::IncludeFrontMatter)); } self.slide_state.ignore_element_line_break = false; self.process_element_for_presentation_mode(element)?; if !self.slide_state.ignore_element_line_break { self.push_line_break(); } } self.slide_state.ignore_element_line_break = true; Ok(()) } } #[derive(Debug, Clone, PartialEq, Deserialize, EnumDiscriminants)] #[strum_discriminants(derive(EnumIter))] #[serde(rename_all = "snake_case")] pub(crate) enum CommentCommand { Alignment(CommentCommandAlignment), Column(usize), EndSlide, FontSize(u8), Include(PathBuf), IncrementalLists(bool), IncrementalTables(bool), #[serde(rename = "column_layout")] InitColumnLayout(Vec), JumpToMiddle, ListItemNewlines(NonZeroU8), #[serde(alias = "newline")] NewLine, #[serde(alias = "newlines")] NewLines(u32), NoFooter, Pause, ResetLayout, SkipSlide, SpeakerNote(String), SnippetOutput(String), Comment(String), } impl CommentCommand { /// Generate sample comment strings for all available commands pub(crate) fn generate_samples() -> Vec<&'static str> { use strum::IntoEnumIterator; CommentCommandDiscriminants::iter() .flat_map(|variant| { use CommentCommandDiscriminants::*; match variant { Alignment => { vec!["", "", ""] } Column => vec![""], EndSlide => vec![""], FontSize => vec![""], Include => vec![""], IncrementalLists => { vec!["", ""] } IncrementalTables => { vec!["", ""] } InitColumnLayout => vec![""], JumpToMiddle => vec![""], ListItemNewlines => vec![""], NewLine => vec![""], NewLines => vec![""], NoFooter => vec![""], Pause => vec![""], ResetLayout => vec![""], SkipSlide => vec![""], SpeakerNote => vec![""], SnippetOutput => vec![""], Comment => vec![""], } }) .collect() } } impl FromStr for CommentCommand { type Err = CommandParseError; fn from_str(s: &str) -> Result { #[derive(Deserialize)] struct CommandWrapper(#[serde(with = "serde_yaml::with::singleton_map")] CommentCommand); let wrapper = serde_yaml::from_str::(s)?; Ok(wrapper.0) } } #[derive(Debug, Clone, PartialEq, Deserialize)] #[serde(rename_all = "snake_case")] pub(crate) enum CommentCommandAlignment { Left, Center, Right, } #[derive(thiserror::Error, Debug)] pub struct CommandParseError(#[from] serde_yaml::Error); impl fmt::Display for CommandParseError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let inner = self.0.to_string(); // Remove the trailing "at line X, ..." that comes from serde_yaml. This otherwise claims // we're always in line 1 because the yaml is parsed in isolation out of the HTML comment. let inner = inner.split(" at line").next().unwrap(); write!(f, "{inner}") } } #[cfg(test)] mod tests { use super::*; use crate::presentation::builder::{PresentationBuilderOptions, utils::Test}; use image::{DynamicImage, ImageEncoder, codecs::png::PngEncoder}; use rstest::rstest; use std::{fs, io::BufWriter}; use tempfile::tempdir; #[rstest] #[case::pause("pause", CommentCommand::Pause)] #[case::pause(" pause ", CommentCommand::Pause)] #[case::end_slide("end_slide", CommentCommand::EndSlide)] #[case::column_layout("column_layout: [1, 2]", CommentCommand::InitColumnLayout(vec![1, 2]))] #[case::column("column: 1", CommentCommand::Column(1))] #[case::reset_layout("reset_layout", CommentCommand::ResetLayout)] #[case::incremental_lists("incremental_lists: true", CommentCommand::IncrementalLists(true))] #[case::incremental_lists("new_lines: 2", CommentCommand::NewLines(2))] #[case::incremental_lists("newlines: 2", CommentCommand::NewLines(2))] #[case::incremental_lists("new_line", CommentCommand::NewLine)] #[case::incremental_lists("newline", CommentCommand::NewLine)] #[case::comment("comment: This is a user comment", CommentCommand::Comment("This is a user comment".into()))] fn command_formatting(#[case] input: &str, #[case] expected: CommentCommand) { let parsed: CommentCommand = input.parse().expect("deserialization failed"); assert_eq!(parsed, expected); } #[rstest] #[case::multiline("hello\nworld")] #[case::many_open_braces("{{{")] #[case::many_close_braces("}}}")] #[case::vim_command("vim: hi")] #[case::padded_vim_command("vim: hi")] #[case::double_slash("// This is a user comment")] #[case::double_slash_padded(" // This is a padded comment ")] #[case::comment_colon("comment: This is a user comment")] fn ignore_comments(#[case] comment: &str) { let input = format!(""); Test::new(input).build(); } #[rstest] #[case::command_with_prefix("cmd:end_slide", true)] #[case::non_command_with_prefix("cmd:bogus", false)] #[case::non_prefixed("random", true)] fn comment_prefix(#[case] comment: &str, #[case] should_work: bool) { let options = PresentationBuilderOptions { command_prefix: "cmd:".into(), ..Default::default() }; let element = MarkdownElement::Comment { comment: comment.into(), source_position: Default::default() }; let result = Test::new(vec![element]).options(options).try_build(); assert_eq!(result.is_ok(), should_work, "{result:?}"); } #[test] fn layout_without_init() { let input = ""; Test::new(input).expect_invalid(); } #[test] fn already_in_column() { let input = " "; Test::new(input).expect_invalid(); } #[test] fn column_index_overflow() { let input = " "; Test::new(input).expect_invalid(); } #[rstest] #[case::empty("column_layout: []")] #[case::zero("column_layout: [0]")] #[case::one_is_zero("column_layout: [1, 0]")] fn invalid_layouts(#[case] definition: &str) { let input = format!(""); Test::new(input).expect_invalid(); } #[test] fn operation_without_enter_column() { let input = " # hi "; Test::new(input).expect_invalid(); } #[test] fn end_slide_inside_layout() { let input = " "; let presentation = Test::new(input).build(); assert_eq!(presentation.iter_slides().count(), 2); } #[test] fn end_slide_inside_column() { let input = " "; let presentation = Test::new(input).build(); assert_eq!(presentation.iter_slides().count(), 2); } #[test] fn columns() { let input = "--- theme: override: column_layout: margin: fixed: 2 --- foo1 foo2 --- bar1 bar2 --- "; let lines = Test::new(input).render().rows(7).columns(20).into_lines(); let expected = &[ " ", "foo1 bar1 ", " ", "foo2 bar2 ", " ", "———————— ————————", " ", ]; assert_eq!(lines, expected); } #[test] fn columns_back_and_forth() { // this is the same as the above but we run back and forth between the columns let input = "--- theme: override: column_layout: margin: fixed: 2 --- foo1 bar1 foo2 --- bar2 --- "; let lines = Test::new(input).render().rows(7).columns(20).into_lines(); let expected = &[ " ", "foo1 bar1 ", " ", "foo2 bar2 ", " ", "———————— ————————", " ", ]; assert_eq!(lines, expected); } #[test] fn uneven_columns() { let input = "--- theme: override: column_layout: margin: fixed: 4 --- foo1 foo2 --- bar1 bar2 --- "; let lines = Test::new(input).render().rows(7).columns(24).into_lines(); let expected = &[ " ", "foo1 bar1", " ", "foo2 bar2", " ", "———————————— ————", " ", ]; assert_eq!(lines, expected); } #[test] fn uneven_three_columns() { let input = "--- theme: override: column_layout: margin: fixed: 4 --- --- --- --- "; let lines = Test::new(input).render().rows(2).columns(32).into_lines(); let expected = &[ // " ", "———— ———————————— ————", ]; assert_eq!(lines, expected); } #[test] fn pause_layout() { let input = r" hi bye "; let lines = Test::new(input).render().rows(5).columns(12).advances(1).into_lines(); let expected = &[" ", "hi ", " ", " ", " "]; assert_eq!(lines, expected); } #[test] fn pause_new_slide() { let input = " hi bye "; let options = PresentationBuilderOptions { pause_create_new_slide: true, ..Default::default() }; let slides = Test::new(input).options(options).build().into_slides(); assert_eq!(slides.len(), 2); } #[test] fn pause_layout_new_slide() { let input = r"--- theme: override: column_layout: margin: fixed: 4 --- hi bye "; let options = PresentationBuilderOptions { pause_create_new_slide: true, ..Default::default() }; let lines = Test::new(input).options(options).render().rows(3).columns(15).advances(1).into_lines(); let expected = &[" ", "hi bye ", " "]; assert_eq!(lines, expected); } #[test] fn skip_slide() { let input = " hi bye "; let lines = Test::new(input).render().rows(5).columns(3).into_lines(); let expected = &[" ", "bye", " ", " ", " "]; assert_eq!(lines, expected); } #[test] fn skip_all_slides() { let input = " hi "; let lines = Test::new(input).render().rows(5).columns(3).into_lines(); let expected = &[" ", " ", " ", " ", " "]; assert_eq!(lines, expected); } #[test] fn skip_slide_pauses() { let input = " hi bye "; let lines = Test::new(input).render().rows(2).columns(3).into_lines(); let expected = &[" ", "bye"]; assert_eq!(lines, expected); } #[test] fn skip_slide_speaker_note() { let input = " hi "; let options = PresentationBuilderOptions { render_speaker_notes_only: true, ..Default::default() }; let lines = Test::new(input).options(options).render().rows(2).columns(3).into_lines(); let expected = &[" ", "bye"]; assert_eq!(lines, expected); } #[test] fn speaker_notes() { let input = " "; let options = PresentationBuilderOptions { render_speaker_notes_only: true, ..Default::default() }; let lines = Test::new(input).options(options).render().rows(4).columns(3).into_lines(); let expected = &[" ", "hi ", " ", "bye"]; assert_eq!(lines, expected); } #[test] fn speaker_notes_pause() { let input = " "; let options = PresentationBuilderOptions { render_speaker_notes_only: true, ..Default::default() }; let lines = Test::new(input).options(options).render().rows(4).columns(3).advances(0).into_lines(); let expected = &[" ", "hi ", " ", " "]; assert_eq!(lines, expected); } #[test] fn alignment() { let input = " hi hello hola "; let lines = Test::new(input).render().rows(6).columns(16).into_lines(); let expected = &[ " ", "hi ", " ", " hello ", " ", " hola", ]; assert_eq!(lines, expected); } #[test] fn include() { let dir = tempdir().expect("failed to created tempdir"); let path = dir.path(); let inner_path = path.join("inner"); fs::create_dir_all(path.join(&inner_path)).expect("failed to create dir"); let image = DynamicImage::new_rgba8(1, 1); let mut buffer = BufWriter::new(fs::File::create(inner_path.join("img.png")).expect("failed to write image")); PngEncoder::new(&mut buffer) .write_image(image.as_bytes(), 1, 1, image.color().into()) .expect("failed to create imager"); drop(buffer); fs::write( path.join("first.md"), r" first === foo ![](inner/img.png) ```file path: inner/foo.txt language: text ``` ", ) .unwrap(); fs::write( inner_path.join("second.md"), r" second ![](img.png) ", ) .unwrap(); fs::write(inner_path.join("foo.txt"), "a").unwrap(); let input = " hi "; let lines = Test::new(input).resources_path(path).render().rows(14).columns(12).into_lines(); let expected = &[ " ", "hi ", " ", "first ", " ", "foo ", " ", " ", " ", "second ", " ", " ", " ", "a ", ]; assert_eq!(lines, expected); } #[test] fn self_include() { let dir = tempdir().expect("failed to created tempdir"); let path = dir.path(); fs::write(path.join("main.md"), "").unwrap(); let input = ""; let err = Test::new(input).resources_path(path).expect_invalid(); assert!(err.to_string().contains("was already imported"), "{err:?}"); } #[test] fn include_cycle() { let dir = tempdir().expect("failed to created tempdir"); let path = dir.path(); fs::write(path.join("main.md"), "").unwrap(); fs::write(path.join("inner.md"), "").unwrap(); let input = ""; let err = Test::new(input).resources_path(path).expect_invalid(); assert!(err.to_string().contains("was already imported"), "{err:?}"); } } ================================================ FILE: src/presentation/builder/error.rs ================================================ use crate::{ code::execute::UnsupportedExecution, markdown::{ elements::SourcePosition, parse::ParseError, text_style::{Color, TextStyle, UndefinedPaletteColorError}, }, presentation::builder::{comment::CommandParseError, images::ImageAttributeError, sources::MarkdownSourceError}, terminal::{capabilities::TerminalCapabilities, image::printer::RegisterImageError}, theme::{ProcessingThemeError, registry::LoadThemeError}, third_party::ThirdPartyRenderError, ui::footer::InvalidFooterTemplateError, }; use std::{ fmt, io::{self}, path::PathBuf, }; /// An error when building a presentation. #[derive(thiserror::Error, Debug)] pub(crate) enum BuildError { #[error("failed to read presentation file {0:?}: {1:?}")] ReadPresentation(PathBuf, io::Error), #[error("failed to register image: {0}")] RegisterImage(#[from] RegisterImageError), #[error("invalid theme: {0}")] InvalidTheme(#[from] LoadThemeError), #[error("invalid code highlighter theme: '{0}'")] InvalidCodeTheme(String), #[error("third party render failed: {0}")] ThirdPartyRender(#[from] ThirdPartyRenderError), #[error(transparent)] UnsupportedExecution(#[from] UnsupportedExecution), #[error(transparent)] UndefinedPaletteColor(#[from] UndefinedPaletteColorError), #[error("processing theme: {0}")] ThemeProcessing(#[from] ProcessingThemeError), #[error("invalid footer: {0}")] InvalidFooter(#[from] InvalidFooterTemplateError), #[error( "invalid markdown at {display_path}:{line}:{column}:\n\n{context}", display_path = .path.display(), line = .error.sourcepos.start.line, column = .error.sourcepos.start.column, )] Parse { path: PathBuf, error: ParseError, context: String }, #[error("cannot process presentation file: {0}")] EnterRoot(MarkdownSourceError), #[error( "error at {display_path}:{line}:{column}:\n\n{context}", display_path = .path.display(), line = .source_position.start.line, column = .source_position.start.column, )] InvalidPresentation { path: PathBuf, source_position: SourcePosition, context: String }, #[error("error in frontmatter:\n\n{0}")] InvalidFrontmatter(String), #[error("need to enter layout column explicitly using `column` command\n\n{0}")] NotInsideColumn(String), } #[derive(Debug, thiserror::Error)] pub(crate) enum InvalidPresentation { #[error("could not load image '{path}': {error}")] LoadImage { path: PathBuf, error: String }, #[error("invalid image attribute: {0}")] ParseImageAttribute(#[from] ImageAttributeError), #[error("invalid snippet: {0}")] Snippet(String), #[error("invalid command: {0}")] CommandParse(#[from] CommandParseError), #[error("invalid markdown in imported file {path:?}: {error}")] ParseInclude { path: PathBuf, error: ParseError }, #[error("could not read included markdown file {path:?}: {error}")] IncludeMarkdown { path: PathBuf, error: io::Error }, #[error("included markdown files cannot contain a front matter")] IncludeFrontMatter, #[error("cannot include markdown file at {path}: {error}")] Import { path: PathBuf, error: MarkdownSourceError }, #[error("can't enter layout: no layout defined")] NoLayout, #[error("can't enter layout column: already in it")] AlreadyInColumn, #[error("can't enter layout column: column index too large")] ColumnIndexTooLarge, #[error("invalid layout: {0}")] InvalidLayout(&'static str), #[error("font sizes must be >= 1 and <= 7")] InvalidFontSize, #[error("snippet id '{0}' not defined")] UndefinedSnippetId(String), #[error("snippet identifiers can only be used in +exec blocks")] SnippetIdNonExec, #[error("snippet id '{0}' already exists")] SnippetAlreadyExists(String), } #[derive(Clone, Debug)] pub(crate) struct FileSourcePosition { pub(crate) source_position: SourcePosition, pub(crate) file: PathBuf, } impl fmt::Display for FileSourcePosition { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let file = self.file.display(); let pos = &self.source_position; write!(f, "{file}:{pos}") } } pub(super) trait FormatError { fn format_error(self) -> String; } impl FormatError for String { fn format_error(self) -> String { TextStyle::default().fg_color(Color::Red).apply(&self, &Default::default()).to_string() } } #[derive(Default)] pub(super) struct ErrorContextBuilder<'a> { line: Option, column: Option, source_line: &'a str, error: &'a str, prefix_style: TextStyle, error_style: TextStyle, } impl<'a> ErrorContextBuilder<'a> { pub(super) fn new(source_line: &'a str, error: &'a str) -> Self { Self { line: None, column: None, source_line, error, prefix_style: TextStyle::default().fg_color(Color::Blue), error_style: TextStyle::default().fg_color(Color::Red), } } pub(super) fn position(mut self, position: SourcePosition) -> Self { self.line = Some(position.start.line); self.column = Some(position.start.column); self } pub(super) fn column(mut self, column: usize) -> Self { self.column = Some(column); self } pub(super) fn build(self) -> String { let Self { line, column, source_line, error, prefix_style, error_style } = self; let (error_line_prefix, empty_line, source_line) = match line { Some(line) => { let line_number = line.to_string(); let empty_prefix = " ".repeat(line_number.len()); let error_line_prefix = format!("{line_number} | "); let empty_line = format!("{empty_prefix} | "); let source_line = source_line.lines().nth(line.saturating_sub(1)).unwrap_or_default(); (error_line_prefix, empty_line, source_line) } None => { let prefix = " | ".to_string(); (prefix.clone(), prefix, source_line) } }; let column = column.map(|c| c.saturating_sub(1)).unwrap_or_default(); let capabilities = TerminalCapabilities::default(); let empty_line = prefix_style.apply(&empty_line, &capabilities).to_string(); let mut output = empty_line.clone(); output.push('\n'); let prefix = prefix_style.apply(&error_line_prefix, &capabilities).to_string(); output.push_str(&format!("{prefix}{source_line}\n")); let indicator = format!("{}^ {error}", " ".repeat(column)); let indicator = error_style.apply(&indicator, &capabilities).to_string(); let indicator_line = format!("{empty_line}{indicator}"); output.push_str(&indicator_line); output } } #[cfg(test)] mod tests { use super::*; use crate::markdown::elements::LineColumn; trait ErrorContextBuilderExt { fn into_lines(self) -> Vec; } impl ErrorContextBuilderExt for ErrorContextBuilder<'_> { fn into_lines(self) -> Vec { let error = self.build(); error.lines().map(ToString::to_string).collect() } } fn make_builder<'a>(source_line: &'a str, error: &'a str) -> ErrorContextBuilder<'a> { let mut builder = ErrorContextBuilder::new(source_line, error); builder.prefix_style = Default::default(); builder.error_style = Default::default(); builder } #[test] fn position() { let lines = make_builder("foo\nbear\ntar", "'a' not allowed") .position(SourcePosition { start: LineColumn { line: 2, column: 3 } }) .into_lines(); let expected = &[ // " | ", "2 | bear", " | ^ 'a' not allowed", ]; assert_eq!(&lines, expected); } #[test] fn no_position() { let lines = make_builder("bear", "'b' not allowed").into_lines(); let expected = &[ // " | ", " | bear", " | ^ 'b' not allowed", ]; assert_eq!(&lines, expected); } #[test] fn column() { let lines = make_builder("bear", "'e' not allowed").column(2).into_lines(); let expected = &[ // " | ", " | bear", " | ^ 'e' not allowed", ]; assert_eq!(&lines, expected); } } ================================================ FILE: src/presentation/builder/frontmatter.rs ================================================ use crate::{ config::OptionsConfig, markdown::{ elements::{Line, Text}, parse::MarkdownParser, text_style::TextStyle, }, presentation::{ PresentationMetadata, PresentationThemeMetadata, builder::{ BuildResult, ErrorContextBuilder, PresentationBuilder, error::{BuildError, FormatError}, }, }, render::operation::RenderOperation, theme::{AuthorPositioning, ElementType, PresentationTheme}, }; use comrak::Arena; impl PresentationBuilder<'_, '_> { pub(crate) fn process_front_matter(&mut self, contents: &str) -> BuildResult { let metadata = match self.options.strict_front_matter_parsing { true => serde_yaml::from_str::(contents).map(PresentationMetadata::from), false => serde_yaml::from_str::(contents), }; let mut metadata = metadata.map_err(|e| BuildError::InvalidFrontmatter(e.to_string().format_error()))?; if metadata.author.is_some() && !metadata.authors.is_empty() { return Err(BuildError::InvalidFrontmatter( ErrorContextBuilder::new("authors:", "cannot have both 'author' and 'authors'").build(), )); } if let Some(options) = metadata.options.take() { self.options.merge(options); } { let footer_context = &mut self.footer_vars; footer_context.title.clone_from(&metadata.title); footer_context.sub_title.clone_from(&metadata.sub_title); footer_context.location.clone_from(&metadata.location); footer_context.event.clone_from(&metadata.event); footer_context.date.clone_from(&metadata.date); footer_context.author.clone_from(&metadata.author); } self.set_theme(&metadata.theme)?; if metadata.has_frontmatter() { self.push_slide_prelude(); self.push_intro_slide(metadata)?; } Ok(()) } fn set_theme(&mut self, metadata: &PresentationThemeMetadata) -> BuildResult { if metadata.name.is_some() && metadata.path.is_some() { return Err(BuildError::InvalidFrontmatter( ErrorContextBuilder::new("path:", "cannot set both 'theme.path' and 'theme.name'").build(), )); } let mut new_theme = None; // Only override the theme if we're not forced to use the default one. if !self.options.force_default_theme { if let Some(theme_name) = &metadata.name { let theme = self.themes.presentation.load_by_name(theme_name).ok_or_else(|| { BuildError::InvalidFrontmatter( ErrorContextBuilder::new(&format!("name: {theme_name}"), "theme does not exist") .column(7) .build(), ) })?; 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::InvalidFrontmatter( ErrorContextBuilder::new(&format!("extends: {name}"), "extended theme does not exist") .column(10) .build(), ) })?; theme = merge_struct::merge(&theme, &base) .map_err(|e| BuildError::InvalidFrontmatter(format!("malformed theme: {e}")))?; } new_theme = Some(theme); } } if let Some(overrides) = &metadata.overrides { if let Some(extends) = &overrides.extends { return Err(BuildError::InvalidFrontmatter( ErrorContextBuilder::new(&format!("extends: {extends}"), "theme overrides can't use 'extends'") .build(), )); } 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) .map_err(|e| BuildError::InvalidFrontmatter(format!("malformed theme: {e}")))?; new_theme = Some(theme); } if let Some(theme) = new_theme { self.theme = PresentationTheme::new(&theme, &self.resources, &self.options.theme_options)?; } Ok(()) } fn push_intro_slide(&mut self, metadata: PresentationMetadata) -> BuildResult { let styles = &self.theme.intro_slide; let create_text = |text: Option, style: TextStyle| -> Option { text.map(|text| Text::new(text, style)) }; let title_lines = metadata .title .map(|t| self.format_multiline(t, &self.theme.intro_slide.title.style, "title")) .transpose()?; let sub_title_lines = metadata .sub_title .map(|t| self.format_multiline(t, &self.theme.intro_slide.subtitle.style, "sub_title")) .transpose()?; let event = create_text(metadata.event, styles.event.style); let location = create_text(metadata.location, styles.location.style); let date = create_text(metadata.date, styles.date.style); let authors: Vec<_> = metadata .author .into_iter() .chain(metadata.authors) .map(|author| Text::new(author, styles.author.style)) .collect(); if !styles.footer { self.slide_state.ignore_footer = true; } self.chunk_operations.push(RenderOperation::JumpToVerticalCenter); if let Some(title_lines) = title_lines { for line in title_lines { self.push_text(line, ElementType::PresentationTitle); self.push_line_break(); } } if let Some(sub_title_lines) = sub_title_lines { for line in sub_title_lines { self.push_text(line, ElementType::PresentationSubTitle); self.push_line_break(); } } if event.is_some() || location.is_some() || date.is_some() { self.push_line_breaks(2); if let Some(event) = event { self.push_intro_slide_text(event, ElementType::PresentationEvent); } if let Some(location) = location { self.push_intro_slide_text(location, ElementType::PresentationLocation); } if let Some(date) = date { self.push_intro_slide_text(date, ElementType::PresentationDate); } } if !authors.is_empty() { match self.theme.intro_slide.author.positioning { AuthorPositioning::BelowTitle => { self.push_line_breaks(3); } AuthorPositioning::PageBottom => { self.chunk_operations.push(RenderOperation::JumpToBottomRow { index: authors.len() as u16 - 1 }); } }; for author in authors { self.push_intro_slide_text(author, ElementType::PresentationAuthor); } } self.slide_state.title = Some(Line::from("[Introduction]")); self.terminate_slide(); Ok(()) } fn push_intro_slide_text(&mut self, text: Text, element_type: ElementType) { self.push_text(Line::from(text), element_type); self.push_line_break(); } fn format_multiline( &self, text: String, style: &TextStyle, attribute: &'static str, ) -> Result, BuildError> { let arena = Arena::default(); let parser = MarkdownParser::new(&arena); let mut lines = Vec::new(); for line in text.lines() { let line = parser.parse_inlines(line).map_err(|e| { BuildError::InvalidFrontmatter( ErrorContextBuilder::new(&format!("{attribute}: ..."), &e.to_string()) .column(attribute.len() + 3) .build(), ) })?; let mut line = line.resolve(&self.theme.palette)?; line.apply_style(style); lines.push(line); } Ok(lines) } } #[derive(serde::Deserialize)] #[serde(deny_unknown_fields)] struct StrictPresentationMetadata { #[serde(default)] title: Option, #[serde(default)] sub_title: Option, #[serde(default)] event: Option, #[serde(default)] location: Option, #[serde(default)] date: Option, #[serde(default)] author: Option, #[serde(default)] authors: Vec, #[serde(default)] theme: PresentationThemeMetadata, #[serde(default)] options: Option, } impl From for PresentationMetadata { fn from(strict: StrictPresentationMetadata) -> Self { let StrictPresentationMetadata { title, sub_title, event, location, date, author, authors, theme, options } = strict; Self { title, sub_title, event, location, date, author, authors, theme, options } } } #[cfg(test)] mod tests { use crate::{presentation::builder::utils::Test, theme::raw}; #[test] fn multiline_centered_title() { let input = "--- title: | Beep Boop boop --- "; let theme = raw::PresentationTheme { intro_slide: raw::IntroSlideStyle { title: raw::IntroSlideTitleStyle { alignment: Some(raw::Alignment::Center { minimum_margin: raw::Margin::Fixed(2), minimum_size: 1 }), ..Default::default() }, ..Default::default() }, ..Default::default() }; let lines = Test::new(input).theme(theme).render().rows(7).columns(16).advances(0).into_lines(); let expected = &[ " ", " ", " Beep ", " Boop boop ", " ", " ", " ", ]; assert_eq!(lines, expected); } } ================================================ FILE: src/presentation/builder/heading.rs ================================================ use crate::{ markdown::elements::{Line, Text}, presentation::builder::{BuildResult, LastElement, PresentationBuilder}, theme::{ElementType, raw::RawColor}, ui::separator::RenderSeparator, }; impl PresentationBuilder<'_, '_> { pub(crate) fn push_slide_title(&mut self, text: Vec>) -> BuildResult { if self.options.implicit_slide_ends && !matches!(self.slide_state.last_element, LastElement::None) { self.terminate_slide(); } let mut style = self.theme.slide_title.clone(); self.push_line_breaks(style.padding_top as usize); for (index, title_line) in text.into_iter().enumerate() { let mut title_line = title_line.resolve(&self.theme.palette)?; self.slide_state.title.get_or_insert_with(|| title_line.clone()); if let (prefix, 0) = (&style.prefix, index) { if !prefix.is_empty() { let mut prefix = prefix.clone(); prefix.push(' '); title_line.0.insert(0, Text::from(prefix)); } } if let Some(font_size) = self.slide_state.font_size { style.style = style.style.size(font_size); } title_line.apply_style(&style.style); self.push_text(title_line, ElementType::SlideTitle); self.push_line_break(); } for _ in 0..style.padding_bottom { self.push_line_break(); } if style.separator { self.chunk_operations .push(RenderSeparator::new(Line::default(), Default::default(), style.style.size).into()); self.push_line_break(); } self.push_line_break(); self.slide_state.ignore_element_line_break = true; Ok(()) } pub(crate) fn push_heading(&mut self, level: u8, text: Line) -> BuildResult { if level == 1 && self.options.h1_slide_titles && (self.slide_state.title.is_none() || self.options.implicit_slide_ends) { return self.push_slide_title(vec![text]); } let mut text = text.resolve(&self.theme.palette)?; let (element_type, style) = match level { 1 => (ElementType::Heading1, &self.theme.headings.h1), 2 => (ElementType::Heading2, &self.theme.headings.h2), 3 => (ElementType::Heading3, &self.theme.headings.h3), 4 => (ElementType::Heading4, &self.theme.headings.h4), 5 => (ElementType::Heading5, &self.theme.headings.h5), 6 => (ElementType::Heading6, &self.theme.headings.h6), 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)); } } text.apply_style(&style.style); self.push_text(text, element_type); self.push_line_break(); Ok(()) } } #[cfg(test)] mod tests { use crate::{ markdown::text_style::Color, presentation::builder::{PresentationBuilderOptions, utils::Test}, theme::raw, }; #[test] fn slide_title() { let input = " title === hi "; let color = Color::new(1, 1, 1); let theme = raw::PresentationTheme { slide_title: raw::SlideTitleStyle { separator: true, padding_top: Some(1), padding_bottom: Some(1), colors: raw::RawColors { foreground: None, background: Some(raw::RawColor::Color(color)) }, ..Default::default() }, ..Default::default() }; let lines = Test::new(input).theme(theme).render().rows(8).columns(5).into_lines(); let expected = &[" ", " ", "title", " ", "—————", " ", "hi ", " "]; assert_eq!(lines, expected); } #[test] fn slide_title_prefix() { let input = "--- theme: override: slide_title: prefix: \"#\" --- title === "; let lines = Test::new(input).render().rows(2).columns(7).into_lines(); let expected = &[" ", "# title"]; assert_eq!(lines, expected); } #[test] fn h1_slide_title() { let input = "--- options: h1_slide_titles: true theme: override: slide_title: separator: true --- # title hi "; let lines = Test::new(input).render().rows(6).columns(5).into_lines(); let expected = &[" ", "title", "—————", " ", "hi ", " "]; assert_eq!(lines, expected); } #[test] fn h1_slide_title_implicit_slides() { let input = "--- options: h1_slide_titles: true implicit_slide_ends: true theme: override: slide_title: separator: true --- # title hi # other bye "; let lines = Test::new(input).render().rows(8).columns(5).into_lines(); let expected = &[" ", "title", "—————", " ", "hi ", " ", " ", " "]; assert_eq!(lines, expected); } #[test] fn centered_slide_title() { let input = " hi === "; let theme = raw::PresentationTheme { slide_title: raw::SlideTitleStyle { alignment: Some(raw::Alignment::Center { minimum_margin: raw::Margin::Fixed(1), minimum_size: 0 }), ..Default::default() }, ..Default::default() }; let lines = Test::new(input).theme(theme).render().rows(3).columns(6).into_lines(); let expected = &[" ", " hi ", " "]; assert_eq!(lines, expected); } #[test] fn implicit_slide_ends() { let input = " hi === foo bye === bar "; let options = PresentationBuilderOptions { implicit_slide_ends: true, ..Default::default() }; let lines = Test::new(input).options(options).render().rows(4).columns(6).advances(1).into_lines(); let expected = &[" ", "bye ", " ", "bar "]; assert_eq!(lines, expected); } #[test] fn headings() { let input = " # A ## B ### C #### D ##### E "; let theme = raw::PresentationTheme { headings: raw::HeadingStyles { h1: raw::HeadingStyle { prefix: Some("!".to_string()), ..Default::default() }, h2: raw::HeadingStyle { prefix: Some("@@".to_string()), ..Default::default() }, h3: raw::HeadingStyle { alignment: Some(raw::Alignment::Center { minimum_margin: raw::Margin::Fixed(1), minimum_size: 0 }), ..Default::default() }, ..Default::default() }, ..Default::default() }; let lines = Test::new(input).theme(theme).render().rows(10).columns(6).advances(1).into_lines(); let expected = &[" ", "! A ", " ", "@@ B ", " ", " C ", " ", "D ", " ", "E "]; assert_eq!(lines, expected); } #[test] fn heading_font_size() { let input = " # hi ## bye "; let lines = Test::new(input).render().rows(6).columns(10).into_lines(); let expected = &[ // " ", "h i ", " ", " ", // text end (3 rows) " ", // a single new line "b y e ", // the next text ]; assert_eq!(lines, expected); } } ================================================ FILE: src/presentation/builder/images.rs ================================================ use crate::{ markdown::elements::{Percent, PercentParseError, SourcePosition}, presentation::builder::{ BuildResult, PresentationBuilder, error::{BuildError, InvalidPresentation}, }, render::operation::{ImageRenderProperties, ImageSize, RenderOperation}, terminal::image::Image, }; use std::path::PathBuf; impl PresentationBuilder<'_, '_> { pub(crate) fn push_image_from_path( &mut self, path: PathBuf, title: String, source_position: SourcePosition, ) -> BuildResult { let base_path = self.resource_base_path(); let image = self.resources.image(&path, &base_path).map_err(|e| { self.invalid_presentation(source_position, InvalidPresentation::LoadImage { path, error: e.to_string() }) })?; self.push_image(image, title, source_position) } pub(crate) fn push_image(&mut self, image: Image, title: String, source_position: SourcePosition) -> BuildResult { let attributes = self.parse_image_attributes(&title, &self.options.image_attribute_prefix, source_position)?; let size = match attributes.width { Some(percent) => ImageSize::WidthScaled { ratio: percent.as_ratio() }, None => ImageSize::ShrinkIfNeeded, }; let properties = ImageRenderProperties { size, background_color: self.theme.default_style.style.colors.background, ..Default::default() }; self.chunk_operations.push(RenderOperation::RenderImage(image, properties)); Ok(()) } fn parse_image_attributes( &self, input: &str, attribute_prefix: &str, source_position: SourcePosition, ) -> Result { let mut attributes = ImageAttributes::default(); for attribute in input.split(',') { let Some((prefix, suffix)) = attribute.split_once(attribute_prefix) else { continue }; if !prefix.is_empty() || (attribute_prefix.is_empty() && suffix.is_empty()) { continue; } Self::parse_image_attribute(suffix, &mut attributes) .map_err(|e| self.invalid_presentation(source_position, e))?; } Ok(attributes) } fn parse_image_attribute(input: &str, attributes: &mut ImageAttributes) -> Result<(), ImageAttributeError> { let Some((key, value)) = input.split_once(':') else { return Err(ImageAttributeError::AttributeMissing); }; match key { "width" | "w" => { let width = value.parse().map_err(ImageAttributeError::InvalidWidth)?; attributes.width = Some(width); Ok(()) } _ => Err(ImageAttributeError::UnknownAttribute(key.to_string())), } } } #[derive(thiserror::Error, Debug)] pub(crate) enum ImageAttributeError { #[error("invalid width: {0}")] InvalidWidth(PercentParseError), #[error("no attribute given")] AttributeMissing, #[error("unknown attribute: '{0}'")] UnknownAttribute(String), } #[derive(Clone, Debug, Default, PartialEq)] struct ImageAttributes { width: Option, } #[cfg(test)] mod tests { use super::*; use crate::presentation::builder::utils::Test; use rstest::rstest; #[rstest] #[case::width("image:width:50%", Some(50))] #[case::w("image:w:50%", Some(50))] #[case::nothing("", None)] #[case::no_prefix("width", None)] fn image_attributes(#[case] input: &str, #[case] expectation: Option) { let attributes = Test::new("").with_builder(|builder| { builder.parse_image_attributes(input, "image:", Default::default()).expect("failed to parse") }); assert_eq!(attributes.width, expectation.map(Percent)); } #[rstest] #[case::width("width:50%", Some(50))] #[case::empty("", None)] fn image_attributes_empty_prefix(#[case] input: &str, #[case] expectation: Option) { let attributes = Test::new("").with_builder(|builder| { builder.parse_image_attributes(input, "", Default::default()).expect("failed to parse") }); assert_eq!(attributes.width, expectation.map(Percent)); } } ================================================ FILE: src/presentation/builder/list.rs ================================================ use crate::{ markdown::{ elements::{ListItem, ListItemType, Text}, text_style::TextStyle, }, presentation::builder::{BuildResult, LastElement, PresentationBuilder}, render::operation::{BlockLine, RenderOperation}, theme::ElementType, }; impl PresentationBuilder<'_, '_> { pub(crate) fn push_list(&mut self, list: Vec) -> BuildResult { let last_chunk_operation = self.slide_chunks.last().and_then(|chunk| chunk.iter_operations().last()); // If the last chunk ended in a list, pop the newline so we get them all next to each // other. if matches!(last_chunk_operation, Some(RenderOperation::RenderLineBreak)) && self.slide_state.last_chunk_ended_in_list && self.chunk_operations.is_empty() { self.slide_chunks.last_mut().unwrap().pop_last(); } // If this chunk just starts (because there was a pause), pick up from the last index. let start_index = match self.slide_state.last_element { LastElement::List { last_index } if self.chunk_operations.is_empty() => last_index + 1, _ => 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 = self.slide_state.incremental_lists.unwrap_or(self.options.incremental_lists); let iter = ListIterator::new(list, start_index); if incremental && self.options.pause_before_incremental_lists { self.push_pause(); } for (index, item) in iter.enumerate() { if index > 0 && incremental { self.push_pause(); } self.push_list_item(item.index, item.item, block_length)?; } if incremental && self.options.pause_after_incremental_lists { self.push_pause(); } 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 { self.apply_theme_text_style(piece); piece.style = piece.style.size(font_size); } let alignment = self.slide_state.alignment.unwrap_or(self.theme.alignment(&ElementType::List)); 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, })); let newlines = self.slide_state.list_item_newlines.unwrap_or(self.options.list_item_newlines); self.push_line_breaks(newlines as usize); if item.depth == 0 { self.slide_state.last_element = LastElement::List { last_index: index }; } Ok(()) } fn list_item_prefix(&self, item: &ListItem) -> Text { let font_size = self.slide_font_size(); let spaces_per_indent = match item.depth { 0 => 3_u8.div_ceil(font_size), _ => { if font_size == 1 { 3 } else { 2 } } }; let padding_length = (item.depth as usize + 1) * spaces_per_indent as usize; let mut prefix: String = " ".repeat(padding_length); match item.item_type { ListItemType::Unordered => { let delimiter = match item.depth { 0 => '•', 1 => '◦', _ => '▪', }; prefix.push(delimiter); prefix.push_str(" "); } ListItemType::OrderedParens(value) => { prefix.push_str(&value.to_string()); prefix.push_str(") "); } ListItemType::OrderedPeriod(value) => { prefix.push_str(&value.to_string()); prefix.push_str(". "); } }; Text::new(prefix, TextStyle::default().size(font_size)) } } struct ListIterator { remaining: I, next_index: usize, current_depth: u8, saved_indexes: Vec, } impl ListIterator { fn new(remaining: T, next_index: usize) -> Self where I: Iterator, T: IntoIterator, { Self { remaining: remaining.into_iter(), next_index, current_depth: 0, saved_indexes: Vec::new() } } } impl Iterator for ListIterator where I: Iterator, { type Item = IndexedListItem; fn next(&mut self) -> Option { let head = self.remaining.next()?; if head.depth != self.current_depth { if head.depth > self.current_depth { // If we're going deeper, save the next index so we can continue later on and start // from 0. self.saved_indexes.push(self.next_index); self.next_index = 0; } else { // if we're getting out, recover the index we had previously saved. for _ in head.depth..self.current_depth { self.next_index = self.saved_indexes.pop().unwrap_or(0); } } self.current_depth = head.depth; } let index = self.next_index; self.next_index += 1; Some(IndexedListItem { index, item: head }) } } #[derive(Debug)] struct IndexedListItem { index: usize, item: ListItem, } #[cfg(test)] mod tests { use super::*; use crate::presentation::builder::{PresentationBuilderOptions, utils::Test}; use rstest::rstest; use std::iter; #[test] fn iterate_list() { let iter = ListIterator::new( vec![ ListItem { depth: 0, contents: "0".into(), item_type: ListItemType::Unordered }, ListItem { depth: 0, contents: "1".into(), item_type: ListItemType::Unordered }, ListItem { depth: 1, contents: "00".into(), item_type: ListItemType::Unordered }, ListItem { depth: 1, contents: "01".into(), item_type: ListItemType::Unordered }, ListItem { depth: 1, contents: "02".into(), item_type: ListItemType::Unordered }, ListItem { depth: 2, contents: "001".into(), item_type: ListItemType::Unordered }, ListItem { depth: 0, contents: "2".into(), item_type: ListItemType::Unordered }, ], 0, ); let expected_indexes = [0, 1, 0, 1, 2, 0, 2]; let indexes: Vec<_> = iter.map(|item| item.index).collect(); assert_eq!(indexes, expected_indexes); } #[test] fn iterate_list_starting_from_other() { let list = ListIterator::new( vec![ ListItem { depth: 0, contents: "0".into(), item_type: ListItemType::Unordered }, ListItem { depth: 0, contents: "1".into(), item_type: ListItemType::Unordered }, ], 3, ); let expected_indexes = [3, 4]; let indexes: Vec<_> = list.into_iter().map(|item| item.index).collect(); assert_eq!(indexes, expected_indexes); } #[test] fn unordered() { let input = " * A * AA * AAA * AB * B * BA "; let lines = Test::new(input).render().rows(7).columns(16).into_lines(); let expected = &[ " ", " • A ", " ◦ AA ", " ▪ AAA ", " ◦ AB ", " • B ", " ◦ BA ", ]; assert_eq!(lines, expected); } #[test] fn unordered_paused() { let input = " * A * B * C "; let lines = Test::new(input).render().rows(4).columns(8).into_lines(); let expected = &[" ", " • A ", " • B ", " • C "]; assert_eq!(lines, expected); } #[test] fn ordered_period() { let input = " 1. A 1. AA 1. AAA 2. AB 2. B 1. BA "; let lines = Test::new(input).render().rows(7).columns(16).into_lines(); let expected = &[ " ", " 1. A ", " 1. AA ", " 1. AAA ", " 2. AB ", " 2. B ", " 1. BA ", ]; assert_eq!(lines, expected); } #[test] fn ordered_parens() { let input = " 1) A 1) AA 2) B "; let lines = Test::new(input).render().rows(4).columns(12).into_lines(); let expected = &[" ", " 1) A ", " 1) AA ", " 2) B "]; assert_eq!(lines, expected); } #[test] fn ordered_paused() { let input = " 1. A 2. B 3. C "; let lines = Test::new(input).render().rows(4).columns(8).into_lines(); let expected = &[" ", " 1. A ", " 2. B ", " 3. C "]; assert_eq!(lines, expected); } #[rstest] #[case::zero(0)] #[case::one(1)] #[case::two(2)] fn visible_pauses(#[case] advances: usize) { let input = " * A * B * C "; let lines = Test::new(input).render().rows(4).columns(8).advances(advances).into_lines(); let mut expected = vec![" ", " • A "]; if advances >= 1 { expected.push(" • B "); } if advances >= 2 { expected.push(" • C "); } expected.extend(iter::repeat_n(" ", 4 - expected.len())); assert_eq!(lines, expected); } #[rstest] #[case::first_no_before_no_after(true, true, 0, 0)] #[case::first_no_before(false, true, 0, 1)] #[case::second_no_before_no_after(true, true, 1, 1)] #[case::second_no_before(false, true, 1, 2)] #[case::second(false, false, 2, 4)] #[case::third_no_before_no_after(true, true, 2, 2)] #[case::third_no_before(false, true, 3, 4)] #[case::third_no_after(true, false, 3, 4)] fn incremental_lists( #[case] pause_before: bool, #[case] pause_after: bool, #[case] advances: usize, #[case] visible: usize, ) { let input = " * A * B * C hi "; let options = PresentationBuilderOptions { pause_before_incremental_lists: pause_before, pause_after_incremental_lists: pause_after, ..Default::default() }; let lines = Test::new(input).options(options).render().rows(6).columns(8).advances(advances).into_lines(); let mut expected = vec![" "]; if visible >= 1 { expected.push(" • A "); } if visible >= 2 { expected.push(" • B "); } if visible >= 3 { expected.push(" • C "); } if visible >= 4 { expected.push(" "); expected.push("hi "); } expected.extend(iter::repeat_n(" ", 6 - expected.len())); assert_eq!(lines, expected); } #[test] fn font_size() { let input = " * A * B "; let lines = Test::new(input).render().rows(4).columns(12).into_lines(); let expected = &[" ", " • A ", " ", " • B "]; assert_eq!(lines, expected); } #[test] fn newlines() { let input = " * A * B "; let lines = Test::new(input).render().rows(4).columns(8).into_lines(); let expected = &[" ", " • A ", " ", " • B "]; assert_eq!(lines, expected); } #[test] fn incremental_lists_end_of_slide() { let input = " * A * B other "; // 3 moves forward should land in the second slide, not an extra pause at the end let lines = Test::new(input).render().rows(4).columns(8).advances(3).into_lines(); let expected = &[" ", "other ", " ", " "]; assert_eq!(lines, expected); } #[test] fn pause_after_list() { let input = " 1. A # hi 2. B "; let lines = Test::new(input).render().rows(4).columns(8).advances(0).into_lines(); let expected = &[" ", " 1. A ", " ", " "]; assert_eq!(lines, expected); } } ================================================ FILE: src/presentation/builder/mod.rs ================================================ use crate::{ code::{ execute::SnippetExecutor, highlighting::{HighlightThemeSet, SnippetHighlighter}, snippet::SnippetLanguage, }, config::{KeyBindingsConfig, OptionsConfig}, markdown::{ elements::{Line, MarkdownElement, SourcePosition, Text}, parse::MarkdownParser, text::WeightedLine, text_style::{Color, Colors}, }, presentation::{ ChunkMutator, Modals, Presentation, PresentationState, RenderOperation, SlideBuilder, SlideChunk, builder::{ error::{BuildError, ErrorContextBuilder, FileSourcePosition, InvalidPresentation}, sources::MarkdownSources, }, }, render::operation::MarginProperties, resource::{ResourceBasePath, Resources}, terminal::image::{ Image, printer::{ImageRegistry, ImageSpec, RegisterImageError}, }, theme::{ Alignment, ElementType, PresentationTheme, ProcessingThemeError, ThemeOptions, raw::{self, RawColor}, registry::PresentationThemeRegistry, }, third_party::ThirdPartyRender, ui::{ execution::output::WrappedSnippetHandle, footer::{FooterGenerator, FooterVariables}, modals::{IndexBuilder, KeyBindingsModalBuilder}, separator::RenderSeparator, }, }; use image::DynamicImage; use std::{ collections::{HashMap, HashSet}, fs, io, iter, mem, path::Path, rc::Rc, sync::Arc, }; pub(crate) mod error; mod comment; pub(crate) use comment::CommentCommand; mod frontmatter; mod heading; mod images; mod list; mod quote; mod snippet; mod sources; mod table; #[cfg(test)] mod tests; pub(crate) type BuildResult = Result<(), BuildError>; #[derive(Default)] pub struct Themes { pub presentation: PresentationThemeRegistry, pub highlight: HighlightThemeSet, } #[derive(Clone, Debug)] pub struct PresentationBuilderOptions { pub allow_mutations: bool, pub implicit_slide_ends: bool, pub command_prefix: String, pub image_attribute_prefix: String, pub incremental_lists: bool, pub incremental_tables: bool, pub force_default_theme: bool, pub end_slide_shorthand: bool, pub print_modal_background: bool, pub strict_front_matter_parsing: bool, pub enable_snippet_execution: bool, pub enable_snippet_execution_replace: bool, pub render_speaker_notes_only: bool, pub auto_render_languages: Vec, pub theme_options: ThemeOptions, pub pause_before_incremental_lists: bool, pub pause_after_incremental_lists: bool, pub pause_before_incremental_tables: bool, pub pause_after_incremental_tables: bool, pub pause_create_new_slide: bool, pub list_item_newlines: u8, pub validate_snippets: bool, pub layout_grid: bool, pub h1_slide_titles: bool, } impl PresentationBuilderOptions { fn merge(&mut self, options: OptionsConfig) { self.implicit_slide_ends = options.implicit_slide_ends.unwrap_or(self.implicit_slide_ends); self.incremental_lists = options.incremental_lists.unwrap_or(self.incremental_lists); self.incremental_tables = options.incremental_tables.unwrap_or(self.incremental_tables); self.end_slide_shorthand = options.end_slide_shorthand.unwrap_or(self.end_slide_shorthand); self.strict_front_matter_parsing = options.strict_front_matter_parsing.unwrap_or(self.strict_front_matter_parsing); self.h1_slide_titles = options.h1_slide_titles.unwrap_or(self.h1_slide_titles); if let Some(prefix) = options.command_prefix { self.command_prefix = prefix; } if let Some(prefix) = options.image_attributes_prefix { self.image_attribute_prefix = prefix; } if !options.auto_render_languages.is_empty() { self.auto_render_languages = options.auto_render_languages; } if let Some(count) = options.list_item_newlines { self.list_item_newlines = count.into(); } } } impl Default for PresentationBuilderOptions { fn default() -> Self { Self { allow_mutations: true, implicit_slide_ends: false, command_prefix: String::default(), image_attribute_prefix: "image:".to_string(), incremental_lists: false, incremental_tables: false, force_default_theme: false, end_slide_shorthand: false, print_modal_background: false, strict_front_matter_parsing: true, enable_snippet_execution: false, enable_snippet_execution_replace: false, render_speaker_notes_only: false, auto_render_languages: Default::default(), theme_options: ThemeOptions { font_size_supported: false }, pause_before_incremental_lists: true, pause_after_incremental_lists: true, pause_before_incremental_tables: true, pause_after_incremental_tables: true, pause_create_new_slide: false, list_item_newlines: 1, validate_snippets: false, layout_grid: false, h1_slide_titles: false, } } } /// Builds a presentation. /// /// This type transforms [MarkdownElement]s and turns them into a presentation, which is made up of /// render operations. pub(crate) struct PresentationBuilder<'a, 'b> { slide_chunks: Vec, chunk_operations: Vec, chunk_mutators: Vec>, slide_builders: Vec, highlighter: SnippetHighlighter, snippet_executor: Arc, theme: PresentationTheme, default_raw_theme: &'a raw::PresentationTheme, resources: Resources, third_party: &'a mut ThirdPartyRender, slide_state: SlideState, presentation_state: PresentationState, footer_vars: FooterVariables, themes: &'a Themes, index_builder: IndexBuilder, image_registry: ImageRegistry, bindings_config: KeyBindingsConfig, slides_without_footer: HashSet, markdown_parser: &'a MarkdownParser<'b>, executable_snippets: HashMap, sources: MarkdownSources, options: PresentationBuilderOptions, } impl<'a, 'b> PresentationBuilder<'a, 'b> { /// Construct a new builder. #[allow(clippy::too_many_arguments)] pub(crate) fn new( default_raw_theme: &'a raw::PresentationTheme, resources: Resources, third_party: &'a mut ThirdPartyRender, code_executor: Arc, themes: &'a Themes, image_registry: ImageRegistry, bindings_config: KeyBindingsConfig, markdown_parser: &'a MarkdownParser<'b>, options: PresentationBuilderOptions, ) -> Result { let theme = PresentationTheme::new(default_raw_theme, &resources, &options.theme_options)?; Ok(Self { slide_chunks: Vec::new(), chunk_operations: Vec::new(), chunk_mutators: Vec::new(), slide_builders: Vec::new(), highlighter: SnippetHighlighter::default(), snippet_executor: code_executor, theme, default_raw_theme, resources, third_party, slide_state: Default::default(), presentation_state: Default::default(), footer_vars: Default::default(), themes, index_builder: Default::default(), image_registry, bindings_config, slides_without_footer: HashSet::new(), markdown_parser, sources: Default::default(), executable_snippets: Default::default(), options, }) } /// Build a presentation from a markdown input. pub(crate) fn build(self, path: &Path) -> Result { self.build_with_reader(path, FilesystemPresentationReader) } /// Build a presentation from already parsed elements. pub(crate) fn build_from_parsed(mut self, elements: Vec) -> Result { let mut skip_first = false; if let Some(MarkdownElement::FrontMatter(contents)) = elements.first() { self.process_front_matter(contents)?; skip_first = true; } let mut elements = elements.into_iter(); if skip_first { elements.next(); } self.set_code_theme()?; if self.chunk_operations.is_empty() { self.push_slide_prelude(); } for element in elements { self.slide_state.ignore_element_line_break = false; if self.options.render_speaker_notes_only { self.process_element_for_speaker_notes_mode(element)?; } else { self.process_element_for_presentation_mode(element)?; } if !self.slide_state.ignore_element_line_break { self.push_line_break(); } } if !self.chunk_operations.is_empty() || !self.slide_chunks.is_empty() { self.terminate_slide(); } // Always have at least one empty slide if self.slide_builders.is_empty() { self.terminate_slide(); } let mut bindings_modal_builder = KeyBindingsModalBuilder::default(); if self.options.print_modal_background { let background = self.build_modal_background()?; self.index_builder.set_background(background.clone()); bindings_modal_builder.set_background(background); }; let mut slides = Vec::new(); let builders = mem::take(&mut self.slide_builders); self.footer_vars.total_slides = builders.len(); for (index, mut builder) in builders.into_iter().enumerate() { self.footer_vars.current_slide = index + 1; if !self.slides_without_footer.contains(&index) { builder = builder.footer(self.generate_footer()?); } slides.push(builder.build()); } let bindings = bindings_modal_builder.build(&self.theme, &self.bindings_config); let slide_index = self.index_builder.build(&self.theme, self.presentation_state.clone()); let modals = Modals { slide_index, bindings }; let presentation = Presentation::new(slides, modals, self.presentation_state); Ok(presentation) } fn build_with_reader(self, path: &Path, reader: F) -> Result { let _guard = self.sources.enter(path).map_err(BuildError::EnterRoot)?; let contents = reader.read(path).map_err(|e| BuildError::ReadPresentation(path.into(), e))?; let elements = self.markdown_parser.parse(&contents).map_err(|error| { let context = ErrorContextBuilder::new(&contents, &error.kind.to_string()).position(error.sourcepos).build(); BuildError::Parse { path: path.into(), error, context } })?; self.build_from_parsed(elements) } fn build_modal_background(&self) -> Result { let color = self.theme.modals.style.colors.background.as_ref().and_then(Color::as_rgb); // If we don't have an rgb color (or we don't have a color at all), we default to a dark // background. let rgba = match color { Some((r, g, b)) => [r, g, b, 255], None => [0, 0, 0, 255], }; 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))?; Ok(image) } fn validate_last_operation(&mut self) -> BuildResult { if !self.slide_state.needs_enter_column { return Ok(()); } let Some(last) = self.chunk_operations.last() else { return Ok(()); }; if matches!(last, RenderOperation::InitColumnLayout { .. }) { return Ok(()); } self.slide_state.needs_enter_column = false; let last_valid = matches!(last, RenderOperation::EnterColumn { .. } | RenderOperation::ExitLayout); if last_valid { Ok(()) } else { let position = self.slide_state.last_layout_comment.as_ref().expect("no last position"); let context = fs::read_to_string(&position.file) .ok() .map(|s| { ErrorContextBuilder::new(&s, "layout was created here").position(position.source_position).build() }) .unwrap_or_default(); Err(BuildError::NotInsideColumn(context)) } } fn set_colors(&mut self, colors: Colors) { self.chunk_operations.push(RenderOperation::SetColors(colors)); } fn push_slide_prelude(&mut self) { let style = self.theme.default_style.style; self.set_colors(style.colors); let footer_height = self.theme.footer.height(); self.chunk_operations.extend([ RenderOperation::ClearScreen, RenderOperation::ApplyMargin(MarginProperties { horizontal: self.theme.default_style.margin, top: 0, bottom: footer_height, }), ]); self.push_line_break(); } fn process_element_for_presentation_mode(&mut self, element: MarkdownElement) -> BuildResult { let should_clear_last = !matches!(element, MarkdownElement::List(_) | MarkdownElement::Comment { .. }); match element { // This one is processed before everything else as it affects how the rest of the // elements is rendered. MarkdownElement::FrontMatter(_) => self.slide_state.ignore_element_line_break = true, MarkdownElement::SetexHeading { text } => self.push_slide_title(text)?, MarkdownElement::Heading { level, text } => self.push_heading(level, text)?, MarkdownElement::Paragraph(elements) => self.push_paragraph(elements)?, MarkdownElement::List(elements) => self.push_list(elements)?, MarkdownElement::Snippet { info, code, source_position } => self.push_code(info, code, source_position)?, MarkdownElement::Table(table) => self.push_table(table)?, MarkdownElement::ThematicBreak => self.process_thematic_break(), MarkdownElement::Comment { comment, source_position } => self.process_comment(comment, source_position)?, MarkdownElement::BlockQuote(lines) => self.push_block_quote(lines)?, MarkdownElement::Image { path, title, source_position } => { self.push_image_from_path(path, title, source_position)? } MarkdownElement::Alert { alert_type, title, lines } => self.push_alert(alert_type, title, lines)?, MarkdownElement::Footnote(line) => { let line = line.resolve(&self.theme.palette)?; self.push_text(line, ElementType::Paragraph); } }; if should_clear_last { self.slide_state.last_element = LastElement::Other; } self.validate_last_operation()?; Ok(()) } fn process_element_for_speaker_notes_mode(&mut self, element: MarkdownElement) -> BuildResult { match element { MarkdownElement::Comment { comment, source_position } => self.process_comment(comment, source_position)?, MarkdownElement::SetexHeading { text } => self.push_slide_title(text)?, MarkdownElement::ThematicBreak => { if self.options.end_slide_shorthand { self.terminate_slide(); self.slide_state.ignore_element_line_break = true; } } _ => {} } // Allows us to start the next speaker slide when a title is pushed and implicit_slide_ends is enabled. self.slide_state.last_element = LastElement::Other; self.slide_state.ignore_element_line_break = true; Ok(()) } fn set_code_theme(&mut self) -> BuildResult { let theme = &self.theme.code.theme_name; let highlighter = self.themes.highlight.load_by_name(theme).ok_or_else(|| BuildError::InvalidCodeTheme(theme.clone()))?; self.highlighter = highlighter; Ok(()) } fn invalid_presentation(&self, source_position: SourcePosition, error: E) -> BuildError where E: Into, { let error = error.into(); let source_position = self.sources.resolve_source_position(source_position); let context = fs::read_to_string(&source_position.file) .ok() .map(|s| ErrorContextBuilder::new(&s, &error.to_string()).position(source_position.source_position).build()) .unwrap_or_default(); let FileSourcePosition { source_position, file } = source_position; BuildError::InvalidPresentation { source_position, path: file, context } } fn resource_base_path(&self) -> ResourceBasePath { ResourceBasePath::Custom(self.sources.current_base_path()) } fn validate_column_layout(&self, columns: &[u8], source_position: SourcePosition) -> BuildResult { if columns.is_empty() { Err(self .invalid_presentation(source_position, InvalidPresentation::InvalidLayout("need at least one column"))) } else if columns.iter().any(|column| column == &0) { Err(self.invalid_presentation( source_position, InvalidPresentation::InvalidLayout("can't have zero sized columns"), )) } else { Ok(()) } } fn push_pause(&mut self) { if self.options.pause_create_new_slide { let operations = self.chunk_operations.clone(); let slide_state = self.slide_state.clone(); self.terminate_slide(); self.chunk_operations = operations; self.slide_state = slide_state; 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); let mutators = mem::take(&mut self.chunk_mutators); self.slide_chunks.push(SlideChunk::new(chunk_operations, mutators)); } fn push_paragraph(&mut self, lines: Vec>) -> BuildResult { for line in lines { let line = line.resolve(&self.theme.palette)?; self.push_text(line, ElementType::Paragraph); self.push_line_breaks(self.slide_font_size() as usize); } Ok(()) } fn process_thematic_break(&mut self) { if self.options.end_slide_shorthand { self.terminate_slide(); self.slide_state.ignore_element_line_break = true; } else { self.chunk_operations.extend([ RenderSeparator::new(Line::default(), Default::default(), self.slide_font_size()).into(), RenderOperation::RenderLineBreak, ]); } } fn push_text(&mut self, line: Line, element_type: ElementType) { let alignment = self.slide_state.alignment.unwrap_or_else(|| self.theme.alignment(&element_type)); self.push_aligned_text(line, alignment); } fn push_aligned_text(&mut self, mut block: Line, alignment: Alignment) { let default_font_size = self.slide_font_size(); for chunk in &mut block.0 { self.apply_theme_text_style(chunk); if default_font_size > 1 { chunk.style = chunk.style.size(default_font_size); } } if !block.0.is_empty() { self.chunk_operations.push(RenderOperation::RenderText { line: WeightedLine::from(block), alignment }); } } fn push_line_break(&mut self) { self.push_line_breaks(1) } fn push_line_breaks(&mut self, count: usize) { self.chunk_operations.extend(iter::repeat_n(RenderOperation::RenderLineBreak, count)); } fn terminate_slide(&mut self) { let operations = mem::take(&mut self.chunk_operations); let mutators = mem::take(&mut self.chunk_mutators); // 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)); } let chunks = mem::take(&mut self.slide_chunks); if !self.slide_state.skip_slide { let builder = SlideBuilder::default().chunks(chunks); self.index_builder .add_title(self.slide_state.title.take().unwrap_or_else(|| Text::from("").into())); if self.slide_state.ignore_footer { self.slides_without_footer.insert(self.slide_builders.len()); } self.slide_builders.push(builder); } self.push_slide_prelude(); self.slide_state = Default::default(); } fn apply_theme_text_style(&self, text: &mut Text) { if text.style.is_code() { text.style.merge(&self.theme.inline_code.style); } if text.style.is_bold() { text.style.merge(&self.theme.bold.style); } if text.style.is_italics() { text.style.merge(&self.theme.italics.style); } } 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, BuildError> { let generator = FooterGenerator::new(self.theme.footer.clone(), &self.footer_vars, &self.theme.palette)?; Ok(vec![ // Exit any layout we're in so this gets rendered on a default screen size. RenderOperation::ExitLayout, // Pop the slide margin so we're at the terminal rect. RenderOperation::PopMargin, RenderOperation::RenderDynamic(Rc::new(generator)), ]) } fn slide_font_size(&self) -> u8 { let font_size = self.slide_state.font_size.unwrap_or(1); if self.options.theme_options.font_size_supported { font_size.clamp(1, 7) } else { 1 } } } trait PresentationReader { fn read(&self, path: &Path) -> io::Result; } struct FilesystemPresentationReader; impl PresentationReader for FilesystemPresentationReader { fn read(&self, path: &Path) -> io::Result { fs::read_to_string(path) } } #[derive(Clone, Debug, Default)] struct SlideState { ignore_element_line_break: bool, ignore_footer: bool, needs_enter_column: bool, last_chunk_ended_in_list: bool, last_element: LastElement, incremental_lists: Option, incremental_tables: Option, list_item_newlines: Option, layout: LayoutState, title: Option, font_size: Option, alignment: Option, skip_slide: bool, last_layout_comment: Option, } #[derive(Clone, Debug, Default)] enum LayoutState { #[default] Default, InLayout { columns_count: usize, }, InColumn { column: usize, columns_count: usize, }, } #[derive(Clone, Debug, Default)] enum LastElement { #[default] None, List { last_index: usize, }, Other, } #[cfg(test)] pub(crate) mod utils { use super::*; use crate::{ render::{engine::RenderEngine, operation::RenderAsyncStartPolicy, properties::WindowSize}, terminal::virt::VirtualTerminal, }; use std::{path::PathBuf, thread::sleep, time::Duration}; struct MemoryPresentationReader { contents: String, } impl PresentationReader for MemoryPresentationReader { fn read(&self, _path: &Path) -> io::Result { Ok(self.contents.clone()) } } pub(crate) enum Input { Markdown(String), Parsed(Vec), } impl From<&'_ str> for Input { fn from(value: &'_ str) -> Self { Self::Markdown(value.to_string()) } } impl From for Input { fn from(value: String) -> Self { Self::Markdown(value) } } impl From> for Input { fn from(value: Vec) -> Self { Self::Parsed(value) } } pub(crate) struct Test { input: Input, options: PresentationBuilderOptions, resources_path: PathBuf, theme: raw::PresentationTheme, } impl Test { pub(crate) fn new>(input: T) -> Self { let options = PresentationBuilderOptions { enable_snippet_execution: true, enable_snippet_execution_replace: true, theme_options: ThemeOptions { font_size_supported: true }, ..Default::default() }; Self { input: input.into(), options, resources_path: std::env::temp_dir(), theme: Default::default() } } pub(crate) fn options(mut self, options: PresentationBuilderOptions) -> Self { self.options = options; self } pub(crate) fn resources_path>(mut self, path: P) -> Self { self.resources_path = path.into(); self } pub(crate) fn theme(mut self, theme: raw::PresentationTheme) -> Self { self.theme = theme; self } pub(crate) fn disable_exec_replace(mut self) -> Self { self.options.enable_snippet_execution_replace = false; self } pub(crate) fn disable_exec(mut self) -> Self { self.options.enable_snippet_execution = false; self } pub(crate) fn with_builder(&self, callback: F) -> T where F: for<'a, 'b> Fn(PresentationBuilder<'a, 'b>) -> T, { let theme = &self.theme; let resources = Resources::new(&self.resources_path, &self.resources_path, Default::default()); let mut third_party = ThirdPartyRender::default(); let code_executor = Arc::new(SnippetExecutor::default()); let themes = Themes::default(); let bindings = KeyBindingsConfig::default(); let arena = Default::default(); let parser = MarkdownParser::new(&arena); let builder = PresentationBuilder::new( theme, resources, &mut third_party, code_executor, &themes, Default::default(), bindings, &parser, self.options.clone(), ) .expect("failed to create builder"); callback(builder) } pub(crate) fn render(self) -> PresentationRender { let presentation = self.build(); PresentationRender::new(presentation) } pub(crate) fn build(self) -> Presentation { self.try_build().expect("build failed") } pub(crate) fn expect_invalid(self) -> BuildError { self.try_build().expect_err("build succeeded") } pub(crate) fn try_build(self) -> Result { self.with_builder(|builder| match &self.input { Input::Markdown(input) => { let reader = MemoryPresentationReader { contents: input.clone() }; let path = self.resources_path.join("presentation.md"); builder.build_with_reader(&path, reader) } Input::Parsed(elements) => builder.build_from_parsed(elements.clone()), }) } } pub(crate) struct PresentationRender { presentation: Presentation, columns: Option, rows: Option, run_async_renders: RunAsyncRendersPolicy, background_maps: Vec<(Color, char)>, advances: Option, } impl PresentationRender { fn new(presentation: Presentation) -> Self { Self { presentation, columns: None, rows: None, run_async_renders: RunAsyncRendersPolicy::All, background_maps: Default::default(), advances: None, } } pub(crate) fn rows(mut self, rows: u16) -> Self { self.rows = Some(rows); self } pub(crate) fn columns(mut self, columns: u16) -> Self { self.columns = Some(columns); self } pub(crate) fn advances(mut self, number: usize) -> Self { self.advances = Some(number); self } pub(crate) fn run_async_renders(mut self, policy: RunAsyncRendersPolicy) -> Self { self.run_async_renders = policy; self } pub(crate) fn map_background(mut self, color: Color, c: char) -> Self { self.background_maps.push((color, c)); self } pub(crate) fn into_lines(self) -> Vec { self.into_parts().0 } pub(crate) fn into_parts(self) -> (Vec, Vec) { let Self { mut presentation, columns, rows, run_async_renders, background_maps, advances } = self; let columns = columns.expect("no columns"); let rows = rows.expect("no rows"); let dimensions = WindowSize { rows, columns, width: 0, height: 0 }; let only_visible = advances.is_some(); if let Some(advances) = advances { for _ in 0..advances { presentation.jump_next(); } } let slide = presentation.current_slide_mut(); for operation in slide.iter_operations_mut() { if let RenderOperation::RenderAsync(operation) = operation { let mut pollable = operation.pollable(); let run = match &run_async_renders { RunAsyncRendersPolicy::None => false, RunAsyncRendersPolicy::All => true, RunAsyncRendersPolicy::OnlyAutomatic => { matches!(operation.start_policy(), RenderAsyncStartPolicy::Automatic) } }; if !run { continue; } while !pollable.poll().is_completed() { sleep(Duration::from_millis(1)); } } } let mut term = VirtualTerminal::new(dimensions, Default::default()); let engine = RenderEngine::new(&mut term, dimensions, Default::default()); if only_visible { engine.render(slide.iter_visible_operations()).expect("failed to render"); } else { engine.render(slide.iter_operations()).expect("failed to render"); } let mut lines = Vec::new(); let mut styles = Vec::new(); for row in term.into_contents().rows { let mut line = String::new(); let mut style = String::new(); for character in &row { let style_char = background_maps .iter() .filter_map(|(b, c)| (character.style.colors.background == Some(*b)).then_some(c)) .next() .unwrap_or(&' '); line.push(character.character); style.push(*style_char); } lines.push(line); styles.push(style); } (lines, styles) } } pub(crate) enum RunAsyncRendersPolicy { None, All, OnlyAutomatic, } } ================================================ FILE: src/presentation/builder/quote.rs ================================================ use crate::{ markdown::{ elements::{Line, Text}, text_style::{Colors, TextStyle}, }, presentation::builder::{BuildResult, PresentationBuilder}, render::operation::{BlockLine, RenderOperation}, theme::{Alignment, ElementType, raw::RawColor}, }; use comrak::nodes::AlertType; use unicode_width::UnicodeWidthStr; impl PresentationBuilder<'_, '_> { pub(crate) fn push_block_quote(&mut self, lines: Vec>) -> BuildResult { let prefix = self.theme.block_quote.prefix.clone(); let prefix_style = self.theme.block_quote.prefix_style; self.push_quoted_text( lines, prefix, self.theme.block_quote.base_style.colors, prefix_style, self.theme.alignment(&ElementType::BlockQuote), ) } pub(crate) fn push_alert( &mut self, alert_type: AlertType, title: Option, mut lines: Vec>, ) -> BuildResult { let style = match alert_type { AlertType::Note => &self.theme.alert.styles.note, AlertType::Tip => &self.theme.alert.styles.tip, AlertType::Important => &self.theme.alert.styles.important, AlertType::Warning => &self.theme.alert.styles.warning, AlertType::Caution => &self.theme.alert.styles.caution, }; let title = format!("{} {}", style.icon, title.as_deref().unwrap_or(style.title.as_ref())); lines.insert(0, Line::from(Text::from(""))); lines.insert(0, Line::from(Text::new(title, style.style.into_raw()))); let prefix = self.theme.alert.prefix.clone(); self.push_quoted_text( lines, prefix, self.theme.alert.base_style.colors, style.style, self.theme.alert.alignment, ) } fn push_quoted_text( &mut self, lines: Vec>, prefix: String, base_colors: Colors, prefix_style: TextStyle, alignment: Alignment, ) -> BuildResult { let block_length = lines.iter().map(|line| line.width() + prefix.width()).max().unwrap_or(0) as u16; let font_size = self.slide_font_size(); let prefix = Text::new(prefix, prefix_style.size(font_size)); for line in lines { let mut line = line.resolve(&self.theme.palette)?; // Apply our colors to each chunk in this line. for text in &mut line.0 { if text.style.colors.background.is_none() && text.style.colors.foreground.is_none() { text.style.colors = base_colors; self.apply_theme_text_style(text); } text.style = text.style.size(font_size); } self.chunk_operations.push(RenderOperation::RenderBlockLine(BlockLine { prefix: prefix.clone().into(), right_padding_length: 0, repeat_prefix_on_wrap: true, text: line.into(), block_length, alignment, block_color: base_colors.background, })); self.push_line_break(); } self.set_colors(self.theme.default_style.style.colors); Ok(()) } } #[cfg(test)] mod tests { use crate::{markdown::text_style::Color, presentation::builder::utils::Test, theme::raw}; use rstest::rstest; #[rstest] #[case::left_no_margin(raw::Alignment::Left{ margin: raw::Margin::Fixed(0) },"▍ hi ", "XXXXXXX", )] #[case::left_one_margin(raw::Alignment::Left{ margin: raw::Margin::Fixed(1) }, " ▍ hi ", " XXXXX ")] #[case::center(raw::Alignment::Center{ minimum_margin: raw::Margin::Fixed(0), minimum_size: 0 }, " ▍ hi ", " XXXX ")] #[test] fn quote(#[case] alignment: raw::Alignment, #[case] line: &str, #[case] style: &str) { let input = " > hi > hi "; let color = Color::new(1, 1, 1); let theme = raw::PresentationTheme { block_quote: raw::BlockQuoteStyle { colors: raw::BlockQuoteColors { base: raw::RawColors { foreground: None, background: Some(raw::RawColor::Color(color)) }, prefix: None, }, alignment: Some(alignment), ..Default::default() }, ..Default::default() }; let (lines, styles) = Test::new(input).theme(theme).render().map_background(color, 'X').rows(4).columns(7).into_parts(); let expected_lines = &[" ", line, line, " "]; let expected_styles = &[" ", style, style, " "]; assert_eq!(lines, expected_lines); assert_eq!(styles, expected_styles); } #[test] fn alert() { let input = " > [!note] > hi "; let theme = raw::PresentationTheme { alert: raw::AlertStyle { styles: raw::AlertTypeStyles { note: raw::AlertTypeStyle { icon: Some("!".to_string()), ..Default::default() }, ..Default::default() }, ..Default::default() }, ..Default::default() }; let lines = Test::new(input).theme(theme).render().rows(5).columns(9).into_lines(); let expected = &[" ", "▍ ! Note ", "▍ ", "▍ hi ", " "]; assert_eq!(lines, expected); } } ================================================ FILE: src/presentation/builder/snippet.rs ================================================ use super::{BuildError, BuildResult}; use crate::{ code::{ execute::LanguageSnippetExecutor, snippet::{ ExternalFile, Highlight, HighlightContext, HighlightGroup, HighlightMutator, HighlightedLine, PtyArgs, Snippet, SnippetExecArgs, SnippetExecution, SnippetExecutorSpec, SnippetLanguage, SnippetLine, SnippetParser, SnippetRepr, SnippetSplitter, }, }, markdown::elements::SourcePosition, presentation::builder::{PresentationBuilder, error::InvalidPresentation}, render::{ operation::{AsRenderOperations, RenderAsyncStartPolicy, RenderOperation}, properties::WindowSize, }, theme::{Alignment, CodeBlockStyle}, third_party::ThirdPartyRenderRequest, ui::execution::{ RunAcquireTerminalSnippet, RunImageSnippet, SnippetExecutionDisabledOperation, SnippetOutputOperation, disabled::ExecutionType, output::{ExecIndicator, ExecIndicatorStyle, RunSnippetTrigger, SnippetHandle, WrappedSnippetHandle}, pty::{PtySnippetHandle, PtySnippetOutputOperation, RunPtySnippetTrigger}, validator::ValidateSnippetOperation, }, }; use itertools::Itertools; use std::{cell::RefCell, rc::Rc}; impl PresentationBuilder<'_, '_> { pub(crate) fn push_code(&mut self, info: String, code: String, source_position: SourcePosition) -> BuildResult { let mut snippet = SnippetParser::parse(info, code) .map_err(|e| self.invalid_presentation(source_position, InvalidPresentation::Snippet(e.to_string())))?; if matches!(snippet.language, SnippetLanguage::File) { snippet = self.load_external_snippet(snippet, source_position)?; } if self.theme.code.line_numbers { snippet.attributes.line_numbers = true; } if self.options.auto_render_languages.contains(&snippet.language) { snippet.attributes.execution = SnippetExecution::Render; } // Ids can only be used in `+exec` snippets. if snippet.attributes.id.is_some() && !matches!( snippet.attributes.execution, SnippetExecution::Exec(SnippetExecArgs { repr: SnippetRepr::SnippetOutput, .. }) ) { return Err(self.invalid_presentation(source_position, InvalidPresentation::SnippetIdNonExec)); } self.push_differ(snippet.contents.clone()); // Redraw slide if attributes change self.push_differ(format!("{:?}", snippet.attributes)); if let Some(spec) = &snippet.attributes.validate { // Take the execution spec if we use the default one, otherwise use the one provided. // This allows using `rust +exec:foo +validate` and using `foo` as the executor. let spec = match (spec, &snippet.attributes.execution) { (SnippetExecutorSpec::Default, SnippetExecution::Exec(args)) => &args.spec, _ => spec, }; let executor = self.snippet_executor.language_executor(&snippet.language, spec)?; self.push_validator(&snippet, &executor); } match &snippet.attributes.execution { SnippetExecution::None => { self.push_code_lines(&snippet); } SnippetExecution::Render => { self.push_rendered_code(snippet, source_position)?; } SnippetExecution::Exec(args) if !self.is_execution_allowed(args) => { self.push_code_lines(&snippet); let mut exec_type = match args.repr { SnippetRepr::Image => ExecutionType::Image, SnippetRepr::ExecReplace => ExecutionType::ExecReplace, SnippetRepr::SnippetOutput | SnippetRepr::AcquireTerminal => ExecutionType::Execute, }; if args.auto { exec_type = ExecutionType::ExecReplace; } self.push_execution_disabled_operation(exec_type); } SnippetExecution::Exec(args) => { let executor = self.snippet_executor.language_executor(&snippet.language, &args.spec)?; match args.repr { SnippetRepr::Image => { self.push_code_as_image(snippet, executor)?; } SnippetRepr::ExecReplace => match &args.pty { Some(args) => { let args = args.clone(); self.push_replace_pty_code_execution(snippet, executor, args)?; } None => { self.push_replace_code_execution(snippet, executor)?; } }, SnippetRepr::AcquireTerminal => { let block_length = self.push_code_lines(&snippet); self.push_acquire_terminal_execution(snippet, block_length, executor)?; } SnippetRepr::SnippetOutput => { let block_length = self.push_code_lines(&snippet); let policy = if args.auto { RenderAsyncStartPolicy::Automatic } else { RenderAsyncStartPolicy::OnDemand }; let handle: WrappedSnippetHandle = match &args.pty { Some(args) => PtySnippetHandle::new(snippet.clone(), executor, policy, args.clone()).into(), None => SnippetHandle::new(snippet.clone(), executor, policy).into(), }; self.chunk_operations.push(RenderOperation::RenderAsync(handle.build_trigger().into())); let alignment = self.code_style(&snippet).alignment; self.push_indicator(handle.clone(), block_length, alignment); match snippet.attributes.id.clone() { Some(id) => { if self.executable_snippets.insert(id.clone(), handle).is_some() { return Err(self.invalid_presentation( source_position, InvalidPresentation::SnippetAlreadyExists(id), )); } } None => { self.push_line_break(); match handle { WrappedSnippetHandle::Normal(handle) => { self.push_code_execution(block_length, handle, alignment)?; } WrappedSnippetHandle::Pty(handle) => self.push_pty_code_execution(handle)?, } } } } } } }; Ok(()) } pub(crate) fn push_detached_code_execution(&mut self, handle: WrappedSnippetHandle) -> BuildResult { match handle { WrappedSnippetHandle::Normal(handle) => { let alignment = self.code_style(&handle.snippet()).alignment; self.push_code_execution(0, handle, alignment) } WrappedSnippetHandle::Pty(handle) => self.push_pty_code_execution(handle), } } fn is_execution_allowed(&self, args: &SnippetExecArgs) -> bool { match args.repr { SnippetRepr::SnippetOutput | SnippetRepr::AcquireTerminal => self.options.enable_snippet_execution, SnippetRepr::Image | SnippetRepr::ExecReplace => self.options.enable_snippet_execution_replace, } } fn push_code_lines(&mut self, snippet: &Snippet) -> u16 { let lines = SnippetSplitter::new(&self.theme.code, self.snippet_executor.hidden_line_prefix(&snippet.language)) .split(snippet); let block_length = lines.iter().map(|line| line.width()).max().unwrap_or(0) * self.slide_font_size() as usize; let block_length = block_length as u16; let (lines, context) = self.highlight_lines(snippet, lines, block_length); for line in lines { self.chunk_operations.push(RenderOperation::RenderDynamic(Rc::new(line))); } self.chunk_operations.push(RenderOperation::SetColors(self.theme.default_style.style.colors)); if self.options.allow_mutations && context.borrow().groups.len() > 1 { self.chunk_mutators.push(Box::new(HighlightMutator::new(context))); } block_length } fn push_replace_code_execution(&mut self, snippet: Snippet, executor: LanguageSnippetExecutor) -> BuildResult { let alignment = match self.code_style(&snippet).alignment { // If we're replacing the snippet output, we have center alignment and no background, use // center alignment but without any margins and minimum sizes so we truly center the output. Alignment::Center { .. } if snippet.attributes.no_background => { Alignment::Center { minimum_margin: Default::default(), minimum_size: 0 } } other => other, }; let handle = SnippetHandle::new(snippet, executor, RenderAsyncStartPolicy::Automatic); self.chunk_operations.push(RenderOperation::RenderAsync(Rc::new(RunSnippetTrigger::new(handle.clone())))); self.push_code_execution(0, handle, alignment) } fn push_replace_pty_code_execution( &mut self, snippet: Snippet, executor: LanguageSnippetExecutor, args: PtyArgs, ) -> BuildResult { let standby = args.standby; let policy = if standby { RenderAsyncStartPolicy::OnDemand } else { RenderAsyncStartPolicy::Automatic }; let handle = PtySnippetHandle::new(snippet, executor, policy, args); // If we're using standby mode we still need a trigger if standby { self.chunk_operations .push(RenderOperation::RenderAsync(WrappedSnippetHandle::from(handle.clone()).build_trigger().into())); } self.chunk_operations.push(RenderOperation::RenderAsync(Rc::new(RunPtySnippetTrigger::new(handle.clone())))); self.push_pty_code_execution(handle) } fn load_external_snippet( &mut self, mut code: Snippet, source_position: SourcePosition, ) -> Result { let file: ExternalFile = serde_yaml::from_str(&code.contents) .map_err(|e| self.invalid_presentation(source_position, InvalidPresentation::Snippet(e.to_string())))?; let path = file.path; let base_path = self.resource_base_path(); let contents = self.resources.external_text_file(&path, &base_path).map_err(|e| { self.invalid_presentation( source_position, InvalidPresentation::Snippet(format!("failed to load snippet {path:?}: {e}")), ) })?; code.language = file.language; code.contents = Self::filter_lines(contents, file.start_line, file.end_line); Ok(code) } fn filter_lines(code: String, start: Option, end: Option) -> String { let start = start.map(|s| s.saturating_sub(1)); match (start, end) { (None, None) => code, (None, Some(end)) => code.lines().take(end).join("\n"), (Some(start), None) => code.lines().skip(start).join("\n"), (Some(start), Some(end)) => code.lines().skip(start).take(end.saturating_sub(start)).join("\n"), } } fn push_rendered_code(&mut self, code: Snippet, source_position: SourcePosition) -> BuildResult { let Snippet { contents, language, attributes } = code; let request = match language { SnippetLanguage::Typst => ThirdPartyRenderRequest::Typst(contents, self.theme.typst.clone()), SnippetLanguage::Latex => ThirdPartyRenderRequest::Latex(contents, self.theme.typst.clone()), SnippetLanguage::Mermaid => ThirdPartyRenderRequest::Mermaid(contents, self.theme.mermaid.clone()), SnippetLanguage::D2 => ThirdPartyRenderRequest::D2(contents, self.theme.d2.clone()), _ => { return Err(self.invalid_presentation( source_position, InvalidPresentation::Snippet(format!("language {language:?} doesn't support rendering")), )); } }; let operation = self.third_party.render(request, &self.theme, attributes.width)?; self.chunk_operations.push(operation); Ok(()) } fn highlight_lines( &self, code: &Snippet, lines: Vec, block_length: u16, ) -> (Vec, Rc>) { let mut code_highlighter = self.highlighter.language_highlighter(&code.language); let style = self.code_style(code); let block_length = self.theme.code.alignment.adjust_size(block_length); let font_size = self.slide_font_size(); let dim_style = { let mut highlighter = self.highlighter.language_highlighter(&SnippetLanguage::Rust); highlighter.style_line("//", &style).0.first().expect("no styles").style.size(font_size) }; let groups = match self.options.allow_mutations { true => code.attributes.highlight_groups.clone(), false => vec![HighlightGroup::new(vec![Highlight::All])], }; let context = Rc::new(RefCell::new(HighlightContext { groups, current: 0, block_length, alignment: style.alignment })); let mut output = Vec::new(); for line in lines.into_iter() { let prefix = line.dim_prefix(&dim_style); let highlighted = line.highlight(&mut code_highlighter, &style, font_size); let not_highlighted = line.dim(&dim_style); let line_number = line.line_number; let context = context.clone(); output.push(HighlightedLine { prefix, right_padding_length: line.right_padding_length * font_size as u16, highlighted, not_highlighted, line_number, context, block_color: dim_style.colors.background, }); } (output, context) } fn code_style(&self, snippet: &Snippet) -> CodeBlockStyle { let mut style = self.theme.code.clone(); if snippet.attributes.no_background { style.background = false; } style } fn push_execution_disabled_operation(&mut self, exec_type: ExecutionType) { let policy = match exec_type { ExecutionType::ExecReplace | ExecutionType::Image => RenderAsyncStartPolicy::Automatic, ExecutionType::Execute => RenderAsyncStartPolicy::OnDemand, }; let operation = SnippetExecutionDisabledOperation::new( self.theme.execution_output.status.failure_style, self.theme.code.alignment, policy, exec_type, ); self.chunk_operations.push(RenderOperation::RenderAsync(Rc::new(operation))); } fn push_code_as_image(&mut self, snippet: Snippet, executor: LanguageSnippetExecutor) -> BuildResult { let operation = RunImageSnippet::new(snippet, executor, self.image_registry.clone(), self.theme.execution_output.status); let operation = RenderOperation::RenderAsync(Rc::new(operation)); self.chunk_operations.push(operation); Ok(()) } fn push_acquire_terminal_execution( &mut self, snippet: Snippet, block_length: u16, executor: LanguageSnippetExecutor, ) -> BuildResult { let block_length = self.theme.code.alignment.adjust_size(block_length); let operation = RunAcquireTerminalSnippet::new( snippet, executor, self.theme.execution_output.status, block_length, self.slide_font_size(), ); let operation = RenderOperation::RenderAsync(Rc::new(operation)); self.chunk_operations.push(operation); Ok(()) } fn push_indicator>(&mut self, handle: T, block_length: u16, alignment: Alignment) { let style = ExecIndicatorStyle { theme: self.theme.execution_output.status, block_length, font_size: self.slide_font_size(), alignment, }; let indicator = Rc::new(ExecIndicator::new(handle, style)); self.chunk_operations.push(RenderOperation::RenderDynamic(indicator)); } fn push_code_execution(&mut self, block_length: u16, handle: SnippetHandle, alignment: Alignment) -> BuildResult { let snippet = handle.snippet(); 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; execution_output_style.padding = Default::default(); } let operation = SnippetOutputOperation::new( handle, default_colors, execution_output_style, block_length, alignment, self.slide_font_size(), ); let operation = RenderOperation::RenderDynamic(Rc::new(operation)); self.chunk_operations.push(operation); Ok(()) } fn push_pty_code_execution(&mut self, handle: PtySnippetHandle) -> BuildResult { let snippet = handle.snippet(); let mut style = self.theme.pty_output.clone(); if snippet.attributes.no_background { style.style.colors.background = None; } let operation = PtySnippetOutputOperation::new(handle, style, self.slide_font_size()); let operation = RenderOperation::RenderDynamic(Rc::new(operation)); self.chunk_operations.push(operation); Ok(()) } fn push_differ(&mut self, text: String) { self.chunk_operations.push(RenderOperation::RenderDynamic(Rc::new(Differ(text)))); } fn push_validator(&mut self, snippet: &Snippet, executor: &LanguageSnippetExecutor) { if !self.options.validate_snippets { return; } let operation = ValidateSnippetOperation::new(snippet.clone(), executor.clone()); self.chunk_operations.push(RenderOperation::RenderAsync(Rc::new(operation))); } } #[derive(Debug)] struct Differ(String); impl AsRenderOperations for Differ { fn as_render_operations(&self, _: &WindowSize) -> Vec { Vec::new() } fn diffable_content(&self) -> Option<&str> { Some(&self.0) } } #[cfg(all(test, target_os = "linux"))] mod tests { use super::*; use crate::{ markdown::text_style::Color, presentation::builder::utils::{RunAsyncRendersPolicy, Test}, theme::raw, }; use rstest::rstest; use std::fs; #[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, #[case] end: Option, #[case] expected: &[&str]) { let code = ["a", "b", "c", "d", "e"].join("\n"); let output = PresentationBuilder::filter_lines(code, start, end); let expected = expected.join("\n"); assert_eq!(output, expected); } #[test] fn plain() { let input = " ```bash echo hi ```"; let lines = Test::new(input).render().rows(3).columns(7).into_lines(); let expected = &[" ", "echo hi", " "]; assert_eq!(lines, expected); } #[test] fn external_snippet() { let temp = tempfile::NamedTempFile::new().expect("failed to create tempfile"); let path = temp.path(); fs::write(path, "echo hi").unwrap(); let path = path.to_string_lossy(); let input = format!( " ```file path: {path} language: bash ``` " ); let lines = Test::new(input).render().rows(3).columns(7).into_lines(); let expected = &[" ", "echo hi", " "]; assert_eq!(lines, expected); } #[test] fn line_numbers() { let input = " ```bash +line_numbers hi bye ```"; let lines = Test::new(input).render().rows(4).columns(5).into_lines(); let expected = &[" ", "1 hi ", "2 bye", " "]; assert_eq!(lines, expected); } #[test] fn line_numbers_via_theme() { let input = "--- theme: override: code: line_numbers: true --- ```bash hi bye ```"; let lines = Test::new(input).render().rows(4).columns(5).into_lines(); let expected = &[" ", "1 hi ", "2 bye", " "]; assert_eq!(lines, expected); } #[test] fn surroundings() { let input = " --- ```bash echo hi ``` ---"; let lines = Test::new(input).render().rows(7).columns(7).into_lines(); let expected = &[ // " ", "———————", " ", "echo hi", " ", "———————", " ", ]; assert_eq!(lines, expected); } #[test] fn padding() { let input = " ```bash echo hi ```"; let theme = raw::PresentationTheme { code: raw::CodeBlockStyle { padding: raw::PaddingRect { horizontal: Some(2), vertical: Some(1) }, ..Default::default() }, ..Default::default() }; let lines = Test::new(input).theme(theme).render().rows(5).columns(13).into_lines(); let expected = &[ // " ", " ", " echo hi ", " ", " ", ]; assert_eq!(lines, expected); } #[test] fn exec_no_run() { let input = " ```bash +exec echo hi ```"; let lines = Test::new(input).render().rows(4).columns(19).run_async_renders(RunAsyncRendersPolicy::None).into_lines(); let expected = &[ // " ", "echo hi ", " ", "—— [not started] ——", ]; assert_eq!(lines, expected); } #[test] fn exec_auto() { let input = " ```bash +auto_exec echo hi ```"; let lines = Test::new(input) .render() .rows(6) .columns(19) .run_async_renders(RunAsyncRendersPolicy::OnlyAutomatic) .into_lines(); let expected = &[ // " ", "echo hi ", " ", "——— [finished] ————", " ", "hi ", ]; assert_eq!(lines, expected); } #[test] fn validate() { let input = " ```bash +validate echo hi ```"; let lines = Test::new(input).render().rows(4).columns(19).run_async_renders(RunAsyncRendersPolicy::None).into_lines(); let expected = &[" ", "echo hi ", " ", " "]; assert_eq!(lines, expected); } #[test] fn exec_disabled() { let input = " ```bash +exec echo hi ```"; let lines = Test::new(input).disable_exec().render().rows(6).columns(25).into_lines(); let expected = &[ " ", "echo hi ", " ", "snippet +exec is ", "disabled, run with -x to ", "enable ", ]; assert_eq!(lines, expected); } #[test] fn exec_replace_disabled() { let input = " ```bash +exec_replace echo hi ```"; let lines = Test::new(input).disable_exec_replace().render().rows(6).columns(25).into_lines(); let expected = &[ " ", "echo hi ", " ", "snippet +exec_replace is ", "disabled, run with -X to ", "enable ", ]; assert_eq!(lines, expected); } #[test] fn exec() { let input = " ```bash +exec echo hi ```"; let theme = raw::PresentationTheme { execution_output: raw::ExecutionOutputBlockStyle { colors: raw::RawColors { background: Some(raw::RawColor::Color(Color::new(45, 45, 45))), foreground: None, }, padding: raw::PaddingRect { horizontal: Some(1), vertical: Some(1) }, ..Default::default() }, ..Default::default() }; let (lines, styles) = Test::new(input) .theme(theme) .render() .map_background(Color::new(45, 45, 45), 'x') .rows(8) .columns(16) .into_parts(); let expected_lines = &[ " ", "echo hi ", " ", "—— [finished] ——", " ", " ", " hi ", " ", ]; let expected_styles = &[ " ", "xxxxxxxxxxxxxxxx", " ", " ", " ", "xxxxxxxxxxxxxxxx", "xxxxxxxxxxxxxxxx", "xxxxxxxxxxxxxxxx", ]; assert_eq!(lines, expected_lines); assert_eq!(styles, expected_styles); } #[test] fn exec_font_size() { let input = " ```bash +exec echo hi ```"; let lines = Test::new(input).render().rows(8).columns(32).into_lines(); let expected = &[ " ", "e c h o h i ", " ", " ", "— — [ f i n i s h e d ] — — ", " ", " ", "h i ", ]; assert_eq!(lines, expected); } #[test] fn exec_font_size_centered() { let input = " ```bash +exec echo hi ```"; let theme = raw::PresentationTheme { code: raw::CodeBlockStyle { alignment: Some(raw::Alignment::Center { minimum_margin: raw::Margin::Fixed(0), minimum_size: 40 }), ..Default::default() }, execution_output: raw::ExecutionOutputBlockStyle { colors: raw::RawColors { background: Some(raw::RawColor::Color(Color::new(45, 45, 45))), foreground: None, }, padding: raw::PaddingRect { horizontal: Some(1), vertical: Some(1) }, ..Default::default() }, ..Default::default() }; let (lines, styles) = Test::new(input) .theme(theme) .render() .map_background(Color::new(45, 45, 45), 'x') .rows(10) .columns(40) .into_parts(); let expected_lines = &[ " ", "e c h o h i ", " ", " ", "— — — — [ f i n i s h e d ] — — — — ", " ", " ", " ", " ", " h i ", ]; let expected_styles = &[ " ", "x x x x x x x x x x x x x x x x x x x x ", " ", " ", " ", " ", " ", "x x x x x x x x x x x x x x x x x x x x ", " ", "x x x x x x x x x x x x x x x x x x x x ", ]; assert_eq!(lines, expected_lines); assert_eq!(styles, expected_styles); } #[test] fn exec_adjacent_detached_output() { let input = " ```bash +exec +id:foo echo hi ``` "; let lines = Test::new(input).render().rows(4).columns(19).run_async_renders(RunAsyncRendersPolicy::None).into_lines(); // this should look exactly the same as if we hadn't detached the output let expected = &[ // " ", "echo hi ", " ", "—— [not started] ——", ]; assert_eq!(lines, expected); } #[test] fn exec_detached_output() { let input = " ```bash +exec +id:foo echo hi ``` bar "; let lines = Test::new(input).render().rows(8).columns(16).into_lines(); let expected = &[ " ", "echo hi ", " ", "—— [finished] ——", " ", "bar ", " ", "hi ", ]; assert_eq!(lines, expected); } #[test] fn exec_replace() { let input = " ```bash +exec_replace echo hi ```"; let lines = Test::new(input).render().rows(3).columns(7).into_lines(); let expected = &[" ", "hi ", " "]; assert_eq!(lines, expected); } #[test] fn snippet_exec_replace_centered() { let input = " ```bash +exec_replace echo hi ```"; let theme = raw::PresentationTheme { code: raw::CodeBlockStyle { alignment: Some(raw::Alignment::Center { minimum_margin: raw::Margin::Fixed(1), minimum_size: 1 }), ..Default::default() }, ..Default::default() }; let lines = Test::new(input).theme(theme).render().rows(3).columns(6).into_lines(); let expected = &[" ", " hi ", " "]; assert_eq!(lines, expected); } #[test] fn exec_replace_font_size() { let input = " ```bash +exec_replace echo hi ```"; let lines = Test::new(input).render().rows(3).columns(7).into_lines(); let expected = &[" ", "h i ", " "]; assert_eq!(lines, expected); } #[test] fn exec_replace_long() { let qr = [ "█▀▀▀▀▀█ ▄▀ ▄▀ █▀▀▀▀▀█", "█ ███ █ ▄▀ ▄ █ ███ █", "█ ▀▀▀ █ ▄▄█▀█ █ ▀▀▀ █", "▀▀▀▀▀▀▀ ▀ █▄█ ▀▀▀▀▀▀▀", "█▀▀██ ▀▀█▀ █▀ █ ▀ ▀▄", "▄▄██▀▄▀▀▄ █▀ ▀ ▄█▀█▀ ", "▀ ▀▀ ▀▀▄█▄█▄█▄▄▀ ▄ █", "█▀▀▀▀▀█ ▀▀ ▄█▄█▀ ▄█▀▄", "█ ███ █ ██▀ █ ▄█▄ ▀ ", "█ ▀▀▀ █ █ ▄▀ ▀ ▄██ ", "▀▀▀▀▀▀▀ ▀▀ ▀ ▀ ▀ ▀ ", ] .join("\n"); let input = format!( r#" ```bash +exec_replace echo "{qr}" ``` "# ); let rows = 13; let columns = 21; let lines = Test::new(input).render().rows(rows).columns(columns).into_lines(); let empty = " ".repeat(columns as usize); let expected: Vec<_> = [empty.as_str()].into_iter().chain(qr.lines()).chain([empty.as_str()]).collect(); assert_eq!(lines, expected); } } ================================================ FILE: src/presentation/builder/sources.rs ================================================ use crate::{markdown::elements::SourcePosition, presentation::builder::error::FileSourcePosition}; use std::{cell::RefCell, path::PathBuf, rc::Rc}; #[derive(Default)] struct Inner { include_paths: Vec, } #[derive(Default)] pub(crate) struct MarkdownSources { inner: Rc>, } impl MarkdownSources { pub(crate) fn enter>(&self, path: P) -> Result { let path = path.into(); if path.parent().is_none() { return Err(MarkdownSourceError::NoParent); } let mut inner = self.inner.borrow_mut(); if inner.include_paths.contains(&path) { return Err(MarkdownSourceError::IncludeCycle(path)); } inner.include_paths.push(path); Ok(SourceGuard(self.inner.clone())) } pub(crate) fn current_base_path(&self) -> PathBuf { self.inner .borrow() .include_paths .last() // SAFETY: we validate we know the parent before pushing into `include_paths` .map(|path| path.parent().expect("no parent").to_path_buf()) .unwrap_or_else(|| PathBuf::from(".")) } pub(crate) fn resolve_source_position(&self, source_position: SourcePosition) -> FileSourcePosition { let file = self.inner.borrow().include_paths.last().cloned().unwrap_or_else(|| PathBuf::from(".")); FileSourcePosition { source_position, file } } } #[must_use] pub(crate) struct SourceGuard(Rc>); impl Drop for SourceGuard { fn drop(&mut self) { self.0.borrow_mut().include_paths.pop(); } } #[derive(Debug, thiserror::Error)] pub(crate) enum MarkdownSourceError { #[error("cannot detect path's parent")] NoParent, #[error("{0:?} was already imported")] IncludeCycle(PathBuf), } #[cfg(test)] mod tests { use super::*; use std::path::Path; #[test] fn paths() { let sources = MarkdownSources::default(); assert_eq!(sources.current_base_path(), Path::new(".")); { let _guard1 = sources.enter("foo.md"); assert_eq!(sources.current_base_path(), Path::new("")); { let _guard2 = sources.enter("inner/bar.md"); assert_eq!(sources.current_base_path(), Path::new("inner")); } assert_eq!(sources.current_base_path(), Path::new("")); } } } ================================================ FILE: src/presentation/builder/table.rs ================================================ use crate::{ markdown::elements::{Line, Table, TableRow, Text}, presentation::builder::{BuildResult, PresentationBuilder, error::BuildError}, theme::ElementType, }; use std::iter; impl PresentationBuilder<'_, '_> { pub(crate) fn push_table(&mut self, table: Table) -> BuildResult { let widths: Vec<_> = (0..table.columns()) .map(|column| table.iter_column(column).map(|text| text.width()).max().unwrap_or(0)) .collect(); let incremental = self.slide_state.incremental_tables.unwrap_or(self.options.incremental_tables); let flattened_header = self.prepare_table_row(table.header, &widths)?; if incremental && self.options.pause_before_incremental_tables { self.push_pause(); } self.push_text(flattened_header, ElementType::Table); self.push_line_break(); let mut separator = Line(Vec::new()); for (index, width) in widths.iter().enumerate() { let mut contents = String::new(); let mut margin = 1; if index > 0 { contents.push('┼'); // Append an extra dash to have 1 column margin on both sides if index < widths.len() - 1 { margin += 1; } } contents.extend(iter::repeat_n("─", *width + margin)); separator.0.push(Text::from(contents)); } self.push_text(separator, ElementType::Table); self.push_line_break(); for row in table.rows { if incremental { self.push_pause(); } let flattened_row = self.prepare_table_row(row, &widths)?; self.push_text(flattened_row, ElementType::Table); self.push_line_break(); } if incremental && self.options.pause_after_incremental_tables { self.push_pause(); } Ok(()) } fn prepare_table_row(&self, row: TableRow, widths: &[usize]) -> Result { let mut flattened_row = Line(Vec::new()); for (column, text) in row.0.into_iter().enumerate() { let text = text.resolve(&self.theme.palette)?; if column > 0 { flattened_row.0.push(Text::from(" │ ")); } let text_length = text.width(); flattened_row.0.extend(text.0.into_iter()); let cell_width = widths[column]; if text_length < cell_width { let padding = " ".repeat(cell_width - text_length); flattened_row.0.push(Text::from(padding)); } } Ok(flattened_row) } } #[cfg(test)] mod tests { use crate::presentation::builder::utils::Test; #[test] fn table() { let input = " | Name | Taste | | ------ | ------ | | Potato | Great | | Carrot | Yuck | "; let lines = Test::new(input).render().rows(6).columns(22).into_lines(); let expected_lines = &[ " ", "Name │ Taste ", "───────┼────── ", "Potato │ Great ", "Carrot │ Yuck ", " ", ]; assert_eq!(lines, expected_lines); } } ================================================ FILE: src/presentation/builder/tests.rs ================================================ use super::*; use crate::presentation::builder::utils::Test; #[test] fn prelude_appears_once() { let input = "--- author: bob --- # hello # bye "; let presentation = Test::new(input).build(); for (index, slide) in presentation.iter_slides().enumerate() { let clear_screen_count = slide.iter_visible_operations().filter(|op| matches!(op, RenderOperation::ClearScreen)).count(); let set_colors_count = slide.iter_visible_operations().filter(|op| matches!(op, RenderOperation::SetColors(_))).count(); assert_eq!(clear_screen_count, 1, "{clear_screen_count} clear screens in slide {index}"); assert_eq!(set_colors_count, 1, "{set_colors_count} clear screens in slide {index}"); } } #[test] fn slides_start_with_one_newline() { let input = r#"--- author: bob --- # hello # bye "#; // land in first slide after into let lines = Test::new(input).render().rows(2).columns(5).advances(1).into_lines(); assert_eq!(lines, &[" ", "hello"]); // land in second one let lines = Test::new(input).render().rows(2).columns(5).advances(2).into_lines(); assert_eq!(lines, &[" ", "bye "]); } #[test] fn extra_fields_in_metadata() { let element = MarkdownElement::FrontMatter("nope: 42".into()); Test::new(vec![element]).expect_invalid(); } #[test] fn end_slide_shorthand() { let input = " hola --- hi "; // first slide let options = PresentationBuilderOptions { end_slide_shorthand: true, ..Default::default() }; let lines = Test::new(input).options(options.clone()).render().rows(2).columns(5).into_lines(); assert_eq!(lines, &[" ", "hola "]); // second slide let lines = Test::new(input).options(options).render().rows(2).columns(5).advances(1).into_lines(); assert_eq!(lines, &[" ", "hi "]); } #[test] fn parse_front_matter_strict() { let options = PresentationBuilderOptions { strict_front_matter_parsing: false, ..Default::default() }; let elements = vec![MarkdownElement::FrontMatter("potato: yes".into())]; let result = Test::new(elements).options(options).try_build(); assert!(result.is_ok()); } #[test] fn footnote() { let elements = vec![MarkdownElement::Footnote(Line::from("hi")), MarkdownElement::Footnote(Line::from("bye"))]; let lines = Test::new(elements).render().rows(3).columns(5).into_lines(); let expected = &[" ", "hi ", "bye "]; assert_eq!(lines, expected); } ================================================ FILE: src/presentation/diff.rs ================================================ use crate::presentation::{Presentation, RenderOperation, SlideChunk}; use std::{any::Any, cmp::Ordering, fmt::Debug, mem}; /// Allow diffing presentations. pub(crate) struct PresentationDiffer; impl PresentationDiffer { /// Find the first modification between two presentations. pub(crate) fn find_first_modification(original: &Presentation, updated: &Presentation) -> Option { let original_slides = original.iter_slides(); let updated_slides = updated.iter_slides(); for (slide_index, (original, updated)) in original_slides.zip(updated_slides).enumerate() { for (chunk_index, (original, updated)) in original.iter_chunks().zip(updated.iter_chunks()).enumerate() { if original.is_content_different(updated) { return Some(Modification { slide_index, chunk_index }); } } let total_original = original.iter_chunks().count(); let total_updated = updated.iter_chunks().count(); match total_original.cmp(&total_updated) { Ordering::Equal => (), Ordering::Less => return Some(Modification { slide_index, chunk_index: total_original }), Ordering::Greater => { return Some(Modification { slide_index, chunk_index: total_updated.saturating_sub(1) }); } } } let total_original = original.iter_slides().count(); let total_updated = updated.iter_slides().count(); match total_original.cmp(&total_updated) { // If they have the same number of slides there's no difference. Ordering::Equal => None, // If the original had fewer, let's scroll to the first new one. Ordering::Less => Some(Modification { slide_index: total_original, chunk_index: 0 }), // If the original had more, let's scroll to the last one. Ordering::Greater => { Some(Modification { slide_index: total_updated.saturating_sub(1), chunk_index: usize::MAX }) } } } } #[derive(Clone, Debug, PartialEq)] pub(crate) struct Modification { pub(crate) slide_index: usize, pub(crate) chunk_index: usize, } trait ContentDiff { fn is_content_different(&self, other: &Self) -> bool; } impl ContentDiff for SlideChunk { fn is_content_different(&self, other: &Self) -> bool { self.iter_operations().is_content_different(&other.iter_operations()) } } impl ContentDiff for RenderOperation { fn is_content_different(&self, other: &Self) -> bool { use RenderOperation::*; let same_variant = mem::discriminant(self) == mem::discriminant(other); // If variants don't even match, content is different. if !same_variant { return true; } match (self, other) { (SetColors(original), SetColors(updated)) if original != updated => false, (RenderText { line: original, .. }, RenderText { line: updated, .. }) if original != updated => true, (RenderText { alignment: original, .. }, RenderText { alignment: updated, .. }) if original != updated => { false } (RenderImage(original, original_properties), RenderImage(updated, updated_properties)) if original != updated || original_properties != updated_properties => { true } (RenderBlockLine(original), RenderBlockLine(updated)) if original != updated => true, (InitColumnLayout { columns: original, .. }, InitColumnLayout { columns: updated, .. }) if original != updated => { true } (EnterColumn { column: original }, EnterColumn { column: updated }) if original != updated => true, (RenderDynamic(original), RenderDynamic(updated)) if original.type_id() != updated.type_id() => true, (RenderDynamic(original), RenderDynamic(updated)) => { original.diffable_content() != updated.diffable_content() } (RenderAsync(original), RenderAsync(updated)) if original.type_id() != updated.type_id() => true, (RenderAsync(original), RenderAsync(updated)) => original.diffable_content() != updated.diffable_content(), _ => false, } } } impl<'a, T, U> ContentDiff for T where T: IntoIterator + Clone, U: ContentDiff + 'a, { fn is_content_different(&self, other: &Self) -> bool { let lhs = self.clone().into_iter(); let rhs = other.clone().into_iter(); for (lhs, rhs) in lhs.zip(rhs) { if lhs.is_content_different(rhs) { return true; } } // If either have more than the other, they've changed self.clone().into_iter().count() != other.clone().into_iter().count() } } #[cfg(test)] mod test { use super::*; use crate::{ markdown::{ text::WeightedLine, text_style::{Color, Colors}, }, presentation::{Slide, SlideBuilder}, render::{ operation::{AsRenderOperations, BlockLine, LayoutGrid, Pollable, RenderAsync, ToggleState}, properties::WindowSize, }, theme::{Alignment, Margin}, }; use rstest::rstest; use std::rc::Rc; #[derive(Debug)] struct Dynamic; impl AsRenderOperations for Dynamic { fn as_render_operations(&self, _dimensions: &WindowSize) -> Vec { Vec::new() } } impl RenderAsync for Dynamic { fn pollable(&self) -> Box { // Use some random one, we don't care Box::new(ToggleState::new(Default::default())) } } #[rstest] #[case(RenderOperation::ClearScreen)] #[case(RenderOperation::JumpToVerticalCenter)] #[case(RenderOperation::JumpToBottomRow{ index: 0 })] #[case(RenderOperation::RenderLineBreak)] #[case(RenderOperation::SetColors(Colors{background: None, foreground: None}))] #[case(RenderOperation::RenderText{line: String::from("asd").into(), alignment: Default::default()})] #[case(RenderOperation::RenderBlockLine( BlockLine{ prefix: "".into(), right_padding_length: 0, repeat_prefix_on_wrap: false, text: WeightedLine::from("".to_string()), alignment: Default::default(), block_length: 42, block_color: None, } ))] #[case(RenderOperation::RenderDynamic(Rc::new(Dynamic)))] #[case(RenderOperation::RenderAsync(Rc::new(Dynamic)))] #[case(RenderOperation::InitColumnLayout{ columns: vec![1, 2], grid: LayoutGrid::None, margin: Default::default() })] #[case(RenderOperation::EnterColumn{ column: 1 })] #[case(RenderOperation::ExitLayout)] fn same_not_modified(#[case] operation: RenderOperation) { let diff = operation.is_content_different(&operation); assert!(!diff); } #[test] fn different_text() { let lhs = RenderOperation::RenderText { line: String::from("foo").into(), alignment: Default::default() }; let rhs = RenderOperation::RenderText { line: String::from("bar").into(), alignment: Default::default() }; assert!(lhs.is_content_different(&rhs)); } #[test] fn different_text_alignment() { let lhs = RenderOperation::RenderText { line: String::from("foo").into(), alignment: Alignment::Left { margin: Margin::Fixed(42) }, }; let rhs = RenderOperation::RenderText { line: String::from("foo").into(), alignment: Alignment::Left { margin: Margin::Fixed(1337) }, }; assert!(!lhs.is_content_different(&rhs)); } #[test] fn different_colors() { let lhs = RenderOperation::SetColors(Colors { background: None, foreground: Some(Color::new(1, 2, 3)) }); let rhs = RenderOperation::SetColors(Colors { background: None, foreground: Some(Color::new(3, 2, 1)) }); assert!(!lhs.is_content_different(&rhs)); } #[test] fn different_column_layout() { let lhs = RenderOperation::InitColumnLayout { columns: vec![1, 2], grid: LayoutGrid::None, margin: Default::default(), }; let rhs = RenderOperation::InitColumnLayout { columns: vec![1, 3], grid: LayoutGrid::None, margin: Default::default(), }; assert!(lhs.is_content_different(&rhs)); } #[test] fn different_column() { let lhs = RenderOperation::EnterColumn { column: 0 }; let rhs = RenderOperation::EnterColumn { column: 1 }; assert!(lhs.is_content_different(&rhs)); } #[test] fn no_slide_changes() { let presentation = Presentation::from(vec![ Slide::from(vec![RenderOperation::ClearScreen]), Slide::from(vec![RenderOperation::ClearScreen]), Slide::from(vec![RenderOperation::ClearScreen]), ]); assert_eq!(PresentationDiffer::find_first_modification(&presentation, &presentation), None); } #[test] fn slides_truncated() { let lhs = Presentation::from(vec![ Slide::from(vec![RenderOperation::ClearScreen]), Slide::from(vec![RenderOperation::ClearScreen]), ]); let rhs = Presentation::from(vec![Slide::from(vec![RenderOperation::ClearScreen])]); assert_eq!( PresentationDiffer::find_first_modification(&lhs, &rhs), Some(Modification { slide_index: 0, chunk_index: usize::MAX }) ); } #[test] fn slides_added() { let lhs = Presentation::from(vec![Slide::from(vec![RenderOperation::ClearScreen])]); let rhs = Presentation::from(vec![ Slide::from(vec![RenderOperation::ClearScreen]), Slide::from(vec![RenderOperation::ClearScreen]), ]); assert_eq!( PresentationDiffer::find_first_modification(&lhs, &rhs), Some(Modification { slide_index: 1, chunk_index: 0 }) ); } #[test] fn second_slide_content_changed() { let lhs = Presentation::from(vec![ Slide::from(vec![RenderOperation::ClearScreen]), Slide::from(vec![RenderOperation::ClearScreen]), Slide::from(vec![RenderOperation::ClearScreen]), ]); let rhs = Presentation::from(vec![ Slide::from(vec![RenderOperation::ClearScreen]), Slide::from(vec![RenderOperation::JumpToVerticalCenter]), Slide::from(vec![RenderOperation::ClearScreen]), ]); assert_eq!( PresentationDiffer::find_first_modification(&lhs, &rhs), Some(Modification { slide_index: 1, chunk_index: 0 }) ); } #[test] fn presentation_changed_style() { let lhs = Presentation::from(vec![Slide::from(vec![RenderOperation::SetColors(Colors { background: None, foreground: Some(Color::new(255, 0, 0)), })])]); let rhs = Presentation::from(vec![Slide::from(vec![RenderOperation::SetColors(Colors { background: None, foreground: Some(Color::new(0, 0, 0)), })])]); assert_eq!(PresentationDiffer::find_first_modification(&lhs, &rhs), None); } #[test] fn chunk_change() { let lhs = Presentation::from(vec![ Slide::from(vec![RenderOperation::ClearScreen]), SlideBuilder::default() .chunks(vec![SlideChunk::default(), SlideChunk::new(vec![RenderOperation::ClearScreen], vec![])]) .build(), ]); let rhs = Presentation::from(vec![ Slide::from(vec![RenderOperation::ClearScreen]), SlideBuilder::default() .chunks(vec![ SlideChunk::default(), SlideChunk::new(vec![RenderOperation::ClearScreen, RenderOperation::ClearScreen], vec![]), ]) .build(), ]); assert_eq!( PresentationDiffer::find_first_modification(&lhs, &rhs), Some(Modification { slide_index: 1, chunk_index: 1 }) ); assert_eq!( PresentationDiffer::find_first_modification(&rhs, &lhs), Some(Modification { slide_index: 1, chunk_index: 1 }) ); } } ================================================ FILE: src/presentation/mod.rs ================================================ use crate::{config::OptionsConfig, render::operation::RenderOperation}; use serde::Deserialize; use std::{ cell::RefCell, fmt::Debug, ops::Deref, rc::Rc, sync::{Arc, Mutex}, }; pub(crate) mod builder; pub(crate) mod diff; pub(crate) mod poller; #[derive(Debug)] pub(crate) struct Modals { pub(crate) slide_index: Vec, pub(crate) bindings: Vec, } /// A presentation. #[derive(Debug)] pub(crate) struct Presentation { slides: Vec, modals: Modals, pub(crate) state: PresentationState, } impl Presentation { /// Construct a new presentation. pub(crate) fn new(slides: Vec, modals: Modals, state: PresentationState) -> Self { Self { slides, modals, state } } /// Iterate the slides in this presentation. pub(crate) fn iter_slides(&self) -> impl Iterator { self.slides.iter() } /// Iterate the slides in this presentation. pub(crate) fn iter_slides_mut(&mut self) -> impl Iterator { self.slides.iter_mut() } /// Iterate the operations that render the slide index. pub(crate) fn iter_slide_index_operations(&self) -> impl Iterator { self.modals.slide_index.iter() } /// Iterate the operations that render the key bindings modal. pub(crate) fn iter_bindings_operations(&self) -> impl Iterator { self.modals.bindings.iter() } /// Consume this presentation and return its slides. pub(crate) fn into_slides(self) -> Vec { self.slides } /// Get the current slide. pub(crate) fn current_slide(&self) -> &Slide { &self.slides[self.current_slide_index()] } /// Get the current slide index. pub(crate) fn current_slide_index(&self) -> usize { self.state.current_slide_index() } /// Jump forwards. pub(crate) fn jump_next(&mut self) -> bool { let current_slide = self.current_slide_mut(); if current_slide.move_next() { return true; } self.jump_next_slide() } /// Jump to the next slide, ignoring any chunks and modifiers. pub(crate) fn jump_next_fast(&mut self) -> bool { self.jump_next_slide() } /// Jump backwards. pub(crate) fn jump_previous(&mut self) -> bool { let current_slide = self.current_slide_mut(); if current_slide.move_previous() { return true; } self.jump_previous_slide() } /// Jump to the previous slide ignoring any chunks and modifiers. pub(crate) fn jump_previous_fast(&mut self) -> bool { let output = self.jump_previous_slide(); self.current_slide_mut().show_first_chunk(); output } /// Jump to the first slide. pub(crate) fn jump_first_slide(&mut self) -> bool { self.go_to_slide(0) } /// Jump to the last slide. pub(crate) fn jump_last_slide(&mut self) -> bool { let last_slide_index = self.slides.len().saturating_sub(1); self.go_to_slide(last_slide_index) } /// Jump to a specific slide. pub(crate) fn go_to_slide(&mut self, slide_index: usize) -> bool { if slide_index < self.slides.len() { self.state.set_current_slide_index(slide_index); // Always show only the first slide when jumping to a particular one. self.current_slide_mut().show_first_chunk(); true } else { false } } /// Jump to a specific chunk within the current slide. pub(crate) fn jump_chunk(&mut self, chunk_index: usize) { self.current_slide_mut().jump_chunk(chunk_index); } /// Get the current slide's chunk. pub(crate) fn current_chunk(&self) -> usize { self.current_slide().current_chunk_index() } pub(crate) fn current_slide_mut(&mut self) -> &mut Slide { let index = self.current_slide_index(); &mut self.slides[index] } /// Show all chunks in the current slide. pub(crate) fn show_all_slide_chunks(&mut self) { self.current_slide_mut().show_all_chunks(); } fn jump_next_slide(&mut self) -> bool { let current_slide_index = self.current_slide_index(); if current_slide_index < self.slides.len() - 1 { self.state.set_current_slide_index(current_slide_index + 1); // Going forward we show only the first chunk. self.current_slide_mut().show_first_chunk(); true } else { false } } fn jump_previous_slide(&mut self) -> bool { let current_slide_index = self.current_slide_index(); if current_slide_index > 0 { self.state.set_current_slide_index(current_slide_index - 1); // Going backwards we show all chunks. self.current_slide_mut().show_all_chunks(); true } else { false } } } impl From> for Presentation { fn from(slides: Vec) -> Self { let modals = Modals { slide_index: vec![], bindings: vec![] }; Self::new(slides, modals, Default::default()) } } #[derive(Debug)] pub(crate) struct AsyncPresentationError { pub(crate) slide: usize, pub(crate) error: String, } pub(crate) type AsyncPresentationErrorHolder = Arc>>; #[derive(Debug, Default)] pub(crate) struct PresentationStateInner { current_slide_index: usize, async_error_holder: AsyncPresentationErrorHolder, } #[derive(Clone, Debug, Default)] pub(crate) struct PresentationState { inner: Rc>, } impl PresentationState { pub(crate) fn async_error_holder(&self) -> AsyncPresentationErrorHolder { self.inner.deref().borrow().async_error_holder.clone() } pub(crate) fn current_slide_index(&self) -> usize { self.inner.deref().borrow().current_slide_index } fn set_current_slide_index(&self, value: usize) { self.inner.deref().borrow_mut().current_slide_index = value; } } /// A slide builder. #[derive(Default)] pub(crate) struct SlideBuilder { chunks: Vec, footer: Vec, } impl SlideBuilder { pub(crate) fn chunks(mut self, chunks: Vec) -> Self { self.chunks = chunks; self } pub(crate) fn footer(mut self, footer: Vec) -> Self { self.footer = footer; self } pub(crate) fn build(self) -> Slide { Slide::new(self.chunks, self.footer) } } /// A slide. /// /// Slides are composed of render operations that can be carried out to materialize this slide into /// the terminal's screen. #[derive(Debug)] pub(crate) struct Slide { chunks: Vec, footer: Vec, visible_chunks: usize, } impl Slide { pub(crate) fn new(chunks: Vec, footer: Vec) -> Self { Self { chunks, footer, visible_chunks: 1 } } pub(crate) fn iter_operations(&self) -> impl Iterator + Clone { self.chunks.iter().flat_map(|chunk| chunk.operations.iter()).chain(self.footer.iter()) } pub(crate) fn iter_operations_mut(&mut self) -> impl Iterator { self.chunks.iter_mut().flat_map(|chunk| chunk.operations.iter_mut()).chain(self.footer.iter_mut()) } pub(crate) fn iter_visible_operations(&self) -> impl Iterator + Clone { self.chunks.iter().take(self.visible_chunks).flat_map(|chunk| chunk.operations.iter()).chain(self.footer.iter()) } pub(crate) fn iter_visible_operations_mut(&mut self) -> impl Iterator { self.chunks .iter_mut() .take(self.visible_chunks) .flat_map(|chunk| chunk.operations.iter_mut()) .chain(self.footer.iter_mut()) } pub(crate) fn iter_chunks(&self) -> impl Iterator { self.chunks.iter() } pub(crate) fn current_chunk_index(&self) -> usize { self.visible_chunks.saturating_sub(1) } pub(crate) fn jump_chunk(&mut self, chunk_index: usize) { self.visible_chunks = chunk_index.saturating_add(1).min(self.chunks.len()); for chunk in self.chunks.iter().take(self.visible_chunks - 1) { chunk.apply_all_mutations(); } } fn current_chunk(&self) -> &SlideChunk { &self.chunks[self.current_chunk_index()] } fn show_first_chunk(&mut self) { self.visible_chunks = 1; self.current_chunk().reset_mutations(); } pub(crate) fn show_all_chunks(&mut self) { self.visible_chunks = self.chunks.len(); for chunk in &self.chunks { chunk.apply_all_mutations(); } } fn move_next(&mut self) -> bool { if self.chunks[self.current_chunk_index()].mutate_next() { return true; } if self.visible_chunks == self.chunks.len() { false } else { self.visible_chunks += 1; self.current_chunk().reset_mutations(); true } } fn move_previous(&mut self) -> bool { if self.chunks[self.current_chunk_index()].mutate_previous() { return true; } if self.visible_chunks == 1 { false } else { self.visible_chunks -= 1; self.current_chunk().apply_all_mutations(); true } } } impl From> for Slide { fn from(operations: Vec) -> Self { Self::new(vec![SlideChunk::new(operations, Vec::new())], vec![]) } } #[derive(Debug, Default)] pub(crate) struct SlideChunk { operations: Vec, mutators: Vec>, } impl SlideChunk { pub(crate) fn new(operations: Vec, mutators: Vec>) -> Self { Self { operations, mutators } } pub(crate) fn iter_operations(&self) -> impl Iterator + Clone { self.operations.iter() } pub(crate) fn pop_last(&mut self) -> Option { self.operations.pop() } fn mutate_next(&self) -> bool { for mutator in &self.mutators { if mutator.mutate_next() { return true; } } false } fn mutate_previous(&self) -> bool { for mutator in self.mutators.iter().rev() { if mutator.mutate_previous() { return true; } } false } fn reset_mutations(&self) { for mutator in &self.mutators { mutator.reset_mutations(); } } fn apply_all_mutations(&self) { for mutator in &self.mutators { mutator.apply_all_mutations(); } } } pub(crate) trait ChunkMutator: Debug { fn mutate_next(&self) -> bool; fn mutate_previous(&self) -> bool; fn reset_mutations(&self); fn apply_all_mutations(&self); #[allow(dead_code)] fn mutations(&self) -> (usize, usize); } /// The metadata for a presentation. #[derive(Clone, Debug, Deserialize)] pub(crate) struct PresentationMetadata { /// The presentation title. pub(crate) title: Option, /// The presentation sub-title. #[serde(default)] pub(crate) sub_title: Option, /// The presentation event. #[serde(default)] pub(crate) event: Option, /// The presentation location. #[serde(default)] pub(crate) location: Option, /// The presentation date. #[serde(default)] pub(crate) date: Option, /// The presentation author. #[serde(default)] pub(crate) author: Option, /// The presentation authors. #[serde(default)] pub(crate) authors: Vec, /// The presentation's theme metadata. #[serde(default)] pub(crate) theme: PresentationThemeMetadata, /// The presentation's options. #[serde(default)] pub(crate) options: Option, } impl PresentationMetadata { /// Check if this presentation has frontmatter. pub(crate) fn has_frontmatter(&self) -> bool { self.title.is_some() || self.sub_title.is_some() || self.event.is_some() || self.location.is_some() || self.date.is_some() || self.author.is_some() || !self.authors.is_empty() } } /// A presentation's theme metadata. #[derive(Clone, Debug, Default, Deserialize)] pub(crate) struct PresentationThemeMetadata { /// The theme name. #[serde(default)] pub(crate) name: Option, /// the theme path. #[serde(default)] pub(crate) path: Option, /// Any specific overrides for the presentation's theme. #[serde(default, rename = "override")] pub(crate) overrides: Option, } #[cfg(test)] mod test { use super::*; use rstest::rstest; use std::cell::RefCell; #[derive(Clone)] enum Jump { First, Last, Next, NextFast, Previous, PreviousFast, Specific(usize), } impl Jump { fn apply(&self, presentation: &mut Presentation) { use Jump::*; match self { First => presentation.jump_first_slide(), Last => presentation.jump_last_slide(), Next => presentation.jump_next(), NextFast => presentation.jump_next_fast(), Previous => presentation.jump_previous(), PreviousFast => presentation.jump_previous_fast(), Specific(index) => presentation.go_to_slide(*index), }; } fn repeat(&self, count: usize) -> Vec { vec![self.clone(); count] } } #[derive(Debug)] struct DummyMutator { current: RefCell, limit: usize, } impl DummyMutator { fn new(limit: usize) -> Self { Self { current: 0.into(), limit } } } impl ChunkMutator for DummyMutator { fn mutate_next(&self) -> bool { let mut current = self.current.borrow_mut(); if *current < self.limit { *current += 1; true } else { false } } fn mutate_previous(&self) -> bool { let mut current = self.current.borrow_mut(); if *current > 0 { *current -= 1; true } else { false } } fn reset_mutations(&self) { *self.current.borrow_mut() = 0; } fn apply_all_mutations(&self) { *self.current.borrow_mut() = self.limit; } fn mutations(&self) -> (usize, usize) { (*self.current.borrow(), self.limit) } } #[rstest] #[case::previous_from_first(0, &[Jump::Previous], 0, 0)] #[case::next_from_first(0, &[Jump::Next], 0, 1)] #[case::next_next_from_first(0, &[Jump::Next, Jump::Next], 0, 2)] #[case::next_next_next_from_first(0, &[Jump::Next, Jump::Next, Jump::Next], 1, 0)] #[case::next_fast_from_first(0, &[Jump::NextFast], 1, 0)] #[case::next_fast_twice_from_first(0, &[Jump::NextFast, Jump::NextFast], 2, 0)] #[case::last_from_first(0, &[Jump::Last], 2, 0)] #[case::previous_from_second(1, &[Jump::Previous], 0, 2)] #[case::previous_fast_from_second(1, &[Jump::PreviousFast], 0, 0)] #[case::previous_fast_twice_from_second(1, &[Jump::PreviousFast, Jump::PreviousFast], 0, 0)] #[case::next_from_second(1, &[Jump::Next], 1, 1)] #[case::specific_first_from_second(1, &[Jump::Specific(0)], 0, 0)] #[case::specific_last_from_second(1, &[Jump::Specific(2)], 2, 0)] #[case::first_from_last(2, &[Jump::First], 0, 0)] fn jumping( #[case] from: usize, #[case] jumps: &[Jump], #[case] expected_slide: usize, #[case] expected_chunk: usize, ) { let mut presentation = Presentation::from(vec![ Slide::new(vec![SlideChunk::default(), SlideChunk::default(), SlideChunk::default()], vec![]), Slide::new(vec![SlideChunk::default(), SlideChunk::default()], vec![]), Slide::new(vec![SlideChunk::default(), SlideChunk::default()], vec![]), ]); presentation.go_to_slide(from); for jump in jumps { jump.apply(&mut presentation); } assert_eq!(presentation.current_slide_index(), expected_slide); assert_eq!(presentation.current_slide().visible_chunks - 1, expected_chunk); } #[rstest] #[case::next_1(0, &[Jump::Next], [1, 0, 0], 0, 0)] #[case::next_previous(0, &[Jump::Next, Jump::Previous], [0, 0, 0], 0, 0)] #[case::next_2(0, &Jump::Next.repeat(2), [1, 1, 0], 0, 0)] #[case::next_3(0, &Jump::Next.repeat(3), [1, 2, 0], 0, 0)] #[case::next_4(0, &Jump::Next.repeat(4), [1, 2, 0], 0, 1)] #[case::next_4_back_4( 0, &[Jump::Next.repeat(4), Jump::Previous.repeat(4)].concat(), [0, 0, 0], 0, 0 )] #[case::last_first(0, &[Jump::Last, Jump::First], [0, 0, 0], 0, 0)] #[case::back_from_second(0, &[Jump::Specific(1), Jump::Previous], [1, 2, 0], 0, 1)] #[case::specific_from_second(0, &[Jump::Specific(1), Jump::Previous, Jump::Specific(0)], [0, 0, 0], 0, 0)] fn jumping_with_mutations( #[case] from: usize, #[case] jumps: &[Jump], #[case] mutations: [usize; 3], #[case] expected_slide: usize, #[case] expected_chunk: usize, ) { let mut presentation = Presentation::from(vec![ SlideBuilder::default() .chunks(vec![ SlideChunk::new(vec![], vec![Box::new(DummyMutator::new(1)), Box::new(DummyMutator::new(2))]), SlideChunk::default(), ]) .build(), SlideBuilder::default() .chunks(vec![SlideChunk::new(vec![], vec![Box::new(DummyMutator::new(2))]), SlideChunk::default()]) .build(), ]); presentation.go_to_slide(from); for jump in jumps { jump.apply(&mut presentation); } let mutators: Vec<_> = presentation .iter_slides() .flat_map(|slide| slide.chunks.iter()) .flat_map(|chunk| chunk.mutators.iter()) .collect(); assert_eq!(mutators.len(), mutations.len(), "unexpected mutation count"); for (index, (mutator, expected_mutations)) in mutators.into_iter().zip(mutations).enumerate() { assert_eq!(mutator.mutations().0, expected_mutations, "diff on {index}"); } assert_eq!(presentation.current_slide_index(), expected_slide, "slide differs"); assert_eq!(presentation.current_slide().visible_chunks - 1, expected_chunk, "chunk differs"); } } ================================================ FILE: src/presentation/poller.rs ================================================ 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, receiver: Receiver, } 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 { 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, slide: usize }, /// Reset all pollables. Reset, } struct PollerWorker { receiver: Receiver, sender: Sender, pollables: Vec<(Box, usize)>, } impl PollerWorker { fn new(receiver: Receiver, sender: Sender) -> 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); } } } ================================================ FILE: src/presenter.rs ================================================ use crate::{ code::execute::SnippetExecutor, commands::{ listener::{Command, CommandListener}, speaker_notes::{SpeakerNotesEvent, SpeakerNotesEventPublisher}, }, config::{KeyBindingsConfig, SlideTransitionConfig, SlideTransitionStyleConfig}, markdown::parse::MarkdownParser, presentation::{ Presentation, Slide, builder::{PresentationBuilder, PresentationBuilderOptions, Themes, error::BuildError}, 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, }, resource::Resources, terminal::{ image::printer::{ImagePrinter, ImageRegistry}, printer::{TerminalCommand, TerminalIo}, virt::{ImageBehavior, TerminalGrid, VirtualTerminal}, }, theme::{ProcessingThemeError, raw::PresentationTheme}, third_party::ThirdPartyRender, transitions::{ AnimateTransition, AnimationFrame, LinesFrame, TransitionDirection, collapse_horizontal::CollapseHorizontalAnimation, fade::FadeAnimation, slide_horizontal::SlideHorizontalAnimation, }, }; use std::{ fmt::Display, io::{self}, mem, ops::Deref, path::Path, sync::Arc, time::{Duration, Instant}, }; pub struct PresenterOptions { pub mode: PresentMode, pub builder_options: PresentationBuilderOptions, pub font_size_fallback: u8, pub bindings: KeyBindingsConfig, pub validate_overflows: bool, pub max_size: MaxSize, pub transition: Option, } /// A slideshow presenter. /// /// This type puts everything else together. pub struct Presenter<'a> { default_theme: &'a PresentationTheme, listener: CommandListener, parser: MarkdownParser<'a>, resources: Resources, third_party: ThirdPartyRender, code_executor: Arc, state: PresenterState, image_printer: Arc, themes: Themes, options: PresenterOptions, speaker_notes_event_publisher: Option, poller: Poller, } impl<'a> Presenter<'a> { /// Construct a new presenter. #[allow(clippy::too_many_arguments)] pub fn new( default_theme: &'a PresentationTheme, listener: CommandListener, parser: MarkdownParser<'a>, resources: Resources, third_party: ThirdPartyRender, code_executor: Arc, themes: Themes, image_printer: Arc, options: PresenterOptions, speaker_notes_event_publisher: Option, ) -> Self { Self { default_theme, listener, parser, resources, third_party, code_executor, state: PresenterState::Empty, image_printer, themes, options, speaker_notes_event_publisher, poller: Poller::launch(), } } /// Run a presentation. pub fn present(mut self, path: &Path) -> Result<(), PresentationError> { if matches!(self.options.mode, PresentMode::Development) { self.resources.watch_presentation_file(path.to_path_buf()); } self.state = PresenterState::Presenting(Presentation::from(vec![])); self.try_reload(path, true)?; let drawer_options = TerminalDrawerOptions { font_size_fallback: self.options.font_size_fallback, max_size: self.options.max_size.clone(), }; let mut drawer = TerminalDrawer::new(self.image_printer.clone(), drawer_options)?; loop { // Poll async renders once before we draw just in case. self.render(&mut drawer)?; loop { if self.process_poller_effects()? { self.render(&mut drawer)?; } let command = match self.listener.try_next_command()? { Some(command) => command, _ => match self.resources.resources_modified() { true => Command::Reload, false => { if self.check_async_error() { break; } continue; } }, }; match self.apply_command(command) { CommandSideEffect::Exit => { self.publish_event(SpeakerNotesEvent::Exit)?; return Ok(()); } CommandSideEffect::Suspend => { self.suspend(&mut drawer); break; } CommandSideEffect::Reload => { 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 => (), }; } if !matches!(self.state, PresenterState::Failure { .. }) { let slide_index = self.state.presentation().current_slide_index() as u32 + 1; let slide = self.state.presentation().current_slide(); self.publish_event(SpeakerNotesEvent::GoTo { slide: slide_index, chunk: slide.current_chunk_index() as u32, })?; } } } fn process_poller_effects(&mut self) -> Result { let current_slide = match &self.state { PresenterState::Presenting(presentation) | PresenterState::SlideIndex(presentation) | PresenterState::KeyBindings(presentation) | PresenterState::Failure { presentation, .. } => presentation.current_slide_index(), PresenterState::Empty => usize::MAX, }; let mut refreshed = false; let mut needs_render = false; while let Some(effect) = self.poller.next_effect() { match effect { PollableEffect::RefreshSlide(index) => { needs_render = needs_render || index == current_slide; refreshed = true; } PollableEffect::DisplayError { slide, error } => { let presentation = mem::take(&mut self.state).into_presentation(); self.state = PresenterState::failure(error, presentation, ErrorSource::Slide(slide + 1), FailureMode::Other); needs_render = true; } } } if refreshed { self.try_scale_transition_images()?; } Ok(needs_render) } fn publish_event(&self, event: SpeakerNotesEvent) -> io::Result<()> { if let Some(publisher) = &self.speaker_notes_event_publisher { publisher.send(event)?; } Ok(()) } fn check_async_error(&mut self) -> bool { let error_holder = self.state.presentation().state.async_error_holder(); let error_holder = error_holder.lock().unwrap(); match error_holder.deref() { Some(error) => { let presentation = mem::take(&mut self.state).into_presentation(); self.state = PresenterState::failure( &error.error, presentation, ErrorSource::Slide(error.slide), FailureMode::Other, ); true } None => false, } } fn render(&mut self, drawer: &mut TerminalDrawer) -> RenderResult { let result = match &self.state { PresenterState::Presenting(presentation) => { drawer.render_operations(presentation.current_slide().iter_visible_operations()) } PresenterState::SlideIndex(presentation) => { drawer.render_operations(presentation.current_slide().iter_visible_operations())?; drawer.render_operations(presentation.iter_slide_index_operations()) } PresenterState::KeyBindings(presentation) => { drawer.render_operations(presentation.current_slide().iter_visible_operations())?; drawer.render_operations(presentation.iter_bindings_operations()) } PresenterState::Failure { error, source, .. } => drawer.render_error(error, source), PresenterState::Empty => panic!("cannot render without state"), }; // If the screen is too small, simply ignore this. Eventually the user will resize the // screen. if matches!(result, Err(RenderError::TerminalTooSmall)) { Ok(()) } else { result } } fn apply_command(&mut self, command: Command) -> CommandSideEffect { // These ones always happens no matter our state. match command { Command::Reload => { return CommandSideEffect::Reload; } Command::HardReload => { if matches!(self.options.mode, PresentMode::Development) { self.resources.clear(); } return CommandSideEffect::Reload; } Command::ToggleLayoutGrid => { self.options.builder_options.layout_grid = !self.options.builder_options.layout_grid; return CommandSideEffect::Reload; } Command::Exit => return CommandSideEffect::Exit, Command::Suspend => return CommandSideEffect::Suspend, _ => (), }; if matches!(command, Command::Redraw) { if !self.is_displaying_other_error() { let presentation = mem::take(&mut self.state).into_presentation(); self.state = self.validate_overflows(presentation); } return CommandSideEffect::Redraw; } // Now apply the commands that require a presentation. let presentation = match &mut self.state { PresenterState::Presenting(presentation) | PresenterState::SlideIndex(presentation) | PresenterState::KeyBindings(presentation) => presentation, _ => { return CommandSideEffect::None; } }; 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::NextFast => presentation.jump_next_fast(), Command::Previous => { let current_slide = presentation.current_slide_index(); if !presentation.jump_previous() { false } else if presentation.current_slide_index() != current_slide { return CommandSideEffect::AnimatePreviousSlide; } else { true } } Command::PreviousFast => presentation.jump_previous_fast(), Command::FirstSlide => presentation.jump_first_slide(), Command::LastSlide => presentation.jump_last_slide(), Command::GoToSlide(number) => presentation.go_to_slide(number.saturating_sub(1) as usize), Command::GoToSlideChunk { slide, chunk } => { presentation.go_to_slide(slide.saturating_sub(1) as usize); presentation.current_slide_mut().jump_chunk(chunk as usize); true } Command::RenderAsyncOperations => { let pollables = Self::trigger_slide_async_renders(presentation); if !pollables.is_empty() { for pollable in pollables { self.poller.send(PollerCommand::Poll { pollable, slide: presentation.current_slide_index() }); } return CommandSideEffect::Redraw; } else { return CommandSideEffect::None; } } Command::ToggleSlideIndex => { self.toggle_slide_index(); true } Command::ToggleKeyBindingsConfig => { self.toggle_key_bindings(); true } Command::CloseModal => { let presentation = mem::take(&mut self.state).into_presentation(); self.state = PresenterState::Presenting(presentation); true } Command::SkipPauses => { presentation.show_all_slide_chunks(); true } // These are handled above as they don't require the presentation Command::Reload | Command::HardReload | Command::Exit | Command::Suspend | Command::Redraw | Command::ToggleLayoutGrid => { panic!("unreachable commands") } }; if needs_redraw { CommandSideEffect::Redraw } else { CommandSideEffect::None } } fn try_reload(&mut self, path: &Path, force: bool) -> RenderResult { if matches!(self.options.mode, PresentMode::Presentation) && !force { return Ok(()); } self.poller.send(PollerCommand::Reset); self.resources.clear_watches(); match self.load_presentation(path) { Ok(mut presentation) => { let current = self.state.presentation(); if let Some(modification) = PresentationDiffer::find_first_modification(current, &presentation) { presentation.go_to_slide(modification.slide_index); presentation.jump_chunk(modification.chunk_index); } else { presentation.go_to_slide(current.current_slide_index()); presentation.jump_chunk(current.current_chunk()); } self.start_automatic_async_renders(&mut presentation); self.state = self.validate_overflows(presentation); self.try_scale_transition_images()?; } Err(e) => { let presentation = mem::take(&mut self.state).into_presentation(); self.state = PresenterState::failure(e, presentation, 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> { let slide = presentation.current_slide_mut(); let mut pollables = Vec::new(); for operation in slide.iter_visible_operations_mut() { if let RenderOperation::RenderAsync(operation) = operation { if let RenderAsyncStartPolicy::OnDemand = operation.start_policy() { pollables.push(operation.pollable()); } } } pollables } fn is_displaying_other_error(&self) -> bool { matches!(self.state, PresenterState::Failure { mode: FailureMode::Other, .. }) } fn validate_overflows(&self, presentation: Presentation) -> PresenterState { if self.options.validate_overflows { let dimensions = match WindowSize::current(self.options.font_size_fallback) { Ok(dimensions) => dimensions, Err(e) => { return PresenterState::failure(e, presentation, ErrorSource::Presentation, FailureMode::Other); } }; match OverflowValidator::validate(&presentation, dimensions) { Ok(()) => PresenterState::Presenting(presentation), Err(e) => PresenterState::failure(e, presentation, ErrorSource::Presentation, FailureMode::Overflow), } } else { PresenterState::Presenting(presentation) } } fn load_presentation(&mut self, path: &Path) -> Result { let presentation = PresentationBuilder::new( self.default_theme, self.resources.clone(), &mut self.third_party, self.code_executor.clone(), &self.themes, ImageRegistry::new(self.image_printer.clone()), self.options.bindings.clone(), &self.parser, self.options.builder_options.clone(), )? .build(path)?; Ok(presentation) } fn toggle_slide_index(&mut self) { let state = mem::take(&mut self.state); match state { PresenterState::Presenting(presentation) | PresenterState::KeyBindings(presentation) => { self.state = PresenterState::SlideIndex(presentation) } PresenterState::SlideIndex(presentation) => self.state = PresenterState::Presenting(presentation), other => self.state = other, } } fn toggle_key_bindings(&mut self) { let state = mem::take(&mut self.state); match state { PresenterState::Presenting(presentation) | PresenterState::SlideIndex(presentation) => { self.state = PresenterState::KeyBindings(presentation) } PresenterState::KeyBindings(presentation) => self.state = PresenterState::Presenting(presentation), other => self.state = other, } } fn suspend(&self, drawer: &mut TerminalDrawer) { #[cfg(unix)] unsafe { drawer.terminal.suspend(); libc::raise(libc::SIGTSTP); drawer.terminal.resume(); } } fn animate_next_slide(&mut self, drawer: &mut TerminalDrawer) -> RenderResult { let Some(config) = self.options.transition.clone() else { return Ok(()); }; let options = drawer.render_engine_options(); let presentation = self.state.presentation_mut(); let dimensions = WindowSize::current(self.options.font_size_fallback)?; presentation.jump_previous(); let left = Self::virtual_render(presentation.current_slide(), dimensions, &options)?; presentation.jump_next(); let right = Self::virtual_render(presentation.current_slide(), dimensions, &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, &options)?; presentation.jump_previous(); let left = Self::virtual_render(presentation.current_slide(), dimensions, &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( &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 { let mut term = VirtualTerminal::new(dimensions, ImageBehavior::PrintAscii); let engine = RenderEngine::new(&mut term, dimensions, options.clone()); engine.render(slide.iter_visible_operations())?; Ok(term.into_contents()) } fn start_automatic_async_renders(&self, presentation: &mut Presentation) { for (index, slide) in presentation.iter_slides_mut().enumerate() { for operation in slide.iter_operations_mut() { if let RenderOperation::RenderAsync(operation) = operation { if let RenderAsyncStartPolicy::Automatic = operation.start_policy() { let pollable = operation.pollable(); self.poller.send(PollerCommand::Poll { pollable, slide: index }); } } } } } } enum CommandSideEffect { Exit, Suspend, Redraw, Reload, AnimateNextSlide, AnimatePreviousSlide, None, } #[derive(Default)] enum PresenterState { #[default] Empty, Presenting(Presentation), SlideIndex(Presentation), KeyBindings(Presentation), Failure { error: String, presentation: Presentation, source: ErrorSource, mode: FailureMode, }, } impl PresenterState { pub(crate) fn failure( error: E, presentation: Presentation, source: ErrorSource, mode: FailureMode, ) -> Self { PresenterState::Failure { error: error.to_string(), presentation, source, mode } } fn presentation(&self) -> &Presentation { match self { Self::Presenting(presentation) | Self::SlideIndex(presentation) | Self::KeyBindings(presentation) | Self::Failure { presentation, .. } => presentation, Self::Empty => panic!("state is empty"), } } fn presentation_mut(&mut self) -> &mut Presentation { match self { Self::Presenting(presentation) | Self::SlideIndex(presentation) | Self::KeyBindings(presentation) | Self::Failure { presentation, .. } => presentation, Self::Empty => panic!("state is empty"), } } fn into_presentation(self) -> Presentation { match self { Self::Presenting(presentation) | Self::SlideIndex(presentation) | Self::KeyBindings(presentation) | Self::Failure { presentation, .. } => presentation, Self::Empty => panic!("state is empty"), } } } enum FailureMode { Overflow, Other, } /// This presentation mode. pub enum PresentMode { /// We are developing the presentation so we want live reloads when the input changes. Development, /// This is a live presentation so we don't want hot reloading. Presentation, } /// An error when loading a presentation. #[derive(thiserror::Error, Debug)] pub enum LoadPresentationError { #[error(transparent)] Processing(#[from] BuildError), #[error("processing theme: {0}")] ProcessingTheme(#[from] ProcessingThemeError), } /// An error during the presentation. #[derive(thiserror::Error, Debug)] pub enum PresentationError { #[error(transparent)] Render(#[from] RenderError), #[error("io: {0}")] Io(#[from] io::Error), } ================================================ FILE: src/render/ascii_scaler.rs ================================================ 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, self.options.clone()); engine.render(slide.iter_operations())?; } thread::spawn(move || Self::scale(collector.images)); Ok(()) } fn scale(images: Vec) { 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, } 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(_) | SetCursorBoundaries { .. } => (), }; Ok(()) } fn cursor_row(&self) -> u16 { self.current_row } } ================================================ FILE: src/render/engine.rs ================================================ use super::{ RenderError, RenderResult, layout::Layout, operation::ImagePosition, properties::CursorPosition, text::TextDrawer, }; use crate::{ config::{MaxColumnsAlignment, MaxRowsAlignment}, markdown::{text::WeightedLine, text_style::Colors}, render::{ operation::{ AsRenderOperations, BlockLine, ImageRenderProperties, ImageSize, LayoutGrid, MarginProperties, RenderAsync, RenderOperation, }, properties::WindowSize, }, terminal::{ image::{ Image, printer::{ImageProperties, PrintOptions}, scale::{ImageScaler, ScaleImage}, }, printer::{TerminalCommand, TerminalIo}, }, theme::{Alignment, Margin}, }; 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, Default, Debug)] pub(crate) struct RenderEngineOptions { pub(crate) validate_overflows: bool, pub(crate) max_size: MaxSize, } pub(crate) struct RenderEngine<'a, T> where T: TerminalIo, { terminal: &'a mut T, window_rects: Vec, colors: Colors, max_modified_row: u16, layout: LayoutState, options: RenderEngineOptions, image_scaler: Box, } impl<'a, T> RenderEngine<'a, T> where T: TerminalIo, { pub(crate) fn new(terminal: &'a mut T, window_dimensions: WindowSize, options: RenderEngineOptions) -> Self { let max_modified_row = terminal.cursor_row(); let current_rect = Self::starting_rect(window_dimensions, &options); let window_rects = vec![current_rect.clone()]; Self { terminal, window_rects, colors: Default::default(), max_modified_row, layout: Default::default(), options, image_scaler: Box::::default(), } } fn starting_rect(mut dimensions: WindowSize, options: &RenderEngineOptions) -> WindowRect { let mut start_row = 0; let mut start_column = 0; if dimensions.columns > options.max_size.max_columns { let extra_width = dimensions.columns - options.max_size.max_columns; dimensions = dimensions.shrink_columns(extra_width); start_column = match options.max_size.max_columns_alignment { MaxColumnsAlignment::Left => 0, MaxColumnsAlignment::Center => extra_width / 2, MaxColumnsAlignment::Right => extra_width, }; } 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) -> RenderResult { let current_rect = self.current_rect().clone(); self.terminal.execute(&TerminalCommand::SetCursorBoundaries { rows: current_rect.dimensions.rows.saturating_add(current_rect.start_row), })?; self.terminal.execute(&TerminalCommand::BeginUpdate)?; if current_rect.start_row != 0 || current_rect.start_column != 0 { self.terminal .execute(&TerminalCommand::MoveTo { column: current_rect.start_column, row: current_rect.start_row })?; } for operation in operations { self.render_one(operation)?; } self.terminal.execute(&TerminalCommand::EndUpdate)?; self.terminal.execute(&TerminalCommand::Flush)?; if self.options.validate_overflows && self.max_modified_row > self.window_rects[0].dimensions.rows { return Err(RenderError::VerticalOverflow); } Ok(()) } fn render_one(&mut self, operation: &RenderOperation) -> RenderResult { match operation { RenderOperation::ClearScreen => self.clear_screen(), RenderOperation::ApplyMargin(properties) => self.apply_margin(properties), RenderOperation::PopMargin => self.pop_margin(), RenderOperation::SetColors(colors) => self.set_colors(colors), RenderOperation::JumpToVerticalCenter => self.jump_to_vertical_center(), RenderOperation::JumpToRow { index } => self.jump_to_row(*index), RenderOperation::JumpToBottomRow { index } => self.jump_to_bottom(*index), RenderOperation::JumpToColumn { index } => self.jump_to_column(*index), RenderOperation::RenderText { line, alignment } => self.render_text(line, *alignment), RenderOperation::RenderLineBreak => self.render_line_break(), RenderOperation::RenderImage(image, properties) => self.render_image(image, properties), RenderOperation::RenderBlockLine(operation) => self.render_block_line(operation), RenderOperation::RenderDynamic(generator) => self.render_dynamic(generator.as_ref()), RenderOperation::RenderDynamicTopLevel(generator) => self.render_dynamic_top_level(generator.as_ref()), RenderOperation::RenderAsync(generator) => self.render_async(generator.as_ref()), RenderOperation::InitColumnLayout { columns, grid, margin } => { self.init_column_layout(columns, *grid, *margin) } RenderOperation::EnterColumn { column } => self.enter_column(*column), RenderOperation::ExitLayout => self.exit_layout(), }?; if let LayoutState::EnteredColumn { column, columns, .. } = &mut self.layout { columns[*column].current_row = self.terminal.cursor_row(); }; self.max_modified_row = self.max_modified_row.max(self.terminal.cursor_row()); Ok(()) } fn current_rect(&self) -> &WindowRect { // This invariant is enforced when popping. self.window_rects.last().expect("no rects") } fn current_dimensions(&self) -> &WindowSize { &self.current_rect().dimensions } fn current_available_dimensions(&self) -> WindowSize { let rect = self.current_rect(); rect.dimensions.shrink_rows(self.terminal.cursor_row()) } 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.max_modified_row = 0; Ok(()) } fn apply_margin(&mut self, properties: &MarginProperties) -> RenderResult { let MarginProperties { horizontal: horizontal_margin, top, bottom } = properties; let current = self.current_rect(); let margin = horizontal_margin.as_characters(current.dimensions.columns); let new_rect = current.shrink_horizontal(margin).shrink_bottom(*bottom).shrink_top(*top); if new_rect.start_row != self.terminal.cursor_row() { self.terminal.execute(&TerminalCommand::MoveToRow(new_rect.start_row))?; } self.window_rects.push(new_rect); Ok(()) } fn pop_margin(&mut self) -> RenderResult { if self.window_rects.len() == 1 { return Err(RenderError::PopDefaultScreen); } self.window_rects.pop(); Ok(()) } fn set_colors(&mut self, colors: &Colors) -> RenderResult { self.colors = *colors; self.apply_colors() } fn apply_colors(&mut self) -> RenderResult { self.terminal.execute(&TerminalCommand::SetColors(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))?; 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))?; 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))?; Ok(()) } fn jump_to_column(&mut self, column: u16) -> RenderResult { // Make this relative to the beginning of the current rect. let column = self.current_rect().start_column.saturating_add(column); self.terminal.execute(&TerminalCommand::MoveToColumn(column))?; Ok(()) } fn render_text(&mut self, text: &WeightedLine, alignment: Alignment) -> RenderResult { let layout = self.build_layout(alignment); let dimensions = self.current_dimensions(); let positioning = layout.compute(dimensions, text.width() as u16); let prefix = "".into(); let text_drawer = TextDrawer::new(&prefix, 0, text, positioning, &self.colors, MINIMUM_LINE_LENGTH)?; let center_newlines = matches!(alignment, Alignment::Center { .. }); let text_drawer = text_drawer.center_newlines(center_newlines); text_drawer.draw(self.terminal)?; // Restore colors self.apply_colors() } fn render_line_break(&mut self) -> RenderResult { self.terminal.execute(&TerminalCommand::MoveToNextLine)?; 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 (width, height) = image.image().dimensions(); let (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) } ImageSize::Specific(columns, rows) => (columns, rows), ImageSize::WidthScaled { ratio } => { let extra_columns = (rect.dimensions.columns as f64 * (1.0 - ratio)).ceil() as u16; let dimensions = rect.dimensions.shrink_columns(extra_columns); let image_scale = self.image_scaler.scale_image(&dimensions, &rect.dimensions, width, height, &starting_cursor); (image_scale.columns, image_scale.rows) } }; let cursor = match &properties.position { ImagePosition::Cursor => starting_cursor.clone(), ImagePosition::Center => Self::center_cursor(columns, &rect.dimensions, &starting_cursor), ImagePosition::Right => Self::align_cursor_right(columns, &rect.dimensions, &starting_cursor), }; self.terminal.execute(&TerminalCommand::MoveToColumn(cursor.column))?; let options = PrintOptions { columns, rows, 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 })?; if properties.restore_cursor { self.terminal.execute(&TerminalCommand::MoveTo { column: starting_cursor.column, row: starting_row })?; } else { self.terminal.execute(&TerminalCommand::MoveToRow(starting_row + rows))?; } self.apply_colors() } fn center_cursor(columns: u16, window: &WindowSize, cursor: &CursorPosition) -> CursorPosition { let start_column = window.columns / 2 - (columns / 2); let start_column = start_column + cursor.column; CursorPosition { row: cursor.row, column: start_column } } fn align_cursor_right(columns: u16, window: &WindowSize, cursor: &CursorPosition) -> CursorPosition { let start_column = window.columns.saturating_sub(columns).saturating_add(cursor.column); CursorPosition { row: cursor.row, column: start_column } } fn render_block_line(&mut self, operation: &BlockLine) -> RenderResult { let BlockLine { text, block_length, alignment, block_color, prefix, right_padding_length, repeat_prefix_on_wrap, } = operation; let layout = self.build_layout(*alignment).with_font_size(text.font_size()); let dimensions = self.current_dimensions(); let positioning = layout.compute(dimensions, *block_length); if self.options.validate_overflows && text.width() as u16 > positioning.max_line_length { return Err(RenderError::HorizontalOverflow); } self.terminal.execute(&TerminalCommand::MoveToColumn(positioning.start_column))?; let text_drawer = TextDrawer::new(prefix, *right_padding_length, text, positioning, &self.colors, MINIMUM_LINE_LENGTH)? .with_surrounding_block(*block_color) .repeat_prefix_on_wrap(*repeat_prefix_on_wrap); text_drawer.draw(self.terminal)?; // Restore colors self.apply_colors()?; Ok(()) } fn render_dynamic(&mut self, generator: &dyn AsRenderOperations) -> RenderResult { let operations = generator.as_render_operations(&self.current_available_dimensions()); for operation in operations { self.render_one(&operation)?; } Ok(()) } fn render_dynamic_top_level(&mut self, generator: &dyn AsRenderOperations) -> RenderResult { let dimensions = self.window_rects.first().expect("no rects").dimensions; let operations = generator.as_render_operations(&dimensions); for operation in operations { self.render_one(&operation)?; } Ok(()) } fn render_async(&mut self, generator: &dyn RenderAsync) -> RenderResult { let operations = generator.as_render_operations(&self.current_available_dimensions()); for operation in operations { self.render_one(&operation)?; } Ok(()) } fn init_column_layout(&mut self, widths: &[u8], grid: LayoutGrid, margin: Margin) -> RenderResult { if !matches!(self.layout, LayoutState::Default) { self.exit_layout()?; } let current_row = self.terminal.cursor_row(); let rect = self.current_rect(); let total_units = widths.iter().copied().map(u16::from).sum::(); let total_columns = rect.dimensions.columns; let unit_columns = total_columns as f64 / total_units as f64; let mut columns = Vec::new(); let mut current_column = rect.start_column; for width in widths { let width = (f64::from(*width) * unit_columns) as u16; let end_column = current_column + width; columns.push(Column { start_column: current_column, end_column, current_row }); current_column = end_column; } self.layout = LayoutState::InitializedColumn { columns, grid, margin, start_row: current_row }; Ok(()) } fn enter_column(&mut self, column_index: usize) -> RenderResult { let (columns, margin, grid, start_row) = match mem::take(&mut self.layout) { LayoutState::Default => return Err(RenderError::InvalidLayoutEnter), LayoutState::InitializedColumn { columns, .. } | LayoutState::EnteredColumn { columns, .. } if column_index >= columns.len() => { return Err(RenderError::InvalidLayoutEnter); } LayoutState::InitializedColumn { columns, margin, grid, start_row } => (columns, margin, grid, start_row), LayoutState::EnteredColumn { columns, margin, grid, start_row, .. } => { // Pop this one and start clean self.pop_margin()?; (columns, margin, grid, start_row) } }; let column = &columns[column_index]; let current_rect = self.current_rect(); let total_columns = column.end_column.saturating_sub(column.start_column); let new_size = current_rect .dimensions .set_columns(total_columns) .shrink_rows(column.current_row.saturating_sub(current_rect.start_row)); let mut dimensions = WindowRect { dimensions: new_size, start_column: column.start_column, start_row: column.current_row }; let total_margin = margin.as_characters(self.window_rects[0].dimensions.columns); if column_index == 0 { dimensions = dimensions.shrink_right(total_margin); } else if column_index == columns.len() - 1 { dimensions = dimensions.shrink_left(total_margin); } else { let margin = total_margin / 2; dimensions = dimensions.shrink_left(margin).shrink_right(margin); } self.window_rects.push(dimensions); self.terminal.execute(&TerminalCommand::MoveToRow(column.current_row))?; self.layout = LayoutState::EnteredColumn { column: column_index, columns, margin, grid, start_row }; Ok(()) } fn exit_layout(&mut self) -> RenderResult { match &self.layout { LayoutState::Default => return Ok(()), LayoutState::InitializedColumn { columns, grid, start_row, .. } | LayoutState::EnteredColumn { columns, grid, start_row, .. } => { if let LayoutGrid::Draw(style) = grid { let style = *style; let max_row = columns.iter().map(|c| c.current_row).max().unwrap_or(0); for row in *start_row..=max_row { self.terminal.execute(&TerminalCommand::MoveToRow(row))?; for column in columns.iter().skip(1) { self.terminal.execute(&TerminalCommand::MoveToColumn(column.start_column))?; self.terminal.execute(&TerminalCommand::PrintText { content: "│", style })?; } } self.apply_colors()?; } } }; match &self.layout { LayoutState::Default | LayoutState::InitializedColumn { .. } => Ok(()), LayoutState::EnteredColumn { .. } => { self.terminal.execute(&TerminalCommand::MoveTo { column: 0, row: self.max_modified_row })?; self.layout = LayoutState::Default; self.pop_margin()?; Ok(()) } } } fn build_layout(&self, alignment: Alignment) -> Layout { Layout::new(alignment).with_start_column(self.current_rect().start_column) } } #[derive(Default)] enum LayoutState { #[default] Default, InitializedColumn { start_row: u16, columns: Vec, margin: Margin, grid: LayoutGrid, }, EnteredColumn { start_row: u16, column: usize, columns: Vec, margin: Margin, grid: LayoutGrid, }, } #[derive(Debug)] struct Column { start_column: u16, end_column: u16, current_row: u16, } #[derive(Clone, Debug)] struct WindowRect { dimensions: WindowSize, start_column: u16, start_row: u16, } impl WindowRect { fn shrink_horizontal(&self, margin: u16) -> Self { let dimensions = self.dimensions.shrink_columns(margin.saturating_mul(2)); let start_column = self.start_column + margin; Self { dimensions, start_column, start_row: self.start_row } } fn shrink_left(&self, size: u16) -> Self { let dimensions = self.dimensions.shrink_columns(size); let start_column = self.start_column.saturating_add(size); Self { dimensions, start_column, start_row: self.start_row } } fn shrink_right(&self, size: u16) -> Self { let dimensions = self.dimensions.shrink_columns(size); Self { dimensions, start_column: self.start_column, start_row: self.start_row } } 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 } } fn shrink_bottom(&self, rows: u16) -> Self { let dimensions = self.dimensions.shrink_rows(rows); Self { dimensions, start_column: self.start_column, start_row: self.start_row } } } #[cfg(test)] mod tests { use super::*; use crate::{ markdown::text_style::{Color, TextStyle}, terminal::{ image::{ ImageSource, printer::{PrintImageError, TerminalImage}, scale::TerminalRect, }, printer::TerminalError, }, theme::Margin, }; use ::image::{ColorType, DynamicImage}; use rstest::rstest; use std::io; use unicode_width::UnicodeWidthStr; #[derive(Debug, PartialEq)] enum Instruction { MoveTo(u16, u16), MoveToRow(u16), MoveToColumn(u16), MoveDown(u16), MoveRight(u16), MoveLeft(u16), MoveToNextLine, PrintText(String), ClearScreen, SetBackgroundColor(Color), PrintImage(PrintOptions), } #[derive(Default)] struct TerminalBuf { instructions: Vec, cursor_row: u16, } impl TerminalBuf { fn push(&mut self, instruction: Instruction) -> io::Result<()> { self.instructions.push(instruction); Ok(()) } fn move_to(&mut self, column: u16, row: u16) -> io::Result<()> { self.cursor_row = row; self.push(Instruction::MoveTo(column, row)) } fn move_to_row(&mut self, row: u16) -> io::Result<()> { self.cursor_row = row; self.push(Instruction::MoveToRow(row)) } fn move_to_column(&mut self, column: u16) -> io::Result<()> { self.push(Instruction::MoveToColumn(column)) } fn move_down(&mut self, amount: u16) -> io::Result<()> { self.push(Instruction::MoveDown(amount)) } fn move_right(&mut self, amount: u16) -> io::Result<()> { self.push(Instruction::MoveRight(amount)) } fn move_left(&mut self, amount: u16) -> io::Result<()> { self.push(Instruction::MoveLeft(amount)) } fn move_to_next_line(&mut self) -> io::Result<()> { self.push(Instruction::MoveToNextLine) } fn print_text(&mut self, content: &str, _style: &TextStyle) -> io::Result<()> { let content = content.to_string(); if content.is_empty() { return Ok(()); } self.cursor_row = content.width() as u16; self.push(Instruction::PrintText(content)) } fn clear_screen(&mut self) -> io::Result<()> { self.cursor_row = 0; self.push(Instruction::ClearScreen) } fn set_colors(&mut self, _colors: Colors) -> io::Result<()> { Ok(()) } fn set_background_color(&mut self, color: Color) -> io::Result<()> { self.push(Instruction::SetBackgroundColor(color)) } fn flush(&mut self) -> io::Result<()> { Ok(()) } fn print_image(&mut self, _image: &Image, options: &PrintOptions) -> Result<(), PrintImageError> { let _ = self.push(Instruction::PrintImage(options.clone())); Ok(()) } } impl TerminalIo for TerminalBuf { fn execute(&mut self, command: &TerminalCommand<'_>) -> Result<(), TerminalError> { use TerminalCommand::*; match command { BeginUpdate => (), EndUpdate => (), MoveTo { column, row } => self.move_to(*column, *row)?, MoveToRow(row) => self.move_to_row(*row)?, MoveToColumn(column) => self.move_to_column(*column)?, MoveDown(amount) => self.move_down(*amount)?, MoveRight(amount) => self.move_right(*amount)?, MoveLeft(amount) => self.move_left(*amount)?, MoveToNextLine => self.move_to_next_line()?, PrintText { content, style } => self.print_text(content, style)?, ClearScreen => self.clear_screen()?, SetColors(colors) => self.set_colors(*colors)?, SetBackgroundColor(color) => self.set_background_color(*color)?, Flush => self.flush()?, PrintImage { image, options } => self.print_image(image, options)?, SetCursorBoundaries { .. } => (), }; Ok(()) } fn cursor_row(&self) -> u16 { self.cursor_row } } struct DummyImageScaler; impl ScaleImage for DummyImageScaler { fn scale_image( &self, _scale_size: &WindowSize, _window_dimensions: &WindowSize, image_width: u32, image_height: u32, _position: &CursorPosition, ) -> TerminalRect { TerminalRect { rows: image_width as u16, columns: image_height as u16 } } fn fit_image_to_rect( &self, _dimensions: &WindowSize, image_width: u32, image_height: u32, _position: &CursorPosition, ) -> TerminalRect { TerminalRect { rows: image_width as u16, columns: image_height as u16 } } } fn do_render(max_size: MaxSize, operations: &[RenderOperation]) -> Vec { let mut buf = TerminalBuf::default(); let dimensions = WindowSize { rows: 100, columns: 100, height: 200, width: 200 }; let options = RenderEngineOptions { validate_overflows: false, max_size }; 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 { do_render(Default::default(), operations) } fn render_with_max_size(operations: &[RenderOperation]) -> Vec { let max_size = MaxSize { max_rows: 10, max_rows_alignment: MaxRowsAlignment::Center, max_columns: 20, max_columns_alignment: MaxColumnsAlignment::Center, }; do_render(max_size, operations) } #[test] fn columns() { let ops = render(&[ RenderOperation::InitColumnLayout { columns: vec![1, 1], grid: LayoutGrid::None, margin: Default::default(), }, // print on column 0 RenderOperation::EnterColumn { column: 0 }, RenderOperation::RenderText { line: "A".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } }, // print on column 1 RenderOperation::EnterColumn { column: 1 }, RenderOperation::RenderText { line: "B".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } }, // go back to column 0 and print RenderOperation::EnterColumn { column: 0 }, RenderOperation::RenderText { line: "1".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } }, ]); let expected = [ Instruction::MoveToRow(0), Instruction::MoveToColumn(0), Instruction::PrintText("A".into()), Instruction::MoveToRow(0), Instruction::MoveToColumn(50), Instruction::PrintText("B".into()), // when we go back we should proceed from where we left off (row == 1) Instruction::MoveToRow(1), Instruction::MoveToColumn(0), Instruction::PrintText("1".into()), ]; assert_eq!(ops, expected); } #[test] fn bottom_margin() { let ops = render(&[ RenderOperation::ApplyMargin(MarginProperties { horizontal: Margin::Fixed(1), top: 0, bottom: 10 }), RenderOperation::RenderText { line: "A".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } }, RenderOperation::JumpToBottomRow { index: 0 }, RenderOperation::RenderText { line: "B".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } }, ]); let expected = [ Instruction::MoveToColumn(1), Instruction::PrintText("A".into()), // 100 - 10 (bottom margin) Instruction::MoveToRow(89), Instruction::MoveToColumn(1), Instruction::PrintText("B".into()), ]; assert_eq!(ops, expected); } #[test] fn top_margin() { let ops = render(&[ RenderOperation::ApplyMargin(MarginProperties { horizontal: Margin::Fixed(1), top: 3, bottom: 0 }), RenderOperation::RenderText { line: "A".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } }, ]); let expected = [Instruction::MoveToRow(3), Instruction::MoveToColumn(1), Instruction::PrintText("A".into())]; assert_eq!(ops, expected); } #[test] fn margins() { let ops = render(&[ RenderOperation::ApplyMargin(MarginProperties { horizontal: Margin::Fixed(1), top: 3, bottom: 10 }), RenderOperation::JumpToRow { index: 0 }, RenderOperation::RenderText { line: "A".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } }, RenderOperation::JumpToBottomRow { index: 0 }, RenderOperation::RenderText { line: "B".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } }, ]); let expected = [ Instruction::MoveToRow(3), Instruction::MoveToRow(3), Instruction::MoveToColumn(1), Instruction::PrintText("A".into()), // 100 - 10 (bottom margin) Instruction::MoveToRow(89), Instruction::MoveToColumn(1), Instruction::PrintText("B".into()), ]; assert_eq!(ops, expected); } #[test] fn nested_margins() { let ops = render(&[ RenderOperation::ApplyMargin(MarginProperties { horizontal: Margin::Fixed(1), top: 0, bottom: 10 }), RenderOperation::ApplyMargin(MarginProperties { horizontal: Margin::Fixed(1), top: 0, bottom: 10 }), RenderOperation::RenderText { line: "A".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } }, RenderOperation::JumpToBottomRow { index: 0 }, RenderOperation::RenderText { line: "B".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } }, // pop and go to bottom, this should go back up to the end of the first margin RenderOperation::PopMargin, RenderOperation::JumpToBottomRow { index: 0 }, RenderOperation::RenderText { line: "C".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } }, ]); let expected = [ Instruction::MoveToColumn(2), Instruction::PrintText("A".into()), // 100 - 10 (margin) - 10 (second margin) Instruction::MoveToRow(79), Instruction::MoveToColumn(2), Instruction::PrintText("B".into()), // 100 - 10 (margin) Instruction::MoveToRow(89), Instruction::MoveToColumn(1), Instruction::PrintText("C".into()), ]; assert_eq!(ops, expected); } #[test] fn margin_with_max_size() { let ops = render_with_max_size(&[ RenderOperation::RenderText { line: "A".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } }, RenderOperation::ApplyMargin(MarginProperties { horizontal: Margin::Fixed(1), top: 2, bottom: 1 }), RenderOperation::RenderText { line: "B".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } }, RenderOperation::JumpToBottomRow { index: 0 }, RenderOperation::RenderText { line: "C".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } }, ]); let expected = [ // centered 20x10 Instruction::MoveTo(40, 45), Instruction::MoveToColumn(40), Instruction::PrintText("A".into()), // jump 2 down because of top margin Instruction::MoveToRow(47), // jump 1 right because of horizontal margin Instruction::MoveToColumn(41), Instruction::PrintText("B".into()), // rows go from 47 to 53 (7 total) Instruction::MoveToRow(53), Instruction::MoveToColumn(41), Instruction::PrintText("C".into()), ]; assert_eq!(ops, expected); } // print the same 2x2 image with all size configs, they should all yield the same #[rstest] #[case::shrink(ImageSize::ShrinkIfNeeded)] #[case::specific(ImageSize::Specific(2, 2))] #[case::width_scaled(ImageSize::WidthScaled { ratio: 1.0 })] fn image(#[case] size: ImageSize) { let image = DynamicImage::new(2, 2, ColorType::Rgba8); let image = Image::new(TerminalImage::Ascii(image.into()), ImageSource::Generated); let properties = ImageRenderProperties { z_index: 0, size, restore_cursor: false, background_color: None, position: ImagePosition::Cursor, }; let ops = render_with_max_size(&[RenderOperation::RenderImage(image, properties)]); let expected = [ // centered 20x10, the image is 2x2 so we stand one away from center Instruction::MoveTo(40, 45), Instruction::MoveToColumn(40), Instruction::PrintImage(PrintOptions { columns: 2, rows: 2, z_index: 0, background_color: None, column_width: 2, row_height: 2, }), // place cursor after the image Instruction::MoveToRow(47), ]; assert_eq!(ops, expected); } // same as the above but center it #[rstest] #[case::shrink(ImageSize::ShrinkIfNeeded)] #[case::specific(ImageSize::Specific(2, 2))] #[case::width_scaled(ImageSize::WidthScaled { ratio: 1.0 })] fn centered_image(#[case] size: ImageSize) { let image = DynamicImage::new(2, 2, ColorType::Rgba8); let image = Image::new(TerminalImage::Ascii(image.into()), ImageSource::Generated); let properties = ImageRenderProperties { z_index: 0, size, restore_cursor: false, background_color: None, position: ImagePosition::Center, }; let ops = render_with_max_size(&[RenderOperation::RenderImage(image, properties)]); let expected = [ // centered 20x10, the image is 2x2 so we stand one away from center Instruction::MoveTo(40, 45), Instruction::MoveToColumn(49), Instruction::PrintImage(PrintOptions { columns: 2, rows: 2, z_index: 0, background_color: None, column_width: 2, row_height: 2, }), // place cursor after the image Instruction::MoveToRow(47), ]; assert_eq!(ops, expected); } // same as the above but use right alignment #[rstest] #[case::shrink(ImageSize::ShrinkIfNeeded)] #[case::specific(ImageSize::Specific(2, 2))] #[case::width_scaled(ImageSize::WidthScaled { ratio: 1.0 })] fn right_aligned_image(#[case] size: ImageSize) { let image = DynamicImage::new(2, 2, ColorType::Rgba8); let image = Image::new(TerminalImage::Ascii(image.into()), ImageSource::Generated); let properties = ImageRenderProperties { z_index: 0, size, restore_cursor: false, background_color: None, position: ImagePosition::Right, }; let ops = render_with_max_size(&[RenderOperation::RenderImage(image, properties)]); let expected = [ // right aligned 20x10, the image is 2x2 so we stand one away from the right Instruction::MoveTo(40, 45), Instruction::MoveToColumn(58), Instruction::PrintImage(PrintOptions { columns: 2, rows: 2, z_index: 0, background_color: None, column_width: 2, row_height: 2, }), // place cursor after the image Instruction::MoveToRow(47), ]; assert_eq!(ops, expected); } // same as the above but center it #[rstest] fn restore_cursor_after_image() { let image = DynamicImage::new(2, 2, ColorType::Rgba8); let image = Image::new(TerminalImage::Ascii(image.into()), ImageSource::Generated); let properties = ImageRenderProperties { z_index: 0, size: ImageSize::ShrinkIfNeeded, restore_cursor: true, background_color: None, position: ImagePosition::Center, }; let ops = render_with_max_size(&[RenderOperation::RenderImage(image, properties)]); let expected = [ // centered 20x10, the image is 2x2 so we stand one away from center Instruction::MoveTo(40, 45), Instruction::MoveToColumn(49), Instruction::PrintImage(PrintOptions { columns: 2, rows: 2, z_index: 0, background_color: None, column_width: 2, row_height: 2, }), // place cursor after the image Instruction::MoveTo(40, 45), ]; assert_eq!(ops, expected); } } ================================================ FILE: src/render/layout.rs ================================================ use crate::{render::properties::WindowSize, theme::Alignment}; #[derive(Debug)] pub(crate) struct Layout { alignment: Alignment, start_column_offset: u16, font_size: u16, } impl Layout { pub(crate) fn new(alignment: Alignment) -> Self { Self { alignment, start_column_offset: 0, font_size: 1 } } pub(crate) fn with_start_column(mut self, column: u16) -> Self { self.start_column_offset = column; self } pub(crate) fn with_font_size(mut self, font_size: u8) -> Self { self.font_size = font_size as u16; self } pub(crate) fn compute(&self, dimensions: &WindowSize, text_length: u16) -> Positioning { let text_length = text_length * self.font_size; let max_line_length; let mut start_column; match &self.alignment { Alignment::Left { margin } => { let margin = margin.as_characters(dimensions.columns); // Ignore the margin if it's larger than the screen: we can't satisfy it so we // might as well not do anything about it. let margin = Self::fit_to_columns(dimensions, margin.saturating_mul(2), margin); start_column = margin; max_line_length = dimensions.columns - margin.saturating_mul(2); } Alignment::Right { margin } => { let margin = margin.as_characters(dimensions.columns); let margin = Self::fit_to_columns(dimensions, margin.saturating_mul(2), margin); start_column = dimensions.columns.saturating_sub(margin).saturating_sub(text_length).max(margin); max_line_length = (dimensions.columns - margin) - start_column; } Alignment::Center { minimum_margin, minimum_size } => { let minimum_margin = minimum_margin.as_characters(dimensions.columns); // Respect minimum size as much as we can if both together overflow. let minimum_size = dimensions.columns.min(*minimum_size); let minimum_margin = Self::fit_to_columns( dimensions, minimum_margin.saturating_mul(2).saturating_add(minimum_size), minimum_margin, ); max_line_length = text_length.min(dimensions.columns - minimum_margin.saturating_mul(2)).max(minimum_size); if max_line_length > dimensions.columns { start_column = minimum_margin; } else { start_column = (dimensions.columns - max_line_length) / 2; start_column = start_column.max(minimum_margin); } } }; start_column += self.start_column_offset; Positioning { max_line_length, start_column } } fn fit_to_columns(dimensions: &WindowSize, required_fit: u16, actual_fit: u16) -> u16 { if required_fit > dimensions.columns { 0 } else { actual_fit } } } #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct Positioning { pub(crate) max_line_length: u16, pub(crate) start_column: u16, } #[cfg(test)] mod test { use super::*; use crate::theme::Margin; use rstest::rstest; #[rstest] #[case::left_no_margin( Alignment::Left{ margin: Margin::Fixed(0) }, 10, Positioning{ max_line_length: 100, start_column: 0 } )] #[case::left_some_margin( Alignment::Left{ margin: Margin::Fixed(5) }, 10, Positioning{ max_line_length: 90, start_column: 5 } )] #[case::left_line_overflows( Alignment::Left{ margin: Margin::Fixed(5) }, 150, Positioning{ max_line_length: 90, start_column: 5 } )] #[case::left_large_margin( Alignment::Left{ margin: Margin::Fixed(60) }, 10, Positioning{ max_line_length: 100, start_column: 0 } )] #[case::left_margin_too_large( Alignment::Left{ margin: Margin::Fixed(105) }, 10, Positioning{ max_line_length: 100, start_column: 0 } )] #[case::right_no_margin( Alignment::Right{ margin: Margin::Fixed(0) }, 10, Positioning{ max_line_length: 10, start_column: 90 } )] #[case::right_some_margin( Alignment::Right{ margin: Margin::Fixed(5) }, 10, Positioning{ max_line_length: 10, start_column: 85 } )] #[case::right_line_overflows( Alignment::Right{ margin: Margin::Fixed(5) }, 150, Positioning{ max_line_length: 90, start_column: 5 } )] #[case::right_large_margin( Alignment::Right{ margin: Margin::Fixed(60) }, 10, Positioning{ max_line_length: 10, start_column: 90 } )] #[case::right_margin_too_large( Alignment::Right{ margin: Margin::Fixed(105) }, 10, Positioning{ max_line_length: 10, start_column: 90 } )] #[case::center_no_minimums( Alignment::Center{ minimum_margin: Margin::Fixed(0), minimum_size: 0 }, 10, Positioning{ max_line_length: 10, start_column: 45 } )] #[case::center_minimum_margin( Alignment::Center{ minimum_margin: Margin::Fixed(10), minimum_size: 0 }, 100, Positioning{ max_line_length: 80, start_column: 10 } )] #[case::center_minimum_size( Alignment::Center{ minimum_margin: Margin::Fixed(0), minimum_size: 50 }, 10, Positioning{ max_line_length: 50, start_column: 25 } )] #[case::center_large_minimum_margin( Alignment::Center{ minimum_margin: Margin::Fixed(60), minimum_size: 0 }, 10, Positioning{ max_line_length: 10, start_column: 45 } )] #[case::center_minimum_margin_too_large( Alignment::Center{ minimum_margin: Margin::Fixed(105), minimum_size: 0 }, 10, Positioning{ max_line_length: 10, start_column: 45 } )] #[case::center_minimum_size_too_large( Alignment::Center{ minimum_margin: Margin::Fixed(0), minimum_size: 105 }, 10, Positioning{ max_line_length: 100, start_column: 0 } )] #[case::center_margin_and_size_overflows( Alignment::Center{ minimum_margin: Margin::Fixed(30), minimum_size: 60 }, 10, Positioning{ max_line_length: 60, start_column: 20 } )] fn layout(#[case] alignment: Alignment, #[case] length: u16, #[case] expected: Positioning) { let dimensions = WindowSize { rows: 0, columns: 100, width: 0, height: 0 }; let positioning = Layout::new(alignment).compute(&dimensions, length); assert_eq!(positioning, expected); } } ================================================ FILE: src/render/mod.rs ================================================ pub(crate) mod ascii_scaler; pub(crate) mod engine; pub(crate) mod layout; pub(crate) mod operation; pub(crate) mod properties; pub(crate) mod text; pub(crate) mod validate; use crate::{ markdown::{ elements::Text, text::WeightedLine, text_style::{Color, Colors, PaletteColorError, TextStyle}, }, render::{operation::RenderOperation, properties::WindowSize}, terminal::{ Terminal, ansi::AnsiParser, image::printer::{ImagePrinter, PrintImageError}, printer::TerminalError, }, theme::Margin, }; use engine::{MaxSize, RenderEngine, RenderEngineOptions}; use operation::{AsRenderOperations, MarginProperties}; use std::{ io::{self, Stdout}, iter, rc::Rc, sync::Arc, }; /// The result of a render operation. pub(crate) type RenderResult = Result<(), RenderError>; pub(crate) struct TerminalDrawerOptions { pub(crate) font_size_fallback: u8, pub(crate) max_size: MaxSize, } impl Default for TerminalDrawerOptions { fn default() -> Self { Self { font_size_fallback: 1, max_size: Default::default() } } } /// Allows drawing on the terminal. pub(crate) struct TerminalDrawer { pub(crate) terminal: Terminal, options: TerminalDrawerOptions, } impl TerminalDrawer { pub(crate) fn new(image_printer: Arc, options: TerminalDrawerOptions) -> io::Result { let terminal = Terminal::new(io::stdout(), image_printer)?; Ok(Self { terminal, options }) } pub(crate) fn render_operations<'a>( &mut self, operations: impl Iterator, ) -> RenderResult { let dimensions = WindowSize::current(self.options.font_size_fallback)?; let engine = self.create_engine(dimensions); engine.render(operations)?; Ok(()) } pub(crate) fn render_error(&mut self, message: &str, source: &ErrorSource) -> RenderResult { let (lines, _) = AnsiParser::new(Default::default()).parse_lines(message.lines()); let lines = lines.into_iter().map(Into::into).collect(); let operation = RenderErrorOperation { lines, source: source.clone() }; let operation = RenderOperation::RenderDynamic(Rc::new(operation)); let dimensions = WindowSize::current(self.options.font_size_fallback)?; let engine = self.create_engine(dimensions); engine.render(iter::once(&operation))?; Ok(()) } pub(crate) fn render_engine_options(&self) -> RenderEngineOptions { RenderEngineOptions { max_size: self.options.max_size.clone(), ..Default::default() } } fn create_engine(&mut self, dimensions: WindowSize) -> RenderEngine<'_, Terminal> { let options = self.render_engine_options(); RenderEngine::new(&mut self.terminal, dimensions, options) } } /// A rendering error. #[derive(thiserror::Error, Debug)] pub(crate) enum RenderError { #[error("io: {0}")] Io(#[from] io::Error), #[error("terminal: {0}")] Terminal(#[from] TerminalError), #[error("screen is too small")] TerminalTooSmall, #[error("tried to move to non existent layout location")] InvalidLayoutEnter, #[error("tried to pop default screen")] PopDefaultScreen, #[error("printing image: {0}")] PrintImage(#[from] PrintImageError), #[error("horizontal overflow")] HorizontalOverflow, #[error("vertical overflow")] VerticalOverflow, #[error(transparent)] PaletteColor(#[from] PaletteColorError), } #[derive(Clone, Debug)] pub(crate) enum ErrorSource { Presentation, Slide(usize), } #[derive(Debug)] struct RenderErrorOperation { lines: Vec, source: ErrorSource, } impl AsRenderOperations for RenderErrorOperation { fn as_render_operations(&self, dimensions: &WindowSize) -> Vec { 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().fg_color(Color::Red)), Text::from(": ")]; let content_width: u16 = self.lines.iter().map(|l| l.width()).max().unwrap_or_default().try_into().unwrap_or(u16::MAX); let minimum_margin = (dimensions.columns as f32 * 0.1) as u16; let margin = dimensions.columns.saturating_sub(content_width).max(minimum_margin) / 2; let total_lines = self.lines.len(); let starting_row = (dimensions.rows / 2).saturating_sub(total_lines as u16 / 2 + 3); let mut operations = vec![ RenderOperation::SetColors(Colors { background: Some(Color::Rgb { r: 0, g: 0, b: 0 }), foreground: Some(Color::White), }), RenderOperation::ClearScreen, RenderOperation::ApplyMargin(MarginProperties { horizontal: Margin::Fixed(margin), top: starting_row, bottom: 0, }), RenderOperation::RenderText { line: WeightedLine::from(heading), alignment: Default::default() }, RenderOperation::RenderLineBreak, RenderOperation::RenderLineBreak, ]; for line in self.lines.iter().cloned() { let op = RenderOperation::RenderText { line, alignment: Default::default() }; operations.extend([op, RenderOperation::RenderLineBreak]); } operations } } ================================================ FILE: src/render/operation.rs ================================================ use super::properties::WindowSize; use crate::{ markdown::{ text::{WeightedLine, WeightedText}, text_style::{Color, Colors, TextStyle}, }, terminal::image::Image, theme::{Alignment, Margin}, }; use std::{ fmt::Debug, rc::Rc, sync::{Arc, Mutex}, }; const DEFAULT_IMAGE_Z_INDEX: i32 = -2; /// A line of preformatted text to be rendered. #[derive(Clone, Debug, PartialEq)] pub(crate) struct BlockLine { pub(crate) prefix: WeightedText, pub(crate) right_padding_length: u16, pub(crate) repeat_prefix_on_wrap: bool, pub(crate) text: WeightedLine, pub(crate) block_length: u16, pub(crate) block_color: Option, pub(crate) alignment: Alignment, } /// A render operation. /// /// Render operations are primitives that allow the input markdown file to be decoupled with what /// we draw on the screen. #[derive(Clone, Debug)] pub(crate) enum RenderOperation { /// Clear the entire screen. ClearScreen, /// Set the colors to be used for any subsequent operations. SetColors(Colors), /// Jump the draw cursor into the vertical center, that is, at `screen_height / 2`. JumpToVerticalCenter, /// Jumps to the N-th row in the current layout. /// /// The index is zero based where 0 represents the top row. JumpToRow { index: u16 }, /// Jumps to the N-th to last row in the current layout. /// /// The index is zero based where 0 represents the bottom row. JumpToBottomRow { index: u16 }, /// Jump to the N-th column in the current layout. JumpToColumn { index: u16 }, /// Render text. RenderText { line: WeightedLine, alignment: Alignment }, /// Render a line break. RenderLineBreak, /// Render an image. RenderImage(Image, ImageRenderProperties), /// Render a line. RenderBlockLine(BlockLine), /// Render a dynamically generated sequence of render operations. /// /// This allows drawing something on the screen that requires knowing dynamic properties of the /// screen, like window size, without coupling the transformation of markdown into /// [RenderOperation] with the screen itself. RenderDynamic(Rc), /// Render a dynamically sequence of render operations drawing it at the top level margin RenderDynamicTopLevel(Rc), /// An operation that is rendered asynchronously. RenderAsync(Rc), /// Initialize a column layout. /// /// The value for each column is the width of the column in column-unit units, where the entire /// screen contains `columns.sum()` column-units. InitColumnLayout { columns: Vec, grid: LayoutGrid, margin: Margin }, /// Enter a column in a column layout. /// /// The index is 0-index based and will be tied to a previous `InitColumnLayout` operation. EnterColumn { column: usize }, /// Exit the current layout and go back to the default one. ExitLayout, /// Apply a margin to every following operation. ApplyMargin(MarginProperties), /// Pop an `ApplyMargin` operation. PopMargin, } /// Grid options for a layout. #[derive(Copy, Clone, Debug)] pub(crate) enum LayoutGrid { None, Draw(TextStyle), } /// The properties of an image being rendered. #[derive(Clone, Debug, PartialEq)] pub(crate) struct ImageRenderProperties { pub(crate) z_index: i32, pub(crate) size: ImageSize, pub(crate) restore_cursor: bool, pub(crate) background_color: Option, pub(crate) position: ImagePosition, } impl Default for ImageRenderProperties { fn default() -> Self { Self { z_index: DEFAULT_IMAGE_Z_INDEX, size: Default::default(), restore_cursor: false, background_color: None, position: ImagePosition::Center, } } } #[derive(Clone, Debug, PartialEq)] pub(crate) enum ImagePosition { Cursor, Center, Right, } /// The size used when printing an image. #[derive(Clone, Debug, Default, PartialEq)] pub(crate) enum ImageSize { #[default] ShrinkIfNeeded, Specific(u16, u16), WidthScaled { ratio: f64, }, } /// Slide properties, set on initialization. #[derive(Clone, Debug, Default)] pub(crate) struct MarginProperties { /// The horizontal margin. pub(crate) horizontal: Margin, /// The margin at the top. pub(crate) top: u16, /// The margin at the bottom. pub(crate) bottom: u16, } /// A type that can generate render operations. pub(crate) trait AsRenderOperations: Debug + 'static { /// Generate render operations. fn as_render_operations(&self, dimensions: &WindowSize) -> Vec; /// Get the content in this type to diff it against another `AsRenderOperations`. fn diffable_content(&self) -> Option<&str> { None } } /// An operation that can be rendered asynchronously. pub(crate) trait RenderAsync: AsRenderOperations { /// Create a pollable for this render async. /// /// 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; /// 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; } /// The state of a [Pollable]. #[derive(Clone, Debug, PartialEq)] pub(crate) enum PollableState { Unmodified, Modified, Done, Failed { error: String }, } impl PollableState { #[cfg(test)] pub(crate) fn is_completed(&self) -> bool { match self { Self::Unmodified | Self::Modified => false, Self::Done | Self::Failed { .. } => true, } } } pub(crate) struct ToggleState { toggled: Arc>, } impl ToggleState { pub(crate) fn new(toggled: Arc>) -> Self { Self { toggled } } } impl Pollable for ToggleState { fn poll(&mut self) -> PollableState { *self.toggled.lock().unwrap() = true; PollableState::Done } } ================================================ FILE: src/render/properties.rs ================================================ use crossterm::terminal; use std::io::{self, ErrorKind}; /// The size of the terminal window. /// /// This is the same as [crossterm::terminal::window_size] except with some added functionality, /// like implementing `Clone`. #[derive(Debug, Clone, Copy, PartialEq)] pub(crate) struct WindowSize { pub(crate) rows: u16, pub(crate) columns: u16, pub(crate) height: u16, pub(crate) width: u16, } impl WindowSize { /// Get the current window size. pub(crate) fn current(font_size_fallback: u8) -> io::Result { let mut size: Self = match terminal::window_size() { Ok(size) => size.into(), Err(e) if e.kind() == ErrorKind::Unsupported => { // Fall back to a `WindowSize` that doesn't have pixel support. let size = terminal::size()?; size.into() } Err(e) => return Err(e), }; let font_size_fallback = font_size_fallback as u16; if size.width == 0 { size.width = size.columns * font_size_fallback.max(1); } if size.height == 0 { size.height = size.rows * font_size_fallback.max(1) * 2; } Ok(size) } /// Shrink a window by the given number of rows. /// /// This preserves the relationship between rows and pixels. pub(crate) fn shrink_rows(&self, amount: u16) -> WindowSize { let pixels_per_row = self.pixels_per_row(); let height_to_shrink = (pixels_per_row * amount as f64) as u16; WindowSize { rows: self.rows.saturating_sub(amount), columns: self.columns, height: self.height.saturating_sub(height_to_shrink), width: self.width, } } /// Shrink a window by the given number of columns. /// /// This preserves the relationship between columns and pixels. pub(crate) fn shrink_columns(&self, amount: u16) -> WindowSize { let pixels_per_column = self.pixels_per_column(); let width_to_shrink = (pixels_per_column * amount as f64) as u16; WindowSize { rows: self.rows, columns: self.columns.saturating_sub(amount), height: self.height, width: self.width.saturating_sub(width_to_shrink), } } /// Set the column count. /// /// This preserves the relationship between columns and pixels. pub(crate) fn set_columns(&self, amount: u16) -> WindowSize { let pixels_per_column = self.pixels_per_column(); let width = (pixels_per_column * amount as f64) as u16; WindowSize { rows: self.rows, columns: amount, height: self.height, width } } /// The number of pixels per column. pub(crate) fn pixels_per_column(&self) -> f64 { self.width as f64 / self.columns as f64 } /// The number of pixels per row. pub(crate) fn pixels_per_row(&self) -> f64 { self.height as f64 / self.rows as f64 } /// The aspect ratio for this size. pub(crate) fn aspect_ratio(&self) -> f64 { (self.rows as f64 / self.height as f64) / (self.columns as f64 / self.width as f64) } } impl From for WindowSize { fn from(size: crossterm::terminal::WindowSize) -> Self { Self { rows: size.rows, columns: size.columns, width: size.width, height: size.height } } } impl From<(u16, u16)> for WindowSize { fn from((columns, rows): (u16, u16)) -> Self { Self { columns, rows, width: 0, height: 0 } } } /// The cursor's position. #[derive(Debug, Clone, Default, PartialEq)] pub(crate) struct CursorPosition { pub(crate) column: u16, pub(crate) row: u16, } #[cfg(test)] mod test { use super::*; #[test] fn shrink() { let dimensions = WindowSize { rows: 10, columns: 10, width: 200, height: 100 }; assert_eq!(dimensions.pixels_per_column(), 20.0); assert_eq!(dimensions.pixels_per_row(), 10.0); let new_dimensions = dimensions.shrink_rows(3); assert_eq!(new_dimensions.rows, 7); assert_eq!(new_dimensions.height, 70); let new_dimensions = new_dimensions.shrink_columns(3); assert_eq!(new_dimensions.columns, 7); assert_eq!(new_dimensions.width, 140); } } ================================================ FILE: src/render/text.rs ================================================ use crate::{ markdown::{ elements::Text, text::{WeightedLine, WeightedText}, text_style::{Color, Colors, TextStyle}, }, render::{RenderError, RenderResult, layout::Positioning}, terminal::printer::{TerminalCommand, TerminalIo}, }; /// Draws text on the screen. /// /// This deals with splitting words and doing word wrapping based on the given positioning. pub(crate) struct TextDrawer<'a> { prefix: &'a WeightedText, right_padding_length: u16, line: &'a WeightedLine, positioning: Positioning, prefix_width: u16, default_colors: &'a Colors, draw_block: bool, block_color: Option, repeat_prefix: bool, center_newlines: bool, } impl<'a> TextDrawer<'a> { pub(crate) fn new( prefix: &'a WeightedText, right_padding_length: u16, line: &'a WeightedLine, positioning: Positioning, default_colors: &'a Colors, minimum_line_length: u16, ) -> Result { let text_length = (line.width() + prefix.width() + right_padding_length as usize) as u16; // If our line doesn't fit and it's just too small then abort if text_length > positioning.max_line_length && positioning.max_line_length <= minimum_line_length { return Err(RenderError::TerminalTooSmall); } let prefix_width = prefix.width() as u16; let positioning = Positioning { max_line_length: positioning .max_line_length .saturating_sub(prefix_width) .saturating_sub(right_padding_length), start_column: positioning.start_column, }; Ok(Self { prefix, right_padding_length, line, positioning, prefix_width, default_colors, draw_block: false, block_color: None, repeat_prefix: false, center_newlines: false, }) } pub(crate) fn with_surrounding_block(mut self, block_color: Option) -> Self { self.draw_block = true; self.block_color = block_color; self } pub(crate) fn repeat_prefix_on_wrap(mut self, value: bool) -> Self { self.repeat_prefix = value; self } pub(crate) fn center_newlines(mut self, value: bool) -> Self { self.center_newlines = value; self } /// Draw text on the given handle. /// /// This performs word splitting and word wrapping. pub(crate) fn draw(self, terminal: &mut T) -> RenderResult where T: TerminalIo, { let mut line_length: u16 = 0; terminal.execute(&TerminalCommand::MoveToColumn(self.positioning.start_column))?; let font_size = self.line.font_size(); // Print the prefix at the beginning of the line. if self.prefix_width > 0 { let Text { content, style } = self.prefix.text(); terminal.execute(&TerminalCommand::PrintText { content, style: *style })?; } for (line_index, line) in self.line.split(self.positioning.max_line_length as usize).enumerate() { if line_index > 0 { // Complete the current line's block to the right before moving down. self.print_block_background(line_length, terminal)?; terminal.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::() as u16; let extra_space = self.positioning.max_line_length.saturating_sub(line_width); self.positioning.start_column + extra_space / 2 } false => self.positioning.start_column, }; terminal.execute(&TerminalCommand::MoveToColumn(start_column))?; line_length = 0; // Complete the new line in this block to the left where the prefix would be. if self.prefix_width > 0 { if self.repeat_prefix { let Text { content, style } = self.prefix.text(); terminal.execute(&TerminalCommand::PrintText { content, style: *style })?; } else { if let Some(color) = self.block_color { terminal.execute(&TerminalCommand::SetBackgroundColor(color))?; } let text = " ".repeat(self.prefix_width as usize / font_size as usize); let style = TextStyle::default().size(font_size); terminal.execute(&TerminalCommand::PrintText { content: &text, style })?; } } } for chunk in line { line_length = line_length.saturating_add(chunk.width() as u16); let (text, style) = chunk.into_parts(); terminal.execute(&TerminalCommand::PrintText { content: text, style })?; // 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))?; } } } self.print_block_background(line_length, terminal)?; Ok(()) } fn print_block_background(&self, line_length: u16, terminal: &mut T) -> RenderResult where T: TerminalIo, { if self.draw_block { let remaining = self.positioning.max_line_length.saturating_sub(line_length).saturating_add(self.right_padding_length); if remaining > 0 { let font_size = self.line.font_size(); if let Some(color) = self.block_color { terminal.execute(&TerminalCommand::SetBackgroundColor(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 })?; } } Ok(()) } } #[cfg(test)] mod tests { use super::*; use crate::terminal::printer::TerminalError; use std::io; use unicode_width::UnicodeWidthStr; #[derive(Debug, PartialEq)] enum Instruction { MoveDown(u16), MoveToColumn(u16), PrintText { content: String, font_size: u8 }, } #[derive(Default)] struct TerminalBuf { instructions: Vec, cursor_row: u16, } impl TerminalBuf { fn push(&mut self, instruction: Instruction) -> io::Result<()> { self.instructions.push(instruction); Ok(()) } fn move_to_column(&mut self, column: u16) -> std::io::Result<()> { self.push(Instruction::MoveToColumn(column)) } fn move_down(&mut self, amount: u16) -> std::io::Result<()> { self.push(Instruction::MoveDown(amount)) } fn print_text(&mut self, content: &str, style: &TextStyle) -> io::Result<()> { let content = content.to_string(); if content.is_empty() { return Ok(()); } self.cursor_row = content.width() as u16; self.push(Instruction::PrintText { content, font_size: style.size })?; Ok(()) } fn clear_screen(&mut self) -> std::io::Result<()> { unimplemented!() } fn set_colors(&mut self, _colors: Colors) -> std::io::Result<()> { Ok(()) } fn set_background_color(&mut self, _color: Color) -> std::io::Result<()> { Ok(()) } fn flush(&mut self) -> std::io::Result<()> { Ok(()) } } impl TerminalIo for TerminalBuf { fn execute(&mut self, command: &TerminalCommand<'_>) -> Result<(), TerminalError> { use TerminalCommand::*; match command { BeginUpdate | EndUpdate | MoveToRow(_) | MoveToNextLine | MoveTo { .. } | MoveRight(_) | MoveLeft(_) | PrintImage { .. } | SetCursorBoundaries { .. } => { unimplemented!() } MoveToColumn(column) => self.move_to_column(*column)?, MoveDown(amount) => self.move_down(*amount)?, PrintText { content, style } => self.print_text(content, style)?, ClearScreen => self.clear_screen()?, SetColors(colors) => self.set_colors(*colors)?, SetBackgroundColor(color) => self.set_background_color(*color)?, Flush => self.flush()?, }; Ok(()) } fn cursor_row(&self) -> u16 { self.cursor_row } } struct TestDrawer { prefix: WeightedText, positioning: Positioning, right_padding_length: u16, repeat_prefix_on_wrap: bool, center_newlines: bool, } impl TestDrawer { fn prefix>(mut self, prefix: T) -> Self { self.prefix = prefix.into(); self } fn start_column(mut self, column: u16) -> Self { self.positioning.start_column = column; self } fn max_line_length(mut self, length: u16) -> Self { self.positioning.max_line_length = length; self } fn repeat_prefix_on_wrap(mut self) -> Self { self.repeat_prefix_on_wrap = true; self } fn center_newlines(mut self) -> Self { self.center_newlines = true; self } fn draw>(self, line: L) -> Vec { let line = line.into(); let colors = Default::default(); let drawer = TextDrawer::new(&self.prefix, self.right_padding_length, &line, self.positioning, &colors, 0) .expect("failed to create drawer") .repeat_prefix_on_wrap(self.repeat_prefix_on_wrap) .center_newlines(self.center_newlines); let mut buf = TerminalBuf::default(); drawer.draw(&mut buf).expect("drawing failed"); buf.instructions } } impl Default for TestDrawer { fn default() -> Self { Self { prefix: WeightedText::from(""), positioning: Positioning { max_line_length: 100, start_column: 0 }, right_padding_length: 0, repeat_prefix_on_wrap: false, center_newlines: false, } } } #[test] fn prefix_on_long_line() { let instructions = TestDrawer::default().prefix("P").max_line_length(3).start_column(1).draw("AAAA"); let expected = &[ Instruction::MoveToColumn(1), Instruction::PrintText { content: "P".into(), font_size: 1 }, Instruction::PrintText { content: "AA".into(), font_size: 1 }, Instruction::MoveDown(1), Instruction::MoveToColumn(1), Instruction::PrintText { content: " ".into(), font_size: 1 }, Instruction::PrintText { content: "AA".into(), font_size: 1 }, ]; assert_eq!(instructions, expected); } #[test] fn prefix_on_long_line_with_font_size() { let text = WeightedLine::from(vec![Text::new("AAAA", TextStyle::default().size(2))]); let prefix = WeightedText::from(Text::new("P", TextStyle::default().size(2))); let instructions = TestDrawer::default().prefix(prefix).max_line_length(6).start_column(1).draw(text); let expected = &[ Instruction::MoveToColumn(1), Instruction::PrintText { content: "P".into(), font_size: 2 }, Instruction::PrintText { content: "AA".into(), font_size: 2 }, Instruction::MoveDown(2), Instruction::MoveToColumn(1), Instruction::PrintText { content: " ".into(), font_size: 2 }, Instruction::PrintText { content: "AA".into(), font_size: 2 }, ]; assert_eq!(instructions, expected); } #[test] fn prefix_on_long_line_with_font_size_and_repeat_prefix() { let text = WeightedLine::from(vec![Text::new("AAAA", TextStyle::default().size(2))]); let prefix = WeightedText::from(Text::new("P", TextStyle::default().size(2))); let instructions = TestDrawer::default().prefix(prefix).max_line_length(6).start_column(1).repeat_prefix_on_wrap().draw(text); let expected = &[ Instruction::MoveToColumn(1), Instruction::PrintText { content: "P".into(), font_size: 2 }, Instruction::PrintText { content: "AA".into(), font_size: 2 }, Instruction::MoveDown(2), Instruction::MoveToColumn(1), Instruction::PrintText { content: "P".into(), font_size: 2 }, Instruction::PrintText { content: "AA".into(), font_size: 2 }, ]; assert_eq!(instructions, expected); } #[test] fn center_newlines() { let text = WeightedLine::from(vec![Text::from("hello world foo")]); let instructions = TestDrawer::default().center_newlines().max_line_length(11).draw(text); let expected = &[ Instruction::MoveToColumn(0), Instruction::PrintText { content: "hello world".into(), font_size: 1 }, Instruction::MoveDown(1), Instruction::MoveToColumn(4), Instruction::PrintText { content: "foo".into(), font_size: 1 }, ]; assert_eq!(instructions, expected); } } ================================================ FILE: src/render/validate.rs ================================================ use super::properties::WindowSize; use crate::{ ImagePrinter, presentation::Presentation, render::{ RenderError, engine::{RenderEngine, RenderEngineOptions}, }, terminal::{Terminal, TerminalWrite}, }; use std::{io, sync::Arc}; pub(crate) struct OverflowValidator; impl OverflowValidator { pub(crate) fn validate(presentation: &Presentation, dimensions: WindowSize) -> Result<(), OverflowError> { let printer = Arc::new(ImagePrinter::Null); for (index, slide) in presentation.iter_slides().enumerate() { let index = index + 1; let mut terminal = Terminal::new(io::Empty::default(), printer.clone()).map_err(RenderError::from)?; let options = RenderEngineOptions { validate_overflows: true, ..Default::default() }; let engine = RenderEngine::new(&mut terminal, dimensions, options); match engine.render(slide.iter_visible_operations()) { Ok(()) => (), Err(RenderError::HorizontalOverflow) => return Err(OverflowError::Horizontal(index)), Err(RenderError::VerticalOverflow) => return Err(OverflowError::Vertical(index)), Err(e) => return Err(OverflowError::Render(e)), }; } Ok(()) } } impl TerminalWrite for io::Empty { fn init(&mut self) -> io::Result<()> { Ok(()) } fn deinit(&mut self) {} } #[derive(Debug, thiserror::Error)] pub(crate) enum OverflowError { #[error("presentation overflows horizontally on slide {0}")] Horizontal(usize), #[error("presentation overflows vertically on slide {0}")] Vertical(usize), #[error(transparent)] Render(#[from] RenderError), } ================================================ FILE: src/resource.rs ================================================ use crate::{ terminal::image::{ Image, printer::{ImageRegistry, ImageSpec, RegisterImageError}, }, theme::{raw::PresentationTheme, registry::LoadThemeError}, }; use std::{ cell::RefCell, collections::HashMap, fs, io, mem, path::{Path, PathBuf}, rc::Rc, sync::{ Arc, atomic::{AtomicBool, Ordering}, mpsc::{Receiver, Sender, channel}, }, thread, time::{Duration, SystemTime}, }; const LOOP_INTERVAL: Duration = Duration::from_millis(250); #[derive(Debug)] struct ResourcesInner { themes: HashMap, external_text_files: HashMap, base_path: PathBuf, themes_path: PathBuf, image_registry: ImageRegistry, watcher: FileWatcherHandle, } /// Manages resources pulled from the filesystem such as images. /// /// All resources are cached so once a specific resource is loaded, looking it up with the same /// path will involve an in-memory lookup. #[derive(Clone, Debug)] pub struct Resources { inner: Rc>, } impl Resources { /// Construct a new resource manager over the provided based path. /// /// Any relative paths will be assumed to be relative to the given base. pub fn new(base_path: P1, themes_path: P2, image_registry: ImageRegistry) -> Self where P1: Into, P2: Into, { let watcher = FileWatcher::spawn(); let inner = ResourcesInner { base_path: base_path.into(), themes_path: themes_path.into(), themes: Default::default(), external_text_files: Default::default(), image_registry, watcher, }; Self { inner: Rc::new(RefCell::new(inner)) } } pub(crate) fn watch_presentation_file(&self, path: PathBuf) { let inner = self.inner.borrow(); inner.watcher.send(WatchEvent::WatchFile { path, watch_forever: true }); } /// Get the image at the given path. pub(crate) fn image>( &self, path: P, base_path: &ResourceBasePath, ) -> Result { let path = self.resolve_path(path, base_path); let inner = self.inner.borrow(); let image = inner.image_registry.register(ImageSpec::Filesystem(path.clone()))?; Ok(image) } pub(crate) fn theme_image>(&self, path: P) -> Result { match self.image(&path, &ResourceBasePath::Presentation) { Ok(image) => return Ok(image), Err(RegisterImageError::Io(e)) if e.kind() != io::ErrorKind::NotFound => return Err(e.into()), _ => (), }; let inner = self.inner.borrow(); let path = inner.themes_path.join(path); let image = inner.image_registry.register(ImageSpec::Filesystem(path.clone()))?; Ok(image) } /// Get the theme at the given path. pub(crate) fn theme>(&self, path: P) -> Result { let mut inner = self.inner.borrow_mut(); let path = inner.base_path.join(path); if let Some(theme) = inner.themes.get(&path) { return Ok(theme.clone()); } let theme = PresentationTheme::from_path(&path)?; inner.themes.insert(path, theme.clone()); Ok(theme) } /// Get the external text file at the given path. pub(crate) fn external_text_file>( &self, path: P, base_path: &ResourceBasePath, ) -> io::Result { let path = self.resolve_path(path, base_path); let mut inner = self.inner.borrow_mut(); if let Some(contents) = inner.external_text_files.get(&path) { return Ok(contents.clone()); } let contents = fs::read_to_string(&path)?; inner.watcher.send(WatchEvent::WatchFile { path: path.clone(), watch_forever: false }); inner.external_text_files.insert(path, contents.clone()); Ok(contents) } pub(crate) fn resources_modified(&self) -> bool { let mut inner = self.inner.borrow_mut(); inner.watcher.has_modifications() } pub(crate) fn clear_watches(&self) { let mut inner = self.inner.borrow_mut(); inner.watcher.send(WatchEvent::ClearWatches); // We could do better than this but this works for now. inner.external_text_files.clear(); } /// Clears all resources. pub(crate) fn clear(&self) { let mut inner = self.inner.borrow_mut(); inner.image_registry.clear(); inner.themes.clear(); } pub(crate) fn resolve_path>(&self, path: P, base_path: &ResourceBasePath) -> PathBuf { match base_path { ResourceBasePath::Presentation => { let inner = self.inner.borrow(); inner.base_path.join(path) } ResourceBasePath::Custom(base) => base.join(path), } } } #[derive(Clone, Debug, Default)] pub(crate) enum ResourceBasePath { #[default] Presentation, Custom(PathBuf), } /// Watches for file changes. /// /// This uses polling rather than something fancier like `inotify`. The latter turned out to make /// code too complex for little added gain. This instead keeps the last modified time for all /// watched paths and uses that to determine if they've changed. struct FileWatcher { receiver: Receiver, watches: HashMap, modifications: Arc, } impl FileWatcher { fn spawn() -> FileWatcherHandle { let (sender, receiver) = channel(); let modifications = Arc::new(AtomicBool::default()); let handle = FileWatcherHandle { sender, modifications: modifications.clone() }; thread::spawn(move || { let watcher = FileWatcher { receiver, watches: Default::default(), modifications }; watcher.run(); }); handle } fn run(mut self) { loop { if let Ok(event) = self.receiver.try_recv() { self.handle_event(event); } if self.watches_modified() { self.modifications.store(true, Ordering::Relaxed); } thread::sleep(LOOP_INTERVAL); } } fn handle_event(&mut self, event: WatchEvent) { match event { WatchEvent::ClearWatches => { let new_watches = mem::take(&mut self.watches).into_iter().filter(|(_, meta)| meta.watch_forever).collect(); self.watches = new_watches; } WatchEvent::WatchFile { path, watch_forever } => { // If we're already watching this forever, don't reset it if self.watches.get(&path).is_some_and(|w| w.watch_forever) { return; } let last_modification = fs::metadata(&path).and_then(|m| m.modified()).unwrap_or(SystemTime::UNIX_EPOCH); let meta = WatchMetadata { last_modification, watch_forever }; self.watches.insert(path, meta); } } } fn watches_modified(&mut self) -> bool { let mut modifications = false; for (path, meta) in &mut self.watches { let Ok(metadata) = fs::metadata(path) else { // If the file no longer exists, it's technically changed since last time. modifications = true; continue; }; let Ok(modified_time) = metadata.modified() else { continue; }; if modified_time > meta.last_modification { meta.last_modification = modified_time; modifications = true; } } modifications } } struct WatchMetadata { last_modification: SystemTime, watch_forever: bool, } #[derive(Debug)] struct FileWatcherHandle { sender: Sender, modifications: Arc, } impl FileWatcherHandle { fn send(&self, event: WatchEvent) { let _ = self.sender.send(event); } fn has_modifications(&mut self) -> bool { self.modifications.swap(false, Ordering::Relaxed) } } enum WatchEvent { /// Clear all watched files. ClearWatches, /// Add a file to the watch list. WatchFile { path: PathBuf, watch_forever: bool }, } ================================================ FILE: src/terminal/ansi.rs ================================================ use crate::markdown::{ elements::{Line, Text}, text_style::{Color, TextStyle}, }; use std::mem; use vte::{ParamsIter, Parser, Perform}; pub(crate) struct AnsiParser { starting_style: TextStyle, } impl AnsiParser { pub(crate) fn new(current_style: TextStyle) -> Self { Self { starting_style: current_style } } pub(crate) fn parse_lines(self, lines: I) -> (Vec, TextStyle) where I: IntoIterator, S: AsRef, { let mut output_lines = Vec::new(); let mut style = self.starting_style; for line in lines { let mut handler = Handler::new(style); let mut parser = Parser::new(); parser.advance(&mut handler, line.as_ref().as_bytes()); let (line, ending_style) = handler.into_parts(); output_lines.push(line); style = ending_style; } (output_lines, style) } } #[derive(Default)] pub(crate) struct AnsiColorParser { starting_style: TextStyle, } impl AnsiColorParser { pub(crate) fn new(starting_style: TextStyle) -> Self { Self { starting_style } } fn parse_8bit(value: u16) -> Option { Color::from_8bit(value.try_into().unwrap_or(u8::MAX)) } fn parse_color(iter: &mut ParamsIter) -> Option { match iter.next()? { [2] => { let r = iter.next()?.first()?; let g = iter.next()?.first()?; let b = iter.next()?.first()?; Self::try_build_rgb_color(*r, *g, *b) } [5] => { let color = *iter.next()?.first()?; Color::from_8bit(color.try_into().unwrap_or(u8::MAX)) } _ => None, } } fn try_build_rgb_color(r: u16, g: u16, b: u16) -> Option { let r = r.try_into().ok()?; let g = g.try_into().ok()?; let b = b.try_into().ok()?; Some(Color::new(r, g, b)) } pub(crate) fn parse(self, mut codes: ParamsIter) -> TextStyle { let mut style = self.starting_style; loop { let Some(&[next]) = codes.next() else { break; }; match next { 0 => style = Default::default(), 1 => style = style.bold(), 3 => style = style.italics(), 4 => style = style.underlined(), 9 => style = style.strikethrough(), 39 => { style.colors.foreground = None; } 49 => { style.colors.background = None; } 30..=37 => { if let Some(color) = Self::parse_8bit(next - 30) { style = style.fg_color(color); } } 40..=47 => { if let Some(color) = Self::parse_8bit(next - 40) { style = style.bg_color(color); } } 38 => { if let Some(color) = Self::parse_color(&mut codes) { style = style.fg_color(color); } } 48 => { if let Some(color) = Self::parse_color(&mut codes) { style = style.bg_color(color); } } _ => (), }; } style } } struct Handler { line: Line, pending_text: Text, style: TextStyle, } impl Handler { fn new(style: TextStyle) -> Self { Self { line: Default::default(), pending_text: Default::default(), style } } fn into_parts(mut self) -> (Line, TextStyle) { self.save_pending_text(); (self.line, self.style) } fn save_pending_text(&mut self) { if !self.pending_text.content.is_empty() { self.line.0.push(mem::take(&mut self.pending_text)); } } } impl Perform for Handler { fn print(&mut self, c: char) { self.pending_text.content.push(c); } fn csi_dispatch(&mut self, params: &vte::Params, _intermediates: &[u8], _ignore: bool, action: char) { if action == 'm' { self.save_pending_text(); self.style = AnsiColorParser::new(self.style).parse(params.iter()); self.pending_text.style = self.style; } } } #[cfg(test)] mod tests { use super::*; use rstest::rstest; #[rstest] #[case::text("hi", Line::from("hi"))] #[case::single_attribute("\x1b[1mhi", Line::from(Text::new("hi", TextStyle::default().bold())))] #[case::two_attributes("\x1b[1;3mhi", Line::from(Text::new("hi", TextStyle::default().bold().italics())))] #[case::three_attributes("\x1b[1;3;4mhi", Line::from(Text::new("hi", TextStyle::default().bold().italics().underlined())))] #[case::four_attributes( "\x1b[1;3;4;9mhi", Line::from(Text::new("hi", TextStyle::default().bold().italics().underlined().strikethrough())) )] #[case::standard_foreground1( "\x1b[38;5;1mhi", Line::from(Text::new("hi", TextStyle::default().fg_color(Color::DarkRed))) )] #[case::standard_foreground2( "\x1b[31mhi", Line::from(Text::new("hi", TextStyle::default().fg_color(Color::DarkRed))) )] #[case::rgb_foreground( "\x1b[38;2;3;4;5mhi", Line::from(Text::new("hi", TextStyle::default().fg_color(Color::new(3, 4, 5)))) )] #[case::standard_background1( "\x1b[48;5;1mhi", Line::from(Text::new("hi", TextStyle::default().bg_color(Color::DarkRed))) )] #[case::standard_background2( "\x1b[41mhi", Line::from(Text::new("hi", TextStyle::default().bg_color(Color::DarkRed))) )] #[case::rgb_background( "\x1b[48;2;3;4;5mhi", Line::from(Text::new("hi", TextStyle::default().bg_color(Color::new(3, 4, 5)))) )] #[case::accumulate( "\x1b[1mhi\x1b[3mbye", Line(vec![ Text::new("hi", TextStyle::default().bold()), Text::new("bye", TextStyle::default().bold().italics()) ]) )] #[case::reset( "\x1b[1mhi\x1b[0;3mbye", Line(vec![ Text::new("hi", TextStyle::default().bold()), Text::new("bye", TextStyle::default().italics()) ]) )] #[case::different_action( "\x1b[01m\x1b[Khi", Line::from(Text::new("hi", TextStyle::default().bold())) )] fn parse_single(#[case] input: &str, #[case] expected: Line) { let splitter = AnsiParser::new(Default::default()); let (lines, _) = splitter.parse_lines([input]); assert_eq!(lines, vec![expected]); } #[rstest] #[case::reset_all("\x1b[0mhi", Line::from("hi"))] #[case::reset_foreground( "\x1b[39mhi", Line::from( Text::new( "hi", TextStyle::default() .bold() .italics() .underlined() .strikethrough() .bg_color(Color::Black) ) ) )] #[case::reset_background( "\x1b[49mhi", Line::from( Text::new( "hi", TextStyle::default() .bold() .italics() .underlined() .strikethrough() .fg_color(Color::Red) ) ) )] fn resets(#[case] input: &str, #[case] expected: Line) { let style = TextStyle::default() .bold() .italics() .underlined() .strikethrough() .fg_color(Color::Red) .bg_color(Color::Black); let splitter = AnsiParser::new(style); let (lines, _) = splitter.parse_lines([input]); assert_eq!(lines, vec![expected]); } } ================================================ FILE: src/terminal/capabilities.rs ================================================ use super::image::protocols::kitty::{Action, ControlCommand, ControlOption, ImageFormat, TransmissionMedium}; use base64::{Engine, engine::general_purpose::STANDARD}; use crossterm::{ QueueableCommand, cursor::{self}, style::Print, terminal, }; use image::{DynamicImage, EncodableLayout}; use std::{ env, io::{self, Write}, sync::{ Arc, atomic::{AtomicBool, Ordering}, }, thread, time::Duration, }; use tempfile::NamedTempFile; #[derive(Default, Debug, Clone)] pub(crate) struct TerminalCapabilities { pub(crate) kitty_local: bool, pub(crate) kitty_remote: bool, pub(crate) sixel: bool, pub(crate) tmux: bool, pub(crate) font_size: bool, pub(crate) fractional_font_size: bool, } impl TerminalCapabilities { pub(crate) fn is_inside_tmux() -> bool { env::var("TERM_PROGRAM").ok().as_deref() == Some("tmux") } pub(crate) fn query() -> io::Result { let tmux = Self::is_inside_tmux(); let mut file = NamedTempFile::new()?; let image = DynamicImage::new_rgba8(1, 1).into_rgba8(); let image_bytes = image.as_raw().as_bytes(); file.write_all(image_bytes)?; file.flush()?; let Some(path) = file.path().as_os_str().to_str() else { return Ok(Default::default()); }; let encoded_path = STANDARD.encode(path); let base_image_id = fastrand::u32(0..=u32::MAX); let ids = KittyImageIds { local: base_image_id, remote: base_image_id.wrapping_add(1) }; Self::write_kitty_local_query(ids.local, encoded_path, tmux)?; Self::write_kitty_remote_query(ids.remote, image_bytes, tmux)?; let (start, sequence, end) = match tmux { true => ("\x1bPtmux;", "\x1b\x1b", "\x1b\\"), false => ("", "\x1b", ""), }; let _guard = RawModeGuard::new()?; let mut stdout = io::stdout(); write!(stdout, "{start}{sequence}[c{end}")?; stdout.flush()?; // Spawn a thread to "save us" in case we don't get an answer from the terminal. let running = Arc::new(AtomicBool::new(true)); Self::launch_timeout_trigger(running.clone()); let response = Self::build_capabilities(ids); running.store(false, Ordering::Relaxed); let mut response = response?; response.tmux = tmux; Ok(response) } fn build_capabilities(ids: KittyImageIds) -> io::Result { let mut response = Self::parse_response(io::stdin(), ids)?; // Use kitty's font size protocol to write 1 character using size 2. If after writing the // cursor has moves 2 columns, the protocol is supported. let mut stdout = io::stdout(); stdout.queue(terminal::EnterAlternateScreen)?; stdout.queue(cursor::MoveTo(0, 0))?; stdout.queue(Print("\x1b]66;s=2; \x1b\\"))?; stdout.queue(Print("\x1b]66;n=1:d=2; \x1b\\"))?; stdout.flush()?; let position = cursor::position()?.0; if position == 1 { // If we only moved one, then only the fractional worked. response.fractional_font_size = true; } else if position == 2 { // If we only moved 2 then the scaled font size one worked. response.font_size = true; } else if position == 3 { // 3 -> both worked. response.font_size = true; response.fractional_font_size = true; } stdout.queue(terminal::LeaveAlternateScreen)?; stdout.flush()?; Ok(response) } fn write_kitty_local_query(image_id: u32, path: String, tmux: bool) -> io::Result<()> { let options = &[ ControlOption::Format(ImageFormat::Rgba), ControlOption::Action(Action::Query), ControlOption::Medium(TransmissionMedium::LocalFile), ControlOption::ImageId(image_id), ControlOption::Width(1), ControlOption::Height(1), ]; let command = ControlCommand { options, payload: path, tmux }; write!(io::stdout(), "{command}") } fn write_kitty_remote_query(image_id: u32, image: &[u8], tmux: bool) -> io::Result<()> { let payload = STANDARD.encode(image); let options = &[ ControlOption::Format(ImageFormat::Rgba), ControlOption::Action(Action::Query), ControlOption::Medium(TransmissionMedium::Direct), ControlOption::ImageId(image_id), ControlOption::Width(1), ControlOption::Height(1), ]; // The image is small enough to fit in a single request so we don't need to bother with // chunks here. let command = ControlCommand { options, payload, tmux }; write!(io::stdout(), "{command}") } fn parse_response(mut term: T, ids: KittyImageIds) -> io::Result { let mut buffer = [0_u8; 128]; let mut state = QueryParseState::default(); let mut capabilities = TerminalCapabilities::default(); loop { let bytes_read = term.read(&mut buffer)?; if bytes_read == 0 { return Ok(capabilities); } for next in &buffer[0..bytes_read] { let next = char::from(*next); let Some(output) = state.update(next) else { continue; }; match output { Response::KittySupported { image_id } => { if image_id == ids.local { capabilities.kitty_local = true; } else if image_id == ids.remote { capabilities.kitty_remote = true; } } Response::Capabilities { sixel } => { capabilities.sixel = sixel; return Ok(capabilities); } Response::StatusReport => { return Ok(capabilities); } } } } } fn launch_timeout_trigger(running: Arc) { // Spawn a thread that will wait a second and if we still are running, will request the // device status report straight from whoever is on top of us (tmux or terminal if no // tmux), which will cause it to answer and wake up our main thread that's reading on // stdin. thread::spawn(move || { thread::sleep(Duration::from_secs(1)); if !running.load(Ordering::Relaxed) { return; } let _ = write!(io::stdout(), "\x1b[5n"); let _ = io::stdout().flush(); }); } } struct RawModeGuard; impl RawModeGuard { fn new() -> io::Result { terminal::enable_raw_mode()?; Ok(Self) } } impl Drop for RawModeGuard { fn drop(&mut self) { let _ = terminal::disable_raw_mode(); } } #[derive(Default)] struct QueryParseState { data: String, current: ResponseType, } impl QueryParseState { fn update(&mut self, next: char) -> Option { match &self.current { ResponseType::Unknown => { match (self.data.as_str(), next) { (_, '\x1b') => { *self = Default::default(); return None; } ("[", '?') => { self.current = ResponseType::Capabilities; } ("[", '0') => { self.current = ResponseType::StatusReport; } ("_Gi", '=') => { self.current = ResponseType::Kitty; } _ => (), }; self.data.push(next); } ResponseType::Kitty => match next { '\\' => { let response = self.build_kitty_response(); *self = Default::default(); return response; } _ => { self.data.push(next); } }, ResponseType::Capabilities => match next { 'c' => { let mut caps = self.data[2..].split(';'); let sixel = caps.any(|cap| cap == "4"); *self = Default::default(); return Some(Response::Capabilities { sixel }); } _ => self.data.push(next), }, ResponseType::StatusReport => match next { 'n' => { *self = Default::default(); return Some(Response::StatusReport); } _ => self.data.push(next), }, }; None } fn build_kitty_response(&self) -> Option { if !self.data.ends_with(";OK\x1b") { return None; } let (_, rest) = self.data.split_once("_Gi=").expect("no kitty prefix"); let (image_id, _) = rest.split_once(';')?; let image_id = image_id.parse::().ok()?; Some(Response::KittySupported { image_id }) } } #[derive(Default)] enum ResponseType { #[default] Unknown, Kitty, Capabilities, StatusReport, } enum Response { KittySupported { image_id: u32 }, Capabilities { sixel: bool }, StatusReport, } struct KittyImageIds { local: u32, remote: u32, } #[cfg(test)] mod tests { use super::*; use io::Cursor; use rstest::rstest; #[rstest] #[case::kitty_local("\x1b_Gi=42;OK\x1b\\\x1b[?c", true, false, false)] #[case::kitty_remote("\x1b_Gi=43;OK\x1b\\\x1b[?c", false, true, false)] #[case::kitty_both("\x1b_Gi=42;OK\x1b\\\x1b_Gi=43;OK\x1b\\\x1b[?c", true, true, false)] #[case::kitty_flipped("\x1b_Gi=43;OK\x1b\\\x1b_Gi=42;OK\x1b\\\x1b[?c", true, true, false)] #[case::all("\x1b_Gi=42;OK\x1b\\\x1b_Gi=43;OK\x1b\\\x1b[?4c", true, true, true)] #[case::none("\x1b[?c", false, false, false)] #[case::sixel_single("\x1b[?4c", false, false, true)] #[case::sixel_first("\x1b[?4;42c", false, false, true)] #[case::sixel_middle("\x1b[?1337;4;42c", false, false, true)] fn detection(#[case] input: &str, #[case] kitty_local: bool, #[case] kitty_remote: bool, #[case] sixel: bool) { let input = Cursor::new(input); let ids = KittyImageIds { local: 42, remote: 43 }; let capabilities = TerminalCapabilities::parse_response(input, ids).expect("reading failed"); assert_eq!(capabilities.kitty_local, kitty_local); assert_eq!(capabilities.kitty_remote, kitty_remote); assert_eq!(capabilities.sixel, sixel); } } ================================================ FILE: src/terminal/emulator.rs ================================================ use super::{GraphicsMode, capabilities::TerminalCapabilities, image::protocols::kitty::KittyMode}; use std::{env, sync::OnceLock}; use strum::IntoEnumIterator; static CAPABILITIES: OnceLock = OnceLock::new(); #[derive(Debug, strum::EnumIter)] pub enum TerminalEmulator { Iterm2, WezTerm, Ghostty, Mintty, Kitty, Konsole, Foot, Yaft, Mlterm, St, Xterm, Unknown, } impl TerminalEmulator { pub fn detect() -> Self { let term = env::var("TERM").unwrap_or_default(); let term_program = env::var("TERM_PROGRAM").unwrap_or_default(); for emulator in Self::iter() { if emulator.is_detected(&term, &term_program) { return emulator; } } TerminalEmulator::Unknown } pub(crate) fn capabilities() -> TerminalCapabilities { CAPABILITIES.get_or_init(|| TerminalCapabilities::query().unwrap_or_default()).clone() } pub(crate) fn disable_capability_detection() { CAPABILITIES.get_or_init(TerminalCapabilities::default); } pub fn preferred_protocol(&self) -> GraphicsMode { let capabilities = Self::capabilities(); // Note: the order here is very important. In particular: // // * We prioritize checking for iterm2 support as the default for terminals that support // it. // * Kitty local is checked before remote since remote should also work when local is // supported but local is more efficient. // * Sixel is not great so we use it as a last resort. // * ASCII blocks is supported by all terminals so it must come last. let modes = [ GraphicsMode::Iterm2, GraphicsMode::Iterm2Multipart, GraphicsMode::Kitty { mode: KittyMode::Local }, GraphicsMode::Kitty { mode: KittyMode::Remote }, GraphicsMode::Sixel, GraphicsMode::AsciiBlocks, ]; for mode in modes { if self.supports_graphics_mode(&mode, &capabilities) { return mode; } } unreachable!("ascii blocks is always supported") } fn is_detected(&self, term: &str, term_program: &str) -> bool { match self { TerminalEmulator::Iterm2 => { term_program.contains("iTerm") || env::var("LC_TERMINAL").is_ok_and(|c| c.contains("iTerm")) } TerminalEmulator::WezTerm => term_program.contains("WezTerm") || env::var("WEZTERM_EXECUTABLE").is_ok(), TerminalEmulator::Mintty => term_program.contains("mintty"), TerminalEmulator::Ghostty => term_program.contains("ghostty"), TerminalEmulator::Kitty => term.contains("kitty"), TerminalEmulator::Konsole => env::var("KONSOLE_VERSION").is_ok(), TerminalEmulator::Foot => ["foot", "foot-extra"].contains(&term), TerminalEmulator::Yaft => term == "yaft-256color", TerminalEmulator::Mlterm => term == "mlterm", TerminalEmulator::St => term == "st-256color", TerminalEmulator::Xterm => ["xterm", "xterm-256color"].contains(&term), TerminalEmulator::Unknown => true, } } fn supports_graphics_mode(&self, mode: &GraphicsMode, capabilities: &TerminalCapabilities) -> bool { match (mode, self) { // Use the kitty protocol in any terminal that supports the kitty graphics protocol. // // Note that this could potentially break for terminals that don't support the unicode // placeholder part of the spec which is required for this to work under tmux, but it's // not our fault terminals half implement the protocol. (GraphicsMode::Kitty { mode, .. }, _) => match mode { KittyMode::Local => capabilities.kitty_local, KittyMode::Remote => capabilities.kitty_remote, }, // All of these support the iterm2 protocol (GraphicsMode::Iterm2, Self::Iterm2 | Self::WezTerm | Self::Mintty | Self::Konsole) => true, // Only iterm2 supports the iterm2 protocol in multipart form. (GraphicsMode::Iterm2Multipart, Self::Iterm2) => true, // All terminals support ascii protocol (GraphicsMode::AsciiBlocks, _) => true, (GraphicsMode::Sixel, Self::Foot | Self::Yaft | Self::Mlterm) => true, (GraphicsMode::Sixel, Self::St | Self::Xterm | Self::Unknown) => capabilities.sixel, _ => false, } } } ================================================ FILE: src/terminal/image/mod.rs ================================================ use self::printer::{ImageProperties, TerminalImage}; use image::DynamicImage; use protocols::ascii::AsciiImage; use std::{ fmt::Debug, ops::Deref, path::PathBuf, sync::{Arc, Mutex}, }; pub(crate) mod printer; pub(crate) mod protocols; pub(crate) mod scale; struct Inner { image: TerminalImage, ascii_image: Mutex>, } /// 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, 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"), TerminalImage::Sixel(image) => DynamicImage::from(image.as_rgba8()).into(), }; *ascii_image = Some(image.clone()); image } } } pub(crate) fn image(&self) -> &TerminalImage { &self.inner.image } } impl PartialEq for Image { fn eq(&self, other: &Self) -> bool { self.source == other.source } } impl Debug for Image { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let (width, height) = self.inner.image.dimensions(); write!(f, "Image<{width}x{height}>") } } #[derive(Clone, Debug, PartialEq)] pub(crate) enum ImageSource { Filesystem(PathBuf), Generated, } ================================================ FILE: src/terminal/image/printer.rs ================================================ use super::{ Image, ImageSource, protocols::{ ascii::{AsciiImage, AsciiPrinter}, iterm::{ItermImage, ItermPrinter}, kitty::{KittyImage, KittyPrinter}, raw::{RawImage, RawPrinter}, }, }; use crate::{ markdown::text_style::{Color, PaletteColorError}, terminal::{ GraphicsMode, emulator::TerminalEmulator, image::protocols::{ iterm::ItermMode, sixel::{SixelImage, SixelPrinter}, }, printer::{TerminalError, TerminalIo}, }, }; use image::{DynamicImage, ImageError}; use std::{ borrow::Cow, collections::HashMap, fmt, io, path::PathBuf, sync::{Arc, Mutex}, }; pub(crate) trait PrintImage { type Image: ImageProperties; /// Register an image. fn register(&self, spec: ImageSpec) -> Result; fn print(&self, image: &Self::Image, options: &PrintOptions, terminal: &mut T) -> Result<(), PrintImageError> where T: TerminalIo; } pub(crate) trait ImageProperties { fn dimensions(&self) -> (u32, u32); } #[derive(Clone, Debug, PartialEq)] pub(crate) struct PrintOptions { pub(crate) columns: u16, pub(crate) rows: u16, pub(crate) z_index: i32, pub(crate) background_color: Option, // Width/height in pixels. #[allow(dead_code)] pub(crate) column_width: u16, #[allow(dead_code)] pub(crate) row_height: u16, } pub(crate) enum TerminalImage { Kitty(KittyImage), Iterm(ItermImage), Ascii(AsciiImage), Raw(RawImage), Sixel(SixelImage), } impl fmt::Debug for TerminalImage { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Kitty(_) => f.debug_tuple("Kitty").finish(), Self::Iterm(_) => f.debug_tuple("Iterm").finish(), Self::Ascii(_) => f.debug_tuple("Ascii").finish(), Self::Raw(_) => f.debug_tuple("Raw").finish(), Self::Sixel(_) => f.debug_tuple("Sixel").finish(), } } } impl ImageProperties for TerminalImage { fn dimensions(&self) -> (u32, u32) { match self { Self::Kitty(image) => image.dimensions(), Self::Iterm(image) => image.dimensions(), Self::Ascii(image) => image.dimensions(), Self::Raw(image) => image.dimensions(), Self::Sixel(image) => image.dimensions(), } } } pub enum ImagePrinter { Kitty(KittyPrinter), Iterm(ItermPrinter), Ascii(AsciiPrinter), Raw(RawPrinter), Null, Sixel(SixelPrinter), } impl Default for ImagePrinter { fn default() -> Self { Self::Ascii(AsciiPrinter) } } impl ImagePrinter { pub fn new(mode: GraphicsMode) -> Result { let capabilities = TerminalEmulator::capabilities(); let printer = match mode { GraphicsMode::Kitty { mode } => Self::Kitty(KittyPrinter::new(mode, capabilities.tmux)?), GraphicsMode::Iterm2 => Self::Iterm(ItermPrinter::new(ItermMode::Single, capabilities.tmux)), GraphicsMode::Iterm2Multipart => Self::Iterm(ItermPrinter::new(ItermMode::Multipart, capabilities.tmux)), GraphicsMode::AsciiBlocks => Self::Ascii(AsciiPrinter), GraphicsMode::Raw => Self::Raw(RawPrinter), GraphicsMode::Sixel => Self::Sixel(SixelPrinter::new()?), }; Ok(printer) } } impl PrintImage for ImagePrinter { type Image = TerminalImage; fn register(&self, spec: ImageSpec) -> Result { 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::Null => return Err(RegisterImageError::Unsupported), Self::Raw(printer) => TerminalImage::Raw(printer.register(spec)?), Self::Sixel(printer) => TerminalImage::Sixel(printer.register(spec)?), }; Ok(image) } fn print(&self, image: &Self::Image, options: &PrintOptions, terminal: &mut T) -> Result<(), PrintImageError> where T: TerminalIo, { 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::Null, _) => Ok(()), (Self::Raw(printer), TerminalImage::Raw(image)) => printer.print(image, options, terminal), (Self::Sixel(printer), TerminalImage::Sixel(image)) => printer.print(image, options, terminal), _ => Err(PrintImageError::Unsupported), } } } #[derive(Clone, Default)] pub(crate) struct ImageRegistry { printer: Arc, images: Arc>>, } impl ImageRegistry { pub fn new(printer: Arc) -> Self { Self { printer, images: Default::default() } } } impl fmt::Debug for ImageRegistry { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let inner = match self.printer.as_ref() { ImagePrinter::Kitty(_) => "Kitty", ImagePrinter::Iterm(_) => "Iterm", ImagePrinter::Ascii(_) => "Ascii", ImagePrinter::Null => "Null", ImagePrinter::Raw(_) => "Raw", ImagePrinter::Sixel(_) => "Sixel", }; write!(f, "ImageRegistry<{inner}>") } } impl ImageRegistry { pub(crate) fn register(&self, spec: ImageSpec) -> Result { let mut images = self.images.lock().unwrap(); let (source, cache_key) = match &spec { ImageSpec::Generated(_) => (ImageSource::Generated, None), ImageSpec::Filesystem(path) => { // Return if already cached if let Some(image) = images.get(path) { return Ok(image.clone()); } (ImageSource::Filesystem(path.clone()), Some(path.clone())) } }; let resource = self.printer.register(spec)?; let image = Image::new(resource, source); if let Some(key) = cache_key { images.insert(key.clone(), image.clone()); } Ok(image) } pub(crate) fn clear(&self) { self.images.lock().unwrap().clear(); } } pub(crate) enum ImageSpec { Generated(DynamicImage), Filesystem(PathBuf), } #[derive(Debug, thiserror::Error)] pub(crate) enum CreatePrinterError { #[error("io: {0}")] Io(#[from] io::Error), } #[derive(Debug, thiserror::Error)] pub(crate) enum PrintImageError { #[error(transparent)] Io(#[from] io::Error), #[error("unsupported image type")] Unsupported, #[error("image decoding: {0}")] Image(#[from] ImageError), #[error("{0}")] Other(Cow<'static, str>), } impl From for PrintImageError { fn from(e: PaletteColorError) -> Self { Self::Other(e.to_string().into()) } } impl From 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)] Io(#[from] io::Error), #[error("image decoding: {0}")] Image(#[from] ImageError), #[error("printer can't register images")] Unsupported, } impl PrintImageError { pub(crate) fn other(message: S) -> Self where S: Into>, { Self::Other(message.into()) } } ================================================ FILE: src/terminal/image/protocols/ascii.rs ================================================ use crate::{ markdown::text_style::{Color, Colors, TextStyle}, terminal::{ image::printer::{ImageProperties, ImageSpec, PrintImage, PrintImageError, PrintOptions, RegisterImageError}, printer::{TerminalCommand, TerminalIo}, }, }; use image::{DynamicImage, GenericImageView, Pixel, Rgba, RgbaImage, imageops::FilterType}; use itertools::Itertools; use std::{ collections::HashMap, fs, sync::{Arc, Mutex}, }; const TOP_CHAR: &str = "▀"; const BOTTOM_CHAR: &str = "▄"; struct Inner { image: DynamicImage, cached_sizes: Mutex>, } #[derive(Clone)] pub(crate) struct AsciiImage { inner: Arc, } impl AsciiImage { pub(crate) fn cache_scaling(&self, columns: u16, rows: u16) { let mut cached_sizes = self.inner.cached_sizes.lock().unwrap(); // lookup on cache/resize the image and store it in cache let cache_key = (columns, rows); if cached_sizes.get(&cache_key).is_none() { let image = self.inner.image.resize_exact(columns as u32, rows as u32, FilterType::Triangle); cached_sizes.insert(cache_key, image.into_rgba8()); } } } impl ImageProperties for AsciiImage { fn dimensions(&self) -> (u32, u32) { self.inner.image.dimensions() } } impl From 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) } } } #[derive(Default)] pub struct AsciiPrinter; impl AsciiPrinter { fn pixel_color(pixel: &Rgba, background: Option) -> Option { let [r, g, b, alpha] = pixel.0; if alpha == 0 { None } else if alpha < 255 { // For alpha > 0 && < 255, we blend it with the background color (if any). This helps // smooth the image's borders. let mut pixel = *pixel; match background { Some(Color::Rgb { r, g, b }) => { pixel.blend(&Rgba([r, g, b, 255 - alpha])); Some(Color::Rgb { r: pixel[0], g: pixel[1], b: pixel[2] }) } // For transparent backgrounds, we can't really know whether we should blend it // towards light or dark. None | Some(_) => Some(Color::Rgb { r, g, b }), } } else { Some(Color::Rgb { r, g, b }) } } } impl PrintImage for AsciiPrinter { type Image = AsciiImage; fn register(&self, spec: ImageSpec) -> Result { 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 print(&self, image: &Self::Image, options: &PrintOptions, terminal: &mut T) -> Result<(), PrintImageError> where T: TerminalIo, { let columns = options.columns; let rows = options.rows * 2; // Scale it first image.cache_scaling(columns, rows); // lookup on cache/resize the image and store it in cache let cache_key = (columns, rows); let cached_sizes = image.inner.cached_sizes.lock().unwrap(); let image = cached_sizes.get(&cache_key).expect("scaled image no longer there"); let default_background = options.background_color; // 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) { let top_row = rows.next().unwrap(); let mut bottom_row = rows.next(); for top_pixel in top_row { let bottom_pixel = bottom_row.as_mut().and_then(|pixels| pixels.next()); // Get pixel colors for both of these. At this point the special case for the odd // number of rows disappears as we treat a transparent pixel and a non-existent // one the same: they're simply transparent. let background = 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), }; terminal.execute(&command)?; } terminal.execute(&TerminalCommand::MoveDown(1))?; terminal.execute(&TerminalCommand::MoveLeft(options.columns))?; } Ok(()) } } ================================================ FILE: src/terminal/image/protocols/iterm.rs ================================================ use crate::terminal::{ image::printer::{ImageProperties, ImageSpec, PrintImage, PrintImageError, PrintOptions, RegisterImageError}, printer::{TerminalCommand, TerminalIo}, }; use base64::{Engine, engine::general_purpose::STANDARD}; use image::{GenericImageView, ImageEncoder, RgbaImage, codecs::png::PngEncoder}; use std::{fs, str}; const CHUNK_SIZE: usize = 32 * 1024; pub(crate) struct ItermImage { dimensions: (u32, u32), raw_length: usize, base64_contents: String, } impl ItermImage { 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 { fn dimensions(&self) -> (u32, u32) { self.dimensions } } pub enum ItermMode { Single, Multipart, } pub struct ItermPrinter { mode: ItermMode, tmux: bool, } impl ItermPrinter { pub(crate) fn new(mode: ItermMode, tmux: bool) -> Self { Self { mode, tmux } } } impl PrintImage for ItermPrinter { type Image = ItermImage; fn register(&self, spec: ImageSpec) -> Result { let (contents, dimensions) = 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())?; (contents, dimensions) } ImageSpec::Filesystem(path) => { let contents = fs::read(path)?; let image = image::load_from_memory(&contents)?; (contents, image.dimensions()) } }; let raw_length = contents.len(); let contents = STANDARD.encode(&contents); Ok(ItermImage { dimensions, raw_length, base64_contents: contents }) } fn print(&self, image: &Self::Image, options: &PrintOptions, terminal: &mut T) -> Result<(), PrintImageError> where T: TerminalIo, { let size = image.raw_length; let columns = options.columns; let rows = options.rows; let (start, end) = match self.tmux { true => ("\x1bPtmux;\x1b\x1b]1337;", "\x07\x1b\\"), false => ("\x1b]1337;", "\x07"), }; let base64 = &image.base64_contents; match &self.mode { ItermMode::Single => { let content = &format!( "{start}File=size={size};width={columns};height={rows};inline=1;preserveAspectRatio=1:{base64}{end}" ); terminal.execute(&TerminalCommand::PrintText { content, style: Default::default() })?; } ItermMode::Multipart => { let content = &format!( "{start}MultipartFile=size={size};width={columns};height={rows};inline=1;preserveAspectRatio=1{end}" ); terminal.execute(&TerminalCommand::PrintText { content, style: Default::default() })?; for chunk in base64.as_bytes().chunks(CHUNK_SIZE) { // SAFETY: this is base64 so it must be utf8 let chunk = str::from_utf8(chunk).expect("not utf8"); let content = &format!("{start}FilePart={chunk}{end}"); terminal.execute(&TerminalCommand::PrintText { content, style: Default::default() })?; } terminal.execute(&TerminalCommand::PrintText { content: &format!("{start}FileEnd{end}"), style: Default::default(), })?; } }; Ok(()) } } ================================================ FILE: src/terminal/image/protocols/kitty.rs ================================================ use crate::{ markdown::text_style::{Color, TextStyle}, terminal::{ image::printer::{ImageProperties, ImageSpec, PrintImage, PrintImageError, PrintOptions, RegisterImageError}, printer::{TerminalCommand, TerminalIo}, }, }; use base64::{Engine, engine::general_purpose::STANDARD}; use image::{AnimationDecoder, Delay, EncodableLayout, ImageReader, RgbaImage, codecs::gif::GifDecoder}; use std::{ fmt, fs::{self, File}, io::{self, BufReader}, path::{Path, PathBuf}, sync::atomic::{AtomicU32, Ordering}, }; use tempfile::{TempDir, tempdir}; const IMAGE_PLACEHOLDER: &str = "\u{10EEEE}"; const DIACRITICS: &[u32] = &[ 0x305, 0x30d, 0x30e, 0x310, 0x312, 0x33d, 0x33e, 0x33f, 0x346, 0x34a, 0x34b, 0x34c, 0x350, 0x351, 0x352, 0x357, 0x35b, 0x363, 0x364, 0x365, 0x366, 0x367, 0x368, 0x369, 0x36a, 0x36b, 0x36c, 0x36d, 0x36e, 0x36f, 0x483, 0x484, 0x485, 0x486, 0x487, 0x592, 0x593, 0x594, 0x595, 0x597, 0x598, 0x599, 0x59c, 0x59d, 0x59e, 0x59f, 0x5a0, 0x5a1, 0x5a8, 0x5a9, 0x5ab, 0x5ac, 0x5af, 0x5c4, 0x610, 0x611, 0x612, 0x613, 0x614, 0x615, 0x616, 0x617, 0x657, 0x658, 0x659, 0x65a, 0x65b, 0x65d, 0x65e, 0x6d6, 0x6d7, 0x6d8, 0x6d9, 0x6da, 0x6db, 0x6dc, 0x6df, 0x6e0, 0x6e1, 0x6e2, 0x6e4, 0x6e7, 0x6e8, 0x6eb, 0x6ec, 0x730, 0x732, 0x733, 0x735, 0x736, 0x73a, 0x73d, 0x73f, 0x740, 0x741, 0x743, 0x745, 0x747, 0x749, 0x74a, 0x7eb, 0x7ec, 0x7ed, 0x7ee, 0x7ef, 0x7f0, 0x7f1, 0x7f3, 0x816, 0x817, 0x818, 0x819, 0x81b, 0x81c, 0x81d, 0x81e, 0x81f, 0x820, 0x821, 0x822, 0x823, 0x825, 0x826, 0x827, 0x829, 0x82a, 0x82b, 0x82c, 0x82d, 0x951, 0x953, 0x954, 0xf82, 0xf83, 0xf86, 0xf87, 0x135d, 0x135e, 0x135f, 0x17dd, 0x193a, 0x1a17, 0x1a75, 0x1a76, 0x1a77, 0x1a78, 0x1a79, 0x1a7a, 0x1a7b, 0x1a7c, 0x1b6b, 0x1b6d, 0x1b6e, 0x1b6f, 0x1b70, 0x1b71, 0x1b72, 0x1b73, 0x1cd0, 0x1cd1, 0x1cd2, 0x1cda, 0x1cdb, 0x1ce0, 0x1dc0, 0x1dc1, 0x1dc3, 0x1dc4, 0x1dc5, 0x1dc6, 0x1dc7, 0x1dc8, 0x1dc9, 0x1dcb, 0x1dcc, 0x1dd1, 0x1dd2, 0x1dd3, 0x1dd4, 0x1dd5, 0x1dd6, 0x1dd7, 0x1dd8, 0x1dd9, 0x1dda, 0x1ddb, 0x1ddc, 0x1ddd, 0x1dde, 0x1ddf, 0x1de0, 0x1de1, 0x1de2, 0x1de3, 0x1de4, 0x1de5, 0x1de6, 0x1dfe, 0x20d0, 0x20d1, 0x20d4, 0x20d5, 0x20d6, 0x20d7, 0x20db, 0x20dc, 0x20e1, 0x20e7, 0x20e9, 0x20f0, 0x2cef, 0x2cf0, 0x2cf1, 0x2de0, 0x2de1, 0x2de2, 0x2de3, 0x2de4, 0x2de5, 0x2de6, 0x2de7, 0x2de8, 0x2de9, 0x2dea, 0x2deb, 0x2dec, 0x2ded, 0x2dee, 0x2def, 0x2df0, 0x2df1, 0x2df2, 0x2df3, 0x2df4, 0x2df5, 0x2df6, 0x2df7, 0x2df8, 0x2df9, 0x2dfa, 0x2dfb, 0x2dfc, 0x2dfd, 0x2dfe, 0x2dff, 0xa66f, 0xa67c, 0xa67d, 0xa6f0, 0xa6f1, 0xa8e0, 0xa8e1, 0xa8e2, 0xa8e3, 0xa8e4, 0xa8e5, 0xa8e6, 0xa8e7, 0xa8e8, 0xa8e9, 0xa8ea, 0xa8eb, 0xa8ec, 0xa8ed, 0xa8ee, 0xa8ef, 0xa8f0, 0xa8f1, 0xaab0, 0xaab2, 0xaab3, 0xaab7, 0xaab8, 0xaabe, 0xaabf, 0xaac1, 0xfe20, 0xfe21, 0xfe22, 0xfe23, 0xfe24, 0xfe25, 0xfe26, 0x10a0f, 0x10a38, 0x1d185, 0x1d186, 0x1d187, 0x1d188, 0x1d189, 0x1d1aa, 0x1d1ab, 0x1d1ac, 0x1d1ad, 0x1d242, 0x1d243, 0x1d244, ]; enum GenericResource { Image(B), Gif(Vec>), } type RawResource = GenericResource; impl RawResource { fn into_memory_resource(self) -> KittyImage { match self { Self::Image(image) => KittyImage { dimensions: image.dimensions(), resource: GenericResource::Image(KittyBuffer::Memory(image.into_raw())), }, Self::Gif(frames) => { let dimensions = frames[0].buffer.dimensions(); let frames = frames .into_iter() .map(|frame| GifFrame { delay: frame.delay, buffer: KittyBuffer::Memory(frame.buffer.into_raw()) }) .collect(); let resource = GenericResource::Gif(frames); KittyImage { dimensions, resource } } } } } pub(crate) struct KittyImage { dimensions: (u32, u32), resource: GenericResource, } 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 } } enum KittyBuffer { Filesystem(PathBuf), Memory(Vec), } impl Drop for KittyBuffer { fn drop(&mut self) { if let Self::Filesystem(path) = self { let _ = fs::remove_file(path); } } } struct GifFrame { delay: Delay, buffer: T, } pub struct KittyPrinter { mode: KittyMode, tmux: bool, base_directory: TempDir, next: AtomicU32, } impl KittyPrinter { pub(crate) fn new(mode: KittyMode, tmux: bool) -> io::Result { let base_directory = tempdir()?; Ok(Self { mode, tmux, base_directory, next: Default::default() }) } fn allocate_tempfile(&self) -> PathBuf { let file_number = self.next.fetch_add(1, Ordering::AcqRel); self.base_directory.path().join(file_number.to_string()) } fn persist_image(&self, image: RgbaImage) -> io::Result { let path = self.allocate_tempfile(); fs::write(&path, image.as_bytes())?; let buffer = KittyBuffer::Filesystem(path); let resource = KittyImage { dimensions: image.dimensions(), resource: GenericResource::Image(buffer) }; Ok(resource) } fn persist_gif(&self, frames: Vec>) -> io::Result { let mut persisted_frames = Vec::new(); let mut dimensions = (0, 0); for frame in frames { let path = self.allocate_tempfile(); fs::write(&path, frame.buffer.as_bytes())?; dimensions = frame.buffer.dimensions(); let frame = GifFrame { delay: frame.delay, buffer: KittyBuffer::Filesystem(path) }; persisted_frames.push(frame); } Ok(KittyImage { dimensions, resource: GenericResource::Gif(persisted_frames) }) } fn persist_resource(&self, resource: RawResource) -> io::Result { match resource { RawResource::Image(image) => self.persist_image(image), RawResource::Gif(frames) => self.persist_gif(frames), } } fn generate_image_id() -> u32 { fastrand::u32(1..u32::MAX) } fn print_image( &self, dimensions: (u32, u32), buffer: &KittyBuffer, terminal: &mut T, print_options: &PrintOptions, ) -> Result<(), PrintImageError> where T: TerminalIo, { let mut options = vec![ ControlOption::Format(ImageFormat::Rgba), ControlOption::Action(Action::TransmitAndDisplay), ControlOption::Width(dimensions.0), ControlOption::Height(dimensions.1), ControlOption::Columns(print_options.columns), ControlOption::Rows(print_options.rows), ControlOption::ZIndex(print_options.z_index), ControlOption::Quiet(2), ]; let mut image_id = 0; if self.tmux { image_id = Self::generate_image_id(); options.extend([ControlOption::UnicodePlaceholder, ControlOption::ImageId(image_id)]); } match &buffer { KittyBuffer::Filesystem(path) => self.print_local(options, path, terminal)?, KittyBuffer::Memory(buffer) => self.print_remote(options, buffer, terminal, false)?, }; if self.tmux { self.print_unicode_placeholders(terminal, print_options, image_id)?; } Ok(()) } fn print_gif( &self, dimensions: (u32, u32), frames: &[GifFrame], terminal: &mut T, print_options: &PrintOptions, ) -> Result<(), PrintImageError> where T: TerminalIo, { let image_id = Self::generate_image_id(); for (frame_id, frame) in frames.iter().enumerate() { let (num, denom) = frame.delay.numer_denom_ms(); // default to 100ms in case somehow the denominator is 0 let delay = num.checked_div(denom).unwrap_or(100); let mut options = vec![ ControlOption::Format(ImageFormat::Rgba), ControlOption::ImageId(image_id), ControlOption::Width(dimensions.0), ControlOption::Height(dimensions.1), ControlOption::ZIndex(print_options.z_index), ControlOption::Quiet(2), ]; if frame_id == 0 { options.extend([ ControlOption::Action(Action::TransmitAndDisplay), ControlOption::Columns(print_options.columns), ControlOption::Rows(print_options.rows), ]); if self.tmux { options.push(ControlOption::UnicodePlaceholder); } } else { options.extend([ControlOption::Action(Action::TransmitFrame), ControlOption::Delay(delay)]); } 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)?, }; if frame_id == 0 { let options = &[ ControlOption::Action(Action::Animate), ControlOption::ImageId(image_id), ControlOption::FrameId(1), ControlOption::Loops(1), ]; let command = self.make_command(options, "").to_string(); terminal.execute(&TerminalCommand::PrintText { content: &command, style: Default::default() })?; } else if frame_id == 1 { let options = &[ ControlOption::Action(Action::Animate), ControlOption::ImageId(image_id), ControlOption::FrameId(1), ControlOption::AnimationState(2), ]; let command = self.make_command(options, "").to_string(); terminal.execute(&TerminalCommand::PrintText { content: &command, style: Default::default() })?; } } if self.tmux { self.print_unicode_placeholders(terminal, print_options, image_id)?; } let options = &[ ControlOption::Action(Action::Animate), ControlOption::ImageId(image_id), ControlOption::FrameId(1), ControlOption::AnimationState(3), ControlOption::Loops(1), ControlOption::Quiet(2), ]; let command = self.make_command(options, "").to_string(); terminal.execute(&TerminalCommand::PrintText { content: &command, style: Default::default() })?; Ok(()) } fn make_command<'a, P>(&self, options: &'a [ControlOption], payload: P) -> ControlCommand<'a, P> { ControlCommand { options, payload, tmux: self.tmux } } fn print_local( &self, mut options: Vec, path: &Path, terminal: &mut T, ) -> Result<(), PrintImageError> where T: TerminalIo, { let Some(path) = path.to_str() else { return Err(PrintImageError::other("path is not valid utf8")); }; 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() })?; Ok(()) } fn print_remote( &self, mut options: Vec, frame: &[u8], terminal: &mut T, is_frame: bool, ) -> Result<(), PrintImageError> where T: TerminalIo, { options.push(ControlOption::Medium(TransmissionMedium::Direct)); let payload = STANDARD.encode(frame); let chunk_size = 4096; let mut index = 0; while index < payload.len() { let start = index; let end = payload.len().min(start + chunk_size); index = end; let more = end != payload.len(); 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() })?; options.clear(); if is_frame { options.push(ControlOption::Action(Action::TransmitFrame)); } } Ok(()) } fn print_unicode_placeholders( &self, terminal: &mut T, options: &PrintOptions, image_id: u32, ) -> Result<(), PrintImageError> where T: TerminalIo, { 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); if options.rows.max(options.columns) >= DIACRITICS.len() as u16 { return Err(PrintImageError::other("image is too large to fit in tmux")); } let last_byte = char::from_u32(DIACRITICS[(image_id >> 24) as usize]).unwrap(); for row in 0..options.rows { 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 })?; } if row != options.rows - 1 { terminal.execute(&TerminalCommand::MoveDown(1))?; } terminal.execute(&TerminalCommand::MoveLeft(options.columns))?; } Ok(()) } fn load_raw_resource(path: &Path) -> Result { let file = File::open(path)?; if path.extension().unwrap_or_default() == "gif" { let decoder = GifDecoder::new(BufReader::new(file))?; let mut frames = Vec::new(); for frame in decoder.into_frames() { let frame = frame?; let frame = GifFrame { delay: frame.delay(), buffer: frame.into_buffer() }; frames.push(frame); } Ok(RawResource::Gif(frames)) } else { let reader = ImageReader::new(BufReader::new(file)).with_guessed_format()?; let image = reader.decode()?; Ok(RawResource::Image(image.into_rgba8())) } } } impl PrintImage for KittyPrinter { type Image = KittyImage; fn register(&self, spec: ImageSpec) -> Result { let image = match spec { ImageSpec::Generated(image) => RawResource::Image(image.into_rgba8()), ImageSpec::Filesystem(path) => Self::load_raw_resource(&path)?, }; let resource = match &self.mode { KittyMode::Local => self.persist_resource(image)?, KittyMode::Remote => image.into_memory_resource(), }; Ok(resource) } fn print(&self, image: &Self::Image, options: &PrintOptions, terminal: &mut T) -> Result<(), PrintImageError> where T: TerminalIo, { match &image.resource { GenericResource::Image(resource) => self.print_image(image.dimensions, resource, terminal, options)?, GenericResource::Gif(frames) => self.print_gif(image.dimensions, frames, terminal, options)?, }; Ok(()) } } #[derive(Clone, Debug)] pub enum KittyMode { Local, Remote, } pub(crate) struct ControlCommand<'a, D> { pub(crate) options: &'a [ControlOption], pub(crate) payload: D, pub(crate) tmux: bool, } impl fmt::Display for ControlCommand<'_, D> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { if self.tmux { write!(f, "\x1bPtmux;\x1b")?; } write!(f, "\x1b_G")?; for (index, option) in self.options.iter().enumerate() { if index > 0 { write!(f, ",")?; } write!(f, "{option}")?; } write!(f, ";{}", &self.payload)?; if self.tmux { write!(f, "\x1b\x1b\\\x1b\\")?; } else { write!(f, "\x1b\\")?; } Ok(()) } } #[derive(Debug, Clone)] pub(crate) enum ControlOption { Action(Action), Format(ImageFormat), Medium(TransmissionMedium), Width(u32), Height(u32), Columns(u16), Rows(u16), MoreData(bool), ImageId(u32), FrameId(u32), Delay(u32), AnimationState(u32), Loops(u32), Quiet(u32), ZIndex(i32), UnicodePlaceholder, } impl fmt::Display for ControlOption { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { use ControlOption::*; match self { Action(action) => write!(f, "a={action}"), Format(format) => write!(f, "f={format}"), Medium(medium) => write!(f, "t={medium}"), Width(width) => write!(f, "s={width}"), Height(height) => write!(f, "v={height}"), Columns(columns) => write!(f, "c={columns}"), Rows(rows) => write!(f, "r={rows}"), MoreData(true) => write!(f, "m=1"), MoreData(false) => write!(f, "m=0"), ImageId(id) => write!(f, "i={id}"), FrameId(id) => write!(f, "r={id}"), Delay(delay) => write!(f, "z={delay}"), AnimationState(state) => write!(f, "s={state}"), Loops(count) => write!(f, "v={count}"), Quiet(option) => write!(f, "q={option}"), ZIndex(index) => write!(f, "z={index}"), UnicodePlaceholder => write!(f, "U=1"), } } } #[derive(Debug, Clone)] pub(crate) enum ImageFormat { Rgba, } impl fmt::Display for ImageFormat { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { use ImageFormat::*; let value = match self { Rgba => 32, }; write!(f, "{value}") } } #[derive(Debug, Clone)] pub(crate) enum TransmissionMedium { Direct, LocalFile, } impl fmt::Display for TransmissionMedium { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { use TransmissionMedium::*; let value = match self { Direct => 'd', LocalFile => 'f', }; write!(f, "{value}") } } #[derive(Debug, Clone)] pub(crate) enum Action { Animate, TransmitAndDisplay, TransmitFrame, Query, } impl fmt::Display for Action { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { use Action::*; let value = match self { Animate => 'a', TransmitAndDisplay => 'T', TransmitFrame => 'f', Query => 'q', }; write!(f, "{value}") } } ================================================ FILE: src/terminal/image/protocols/mod.rs ================================================ pub(crate) mod ascii; pub(crate) mod iterm; pub(crate) mod kitty; pub(crate) mod raw; pub(crate) mod sixel; ================================================ FILE: src/terminal/image/protocols/raw.rs ================================================ 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, 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 { 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(&self, _image: &Self::Image, _options: &PrintOptions, _terminal: &mut T) -> Result<(), PrintImageError> where T: TerminalIo, { Err(PrintImageError::Other("raw images can't be printed".into())) } } ================================================ FILE: src/terminal/image/protocols/sixel.rs ================================================ use crate::terminal::{ image::printer::{ CreatePrinterError, ImageProperties, ImageSpec, PrintImage, PrintImageError, PrintOptions, RegisterImageError, }, printer::{TerminalCommand, TerminalIo}, }; use icy_sixel::encoder::{EncodeOptions, sixel_encode}; use image::{DynamicImage, GenericImageView, RgbaImage, imageops::FilterType}; use std::fs; pub(crate) struct SixelImage(DynamicImage); impl SixelImage { pub(crate) fn as_rgba8(&self) -> RgbaImage { self.0.to_rgba8() } } impl ImageProperties for SixelImage { fn dimensions(&self) -> (u32, u32) { self.0.dimensions() } } #[derive(Default)] pub struct SixelPrinter; impl SixelPrinter { pub(crate) fn new() -> Result { Ok(Self) } } impl PrintImage for SixelPrinter { type Image = SixelImage; fn register(&self, spec: ImageSpec) -> Result { 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 print(&self, image: &Self::Image, options: &PrintOptions, terminal: &mut T) -> Result<(), PrintImageError> where T: TerminalIo, { // We're already positioned in the right place but we may not have flushed that yet. terminal.execute(&TerminalCommand::Flush)?; // This check was taken from viuer: it seems to be a bug in xterm let width = (options.column_width * options.columns).min(1000); let height = options.row_height * options.rows; let image = image.0.resize_exact(width as u32, height as u32, FilterType::Triangle); let bytes = image.into_rgba8().into_raw(); let content = sixel_encode(&bytes, width as usize, height as usize, &EncodeOptions::default()) .map_err(|e| PrintImageError::other(format!("encoding sixel image: {e:?}")))?; terminal.execute(&TerminalCommand::PrintText { content: &content, style: Default::default() })?; Ok(()) } } ================================================ FILE: src/terminal/image/scale.rs ================================================ 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( &self, scale_size: &WindowSize, window_dimensions: &WindowSize, image_width: u32, image_height: u32, position: &CursorPosition, ) -> TerminalRect { let aspect_ratio = image_height as f64 / image_width as f64; let column_in_pixels = scale_size.pixels_per_column(); let width_in_columns = scale_size.columns; let image_width = width_in_columns as f64 * column_in_pixels; let image_height = image_width * aspect_ratio; self.fit_image_to_rect(window_dimensions, image_width as u32, image_height as u32, position) } fn fit_image_to_rect( &self, dimensions: &WindowSize, image_width: u32, image_height: u32, position: &CursorPosition, ) -> TerminalRect { let aspect_ratio = image_height as f64 / image_width as f64; // Compute the image's width in columns by translating pixels -> columns. let column_in_pixels = dimensions.pixels_per_column(); let column_margin = (dimensions.columns as f64 * (1.0 - self.horizontal_margin)) as u32; let mut width_in_columns = (image_width as f64 / column_in_pixels) as u32; // Do the same for its height. let row_in_pixels = dimensions.pixels_per_row(); let height_in_rows = (image_height as f64 / row_in_pixels) as u32; // If the image doesn't fit vertically, shrink it. let available_height = dimensions.rows.saturating_sub(position.row) as u32; if height_in_rows > available_height { // Because we only use the width to draw, here we scale the width based on how much we // need to shrink the height. let shrink_ratio = available_height as f64 / height_in_rows as f64; width_in_columns = (width_in_columns as f64 * shrink_ratio).round() as u32; } // Don't go too far wide. let width_in_columns = width_in_columns.min(column_margin); // Now translate width -> height by using the original aspect ratio + translate based on // the window size's aspect ratio. let height_in_rows = (width_in_columns as f64 * aspect_ratio * dimensions.aspect_ratio()).round() as u16; let width_in_columns = width_in_columns.max(1); let height_in_rows = height_in_rows.max(1); TerminalRect { columns: width_in_columns as u16, rows: height_in_rows } } } impl Default for ImageScaler { fn default() -> Self { Self { horizontal_margin: 0.05 } } } #[derive(Debug, PartialEq)] pub(crate) struct TerminalRect { pub(crate) columns: u16, pub(crate) rows: u16, } #[cfg(test)] mod tests { use super::*; use rstest::rstest; const WINDOW: WindowSize = WindowSize { rows: 50, columns: 100, height: 200, width: 200 }; const SMALL_WINDOW: WindowSize = WindowSize { rows: 3, columns: 6, height: 10, width: 10 }; const OTHER_RATIO: WindowSize = WindowSize { rows: 10, columns: 10, height: 10, width: 10 }; #[rstest] #[case::squares(WINDOW, 100, 100, TerminalRect { columns: 50, rows: 25 })] #[case::squares_smaller(WINDOW, 50, 50, TerminalRect { columns: 25, rows: 13 })] #[case::square_too_large(WINDOW, 400, 400, TerminalRect { columns: 100, rows: 50 })] #[case::too_tall(WINDOW, 200, 400, TerminalRect { columns: 50, rows: 50 })] #[case::too_wide(WINDOW, 400, 200, TerminalRect { columns: 100, rows: 25 })] #[case::small(SMALL_WINDOW, 899, 872, TerminalRect { columns: 6, rows: 3 })] #[case::other_ratio(OTHER_RATIO, 100, 100, TerminalRect { columns: 10, rows: 10 })] fn image_fitting( #[case] window: WindowSize, #[case] width: u32, #[case] height: u32, #[case] expected: TerminalRect, ) { let cursor = CursorPosition::default(); let rect = ImageScaler { horizontal_margin: 0.0 }.fit_image_to_rect(&window, width, height, &cursor); assert_eq!(rect, expected); } } ================================================ FILE: src/terminal/mod.rs ================================================ pub(crate) mod ansi; pub(crate) mod capabilities; pub(crate) mod emulator; pub(crate) mod image; pub(crate) mod printer; pub(crate) mod virt; pub(crate) use printer::{Terminal, TerminalWrite, should_hide_cursor}; #[derive(Clone, Debug)] pub enum GraphicsMode { Iterm2, Iterm2Multipart, Kitty { mode: image::protocols::kitty::KittyMode }, AsciiBlocks, Raw, Sixel, } ================================================ FILE: src/terminal/printer.rs ================================================ use super::emulator::TerminalEmulator; use crate::{ markdown::text_style::{Color, Colors, TextStyle}, terminal::image::{ Image, printer::{ImagePrinter, PrintImage, PrintImageError, PrintOptions}, }, }; use crossterm::{ QueueableCommand, cursor, style, terminal::{self}, }; use std::{ io::{self, Write}, 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), SetCursorBoundaries { rows: u16 }, Flush, PrintImage { image: Image, options: PrintOptions }, } pub(crate) trait TerminalIo { fn execute(&mut self, command: &TerminalCommand<'_>) -> Result<(), TerminalError>; fn cursor_row(&self) -> u16; } #[derive(Debug, thiserror::Error)] pub(crate) enum TerminalError { #[error("io: {0}")] Io(#[from] io::Error), #[error("image: {0}")] Image(#[from] PrintImageError), } /// A wrapper over the terminal write handle. pub(crate) struct Terminal { writer: I, image_printer: Arc, cursor_row: u16, current_row_height: u16, rows: u16, last_cleared_background_color: Option, background_color: Option, osc11_background: bool, } impl Terminal { pub(crate) fn new(mut writer: I, image_printer: Arc) -> io::Result { writer.init()?; Ok(Self { writer, image_printer, cursor_row: 0, current_row_height: 1, rows: u16::MAX, last_cleared_background_color: None, background_color: None, // Only use OSC11 when outside of tmux temporarily since it somehow breaks under kitty osc11_background: !TerminalEmulator::capabilities().tmux, }) } fn begin_update(&mut self) -> io::Result<()> { self.writer.queue(terminal::BeginSynchronizedUpdate)?; Ok(()) } fn end_update(&mut self) -> io::Result<()> { self.writer.queue(terminal::EndSynchronizedUpdate)?; Ok(()) } fn move_to(&mut self, column: u16, row: u16) -> io::Result<()> { self.writer.queue(cursor::MoveTo(column, row))?; self.cursor_row = row; Ok(()) } fn move_to_row(&mut self, row: u16) -> io::Result<()> { self.writer.queue(cursor::MoveToRow(row))?; self.cursor_row = row; Ok(()) } fn move_to_column(&mut self, column: u16) -> io::Result<()> { self.writer.queue(cursor::MoveToColumn(column))?; Ok(()) } fn move_down(&mut self, amount: u16) -> io::Result<()> { self.writer.queue(cursor::MoveDown(amount))?; self.cursor_row += amount; Ok(()) } fn move_right(&mut self, amount: u16) -> io::Result<()> { self.writer.queue(cursor::MoveRight(amount))?; Ok(()) } fn move_left(&mut self, amount: u16) -> io::Result<()> { self.writer.queue(cursor::MoveLeft(amount))?; Ok(()) } fn move_to_next_line(&mut self) -> io::Result<()> { let amount = self.current_row_height; self.writer.queue(cursor::MoveToNextLine(amount))?; self.cursor_row += amount; self.current_row_height = 1; Ok(()) } fn print_text(&mut self, content: &str, style: &TextStyle) -> io::Result<()> { // Don't print text if it overflows vertically. if self.cursor_row.saturating_add(style.size as u16) > self.rows { return Ok(()); } let capabilities = TerminalEmulator::capabilities(); let content = style.apply(content, &capabilities); self.writer.queue(style::PrintStyledContent(content))?; self.current_row_height = self.current_row_height.max(style.size as u16); Ok(()) } fn clear_screen(&mut self) -> io::Result<()> { if self.osc11_background { match (self.last_cleared_background_color, self.background_color) { (_, Some(Color::Rgb { r, g, b })) => { // Set background via OSC 11 if we have an RGB color write!(self.writer, "\x1b]11;#{r:02x}{g:02x}{b:02x}\x1b\\")?; } // If it was RGB and it no longer is, or we have no background now, clear it. (Some(Color::Rgb { .. }), Some(_)) | (_, None) => write!(self.writer, "\x1b]111\x1b\\")?, _ => (), }; } self.last_cleared_background_color = self.background_color; self.writer.queue(terminal::Clear(terminal::ClearType::All))?; self.cursor_row = 0; self.current_row_height = 1; Ok(()) } fn set_colors(&mut self, colors: Colors) -> io::Result<()> { // Save this for when the screen is cleared.. self.background_color = colors.background; let colors = colors.into(); self.writer.queue(style::ResetColor)?; self.writer.queue(style::SetColors(colors))?; Ok(()) } fn set_background_color(&mut self, color: Color) -> io::Result<()> { self.background_color = Some(color); let color = color.into(); self.writer.queue(style::SetBackgroundColor(color))?; Ok(()) } fn set_cursor_boundaries(&mut self, rows: u16) { self.rows = rows; } fn flush(&mut self) -> io::Result<()> { self.writer.flush()?; Ok(()) } fn print_image(&mut self, image: &Image, options: &PrintOptions) -> Result<(), PrintImageError> { let image_printer = self.image_printer.clone(); image_printer.print(image.image(), options, self)?; self.cursor_row += options.rows; Ok(()) } pub(crate) fn suspend(&mut self) { self.writer.deinit(); } pub(crate) fn resume(&mut self) { let _ = self.writer.init(); } } impl TerminalIo for Terminal { 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)?, SetCursorBoundaries { rows } => self.set_cursor_boundaries(*rows), Flush => self.flush()?, PrintImage { image, options } => self.print_image(image, options)?, }; Ok(()) } fn cursor_row(&self) -> u16 { self.cursor_row } } impl Drop for Terminal { fn drop(&mut self) { if self.osc11_background { if let Some(Color::Rgb { .. }) = self.background_color { let _ = write!(self.writer, "\x1b]111\x1b\\"); } } self.writer.deinit(); } } 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. let term = std::env::var("TERM_PROGRAM"); let is_wezterm = term.as_ref().map(|s| s.as_str()) == Ok("WezTerm"); !(is_windows_based_os() && is_wezterm) } fn is_windows_based_os() -> bool { let is_windows = std::env::consts::OS == "windows"; let is_wsl = std::env::var("WSL_DISTRO_NAME").is_ok(); is_windows || is_wsl } pub(crate) trait TerminalWrite: io::Write { fn init(&mut self) -> io::Result<()>; fn deinit(&mut self); } impl TerminalWrite for io::Stdout { fn init(&mut self) -> io::Result<()> { terminal::enable_raw_mode()?; if should_hide_cursor() { self.queue(cursor::Hide)?; } self.queue(terminal::EnterAlternateScreen)?; Ok(()) } fn deinit(&mut self) { let _ = self.queue(terminal::LeaveAlternateScreen); if should_hide_cursor() { let _ = self.queue(cursor::Show); } let _ = self.flush(); let _ = terminal::disable_raw_mode(); } } ================================================ FILE: src/terminal/virt.rs ================================================ use super::{ image::{ Image, printer::{PrintImage, PrintImageError, PrintOptions}, protocols::ascii::AsciiPrinter, }, printer::{TerminalError, TerminalIo}, }; use crate::{ WindowSize, markdown::{ elements::Text, text_style::{Color, Colors, TextStyle}, }, terminal::printer::TerminalCommand, }; use core::fmt; 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 { 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>, pub(crate) background_color: Option, pub(crate) images: HashMap<(u16, u16), PrintedImage>, } pub(crate) struct VirtualTerminal { row: u16, column: u16, colors: Colors, rows: Vec>, background_color: Option, images: HashMap<(u16, u16), PrintedImage>, row_heights: Vec, image_behavior: ImageBehavior, } impl VirtualTerminal { pub(crate) fn new(dimensions: WindowSize, image_behavior: ImageBehavior) -> Self { let rows = vec![vec![StyledChar::default(); dimensions.columns as usize]; dimensions.rows as usize]; let row_heights = vec![1; dimensions.rows as usize]; Self { row: 0, column: 0, colors: Default::default(), rows, background_color: None, images: Default::default(), row_heights, image_behavior, } } pub(crate) fn into_contents(self) -> TerminalGrid { TerminalGrid { rows: self.rows, background_color: self.background_color, images: self.images } } fn current_cell_mut(&mut self) -> Option<&mut StyledChar> { self.rows.get_mut(self.row as usize).and_then(|row| row.get_mut(self.column as usize)) } fn set_current_row_height(&mut self, height: u16) { if let Some(current) = self.row_heights.get_mut(self.row as usize) { *current = height; } } fn current_row_height(&self) -> u16 { *self.row_heights.get(self.row as usize).unwrap_or(&1) } fn move_to(&mut self, column: u16, row: u16) -> io::Result<()> { self.column = column; self.row = row; Ok(()) } fn move_to_row(&mut self, row: u16) -> io::Result<()> { self.row = row; self.set_current_row_height(1); Ok(()) } fn move_to_column(&mut self, column: u16) -> io::Result<()> { self.column = column; Ok(()) } fn move_down(&mut self, amount: u16) -> io::Result<()> { self.row += amount; Ok(()) } fn move_right(&mut self, amount: u16) -> io::Result<()> { self.column += amount; Ok(()) } fn move_left(&mut self, amount: u16) -> io::Result<()> { self.column = self.column.saturating_sub(amount); Ok(()) } fn move_to_next_line(&mut self) -> io::Result<()> { let amount = self.current_row_height(); self.row += amount; self.column = 0; self.set_current_row_height(1); Ok(()) } fn print_text(&mut self, content: &str, style: &TextStyle) -> io::Result<()> { let style = style.merged(&TextStyle::default().colors(self.colors)); for c in content.chars() { let Some(cell) = self.current_cell_mut() else { continue; }; cell.character = c; cell.style = style; self.column += style.size as u16; } let height = self.current_row_height().max(style.size as u16); self.set_current_row_height(height); Ok(()) } fn clear_screen(&mut self) -> io::Result<()> { for row in &mut self.rows { for cell in row { cell.character = ' '; } } self.background_color = self.colors.background; Ok(()) } fn set_colors(&mut self, colors: crate::markdown::text_style::Colors) -> io::Result<()> { self.colors = colors; Ok(()) } fn set_background_color(&mut self, color: Color) -> io::Result<()> { self.colors.background = Some(color); Ok(()) } fn flush(&mut self) -> io::Result<()> { Ok(()) } fn print_image(&mut self, image: &Image, options: &PrintOptions) -> Result<(), PrintImageError> { match &self.image_behavior { ImageBehavior::Store => { let key = (self.row, self.column); let image = PrintedImage { image: image.clone(), width_columns: options.columns }; self.images.insert(key, image); } ImageBehavior::PrintAscii => { let image = image.to_ascii(); let image_printer = AsciiPrinter; image_printer.print(&image, options, self)? } }; Ok(()) } } impl fmt::Debug for VirtualTerminal { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("VirtualTerminal") .field("row", &self.row) .field("column", &self.column) .field("colors", &self.colors) .field("background_color", &self.background_color) .field("images", &self.images) .field("row_heights", &self.row_heights) .field("image_behavior", &self.image_behavior) .finish() } } 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)?, SetCursorBoundaries { .. } => (), Flush => self.flush()?, PrintImage { image, options } => self.print_image(image, options)?, }; Ok(()) } fn cursor_row(&self) -> u16 { self.row } } #[derive(Clone, Debug, Default)] pub(crate) enum ImageBehavior { #[default] Store, PrintAscii, } #[derive(Clone, Copy, Debug, PartialEq)] pub(crate) struct StyledChar { pub(crate) character: char, pub(crate) style: TextStyle, } impl StyledChar { #[cfg(test)] pub(crate) fn new(character: char, style: TextStyle) -> Self { Self { character, style } } } impl From for StyledChar { fn from(character: char) -> Self { Self { character, style: Default::default() } } } impl Default for StyledChar { fn default() -> Self { Self { character: ' ', style: Default::default() } } } #[cfg(test)] mod tests { use super::*; trait TerminalGridExt { fn assert_contents(&self, lines: &[&str]); } impl TerminalGridExt for TerminalGrid { fn assert_contents(&self, lines: &[&str]) { assert_eq!(self.rows.len(), lines.len()); for (line, expected) in self.rows.iter().zip(lines) { let line: String = line.iter().map(|c| c.character).collect(); assert_eq!(line, *expected); } } } #[test] fn text() { let dimensions = WindowSize { rows: 2, columns: 3, height: 0, width: 0 }; let mut term = VirtualTerminal::new(dimensions, Default::default()); for c in "abc".chars() { term.print_text(&c.to_string(), &Default::default()).expect("print failed"); } term.move_to_next_line().unwrap(); term.print_text("A", &Default::default()).expect("print failed"); let grid = term.into_contents(); grid.assert_contents(&["abc", "A "]); } #[test] fn movement() { let dimensions = WindowSize { rows: 2, columns: 3, height: 0, width: 0 }; let mut term = VirtualTerminal::new(dimensions, Default::default()); term.print_text("A", &Default::default()).unwrap(); term.move_down(1).unwrap(); term.print_text("B", &Default::default()).unwrap(); term.move_to(2, 0).unwrap(); term.print_text("C", &Default::default()).unwrap(); term.move_to_row(1).unwrap(); term.move_to_column(2).unwrap(); term.print_text("D", &Default::default()).unwrap(); let grid = term.into_contents(); grid.assert_contents(&["A C", " BD"]); } #[test] fn iterator() { let row = &[ StyledChar { character: ' ', style: TextStyle::default() }, StyledChar { character: 'A', style: TextStyle::default() }, StyledChar { character: 'B', style: TextStyle::default().bold() }, StyledChar { character: 'C', style: TextStyle::default().bold() }, StyledChar { character: 'D', style: TextStyle::default() }, ]; let texts: Vec<_> = TerminalRowIterator::new(row).collect(); assert_eq!(texts, &[Text::from(" A"), Text::new("BC", TextStyle::default().bold()), Text::from("D")]); } } ================================================ FILE: src/theme/clean.rs ================================================ use super::{ AuthorPositioning, FooterTemplate, Margin, raw::{self, RawColor}, }; use crate::{ markdown::text_style::{Color, Colors, TextStyle, UndefinedPaletteColorError}, resource::Resources, terminal::image::{Image, printer::RegisterImageError}, }; use std::collections::BTreeMap; const DEFAULT_CODE_HIGHLIGHT_THEME: &str = "base16-eighties.dark"; const DEFAULT_BLOCK_QUOTE_PREFIX: &str = "▍ "; const DEFAULT_PROGRESS_BAR_CHAR: char = '█'; const DEFAULT_FOOTER_HEIGHT: u16 = 3; const DEFAULT_TYPST_HORIZONTAL_MARGIN: u16 = 5; const DEFAULT_TYPST_VERTICAL_MARGIN: u16 = 7; const DEFAULT_MERMAID_THEME: &str = "default"; const DEFAULT_MERMAID_BACKGROUND: &str = "transparent"; const DEFAULT_D2_THEME: u32 = 0; const DEFAULT_PTY_CURSOR_SYMBOL: char = '█'; #[derive(Clone, Debug, Default)] pub(crate) struct ThemeOptions { pub(crate) font_size_supported: bool, } impl ThemeOptions { fn adjust_font_size(&self, font_size: Option) -> u8 { if !self.font_size_supported { 1 } else { font_size.unwrap_or(1).clamp(1, 7) } } } #[derive(Clone, Debug)] pub(crate) struct PresentationTheme { pub(crate) slide_title: SlideTitleStyle, pub(crate) code: CodeBlockStyle, pub(crate) execution_output: ExecutionOutputBlockStyle, pub(crate) pty_output: PtyOutputBlockStyle, pub(crate) inline_code: ModifierStyle, pub(crate) bold: ModifierStyle, pub(crate) italics: ModifierStyle, pub(crate) table: Option, pub(crate) block_quote: BlockQuoteStyle, pub(crate) alert: AlertStyle, pub(crate) default_style: DefaultStyle, pub(crate) column_layout: ColumnLayoutStyle, pub(crate) headings: HeadingStyles, pub(crate) intro_slide: IntroSlideStyle, pub(crate) footer: FooterStyle, pub(crate) typst: TypstStyle, pub(crate) mermaid: MermaidStyle, pub(crate) d2: D2Style, pub(crate) modals: ModalStyle, pub(crate) layout_grid: LayoutGridStyle, pub(crate) palette: ColorPalette, } impl PresentationTheme { pub(crate) fn new( raw: &raw::PresentationTheme, resources: &Resources, options: &ThemeOptions, ) -> Result { let raw::PresentationTheme { slide_title, code, execution_output, pty_output, inline_code, bold, italics, table, block_quote, alert, default_style, column_layout, headings, intro_slide, footer, typst, mermaid, d2, modals, layout_grid, palette, extends: _, } = raw; let palette = ColorPalette::try_from(palette)?; let default_style = DefaultStyle::new(default_style, &palette)?; Ok(Self { slide_title: SlideTitleStyle::new(slide_title, &palette, options)?, code: CodeBlockStyle::new(code), execution_output: ExecutionOutputBlockStyle::new(execution_output, &palette)?, pty_output: PtyOutputBlockStyle::new(pty_output, &palette)?, inline_code: ModifierStyle::new(inline_code, &palette)?, bold: ModifierStyle::new(bold, &palette)?, italics: ModifierStyle::new(italics, &palette)?, table: table.clone().map(Into::into), block_quote: BlockQuoteStyle::new(block_quote, &palette)?, alert: AlertStyle::new(alert, &palette)?, default_style: default_style.clone(), column_layout: ColumnLayoutStyle::new(column_layout), headings: HeadingStyles::new(headings, &palette, options)?, intro_slide: IntroSlideStyle::new(intro_slide, &palette, options)?, footer: FooterStyle::new(&footer.clone().unwrap_or_default(), &palette, resources)?, typst: TypstStyle::new(typst, &palette)?, mermaid: MermaidStyle::new(mermaid), d2: D2Style::new(d2), modals: ModalStyle::new(modals, &default_style, &palette)?, layout_grid: LayoutGridStyle::new(layout_grid, &default_style, &palette)?, palette, }) } pub(crate) fn alignment(&self, element: &ElementType) -> Alignment { use ElementType::*; let alignment = match element { SlideTitle => self.slide_title.alignment, Heading1 => self.headings.h1.alignment, Heading2 => self.headings.h2.alignment, Heading3 => self.headings.h3.alignment, Heading4 => self.headings.h4.alignment, Heading5 => self.headings.h5.alignment, Heading6 => self.headings.h6.alignment, Paragraph | List => Some(self.default_style.alignment), PresentationTitle => self.intro_slide.title.alignment, PresentationSubTitle => self.intro_slide.subtitle.alignment, PresentationEvent => self.intro_slide.event.alignment, PresentationLocation => self.intro_slide.location.alignment, PresentationDate => self.intro_slide.date.alignment, PresentationAuthor => self.intro_slide.author.alignment, Table => self.table, BlockQuote => self.block_quote.alignment, }; alignment.unwrap_or(self.default_style.alignment) } } #[derive(Debug, thiserror::Error)] pub(crate) enum ProcessingThemeError { #[error(transparent)] Palette(#[from] UndefinedPaletteColorError), #[error("palette cannot contain other palette colors")] PaletteColorInPalette, #[error("invalid footer image: {0}")] FooterImage(RegisterImageError), } #[derive(Clone, Debug)] pub(crate) struct SlideTitleStyle { pub(crate) alignment: Option, pub(crate) separator: bool, pub(crate) padding_top: u8, pub(crate) padding_bottom: u8, pub(crate) style: TextStyle, pub(crate) prefix: String, } impl SlideTitleStyle { fn new( raw: &raw::SlideTitleStyle, palette: &ColorPalette, options: &ThemeOptions, ) -> Result { let raw::SlideTitleStyle { alignment, separator, padding_top, padding_bottom, colors, bold, italics, underlined, font_size, prefix, } = raw; let colors = colors.resolve(palette)?; let mut style = TextStyle::colored(colors).size(options.adjust_font_size(*font_size)); if bold.unwrap_or_default() { style = style.bold(); } if italics.unwrap_or_default() { style = style.italics(); } if underlined.unwrap_or_default() { style = style.underlined(); } Ok(Self { alignment: alignment.clone().map(Into::into), separator: *separator, padding_top: padding_top.unwrap_or_default(), padding_bottom: padding_bottom.unwrap_or_default(), style, prefix: prefix.clone().unwrap_or_default(), }) } } #[derive(Clone, Debug)] pub(crate) struct HeadingStyles { pub(crate) h1: HeadingStyle, pub(crate) h2: HeadingStyle, pub(crate) h3: HeadingStyle, pub(crate) h4: HeadingStyle, pub(crate) h5: HeadingStyle, pub(crate) h6: HeadingStyle, } impl HeadingStyles { fn new( raw: &raw::HeadingStyles, palette: &ColorPalette, options: &ThemeOptions, ) -> Result { let raw::HeadingStyles { h1, h2, h3, h4, h5, h6 } = raw; Ok(Self { h1: HeadingStyle::new(h1, palette, options)?, h2: HeadingStyle::new(h2, palette, options)?, h3: HeadingStyle::new(h3, palette, options)?, h4: HeadingStyle::new(h4, palette, options)?, h5: HeadingStyle::new(h5, palette, options)?, h6: HeadingStyle::new(h6, palette, options)?, }) } } #[derive(Clone, Debug)] pub(crate) struct HeadingStyle { pub(crate) alignment: Option, pub(crate) prefix: Option, pub(crate) style: TextStyle, } impl HeadingStyle { fn new( raw: &raw::HeadingStyle, palette: &ColorPalette, options: &ThemeOptions, ) -> Result { let raw::HeadingStyle { alignment, prefix, colors, font_size, bold, underlined, italics } = raw; let alignment = alignment.clone().map(Into::into); let mut style = TextStyle::colored(colors.resolve(palette)?).size(options.adjust_font_size(*font_size)); if bold.unwrap_or_default() { style = style.bold(); } if underlined.unwrap_or_default() { style = style.underlined(); } if italics.unwrap_or_default() { style = style.italics(); } Ok(Self { alignment, prefix: prefix.clone(), style }) } } #[derive(Clone, Debug)] pub(crate) struct BlockQuoteStyle { pub(crate) alignment: Option, pub(crate) prefix: String, pub(crate) base_style: TextStyle, pub(crate) prefix_style: TextStyle, } impl BlockQuoteStyle { fn new(raw: &raw::BlockQuoteStyle, palette: &ColorPalette) -> Result { let raw::BlockQuoteStyle { alignment, prefix, colors } = raw; let alignment = alignment.clone().map(Into::into); let prefix = prefix.as_deref().unwrap_or(DEFAULT_BLOCK_QUOTE_PREFIX).to_string(); let base_style = TextStyle::colored(colors.base.resolve(palette)?); let mut prefix_style = TextStyle::colored(colors.base.resolve(palette)?); if let Some(color) = &colors.prefix { prefix_style.colors.foreground = color.resolve(palette)?; } Ok(Self { alignment, prefix, base_style, prefix_style }) } } #[derive(Clone, Debug)] pub(crate) struct AlertStyle { pub(crate) alignment: Alignment, pub(crate) base_style: TextStyle, pub(crate) prefix: String, pub(crate) styles: AlertTypeStyles, } impl AlertStyle { fn new(raw: &raw::AlertStyle, palette: &ColorPalette) -> Result { let raw::AlertStyle { alignment, base_colors, prefix, styles } = raw; let alignment = alignment.clone().unwrap_or_default().into(); let base_style = TextStyle::colored(base_colors.resolve(palette)?); let prefix = prefix.as_deref().unwrap_or(DEFAULT_BLOCK_QUOTE_PREFIX).to_string(); let styles = AlertTypeStyles::new(styles, base_style, palette)?; Ok(Self { alignment, base_style, prefix, styles }) } } #[derive(Clone, Debug)] pub(crate) struct AlertTypeStyles { pub(crate) note: AlertTypeStyle, pub(crate) tip: AlertTypeStyle, pub(crate) important: AlertTypeStyle, pub(crate) warning: AlertTypeStyle, pub(crate) caution: AlertTypeStyle, } impl AlertTypeStyles { fn new( raw: &raw::AlertTypeStyles, base_style: TextStyle, palette: &ColorPalette, ) -> Result { let raw::AlertTypeStyles { note, tip, important, warning, caution } = raw; Ok(Self { note: AlertTypeStyle::new( note, &AlertTypeDefaults { title: "Note", icon: "󰋽", color: Color::Blue }, base_style, palette, )?, tip: AlertTypeStyle::new( tip, &AlertTypeDefaults { title: "Tip", icon: "", color: Color::Green }, base_style, palette, )?, important: AlertTypeStyle::new( important, &AlertTypeDefaults { title: "Important", icon: "", color: Color::Cyan }, base_style, palette, )?, warning: AlertTypeStyle::new( warning, &AlertTypeDefaults { title: "Warning", icon: "", color: Color::Yellow }, base_style, palette, )?, caution: AlertTypeStyle::new( caution, &AlertTypeDefaults { title: "Caution", icon: "󰳦", color: Color::Red }, base_style, palette, )?, }) } } #[derive(Clone, Debug)] pub(crate) struct AlertTypeStyle { pub(crate) style: TextStyle, pub(crate) title: String, pub(crate) icon: String, } impl AlertTypeStyle { fn new( raw: &raw::AlertTypeStyle, defaults: &AlertTypeDefaults, base_style: TextStyle, palette: &ColorPalette, ) -> Result { let raw::AlertTypeStyle { color, title, icon, .. } = raw; let color = color.as_ref().map(|c| c.resolve(palette)).transpose()?.flatten().unwrap_or(defaults.color); let style = base_style.fg_color(color); let title = title.as_deref().unwrap_or(defaults.title).to_string(); let icon = icon.as_deref().unwrap_or(defaults.icon).to_string(); Ok(Self { style, title, icon }) } } struct AlertTypeDefaults { title: &'static str, icon: &'static str, color: Color, } #[derive(Clone, Debug)] pub(crate) struct IntroSlideStyle { pub(crate) title: IntroSlideTitleStyle, pub(crate) subtitle: IntroSlideLabelStyle, pub(crate) event: IntroSlideLabelStyle, pub(crate) location: IntroSlideLabelStyle, pub(crate) date: IntroSlideLabelStyle, pub(crate) author: AuthorStyle, pub(crate) footer: bool, } impl IntroSlideStyle { fn new( raw: &raw::IntroSlideStyle, palette: &ColorPalette, options: &ThemeOptions, ) -> Result { let raw::IntroSlideStyle { title, subtitle, event, location, date, author, footer } = raw; Ok(Self { title: IntroSlideTitleStyle::new(title, palette, options)?, subtitle: IntroSlideLabelStyle::new(subtitle, palette)?, event: IntroSlideLabelStyle::new(event, palette)?, location: IntroSlideLabelStyle::new(location, palette)?, date: IntroSlideLabelStyle::new(date, palette)?, author: AuthorStyle::new(author, palette)?, footer: footer.unwrap_or(false), }) } } #[derive(Clone, Debug, Default)] pub(crate) struct IntroSlideLabelStyle { pub(crate) alignment: Option, pub(crate) style: TextStyle, } impl IntroSlideLabelStyle { fn new(raw: &raw::BasicStyle, palette: &ColorPalette) -> Result { let raw::BasicStyle { alignment, colors } = raw; let style = TextStyle::colored(colors.resolve(palette)?); Ok(Self { alignment: alignment.clone().map(Into::into), style }) } } #[derive(Clone, Debug, Default)] pub(crate) struct IntroSlideTitleStyle { pub(crate) alignment: Option, pub(crate) style: TextStyle, } impl IntroSlideTitleStyle { fn new( raw: &raw::IntroSlideTitleStyle, palette: &ColorPalette, options: &ThemeOptions, ) -> Result { let raw::IntroSlideTitleStyle { alignment, colors, font_size } = raw; let style = TextStyle::colored(colors.resolve(palette)?).size(options.adjust_font_size(*font_size)); Ok(Self { alignment: alignment.clone().map(Into::into), style }) } } #[derive(Clone, Debug, Default)] pub(crate) struct AuthorStyle { pub(crate) alignment: Option, pub(crate) style: TextStyle, pub(crate) positioning: AuthorPositioning, } impl AuthorStyle { fn new(raw: &raw::AuthorStyle, palette: &ColorPalette) -> Result { let raw::AuthorStyle { alignment, colors, positioning } = raw; let style = TextStyle::colored(colors.resolve(palette)?); Ok(Self { alignment: alignment.clone().map(Into::into), style, positioning: positioning.clone() }) } } #[derive(Clone, Debug, Default)] pub(crate) struct DefaultStyle { pub(crate) margin: Margin, pub(crate) style: TextStyle, pub(crate) alignment: Alignment, } impl DefaultStyle { fn new(raw: &raw::DefaultStyle, palette: &ColorPalette) -> Result { let raw::DefaultStyle { margin, colors, alignment } = raw; let margin = margin.unwrap_or_default(); let style = TextStyle::colored(colors.resolve(palette)?); let alignment = alignment.clone().unwrap_or_default().into(); Ok(Self { margin, style, alignment }) } } #[derive(Clone, Debug, Default)] pub(crate) struct ColumnLayoutStyle { pub(crate) margin: Margin, } impl ColumnLayoutStyle { fn new(raw: &raw::ColumnLayoutStyle) -> Self { let raw::ColumnLayoutStyle { margin } = raw; let margin = margin.unwrap_or_default(); Self { margin } } } #[derive(Copy, Clone, Debug, PartialEq)] pub(crate) enum Alignment { Left { margin: Margin }, Right { margin: Margin }, Center { minimum_margin: Margin, minimum_size: u16 }, } impl Alignment { pub(crate) fn adjust_size(&self, size: u16) -> u16 { match self { Self::Left { .. } | Self::Right { .. } => size, Self::Center { minimum_size, .. } => size.max(*minimum_size), } } } impl From for Alignment { fn from(alignment: raw::Alignment) -> Self { match alignment { raw::Alignment::Left { margin } => Self::Left { margin }, raw::Alignment::Right { margin } => Self::Right { margin }, raw::Alignment::Center { minimum_margin, minimum_size } => Self::Center { minimum_margin, minimum_size }, } } } impl Default for Alignment { fn default() -> Self { Self::Left { margin: Margin::Fixed(0) } } } #[derive(Clone, Debug, Default)] pub(crate) enum FooterStyle { Template { left: Option, center: Option, right: Option, style: TextStyle, height: u16, }, ProgressBar { character: char, style: TextStyle, }, #[default] Empty, } impl FooterStyle { fn new( raw: &raw::FooterStyle, palette: &ColorPalette, resources: &Resources, ) -> Result { match raw { raw::FooterStyle::Template { left, center, right, colors, height } => { let left = left.as_ref().map(|t| FooterContent::new(t, resources)).transpose()?; let center = center.as_ref().map(|t| FooterContent::new(t, resources)).transpose()?; let right = right.as_ref().map(|t| FooterContent::new(t, resources)).transpose()?; let style = TextStyle::colored(colors.resolve(palette)?); let height = height.unwrap_or(DEFAULT_FOOTER_HEIGHT); Ok(Self::Template { left, center, right, style, height }) } raw::FooterStyle::ProgressBar { character, colors } => { let character = character.unwrap_or(DEFAULT_PROGRESS_BAR_CHAR); let style = TextStyle::colored(colors.resolve(palette)?); Ok(Self::ProgressBar { character, style }) } raw::FooterStyle::Empty => Ok(Self::Empty), } } pub(crate) fn height(&self) -> u16 { match self { Self::Template { height, .. } => *height, _ => DEFAULT_FOOTER_HEIGHT, } } } #[derive(Clone, Debug)] pub(crate) enum FooterContent { Template(FooterTemplate), Image(Image), } impl FooterContent { fn new(raw: &raw::FooterContent, resources: &Resources) -> Result { match raw { raw::FooterContent::Template(template) => Ok(Self::Template(template.clone())), raw::FooterContent::Image { path } => { let image = resources.theme_image(path).map_err(ProcessingThemeError::FooterImage)?; Ok(Self::Image(image)) } } } } #[derive(Clone, Debug, Default)] pub(crate) struct CodeBlockStyle { pub(crate) alignment: Alignment, pub(crate) padding: PaddingRect, pub(crate) theme_name: String, pub(crate) background: bool, pub(crate) line_numbers: bool, } impl CodeBlockStyle { fn new(raw: &raw::CodeBlockStyle) -> Self { let raw::CodeBlockStyle { alignment, padding, theme_name, background, line_numbers } = raw; let padding = PaddingRect { horizontal: padding.horizontal.unwrap_or_default(), vertical: padding.vertical.unwrap_or_default(), }; Self { alignment: alignment.clone().unwrap_or_default().into(), padding, theme_name: theme_name.as_deref().unwrap_or(DEFAULT_CODE_HIGHLIGHT_THEME).to_string(), background: background.unwrap_or(true), line_numbers: line_numbers.unwrap_or_default(), } } } /// Vertical/horizontal padding. #[derive(Clone, Copy, Debug, Default)] pub(crate) struct PaddingRect { /// The number of columns to use as horizontal padding. pub(crate) horizontal: u8, /// The number of rows to use as vertical padding. pub(crate) vertical: u8, } #[derive(Clone, Debug, Default)] pub(crate) struct ExecutionOutputBlockStyle { pub(crate) style: TextStyle, pub(crate) status: ExecutionStatusBlockStyle, pub(crate) padding: PaddingRect, } impl ExecutionOutputBlockStyle { fn new(raw: &raw::ExecutionOutputBlockStyle, palette: &ColorPalette) -> Result { let raw::ExecutionOutputBlockStyle { colors, status, padding } = raw; let colors = colors.resolve(palette)?; let style = TextStyle::colored(colors); let padding = PaddingRect { horizontal: padding.horizontal.unwrap_or_default(), vertical: padding.vertical.unwrap_or_default(), }; Ok(Self { style, status: ExecutionStatusBlockStyle::new(status, palette)?, padding }) } } #[derive(Copy, Clone, Debug, Default)] pub(crate) struct ExecutionStatusBlockStyle { pub(crate) running_style: TextStyle, pub(crate) success_style: TextStyle, pub(crate) failure_style: TextStyle, pub(crate) not_started_style: TextStyle, } impl ExecutionStatusBlockStyle { fn new(raw: &raw::ExecutionStatusBlockStyle, palette: &ColorPalette) -> Result { let raw::ExecutionStatusBlockStyle { running, success, failure, not_started } = raw; let running_style = TextStyle::colored(running.resolve(palette)?); let success_style = TextStyle::colored(success.resolve(palette)?); let failure_style = TextStyle::colored(failure.resolve(palette)?); let not_started_style = TextStyle::colored(not_started.resolve(palette)?); Ok(Self { running_style, success_style, failure_style, not_started_style }) } } #[derive(Clone, Debug, Default)] pub(crate) struct PtyOutputBlockStyle { pub(crate) style: TextStyle, pub(crate) standby: PtyStandbyStyle, pub(crate) cursor: PtyCursorStyle, } impl PtyOutputBlockStyle { fn new(raw: &raw::PtyOutputBlockStyle, palette: &ColorPalette) -> Result { let raw::PtyOutputBlockStyle { colors, standby, cursor } = raw; let colors = colors.resolve(palette)?; let style = TextStyle::colored(colors); let standby = match standby { Some(raw::PtyStandbyStyle::LargePlay) => PtyStandbyStyle::LargePlay, None => Default::default(), }; let cursor = PtyCursorStyle::new(cursor, palette)?; Ok(Self { style, standby, cursor }) } } #[derive(Clone, Debug, Default)] pub(crate) struct PtyCursorStyle { pub(crate) symbol: String, pub(crate) highlight_style: TextStyle, } impl PtyCursorStyle { fn new(raw: &raw::PtyCursorStyle, palette: &ColorPalette) -> Result { let raw::PtyCursorStyle { symbol, highlight_colors } = raw; let symbol = symbol.unwrap_or(DEFAULT_PTY_CURSOR_SYMBOL).to_string(); let highlight_style = TextStyle::colored(highlight_colors.resolve(palette)?); Ok(Self { symbol, highlight_style }) } } #[derive(Clone, Debug, Default)] pub(crate) enum PtyStandbyStyle { #[default] LargePlay, } impl PtyStandbyStyle { pub(crate) fn as_lines(&self) -> &[&str] { match self { Self::LargePlay => &[ "⠀⠀⠀⠀⠀⠀⠀⢀⣠⣤⣤⣶⣶⣶⣶⣤⣤⣄⡀⠀⠀⠀⠀⠀⠀⠀", "⠀⠀⠀⠀⢀⣤⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣤⡀⠀⠀⠀⠀", "⠀⠀⠀⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⠀⠀⠀", "⠀⢀⣾⣿⣿⣿⣿⣿⣿⣿⡿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡀⠀", "⠀⣾⣿⣿⣿⣿⣿⣿⣿⣿⡇⠈⠙⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⠀", "⢠⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠀⠀⠀⠈⠻⢿⣿⣿⣿⣿⣿⣿⣿⣿⡄", "⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀⣉⣿⣿⣿⣿⣿⣿⣿⡇", "⠘⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠀⠀⠀⢀⣴⣾⣿⣿⣿⣿⣿⣿⣿⣿⠃", "⠀⢿⣿⣿⣿⣿⣿⣿⣿⣿⡇⢀⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠀", "⠀⠈⢿⣿⣿⣿⣿⣿⣿⣿⣷⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠁⠀", "⠀⠀⠀⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠀⠀⠀", "⠀⠀⠀⠀⠈⠛⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠛⠁⠀⠀⠀⠀", "⠀⠀⠀⠀⠀⠀⠀⠈⠙⠛⠛⠿⠿⠿⠿⠛⠛⠋⠁⠀⠀⠀⠀⠀⠀⠀", ], } } } #[derive(Clone, Debug, Default)] pub(crate) struct ModifierStyle { pub(crate) style: TextStyle, } impl ModifierStyle { fn new(raw: &raw::ModifierStyle, palette: &ColorPalette) -> Result { let raw::ModifierStyle { colors } = raw; let style = TextStyle::colored(colors.resolve(palette)?); Ok(Self { style }) } } #[derive(Clone, Debug)] pub(crate) enum ElementType { SlideTitle, Heading1, Heading2, Heading3, Heading4, Heading5, Heading6, Paragraph, PresentationTitle, PresentationSubTitle, PresentationEvent, PresentationLocation, PresentationDate, PresentationAuthor, Table, BlockQuote, List, } #[derive(Clone, Debug)] pub(crate) struct TypstStyle { pub(crate) horizontal_margin: u16, pub(crate) vertical_margin: u16, pub(crate) style: TextStyle, } impl TypstStyle { fn new(raw: &raw::TypstStyle, palette: &ColorPalette) -> Result { let raw::TypstStyle { horizontal_margin, vertical_margin, colors } = raw; let horizontal_margin = horizontal_margin.unwrap_or(DEFAULT_TYPST_HORIZONTAL_MARGIN); let vertical_margin = vertical_margin.unwrap_or(DEFAULT_TYPST_VERTICAL_MARGIN); let style = TextStyle::colored(colors.resolve(palette)?); Ok(Self { horizontal_margin, vertical_margin, style }) } } #[derive(Clone, Debug)] pub(crate) struct MermaidStyle { pub(crate) theme: String, pub(crate) background: String, } impl MermaidStyle { fn new(raw: &raw::MermaidStyle) -> Self { let raw::MermaidStyle { theme, background } = raw; let theme = theme.as_deref().unwrap_or(DEFAULT_MERMAID_THEME).to_string(); let background = background.as_deref().unwrap_or(DEFAULT_MERMAID_BACKGROUND).to_string(); Self { theme, background } } } #[derive(Clone, Debug)] pub(crate) struct D2Style { pub(crate) theme: String, } impl D2Style { fn new(raw: &raw::D2Style) -> Self { let raw::D2Style { theme } = raw; let theme = theme.unwrap_or(DEFAULT_D2_THEME).to_string(); Self { theme } } } #[derive(Clone, Debug)] pub(crate) struct ModalStyle { pub(crate) style: TextStyle, pub(crate) selection_style: TextStyle, } impl ModalStyle { fn new( raw: &raw::ModalStyle, default_style: &DefaultStyle, palette: &ColorPalette, ) -> Result { let raw::ModalStyle { colors, selection_colors } = raw; let mut style = default_style.style; style.merge(&TextStyle::colored(colors.resolve(palette)?)); let mut selection_style = style.bold(); selection_style.merge(&TextStyle::colored(selection_colors.resolve(palette)?)); Ok(Self { style, selection_style }) } } #[derive(Clone, Debug)] pub(crate) struct LayoutGridStyle { pub(crate) style: TextStyle, } impl LayoutGridStyle { fn new( raw: &raw::LayoutGridStyle, default_style: &DefaultStyle, palette: &ColorPalette, ) -> Result { let raw::LayoutGridStyle { color } = raw; let mut style = default_style.style; if let Some(color) = color { style.colors.foreground = color.resolve(palette)?; } Ok(Self { style }) } } /// The color palette. #[derive(Clone, Debug, Default)] pub(crate) struct ColorPalette { pub(crate) colors: BTreeMap, pub(crate) classes: BTreeMap, } impl TryFrom<&raw::ColorPalette> for ColorPalette { type Error = ProcessingThemeError; fn try_from(palette: &raw::ColorPalette) -> Result { let mut colors = BTreeMap::new(); let mut classes = BTreeMap::new(); for (name, color) in &palette.colors { let raw::RawColor::Color(color) = color else { return Err(ProcessingThemeError::PaletteColorInPalette); }; colors.insert(name.clone(), *color); } let resolve_local = |color: &RawColor| match color { raw::RawColor::Color(c) => Ok(*c), raw::RawColor::Palette(name) => colors .get(name) .copied() .ok_or_else(|| ProcessingThemeError::Palette(UndefinedPaletteColorError(name.clone()))), _ => Err(ProcessingThemeError::PaletteColorInPalette), }; for (name, colors) in &palette.classes { let foreground = colors.foreground.as_ref().map(resolve_local).transpose()?; let background = colors.background.as_ref().map(resolve_local).transpose()?; classes.insert(name.clone(), Colors { foreground, background }); } Ok(Self { colors, classes }) } } ================================================ FILE: src/theme/mod.rs ================================================ pub(crate) mod clean; pub(crate) mod raw; pub(crate) mod registry; pub(crate) use clean::*; pub(crate) use raw::{AuthorPositioning, FooterTemplate, FooterTemplateChunk, Margin}; ================================================ FILE: src/theme/raw.rs ================================================ use super::registry::LoadThemeError; use crate::markdown::text_style::{Color, Colors, UndefinedPaletteColorError}; use hex::{FromHex, FromHexError}; use serde::{Deserialize, Serialize, de::Visitor}; use std::{ collections::BTreeMap, fmt, fs, path::{Path, PathBuf}, str::FromStr, }; pub(crate) type RawColors = Colors; /// A presentation theme. #[derive(Default, Clone, Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct PresentationTheme { /// The theme this theme extends from. #[serde(default)] pub(crate) extends: Option, /// The style for a slide's title. #[serde(default)] pub(crate) slide_title: SlideTitleStyle, /// The style for a block of code. #[serde(default)] pub(crate) code: CodeBlockStyle, /// The style for the execution output of a piece of code. #[serde(default)] pub(crate) execution_output: ExecutionOutputBlockStyle, /// The style for the pty output of a piece of code. #[serde(default)] pub(crate) pty_output: PtyOutputBlockStyle, /// The style for inline code. #[serde(default)] pub(crate) inline_code: ModifierStyle, /// The style for bold text. #[serde(default)] pub(crate) bold: ModifierStyle, /// The style for italics. #[serde(default, alias = "italic")] pub(crate) italics: ModifierStyle, /// The style for a table. #[serde(default)] pub(crate) table: Option, /// The style for a block quote. #[serde(default)] pub(crate) block_quote: BlockQuoteStyle, /// The style for an alert. #[serde(default)] pub(crate) alert: AlertStyle, /// The default style. #[serde(rename = "default", default)] pub(crate) default_style: DefaultStyle, /// The style for column layouts. #[serde(default)] pub(crate) column_layout: ColumnLayoutStyle, //// The style of all headings. #[serde(default)] pub(crate) headings: HeadingStyles, /// The style of the introduction slide. #[serde(default)] pub(crate) intro_slide: IntroSlideStyle, /// The style of the presentation footer. #[serde(default)] pub(crate) footer: Option, /// The style for typst auto-rendered code blocks. #[serde(default)] pub(crate) typst: TypstStyle, /// The style for mermaid auto-rendered code blocks. #[serde(default)] pub(crate) mermaid: MermaidStyle, /// The style for d2 auto-rendered code blocks. #[serde(default)] pub(crate) d2: D2Style, /// The style for modals. #[serde(default)] pub(crate) modals: ModalStyle, /// The style for layouts. #[serde(default)] pub(crate) layout_grid: LayoutGridStyle, /// The color palette. #[serde(default)] pub(crate) palette: ColorPalette, } impl PresentationTheme { /// Construct a presentation from a path. pub(crate) fn from_path>(path: P) -> Result { let contents = fs::read_to_string(&path).map_err(|e| LoadThemeError::Reading(path.as_ref().into(), e))?; let theme = serde_yaml::from_str(&contents) .map_err(|e| LoadThemeError::Corrupted(path.as_ref().display().to_string(), e.into()))?; Ok(theme) } } /// The style of a slide title. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(crate) struct SlideTitleStyle { /// The alignment. #[serde(flatten, default)] pub(crate) alignment: Option, /// Whether to use a separator line. #[serde(default)] pub(crate) separator: bool, /// The padding that should be added before the text. #[serde(default)] pub(crate) padding_top: Option, /// The padding that should be added after the text. #[serde(default)] pub(crate) padding_bottom: Option, /// The colors to be used. #[serde(default)] pub(crate) colors: RawColors, /// The prefix to be added to the slide title. #[serde(default)] pub(crate) prefix: Option, /// Whether to use bold font for slide titles. #[serde(default)] pub(crate) bold: Option, /// Whether to use italics font for slide titles. #[serde(default, alias = "italic")] pub(crate) italics: Option, /// Whether to use underlined font for slide titles. #[serde(default)] pub(crate) underlined: Option, /// The font size to be used if the terminal supports it. #[serde(default)] pub(crate) font_size: Option, } /// The style for all headings. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(crate) struct HeadingStyles { /// H1 style. #[serde(default)] pub(crate) h1: HeadingStyle, /// H2 style. #[serde(default)] pub(crate) h2: HeadingStyle, /// H3 style. #[serde(default)] pub(crate) h3: HeadingStyle, /// H4 style. #[serde(default)] pub(crate) h4: HeadingStyle, /// H5 style. #[serde(default)] pub(crate) h5: HeadingStyle, /// H6 style. #[serde(default)] pub(crate) h6: HeadingStyle, } /// The style for a heading. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(crate) struct HeadingStyle { /// The alignment. #[serde(flatten, default)] pub(crate) alignment: Option, /// The prefix to be added to this heading. /// /// This allows adding text like "->" to every heading. #[serde(default)] pub(crate) prefix: Option, /// The colors to be used. #[serde(default)] pub(crate) colors: RawColors, /// The font size to be used if the terminal supports it. #[serde(default)] pub(crate) font_size: Option, /// Whether the heading is bold. #[serde(default)] pub(crate) bold: Option, /// Whether the heading is underlined. #[serde(default)] pub(crate) underlined: Option, /// Whether the heading uses italics. #[serde(default, alias = "italic")] pub(crate) italics: Option, } /// The style of a block quote. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(crate) struct BlockQuoteStyle { /// The alignment. #[serde(flatten, default)] pub(crate) alignment: Option, /// The prefix to be added to this block quote. /// /// This allows adding something like a vertical bar before the text. #[serde(default)] pub(crate) prefix: Option, /// The colors to be used. #[serde(default)] pub(crate) colors: BlockQuoteColors, } /// The colors of a block quote. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(crate) struct BlockQuoteColors { /// The foreground/background colors. #[serde(flatten)] pub(crate) base: RawColors, /// The color of the vertical bar that prefixes each line in the quote. #[serde(default)] pub(crate) prefix: Option, } /// The style of an alert. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(crate) struct AlertStyle { /// The alignment. #[serde(flatten, default)] pub(crate) alignment: Option, /// The base colors. #[serde(default)] pub(crate) base_colors: RawColors, /// The prefix to be added to this block quote. /// /// This allows adding something like a vertical bar before the text. #[serde(default)] pub(crate) prefix: Option, /// The style for each alert type. #[serde(default)] pub(crate) styles: AlertTypeStyles, } /// The style for each alert type. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(crate) struct AlertTypeStyles { /// The style for note alert types. #[serde(default)] pub(crate) note: AlertTypeStyle, /// The style for tip alert types. #[serde(default)] pub(crate) tip: AlertTypeStyle, /// The style for important alert types. #[serde(default)] pub(crate) important: AlertTypeStyle, /// The style for warning alert types. #[serde(default)] pub(crate) warning: AlertTypeStyle, /// The style for caution alert types. #[serde(default)] pub(crate) caution: AlertTypeStyle, } /// The style for an alert type. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(crate) struct AlertTypeStyle { /// The color to be used. #[serde(default)] pub(crate) color: Option, /// The title to be used. #[serde(default)] pub(crate) title: Option, /// The icon to be used. #[serde(default)] pub(crate) icon: Option, } /// The style for the presentation introduction slide. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(crate) struct IntroSlideStyle { /// The style of the title line. #[serde(default)] pub(crate) title: IntroSlideTitleStyle, /// The style of the subtitle line. #[serde(default)] pub(crate) subtitle: BasicStyle, /// The style of the event line. #[serde(default)] pub(crate) event: BasicStyle, /// The style of the location line. #[serde(default)] pub(crate) location: BasicStyle, /// The style of the date line. #[serde(default)] pub(crate) date: BasicStyle, /// The style of the author line. #[serde(default)] pub(crate) author: AuthorStyle, /// Whether we want a footer in the intro slide. #[serde(default)] pub(crate) footer: Option, } /// A simple style. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(crate) struct DefaultStyle { /// The margin on the left/right of the screen. #[serde(default, with = "serde_yaml::with::singleton_map")] pub(crate) margin: Option, /// The colors to be used. #[serde(default)] pub(crate) colors: RawColors, /// The alignment for all elements. #[serde(flatten, default)] pub(crate) alignment: Option, } /// The column layout style. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(crate) struct ColumnLayoutStyle { /// The margin in between two columns. #[serde(default, with = "serde_yaml::with::singleton_map")] pub(crate) margin: Option, } /// A simple style. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(crate) struct BasicStyle { /// The alignment. #[serde(flatten, default)] pub(crate) alignment: Option, /// The colors to be used. #[serde(default)] pub(crate) colors: RawColors, } /// The intro slide title's style. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(crate) struct IntroSlideTitleStyle { /// The alignment. #[serde(flatten, default)] pub(crate) alignment: Option, /// The colors to be used. #[serde(default)] pub(crate) colors: RawColors, /// The font size to be used if the terminal supports it. #[serde(default)] pub(crate) font_size: Option, } /// Text alignment. /// /// This allows anchoring presentation elements to the left, center, or right of the screen. #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[serde(tag = "alignment", rename_all = "snake_case")] pub(crate) enum Alignment { /// Left alignment. Left { /// The margin before any text. #[serde(default)] margin: Margin, }, /// Right alignment. Right { /// The margin after any text. #[serde(default)] margin: Margin, }, /// Center alignment. Center { /// The minimum margin expected. #[serde(default)] minimum_margin: Margin, /// The minimum size of this element, in columns. #[serde(default)] minimum_size: u16, }, } impl Default for Alignment { fn default() -> Self { Self::Left { margin: Margin::Fixed(0) } } } /// The style for the author line in the presentation intro slide. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(crate) struct AuthorStyle { /// The alignment. #[serde(flatten, default)] pub(crate) alignment: Option, /// The colors to be used. #[serde(default)] pub(crate) colors: RawColors, /// The positioning of the author's name. #[serde(default)] pub(crate) positioning: AuthorPositioning, } /// The style of the footer that's shown in every slide. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(tag = "style", rename_all = "snake_case")] pub(crate) enum FooterStyle { /// Use a template to generate the footer. Template { /// The content to be put on the left. left: Option, /// The content to be put on the center. center: Option, /// The content to be put on the right. right: Option, /// The colors to be used. #[serde(default)] colors: RawColors, /// The height of the footer area. height: Option, }, /// Use a progress bar. ProgressBar { /// The character that will be used for the progress bar. character: Option, /// The colors to be used. #[serde(default)] colors: RawColors, }, /// No footer. Empty, } impl Default for FooterStyle { fn default() -> Self { Self::Template { left: None, center: None, right: None, colors: RawColors::default(), height: None } } } #[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] pub(crate) enum FooterTemplateChunk { Literal(String), OpenBrace, ClosedBrace, CurrentSlide, TotalSlides, Author, Title, SubTitle, Event, Location, Date, } #[derive(Clone, Debug, Serialize)] #[serde(untagged)] pub(crate) enum FooterContent { Template(FooterTemplate), Image { #[serde(rename = "image")] path: PathBuf, }, } struct FooterContentVisitor; impl<'de> Visitor<'de> for FooterContentVisitor { type Value = FooterContent; fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { formatter.write_str("a valid footer") } fn visit_str(self, v: &str) -> Result where E: serde::de::Error, { let template = FooterTemplate::from_str(v).map_err(|e| E::custom(e.to_string()))?; Ok(FooterContent::Template(template)) } fn visit_map(self, mut map: A) -> Result where A: serde::de::MapAccess<'de>, { let Some((key, value)): Option<(String, PathBuf)> = map.next_entry()? else { return Err(serde::de::Error::custom("invalid footer")); }; match key.as_str() { "image" => Ok(FooterContent::Image { path: value }), _ => Err(serde::de::Error::invalid_value(serde::de::Unexpected::Str(&key), &self)), } } } impl<'de> Deserialize<'de> for FooterContent { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { deserializer.deserialize_any(FooterContentVisitor) } } #[derive(Clone, Debug)] pub(crate) struct FooterTemplate(pub(crate) Vec); crate::utils::impl_deserialize_from_str!(FooterTemplate); crate::utils::impl_serialize_from_display!(FooterTemplate); impl FromStr for FooterTemplate { type Err = ParseFooterTemplateError; fn from_str(s: &str) -> Result { let mut chunks = Vec::new(); let mut chunk_start = 0; let mut in_variable = false; let mut iter = s.char_indices().peekable(); while let Some((index, c)) = iter.next() { if c == '{' { if in_variable { return Err(ParseFooterTemplateError::NestedOpenBrace); } let double_brace = iter.peek() == Some(&(index + 1, '{')); if double_brace { iter.next(); if chunk_start != index { chunks.push(FooterTemplateChunk::Literal(s[chunk_start..index].to_string())); } chunks.push(FooterTemplateChunk::OpenBrace); chunk_start = index + 2; } else { in_variable = true; if chunk_start != index { chunks.push(FooterTemplateChunk::Literal(s[chunk_start..index].to_string())); } chunk_start = index + 1; } } else if c == '}' { if !in_variable { let double_brace = iter.peek() == Some(&(index + 1, '}')); if double_brace { iter.next(); chunks.push(FooterTemplateChunk::Literal(s[chunk_start..index].to_string())); chunks.push(FooterTemplateChunk::ClosedBrace); in_variable = false; chunk_start = index + 2; continue; } return Err(ParseFooterTemplateError::ClosedBraceWithoutOpen); } let variable = &s[chunk_start..index]; let chunk = match variable { "current_slide" => FooterTemplateChunk::CurrentSlide, "total_slides" => FooterTemplateChunk::TotalSlides, "author" => FooterTemplateChunk::Author, "title" => FooterTemplateChunk::Title, "sub_title" => FooterTemplateChunk::SubTitle, "event" => FooterTemplateChunk::Event, "location" => FooterTemplateChunk::Location, "date" => FooterTemplateChunk::Date, _ => return Err(ParseFooterTemplateError::UnsupportedVariable(variable.to_string())), }; chunks.push(chunk); in_variable = false; chunk_start = index + 1; } } if in_variable { return Err(ParseFooterTemplateError::TrailingBrace); } else if chunk_start != s.len() { chunks.push(FooterTemplateChunk::Literal(s[chunk_start..].to_string())); } Ok(Self(chunks)) } } impl fmt::Display for FooterTemplate { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { use FooterTemplateChunk::*; for c in &self.0 { match c { Literal(l) => write!(f, "{l}"), OpenBrace => write!(f, "{{{{"), ClosedBrace => write!(f, "}}}}"), CurrentSlide => write!(f, "{{current_slide}}"), TotalSlides => write!(f, "{{total_slides}}"), Author => write!(f, "{{author}}"), Title => write!(f, "{{title}}"), SubTitle => write!(f, "{{sub_title}}"), Event => write!(f, "{{event}}"), Location => write!(f, "{{location}}"), Date => write!(f, "{{date}}"), }?; } Ok(()) } } #[derive(Debug, thiserror::Error)] pub(crate) enum ParseFooterTemplateError { #[error("found '{{' while already inside '{{' scope")] NestedOpenBrace, #[error("open '{{' was not closed")] TrailingBrace, #[error("found '}}' but no '{{' was found")] ClosedBraceWithoutOpen, #[error("unsupported variable: '{0}'")] UnsupportedVariable(String), } /// The style for a piece of code. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(crate) struct CodeBlockStyle { /// The alignment. #[serde(flatten)] pub(crate) alignment: Option, /// The padding. #[serde(default)] pub(crate) padding: PaddingRect, /// The syntect theme name to use. #[serde(default)] pub(crate) theme_name: Option, /// Whether to use the theme's background color. pub(crate) background: Option, /// Whether to show line numbers in all code blocks. #[serde(default)] pub(crate) line_numbers: Option, } /// The style for the output of a code execution block. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(crate) struct ExecutionOutputBlockStyle { /// The colors to be used for the output pane. #[serde(default)] pub(crate) colors: RawColors, /// The colors to be used for the text that represents the status of the execution block. #[serde(default)] pub(crate) status: ExecutionStatusBlockStyle, /// The padding. #[serde(default)] pub(crate) padding: PaddingRect, } /// The style for the output of a code execution block running in pty mode. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(crate) struct PtyOutputBlockStyle { /// The colors to be used for the output pane. #[serde(default)] pub(crate) colors: RawColors, /// The style for the standby state. #[serde(default)] pub(crate) standby: Option, #[serde(default)] pub(crate) cursor: PtyCursorStyle, } /// The style for a PTY's cursor. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(crate) struct PtyCursorStyle { /// The symbol to use on the cursor. #[serde(default)] pub(crate) symbol: Option, /// The colors used when the cursor is on top of non empty cells. #[serde(default)] pub(crate) highlight_colors: RawColors, } /// The style for the standby state. #[derive(Clone, Debug, Deserialize, Serialize)] pub(crate) enum PtyStandbyStyle { /// Show a play icon. LargePlay, } /// The style for the status of a code execution block. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(crate) struct ExecutionStatusBlockStyle { /// The colors for the "running" status. #[serde(default)] pub(crate) running: RawColors, /// The colors for the "finished" status. #[serde(default)] pub(crate) success: RawColors, /// The colors for the "finished with error" status. #[serde(default)] pub(crate) failure: RawColors, /// The colors for the "not started" status. #[serde(default)] pub(crate) not_started: RawColors, } #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(crate) struct ModifierStyle { /// The colors to be used. #[serde(default)] pub(crate) colors: RawColors, } /// Vertical/horizontal padding. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(crate) struct PaddingRect { /// The number of columns to use as horizontal padding. #[serde(default)] pub(crate) horizontal: Option, /// The number of rows to use as vertical padding. #[serde(default)] pub(crate) vertical: Option, } /// A margin. #[derive(Copy, Clone, Debug, Deserialize, Serialize, PartialEq)] #[serde(rename_all = "snake_case")] pub(crate) enum Margin { /// A fixed number of characters. Fixed(u16), /// A percent of the screen size. Percent(u16), } impl Margin { pub(crate) fn as_characters(&self, screen_size: u16) -> u16 { match *self { Self::Fixed(value) => value, Self::Percent(percent) => { let ratio = percent as f64 / 100.0; (screen_size as f64 * ratio).ceil() as u16 } } } pub(crate) fn is_empty(&self) -> bool { matches!(self, Self::Fixed(0) | Self::Percent(0)) } } impl Default for Margin { fn default() -> Self { Self::Fixed(0) } } /// Where to position the author's name in the intro slide. #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] pub(crate) enum AuthorPositioning { /// Right below the title. BelowTitle, /// At the bottom of the page. #[default] PageBottom, } /// Typst styles. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(crate) struct TypstStyle { /// The horizontal margin on the generated images. pub(crate) horizontal_margin: Option, /// The vertical margin on the generated images. pub(crate) vertical_margin: Option, /// The colors to be used. #[serde(default)] pub(crate) colors: RawColors, } /// Mermaid styles. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(crate) struct MermaidStyle { /// The mermaidjs theme to use. pub(crate) theme: Option, /// The background color to use. pub(crate) background: Option, } /// D2 styles. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(crate) struct D2Style { /// The d2 theme id to use. pub(crate) theme: Option, } /// Modals style. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(crate) struct ModalStyle { /// The default colors to use for everything in the modal. #[serde(default)] pub(crate) colors: RawColors, /// The colors to use for selected lines. #[serde(default)] pub(crate) selection_colors: RawColors, } /// Layout grid style. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(crate) struct LayoutGridStyle { /// The color for layout grids. #[serde(default)] pub(crate) color: Option, } /// The color palette. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(crate) struct ColorPalette { #[serde(default)] pub(crate) colors: BTreeMap, #[serde(default)] pub(crate) classes: BTreeMap, } #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum RawColor { Color(Color), Palette(String), ForegroundClass(String), BackgroundClass(String), } crate::utils::impl_deserialize_from_str!(RawColor); crate::utils::impl_serialize_from_display!(RawColor); impl RawColor { fn new_palette(name: &str) -> Result { if name.is_empty() { Err(ParseColorError::PaletteColorEmpty) } else { Ok(Self::Palette(name.into())) } } pub(crate) fn resolve( &self, palette: &crate::theme::clean::ColorPalette, ) -> Result, UndefinedPaletteColorError> { let color = match self { Self::Color(c) => Some(*c), Self::Palette(name) => { Some(palette.colors.get(name).copied().ok_or(UndefinedPaletteColorError(name.clone()))?) } Self::ForegroundClass(name) => { palette.classes.get(name).ok_or(UndefinedPaletteColorError(name.clone()))?.foreground } Self::BackgroundClass(name) => { palette.classes.get(name).ok_or(UndefinedPaletteColorError(name.clone()))?.background } }; Ok(color) } } impl From for RawColor { fn from(color: Color) -> Self { Self::Color(color) } } impl FromStr for RawColor { type Err = ParseColorError; fn from_str(input: &str) -> Result { let output = match input { "black" => Color::Black.into(), "white" => Color::White.into(), "grey" => Color::Grey.into(), "dark_grey" => Color::DarkGrey.into(), "red" => Color::Red.into(), "dark_red" => Color::DarkRed.into(), "green" => Color::Green.into(), "dark_green" => Color::DarkGreen.into(), "blue" => Color::Blue.into(), "dark_blue" => Color::DarkBlue.into(), "yellow" => Color::Yellow.into(), "dark_yellow" => Color::DarkYellow.into(), "magenta" => Color::Magenta.into(), "dark_magenta" => Color::DarkMagenta.into(), "cyan" => Color::Cyan.into(), "dark_cyan" => Color::DarkCyan.into(), other if other.starts_with("palette:") => Self::new_palette(other.trim_start_matches("palette:"))?, other if other.starts_with("p:") => Self::new_palette(other.trim_start_matches("p:"))?, // Fallback to hex-encoded rgb _ => { let hex = match input.len() { 6 => input.to_string(), 3 => input.chars().flat_map(|c| [c, c]).collect::(), len => return Err(ParseColorError::InvalidHexLength(len)), }; let values = <[u8; 3]>::from_hex(hex)?; Color::Rgb { r: values[0], g: values[1], b: values[2] }.into() } }; Ok(output) } } impl fmt::Display for RawColor { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { use Color::*; match self { Self::Color(Rgb { r, g, b }) => write!(f, "{}", hex::encode([*r, *g, *b])), Self::Color(Black) => write!(f, "black"), Self::Color(White) => write!(f, "white"), Self::Color(Grey) => write!(f, "grey"), Self::Color(DarkGrey) => write!(f, "dark_grey"), Self::Color(Red) => write!(f, "red"), Self::Color(DarkRed) => write!(f, "dark_red"), Self::Color(Green) => write!(f, "green"), Self::Color(DarkGreen) => write!(f, "dark_green"), Self::Color(Blue) => write!(f, "blue"), Self::Color(DarkBlue) => write!(f, "dark_blue"), Self::Color(Yellow) => write!(f, "yellow"), Self::Color(DarkYellow) => write!(f, "dark_yellow"), Self::Color(Magenta) => write!(f, "magenta"), Self::Color(DarkMagenta) => write!(f, "dark_magenta"), Self::Color(Cyan) => write!(f, "cyan"), Self::Color(DarkCyan) => write!(f, "dark_cyan"), Self::Palette(name) => write!(f, "palette:{name}"), Self::ForegroundClass(_) => Err(fmt::Error), Self::BackgroundClass(_) => Err(fmt::Error), } } } #[derive(thiserror::Error, Debug)] pub(crate) enum ParseColorError { #[error("invalid hex color: {0}")] Hex(#[from] FromHexError), #[error("hex color should only be 3 or 6 long, got hex string of length {0}")] InvalidHexLength(usize), #[error("palette color name is empty")] PaletteColorEmpty, } #[cfg(test)] mod test { use super::*; use rstest::rstest; #[test] fn parse_all_footer_template_variables() { use FooterTemplateChunk::*; let raw = "hi {current_slide} {total_slides} {author} {title} {sub_title} {event} {location} {event}"; let t: FooterTemplate = raw.parse().expect("invalid input"); let expected = vec![ Literal("hi ".into()), CurrentSlide, Literal(" ".into()), TotalSlides, Literal(" ".into()), Author, Literal(" ".into()), Title, Literal(" ".into()), SubTitle, Literal(" ".into()), Event, Literal(" ".into()), Location, Literal(" ".into()), Event, ]; assert_eq!(t.0, expected); assert_eq!(t.to_string(), raw); } #[test] fn parse_double_braces() { use FooterTemplateChunk::*; let raw = "hi {{beep}} {{author}} {{{{}}}}"; let t: FooterTemplate = raw.parse().expect("invalid input"); let merged: String = t.0.into_iter() .map(|l| match l { Literal(s) => s, OpenBrace => "{".to_string(), ClosedBrace => "}".to_string(), _ => panic!("not a literal"), }) .collect(); assert_eq!(merged, "hi {beep} {author} {{}}"); } #[rstest] #[case::trailing("{author")] #[case::close_without_open2("author}")] fn invalid_footer_templates(#[case] input: &str) { FooterTemplate::from_str(input).expect_err("parse succeeded"); } #[test] fn color_serde() { let color: RawColor = "beef42".parse().unwrap(); assert_eq!(color.to_string(), "beef42"); let short_color: RawColor = "ded".parse().unwrap(); assert_eq!(short_color.to_string(), "ddeedd"); } #[rstest] #[case::empty1("p:")] #[case::empty2("palette:")] fn invalid_palette_color_names(#[case] input: &str) { RawColor::from_str(input).expect_err("not an error"); } #[rstest] #[case::short("p:hi", "hi")] #[case::long("palette:bye", "bye")] fn valid_palette_color_names(#[case] input: &str, #[case] expected: &str) { let color = RawColor::from_str(input).expect("failed to parse"); let RawColor::Palette(name) = color else { panic!("not a palette color") }; assert_eq!(name, expected); } } ================================================ FILE: src/theme/registry.rs ================================================ use super::raw::PresentationTheme; use std::{ collections::BTreeMap, fs, io, path::{Path, PathBuf}, }; include!(concat!(env!("OUT_DIR"), "/themes.rs")); #[derive(Default)] pub struct PresentationThemeRegistry { custom_themes: BTreeMap, } impl PresentationThemeRegistry { /// Loads a theme from its name. pub fn load_by_name(&self, name: &str) -> Option { match THEMES.get(name) { Some(contents) => { // This is going to be caught by the test down here. let theme = serde_yaml::from_slice(contents).expect("corrupted theme"); Some(theme) } None => self.custom_themes.get(name).cloned(), } } /// Register all the themes in the given directory. pub fn register_from_directory>(&mut self, path: P) -> Result<(), LoadThemeError> { let handle = match fs::read_dir(&path) { Ok(handle) => handle, Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()), Err(e) => return Err(e.into()), }; let mut dependencies = BTreeMap::new(); for entry in handle { let entry = entry?; let Some(file_name) = entry.file_name().to_str().map(ToOwned::to_owned) else { continue; }; if file_name.ends_with(".yaml") { let theme_name = file_name.trim_end_matches(".yaml"); if THEMES.contains_key(theme_name) { return Err(LoadThemeError::Duplicate(theme_name.into())); } let theme = PresentationTheme::from_path(entry.path())?; let base = theme.extends.clone(); self.custom_themes.insert(theme_name.into(), theme); dependencies.insert(theme_name.to_string(), base); } } let mut graph = ThemeGraph::new(dependencies); for theme_name in graph.dependents.keys() { let theme_name = theme_name.as_str(); if !THEMES.contains_key(theme_name) && !self.custom_themes.contains_key(theme_name) { return Err(LoadThemeError::ExtendedThemeNotFound(theme_name.into())); } } while let Some(theme_name) = graph.pop() { self.extend_theme(&theme_name)?; } if !graph.dependents.is_empty() { return Err(LoadThemeError::ExtensionLoop(graph.dependents.into_keys().collect())); } Ok(()) } fn extend_theme(&mut self, theme_name: &str) -> Result<(), LoadThemeError> { let Some(base_name) = self.custom_themes.get(theme_name).expect("theme not found").extends.clone() else { return Ok(()); }; let Some(base_theme) = self.load_by_name(&base_name) else { return Err(LoadThemeError::ExtendedThemeNotFound(base_name.clone())); }; let theme = self.custom_themes.get_mut(theme_name).expect("theme not found"); *theme = merge_struct::merge(&base_theme, theme) .map_err(|e| LoadThemeError::Corrupted(base_name.to_string(), e.into()))?; Ok(()) } /// Get all the registered theme names. pub fn theme_names(&self) -> Vec { let builtin_themes = THEMES.keys().map(|name| name.to_string()); let themes = self.custom_themes.keys().cloned().chain(builtin_themes).collect(); themes } } struct ThemeGraph { dependents: BTreeMap>, ready: Vec, } impl ThemeGraph { fn new(dependencies: I) -> Self where I: IntoIterator)>, { let mut dependents: BTreeMap<_, Vec<_>> = BTreeMap::new(); let mut ready = Vec::new(); for (name, extends) in dependencies { dependents.entry(name.clone()).or_default(); match extends { // If we extend from a non built in theme, make ourselves their dependent Some(base) if !THEMES.contains_key(base.as_str()) => { dependents.entry(base).or_default().push(name); } // Otherwise this theme is ready to be processed _ => ready.push(name), } } Self { dependents, ready } } fn pop(&mut self) -> Option { let theme = self.ready.pop()?; if let Some(dependents) = self.dependents.remove(&theme) { self.ready.extend(dependents); } Some(theme) } } /// An error loading a presentation theme. #[derive(thiserror::Error, Debug)] pub enum LoadThemeError { #[error(transparent)] Io(#[from] io::Error), #[error("failed to read custom theme {0:?}: {1}")] Reading(PathBuf, io::Error), #[error("theme '{0}' is corrupted: {1}")] Corrupted(String, Box), #[error("duplicate custom theme '{0}'")] Duplicate(String), #[error("extended theme does not exist: {0}")] ExtendedThemeNotFound(String), #[error("theme has an extension loop involving: {0:?}")] ExtensionLoop(Vec), } #[cfg(test)] mod test { use crate::resource::Resources; use super::*; use tempfile::{TempDir, tempdir}; fn write_theme(name: &str, theme: PresentationTheme, directory: &TempDir) { let theme = serde_yaml::to_string(&theme).unwrap(); let file_name = format!("{name}.yaml"); fs::write(directory.path().join(file_name), theme).expect("writing theme"); } #[test] fn validate_themes() { let themes = PresentationThemeRegistry::default(); for theme_name in THEMES.keys() { let Some(theme) = themes.load_by_name(theme_name).clone() else { panic!("theme '{theme_name}' is corrupted"); }; // Built-in themes can't use this because... I don't feel like supporting this now. assert!(theme.extends.is_none(), "theme '{theme_name}' uses extends"); let merged = merge_struct::merge(&PresentationTheme::default(), &theme); assert!(merged.is_ok(), "theme '{theme_name}' can't be merged: {}", merged.unwrap_err()); let resources = Resources::new("/tmp/foo", "/tmp/foo", Default::default()); crate::theme::PresentationTheme::new(&theme, &resources, &Default::default()).expect("malformed theme"); } } #[test] fn load_custom() { let directory = tempdir().expect("creating tempdir"); write_theme( "potato", PresentationTheme { extends: Some("dark".to_string()), ..Default::default() }, &directory, ); let mut themes = PresentationThemeRegistry::default(); themes.register_from_directory(directory.path()).expect("loading themes"); let mut theme = themes.load_by_name("potato").expect("theme not found"); // Since we extend the dark theme they must match after we remove the "extends" field. let dark = themes.load_by_name("dark"); theme.extends.take().expect("no extends"); assert_eq!(serde_yaml::to_string(&theme).unwrap(), serde_yaml::to_string(&dark).unwrap()); } #[test] fn load_derive_chain() { let directory = tempdir().expect("creating tempdir"); write_theme("A", PresentationTheme { extends: Some("dark".to_string()), ..Default::default() }, &directory); write_theme("B", PresentationTheme { extends: Some("C".to_string()), ..Default::default() }, &directory); write_theme("C", PresentationTheme { extends: Some("A".to_string()), ..Default::default() }, &directory); write_theme("D", PresentationTheme::default(), &directory); let mut themes = PresentationThemeRegistry::default(); themes.register_from_directory(directory.path()).expect("loading themes"); themes.load_by_name("A").expect("A not found"); themes.load_by_name("B").expect("B not found"); themes.load_by_name("C").expect("C not found"); themes.load_by_name("D").expect("D not found"); } #[test] fn invalid_derives() { let directory = tempdir().expect("creating tempdir"); write_theme( "A", PresentationTheme { extends: Some("non-existent-theme".to_string()), ..Default::default() }, &directory, ); let mut themes = PresentationThemeRegistry::default(); themes.register_from_directory(directory.path()).expect_err("loading themes succeeded"); } #[test] fn load_derive_chain_loop() { let directory = tempdir().expect("creating tempdir"); write_theme("A", PresentationTheme { extends: Some("B".to_string()), ..Default::default() }, &directory); write_theme("B", PresentationTheme { extends: Some("A".to_string()), ..Default::default() }, &directory); let mut themes = PresentationThemeRegistry::default(); let err = themes.register_from_directory(directory.path()).expect_err("loading themes succeeded"); let LoadThemeError::ExtensionLoop(names) = err else { panic!("not an extension loop error") }; assert_eq!(names, &["A", "B"]); } #[test] fn register_from_missing_directory() { let mut themes = PresentationThemeRegistry::default(); let result = themes.register_from_directory("/tmp/presenterm/8ee2027983915ec78acc45027d874316"); result.expect("loading failed"); } } ================================================ FILE: src/third_party.rs ================================================ use crate::{ ImageRegistry, config::{default_mermaid_scale, default_snippet_render_threads, default_typst_ppi}, markdown::{ elements::{Line, Percent, Text}, text_style::{Color, TextStyle}, }, render::{ operation::{ AsRenderOperations, ImageRenderProperties, ImageSize, Pollable, PollableState, RenderAsync, RenderAsyncStartPolicy, RenderOperation, }, properties::WindowSize, }, terminal::image::{ Image, printer::{ImageSpec, RegisterImageError}, }, theme::{Alignment, D2Style, MermaidStyle, PresentationTheme, TypstStyle, raw::RawColor}, tools::{ExecutionError, ThirdPartyTools}, }; use std::{ collections::{HashMap, VecDeque}, fs, io, mem, path::Path, rc::Rc, sync::{Arc, Condvar, Mutex}, thread, }; pub struct ThirdPartyConfigs { pub typst_ppi: String, pub mermaid_scale: String, pub mermaid_puppeteer_file: Option, pub mermaid_config_file: Option, pub d2_scale: String, pub threads: usize, } pub struct ThirdPartyRender { render_pool: RenderPool, } impl ThirdPartyRender { pub fn new(config: ThirdPartyConfigs, image_registry: ImageRegistry, root_dir: &Path) -> Self { // typst complains about empty paths so we give it a "." if we don't have one. let root_dir = match root_dir.to_string_lossy().to_string() { path if path.is_empty() => ".".into(), path => path, }; let render_pool = RenderPool::new(config, root_dir, image_registry); Self { render_pool } } pub(crate) fn render( &self, request: ThirdPartyRenderRequest, theme: &PresentationTheme, width: Option, ) -> Result { let result = self.render_pool.render(request); let operation = Rc::new(RenderThirdParty::new(result, theme.default_style.style, width)); Ok(RenderOperation::RenderAsync(operation)) } } impl Default for ThirdPartyRender { fn default() -> Self { let config = ThirdPartyConfigs { typst_ppi: default_typst_ppi().to_string(), mermaid_scale: default_mermaid_scale().to_string(), mermaid_puppeteer_file: None, mermaid_config_file: None, d2_scale: "-1".to_string(), threads: default_snippet_render_threads(), }; Self::new(config, Default::default(), Path::new(".")) } } #[derive(Debug)] pub(crate) enum ThirdPartyRenderRequest { Typst(String, TypstStyle), Latex(String, TypstStyle), Mermaid(String, MermaidStyle), D2(String, D2Style), } #[derive(Debug, Default)] enum RenderResult { Success(Image), Failure(String), #[default] Pending, } struct RenderPoolState { requests: VecDeque<(ThirdPartyRenderRequest, Arc>)>, image_registry: ImageRegistry, cache: HashMap, } struct Shared { config: ThirdPartyConfigs, root_dir: String, signal: Condvar, } struct RenderPool { state: Arc>, shared: Arc, } impl RenderPool { fn new(config: ThirdPartyConfigs, root_dir: String, image_registry: ImageRegistry) -> Self { let threads = config.threads; let shared = Shared { config, root_dir, signal: Default::default() }; let state = RenderPoolState { requests: Default::default(), image_registry, cache: Default::default() }; let this = Self { state: Arc::new(Mutex::new(state)), shared: Arc::new(shared) }; for _ in 0..threads { let worker = Worker { state: this.state.clone(), shared: this.shared.clone() }; thread::spawn(move || worker.run()); } this } fn render(&self, request: ThirdPartyRenderRequest) -> Arc> { let result: Arc> = Default::default(); let mut state = self.state.lock().expect("lock poisoned"); state.requests.push_back((request, result.clone())); self.shared.signal.notify_one(); result } } struct Worker { state: Arc>, shared: Arc, } impl Worker { fn run(self) { loop { let mut state = self.state.lock().unwrap(); let (request, result) = loop { let Some((request, result)) = state.requests.pop_front() else { state = self.shared.signal.wait(state).unwrap(); continue; }; break (request, result); }; drop(state); self.render(request, result); } } fn render(&self, request: ThirdPartyRenderRequest, result: Arc>) { let output = match request { ThirdPartyRenderRequest::Typst(input, style) => self.render_typst(input, &style), ThirdPartyRenderRequest::Latex(input, style) => self.render_latex(input, &style), ThirdPartyRenderRequest::Mermaid(input, style) => self.render_mermaid(input, &style), ThirdPartyRenderRequest::D2(input, style) => self.render_d2(input, &style), }; let mut result = result.lock().unwrap(); match output { Ok(image) => *result = RenderResult::Success(image), Err(error) => *result = RenderResult::Failure(error.to_string()), }; } pub(crate) fn render_typst(&self, input: String, style: &TypstStyle) -> Result { let snippet = ImageSnippet { snippet: input.clone(), source: SnippetSource::Typst }; if let Some(image) = self.state.lock().unwrap().cache.get(&snippet).cloned() { return Ok(image); } self.do_render_typst(snippet, &input, style) } pub(crate) fn render_latex(&self, input: String, style: &TypstStyle) -> Result { let snippet = ImageSnippet { snippet: input.clone(), source: SnippetSource::Latex }; if let Some(image) = self.state.lock().unwrap().cache.get(&snippet).cloned() { return Ok(image); } let output = ThirdPartyTools::pandoc(&["--from", "latex", "--to", "typst"]) .stdin(input.as_bytes().into()) .run_and_capture_stdout()?; let input = String::from_utf8_lossy(&output); self.do_render_typst(snippet, &input, style) } pub(crate) fn render_mermaid(&self, input: String, style: &MermaidStyle) -> Result { let snippet = ImageSnippet { snippet: input.clone(), source: SnippetSource::Mermaid }; if let Some(image) = self.state.lock().unwrap().cache.get(&snippet).cloned() { return Ok(image); } let workdir = tempfile::Builder::default().prefix(".presenterm").tempdir()?; let output_path = workdir.path().join("output.png"); let input_path = workdir.path().join("input.mmd"); fs::write(&input_path, input)?; let input_path = input_path.to_string_lossy(); let output_path_str = output_path.to_string_lossy(); let mut args = vec![ "-i", &input_path, "-o", &output_path_str, "-s", &self.shared.config.mermaid_scale, "-t", &style.theme, "-b", &style.background, ]; if let Some(path) = &self.shared.config.mermaid_puppeteer_file { args.extend(&["-p", path]); } if let Some(path) = &self.shared.config.mermaid_config_file { args.extend(&["-c", path]); } ThirdPartyTools::mermaid(&args).run()?; self.load_image(snippet, &output_path) } pub(crate) fn render_d2(&self, input: String, style: &D2Style) -> Result { let snippet = ImageSnippet { snippet: input.clone(), source: SnippetSource::D2 }; let workdir = tempfile::Builder::default().prefix(".presenterm").tempdir()?; let output_path = workdir.path().join("output.png"); let input_path = workdir.path().join("input.d2"); fs::write(&input_path, input)?; ThirdPartyTools::d2(&[ &input_path.to_string_lossy(), &output_path.to_string_lossy(), "--pad", "0", "--scale", &self.shared.config.d2_scale, "--theme", &style.theme, ]) .run()?; self.load_image(snippet, &output_path) } fn do_render_typst( &self, snippet: ImageSnippet, input: &str, style: &TypstStyle, ) -> Result { let workdir = tempfile::Builder::default().prefix(".presenterm").tempdir_in(&self.shared.root_dir)?; let mut typst_input = Self::generate_page_header(style)?; typst_input.push_str(input); let input_path = workdir.path().join("input.typst"); fs::write(&input_path, &typst_input)?; let output_path = workdir.path().join("output.png"); ThirdPartyTools::typst(&[ "compile", "--format", "png", "--root", &self.shared.root_dir, "--ppi", &self.shared.config.typst_ppi, &input_path.to_string_lossy(), &output_path.to_string_lossy(), ]) .run()?; self.load_image(snippet, &output_path) } fn generate_page_header(style: &TypstStyle) -> Result { let x_margin = style.horizontal_margin; let y_margin = style.vertical_margin; let background = style .style .colors .background .as_ref() .map(Self::as_typst_color) .unwrap_or_else(|| Ok(String::from("none")))?; let mut header = format!( "#set page(width: auto, height: auto, margin: (x: {x_margin}pt, y: {y_margin}pt), fill: {background})\n" ); if let Some(color) = &style.style.colors.foreground { let color = Self::as_typst_color(color)?; header.push_str(&format!("#set text(fill: {color})\n")); } Ok(header) } fn as_typst_color(color: &Color) -> Result { match color.as_rgb() { Some((r, g, b)) => Ok(format!("rgb(\"#{r:02x}{g:02x}{b:02x}\")")), None => Err(ThirdPartyRenderError::UnsupportedColor(RawColor::from(*color).to_string())), } } fn load_image(&self, snippet: ImageSnippet, path: &Path) -> Result { let contents = fs::read(path)?; let image = image::load_from_memory(&contents)?; let image = self.state.lock().unwrap().image_registry.register(ImageSpec::Generated(image))?; self.state.lock().unwrap().cache.insert(snippet, image.clone()); Ok(image) } } #[derive(Debug, thiserror::Error)] pub enum ThirdPartyRenderError { #[error(transparent)] Execution(#[from] ExecutionError), #[error("io: {0}")] Io(#[from] io::Error), #[error("invalid image: {0}")] InvalidImage(#[from] image::ImageError), #[error("invalid image: {0}")] RegisterImage(#[from] RegisterImageError), #[error("unsupported color '{0}', only RGB is supported")] UnsupportedColor(String), } #[derive(Hash, PartialEq, Eq)] enum SnippetSource { Typst, Latex, Mermaid, D2, } #[derive(Hash, PartialEq, Eq)] struct ImageSnippet { snippet: String, source: SnippetSource, } #[derive(Debug)] pub(crate) struct RenderThirdParty { contents: Arc>>, pending_result: Arc>, default_style: TextStyle, width: Option, } impl RenderThirdParty { fn new(pending_result: Arc>, default_style: TextStyle, width: Option) -> Self { Self { contents: Default::default(), pending_result, default_style, width } } } impl RenderAsync for RenderThirdParty { fn pollable(&self) -> Box { Box::new(OperationPollable { contents: self.contents.clone(), pending_result: self.pending_result.clone() }) } fn start_policy(&self) -> RenderAsyncStartPolicy { RenderAsyncStartPolicy::Automatic } } impl AsRenderOperations for RenderThirdParty { fn as_render_operations(&self, _: &WindowSize) -> Vec { match &*self.contents.lock().unwrap() { Some(Output::Image(image)) => { let size = match &self.width { Some(percent) => ImageSize::WidthScaled { ratio: percent.as_ratio() }, None => Default::default(), }; let properties = ImageRenderProperties { size, background_color: self.default_style.colors.background, ..Default::default() }; 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 { line: text.into(), alignment: Alignment::Center { minimum_margin: Default::default(), minimum_size: 0 }, }] } } } } #[derive(Debug)] enum Output { Image(Image), Error, } #[derive(Clone)] struct OperationPollable { contents: Arc>>, pending_result: Arc>, } 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, } } } ================================================ FILE: src/tools.rs ================================================ use itertools::Itertools; use std::{ io::{self, Write}, process::{Command, Output, Stdio}, }; const DEFAULT_MAX_ERROR_LINES: usize = 10; pub(crate) struct ThirdPartyTools; impl ThirdPartyTools { pub(crate) fn pandoc(args: &[&str]) -> Tool { Tool::new("pandoc", args) } pub(crate) fn typst(args: &[&str]) -> Tool { Tool::new("typst", args) } pub(crate) fn mermaid(args: &[&str]) -> Tool { let mmdc = if cfg!(windows) { "mmdc.cmd" } else { "mmdc" }; Tool::new(mmdc, args) } pub(crate) fn d2(args: &[&str]) -> Tool { Tool::new("d2", args) } pub(crate) fn weasyprint(args: &[&str]) -> Tool { Tool::new("weasyprint", args).inherit_stdout().max_error_lines(100) } } pub(crate) struct Tool { command_name: &'static str, command: Command, stdin: Option>, max_error_lines: usize, } impl Tool { fn new(command_name: &'static str, args: &[&str]) -> Self { let mut command = Command::new(command_name); command.args(args).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::piped()); Self { command_name, command, stdin: None, max_error_lines: DEFAULT_MAX_ERROR_LINES } } pub(crate) fn stdin(mut self, stdin: Vec) -> Self { self.stdin = Some(stdin); self } pub(crate) fn inherit_stdout(mut self) -> Self { self.command.stdout(Stdio::inherit()); self } pub(crate) fn max_error_lines(mut self, value: usize) -> Self { self.max_error_lines = value; self } pub(crate) fn run(self) -> Result<(), ExecutionError> { self.spawn()?; Ok(()) } pub(crate) fn run_and_capture_stdout(mut self) -> Result, ExecutionError> { self.command.stdout(Stdio::piped()); let output = self.spawn()?; Ok(output.stdout) } fn spawn(mut self) -> Result { use ExecutionError::*; if self.stdin.is_some() { self.command.stdin(Stdio::piped()); } let mut child = match self.command.spawn() { Ok(child) => child, Err(e) if e.kind() == io::ErrorKind::NotFound => return Err(SpawnNotFound { command: self.command_name }), Err(error) => return Err(Spawn { command: self.command_name, error }), }; if let Some(data) = &self.stdin { let mut stdin = child.stdin.take().expect("no stdin"); stdin .write_all(data) .and_then(|_| stdin.flush()) .map_err(|error| Communication { command: self.command_name, error })?; } let output = child.wait_with_output().map_err(|error| Communication { command: self.command_name, error })?; self.validate_output(&output)?; Ok(output) } fn validate_output(self, output: &Output) -> Result<(), ExecutionError> { if output.status.success() { Ok(()) } else { let stderr = String::from_utf8_lossy(&output.stderr).lines().take(self.max_error_lines).join("\n"); Err(ExecutionError::Execution { command: self.command_name, stderr }) } } } #[derive(Debug, thiserror::Error)] pub enum ExecutionError { #[error("spawning '{command}' failed: {error}")] Spawn { command: &'static str, error: io::Error }, #[error("spawning '{command}' failed (is '{command}' installed?)")] SpawnNotFound { command: &'static str }, #[error("communicating with '{command}' failed: {error}")] Communication { command: &'static str, error: io::Error }, #[error("'{command}' execution failed: \n{stderr}")] Execution { command: &'static str, stderr: String }, } ================================================ FILE: src/transitions/collapse_horizontal.rs ================================================ 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); } } ================================================ FILE: src/transitions/fade.rs ================================================ 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, } 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, } impl AnimationFrame for FadeCellsFrame { fn build_commands(&self) -> Vec> { 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); } } ================================================ FILE: src/transitions/mod.rs ================================================ 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>; } #[derive(Debug)] pub(crate) struct LinesFrame { pub(crate) lines: Vec, pub(crate) background_color: Option, } 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> { 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); } } ================================================ FILE: src/transitions/slide_horizontal.rs ================================================ 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); } } ================================================ FILE: src/ui/execution/acquire_terminal.rs ================================================ use crate::{ code::{execute::LanguageSnippetExecutor, 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: LanguageSnippetExecutor, colors: ExecutionStatusBlockStyle, state: Arc>, font_size: u8, } impl RunAcquireTerminalSnippet { pub(crate) fn new( snippet: Snippet, executor: LanguageSnippetExecutor, 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 { 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 { // 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), } 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 } } ================================================ FILE: src/ui/execution/disabled.rs ================================================ 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>, } 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 { 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 { Box::new(ToggleState::new(self.toggled.clone())) } fn start_policy(&self) -> RenderAsyncStartPolicy { self.policy } } #[derive(Debug)] pub(crate) enum ExecutionType { Execute, ExecReplace, Image, } ================================================ FILE: src/ui/execution/image.rs ================================================ use crate::{ code::{ execute::{ExecutionHandle, LanguageSnippetExecutor, ProcessStatus}, 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, state: Arc>, image_registry: ImageRegistry, colors: ExecutionStatusBlockStyle, } impl RunImageSnippet { pub(crate) fn new( snippet: Snippet, executor: LanguageSnippetExecutor, image_registry: ImageRegistry, colors: ExecutionStatusBlockStyle, ) -> Self { let state = Arc::new(Mutex::new(State::NotStarted(executor))); Self { snippet, image_registry, colors, state } } } impl RenderAsync for RunImageSnippet { fn pollable(&self) -> Box { Box::new(OperationPollable { state: self.state.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 { 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>, snippet: Snippet, image_registry: ImageRegistry, } impl OperationPollable { fn load_image(&self, data: &[u8]) -> Result { 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(executor) => match 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)] enum State { NotStarted(LanguageSnippetExecutor), Running(ExecutionHandle), Success(Image), Failure(Vec), } ================================================ FILE: src/ui/execution/mod.rs ================================================ pub(crate) mod acquire_terminal; pub(crate) mod disabled; pub(crate) mod image; pub(crate) mod output; pub(crate) mod pty; pub(crate) mod validator; pub(crate) use acquire_terminal::RunAcquireTerminalSnippet; pub(crate) use disabled::SnippetExecutionDisabledOperation; pub(crate) use image::RunImageSnippet; pub(crate) use output::SnippetOutputOperation; ================================================ FILE: src/ui/execution/output.rs ================================================ use crate::{ code::{ execute::{ExecutionHandle, ExecutionState, LanguageSnippetExecutor, ProcessStatus}, snippet::Snippet, }, markdown::{ elements::{Line, Text}, text_style::{Colors, TextStyle}, }, render::{ operation::{ AsRenderOperations, BlockLine, Pollable, PollableState, RenderAsync, RenderAsyncStartPolicy, RenderOperation, }, properties::WindowSize, }, terminal::ansi::AnsiParser, theme::{Alignment, ExecutionOutputBlockStyle, ExecutionStatusBlockStyle}, ui::{ execution::pty::{PtySnippetHandle, RunPtySnippetTrigger}, separator::{RenderSeparator, SeparatorWidth}, }, }; use std::{ io::BufRead, iter, rc::Rc, sync::{Arc, Mutex}, }; const MINIMUM_SEPARATOR_WIDTH: u16 = 32; #[derive(Default, Debug)] enum State { #[default] Initial, Running(ExecutionHandle), Done, } #[derive(Debug)] struct Inner { snippet: Snippet, executor: LanguageSnippetExecutor, output_lines: Vec, max_line_length: u16, process_status: Option, state: State, policy: RenderAsyncStartPolicy, } #[derive(Debug)] pub(crate) struct SnippetOutputOperation { default_colors: Colors, style: ExecutionOutputBlockStyle, block_length: u16, alignment: Alignment, handle: SnippetHandle, font_size: u8, } impl SnippetOutputOperation { #[allow(clippy::too_many_arguments)] pub(crate) fn new( handle: SnippetHandle, default_colors: Colors, style: ExecutionOutputBlockStyle, block_length: u16, alignment: Alignment, font_size: u8, ) -> Self { let block_length = alignment.adjust_size(block_length); Self { default_colors, style, block_length, alignment, handle, font_size } } } impl AsRenderOperations for SnippetOutputOperation { fn as_render_operations(&self, _dimensions: &WindowSize) -> Vec { let inner = self.handle.0.lock().unwrap(); if let State::Initial = inner.state { return Vec::new(); } let mut operations = vec![]; let block_colors = self.style.style.colors; if block_colors.background.is_some() { operations.push(RenderOperation::SetColors(block_colors)); } if !inner.output_lines.is_empty() { 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 padding = self.style.padding; let block_length = if has_margin { self.block_length.max(inner.max_line_length) } else { inner.max_line_length }; let vertical_padding = iter::repeat_n(" ", padding.vertical as usize).map(Line::from); let lines = vertical_padding.clone().chain(inner.output_lines.iter().cloned()).chain(vertical_padding); let style = TextStyle::default().size(self.font_size); for mut line in lines { line.apply_style(&style); let prefix = Text::new(" ".repeat(padding.horizontal as usize), style).into(); operations.push(RenderOperation::RenderBlockLine(BlockLine { prefix, right_padding_length: padding.horizontal as u16, repeat_prefix_on_wrap: false, text: line.into(), block_length, alignment: self.alignment, block_color: block_colors.background, })); operations.push(RenderOperation::RenderLineBreak); } } operations.extend([RenderOperation::SetColors(self.default_colors)]); operations } } struct OperationPollable { inner: Arc>, last_length: usize, } impl OperationPollable { fn try_start(&self, inner: &mut Inner) { // Don't run twice. if !matches!(inner.state, State::Initial) { return; } inner.state = match inner.executor.execute_async(&inner.snippet) { Ok(handle) => State::Running(handle), Err(e) => { inner.output_lines = vec![e.to_string().into()]; State::Done } } } } impl Pollable for OperationPollable { fn poll(&mut self) -> PollableState { let mut inner = self.inner.lock().unwrap(); self.try_start(&mut inner); // At this point if we don't have a handle it's because we're done. let State::Running(handle) = &mut inner.state 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; 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, _) = AnsiParser::new(Default::default()).parse_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 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 { inner.state = State::Done; PollableState::Done } else { match modified { true => PollableState::Modified, false => PollableState::Unmodified, } } } } #[derive(Debug, Clone)] pub(crate) struct SnippetHandle(Arc>); impl SnippetHandle { pub(crate) fn new(code: Snippet, executor: LanguageSnippetExecutor, policy: RenderAsyncStartPolicy) -> Self { let inner = Inner { snippet: code, executor, process_status: Default::default(), output_lines: Default::default(), max_line_length: Default::default(), state: Default::default(), policy, }; Self(Arc::new(Mutex::new(inner))) } pub(crate) fn snippet(&self) -> Snippet { self.0.lock().unwrap().snippet.clone() } } #[derive(Debug)] pub(crate) struct RunSnippetTrigger(Arc>); impl RunSnippetTrigger { pub(crate) fn new(handle: SnippetHandle) -> Self { Self(handle.0) } } impl AsRenderOperations for RunSnippetTrigger { fn as_render_operations(&self, _dimensions: &WindowSize) -> Vec { vec![] } } impl RenderAsync for RunSnippetTrigger { fn pollable(&self) -> Box { Box::new(OperationPollable { inner: self.0.clone(), last_length: 0 }) } fn start_policy(&self) -> RenderAsyncStartPolicy { self.0.lock().unwrap().policy } } #[derive(Debug)] pub(crate) struct ExecIndicatorStyle { pub(crate) theme: ExecutionStatusBlockStyle, pub(crate) block_length: u16, pub(crate) font_size: u8, pub(crate) alignment: Alignment, } #[derive(Clone, Debug)] pub(crate) enum WrappedSnippetHandle { Normal(SnippetHandle), Pty(PtySnippetHandle), } impl WrappedSnippetHandle { pub(crate) fn process_status(&self) -> Option { match self { Self::Normal(handle) => handle.0.lock().unwrap().process_status, Self::Pty(handle) => handle.process_status(), } } pub(crate) fn build_trigger(&self) -> Box { match self.clone() { Self::Normal(handle) => Box::new(RunSnippetTrigger::new(handle)), Self::Pty(handle) => Box::new(RunPtySnippetTrigger::new(handle)), } } } impl From for WrappedSnippetHandle { fn from(handle: SnippetHandle) -> Self { Self::Normal(handle) } } impl From for WrappedSnippetHandle { fn from(handle: PtySnippetHandle) -> Self { Self::Pty(handle) } } #[derive(Debug)] pub(crate) struct ExecIndicator { handle: WrappedSnippetHandle, separator_width: SeparatorWidth, theme: ExecutionStatusBlockStyle, font_size: u8, } impl ExecIndicator { pub(crate) fn new>(handle: T, style: ExecIndicatorStyle) -> Self { let ExecIndicatorStyle { theme, block_length, font_size, alignment } = style; let block_length = alignment.adjust_size(block_length); let separator_width = match &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(block_length.max(MINIMUM_SEPARATOR_WIDTH * font_size as u16)) } }; let handle = handle.into(); Self { handle, separator_width, theme, font_size } } } impl AsRenderOperations for ExecIndicator { fn as_render_operations(&self, _dimensions: &WindowSize) -> Vec { let status = self.handle.process_status(); let description = match status { Some(ProcessStatus::Running) => Text::new("running", self.theme.running_style), Some(ProcessStatus::Success) => Text::new("finished", self.theme.success_style), Some(ProcessStatus::Failure) => Text::new("finished with error", self.theme.failure_style), None => Text::new("not started", self.theme.not_started_style), }; let heading = Line(vec![" [".into(), description.clone(), "] ".into()]); let separator = RenderSeparator::new(heading, self.separator_width, self.font_size); vec![ RenderOperation::RenderLineBreak, RenderOperation::RenderDynamic(Rc::new(separator)), RenderOperation::RenderLineBreak, ] } } #[cfg(all(target_os = "linux", test))] mod tests { use super::*; use crate::{ code::{ execute::SnippetExecutor, snippet::{SnippetAttributes, SnippetExecution, SnippetLanguage}, }, markdown::{ elements::{Line, Text}, text_style::Color, }, }; fn make_run_shell(code: &str) -> RunSnippetTrigger { let snippet = Snippet { contents: code.into(), language: SnippetLanguage::Bash, attributes: SnippetAttributes { execution: SnippetExecution::Exec(Default::default()), ..Default::default() }, }; let executor = SnippetExecutor::default().language_executor(&snippet.language, &Default::default()).unwrap(); let policy = RenderAsyncStartPolicy::OnDemand; let handle = SnippetHandle::new(snippet, executor, policy); RunSnippetTrigger::new(handle) } #[test] fn run_command() { let handle = make_run_shell("echo -e '\\033[1;31mhi mom'"); let mut pollable = handle.pollable(); // Run until done while let PollableState::Modified | PollableState::Unmodified = pollable.poll() {} // Expect to see the output lines let inner = handle.0.lock().unwrap(); let line = Line::from(Text::new("hi mom", TextStyle::default().fg_color(Color::DarkRed).bold())); assert_eq!(inner.output_lines, vec![line]); } #[test] fn multiple_pollables() { let handle = make_run_shell("echo -e '\\033[1;31mhi mom'"); let mut main_pollable = handle.pollable(); let mut pollable2 = handle.pollable(); // Run until done while let PollableState::Modified | PollableState::Unmodified = main_pollable.poll() {} // Polling a pollable created early should return `Done` immediately assert_eq!(pollable2.poll(), PollableState::Done); // A new pollable should claim `Done` immediately let mut pollable3 = handle.pollable(); assert_eq!(pollable3.poll(), PollableState::Done); } } ================================================ FILE: src/ui/execution/pty.rs ================================================ use crate::{ code::{ execute::{LanguageSnippetExecutor, ProcessStatus, PtySnippetContext}, snippet::{PtyArgs, Snippet}, }, markdown::{ elements::{Line, Text}, text_style::{Color, TextStyle}, }, render::{ operation::{ AsRenderOperations, BlockLine, Pollable, PollableState, RenderAsync, RenderAsyncStartPolicy, RenderOperation, }, properties::WindowSize, }, theme::{Alignment, PtyOutputBlockStyle}, }; use portable_pty::{MasterPty, PtySize, native_pty_system}; use std::{ fmt, io, iter, mem, sync::{Arc, Mutex}, thread, }; use unicode_width::UnicodeWidthStr; const DEFAULT_COLUMNS: u16 = 80; const DEFAULT_ROWS: u16 = 24; #[derive(Default, Debug)] enum State { #[default] Initial, Running { pty: PtyMaster, dirty: bool, }, ProcessTerminated(ProcessStatus), Done(ProcessStatus), } struct Inner { snippet: Snippet, executor: LanguageSnippetExecutor, parser: vt100::Parser, expected_size: WindowSize, actual_size: WindowSize, update_size: bool, standby: bool, policy: RenderAsyncStartPolicy, state: State, } impl fmt::Debug for Inner { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("Inner") .field("snippet", &self.snippet) .field("executor", &self.executor) .field("expected_size", &self.expected_size) .field("actual_size", &self.actual_size) .field("update_size", &self.update_size) .field("standby", &self.standby) .field("parser", &"...") .field("policy", &self.policy) .field("state", &"...") .finish() } } #[derive(Debug)] pub(crate) struct PtySnippetOutputOperation { handle: PtySnippetHandle, style: PtyOutputBlockStyle, font_size: u8, } impl PtySnippetOutputOperation { pub(crate) fn new(handle: PtySnippetHandle, style: PtyOutputBlockStyle, font_size: u8) -> Self { Self { handle, style, font_size } } fn standby_row(&self, row: u16, dimensions: &WindowSize) -> Line { let lines = self.style.standby.as_lines(); let start_index = (dimensions.rows / 2).saturating_sub(lines.len() as u16 / 2); if row < start_index || row >= start_index + lines.len() as u16 { Text::new("", TextStyle::default().size(self.font_size)).into() } else { let index = (row - start_index) as usize; let padding = usize::from(dimensions.columns / 2).saturating_sub(lines[index].width() / 2); let line: String = iter::repeat_n(' ', padding).chain(lines[index].chars()).collect(); Text::new(line, self.style.style.size(self.font_size)).into() } } } impl AsRenderOperations for PtySnippetOutputOperation { fn as_render_operations(&self, dimensions: &WindowSize) -> Vec { let mut inner = self.handle.0.lock().unwrap(); let dimensions = dimensions .shrink_rows(dimensions.rows - dimensions.rows / self.font_size as u16) .shrink_columns(dimensions.columns - dimensions.columns / self.font_size as u16); if inner.update_size && inner.expected_size != dimensions && dimensions.rows > 0 { inner.expected_size = dimensions; inner.parser.screen_mut().set_size(dimensions.rows, dimensions.columns); } if matches!(inner.state, State::Initial) { let mut operations = Vec::new(); if inner.standby { let dimensions = inner.expected_size; for row in 0..dimensions.rows { let line = self.standby_row(row, &dimensions); operations.extend([ RenderOperation::RenderBlockLine(BlockLine { prefix: "".into(), right_padding_length: 0, repeat_prefix_on_wrap: false, text: line.into(), block_length: dimensions.columns, block_color: self.style.style.colors.background, alignment: Alignment::Center { minimum_margin: Default::default(), minimum_size: Default::default(), }, }), RenderOperation::RenderLineBreak, ]); } } return operations; } let screen = inner.parser.screen(); let (rows, columns) = screen.size(); let mut operations = vec![]; let cursor_position = if screen.hide_cursor() { None } else { Some(screen.cursor_position()) }; for row in 0..rows { let mut line = Vec::new(); let mut current_text = String::new(); let mut current_style = TextStyle::default(); for column in 0..columns { let cell = screen.cell(row, column).expect("no cell"); let mut style = TextStyle::from(cell).size(self.font_size); if style.colors.foreground.is_none() { style.colors.foreground = self.style.style.colors.foreground; } if style.colors.background.is_none() { style.colors.background = self.style.style.colors.background; } let (contents, style) = match cursor_position == Some((row, column)) { true => { let contents = cell.contents(); if contents.is_empty() { (self.style.cursor.symbol.as_str(), style) } else { (contents, self.style.cursor.highlight_style) } } false => (cell.contents(), style), }; if current_style != style && !current_text.is_empty() { line.push(Text::new(mem::take(&mut current_text), current_style)); } current_style = style; if contents.is_empty() { current_text.push(' '); } else { current_text.push_str(contents); } } if !current_text.is_empty() { line.push(Text::new(current_text, current_style)); } operations.extend([ RenderOperation::RenderBlockLine(BlockLine { prefix: "".into(), right_padding_length: 0, repeat_prefix_on_wrap: false, text: line.into(), block_length: columns, block_color: None, alignment: Alignment::Center { minimum_margin: Default::default(), minimum_size: Default::default(), }, }), RenderOperation::RenderLineBreak, ]); } operations } } impl RenderAsync for PtySnippetOutputOperation { fn pollable(&self) -> Box { Box::new(OperationPollable { handle: self.handle.clone() }) } } #[derive(Debug)] struct OperationPollable { handle: PtySnippetHandle, } impl OperationPollable { fn spawn(ctx: PtySnippetContext, dimensions: WindowSize, handle: PtySnippetHandle) -> anyhow::Result { let pty_system = native_pty_system(); let pty_size = PtySize { rows: dimensions.rows, cols: dimensions.columns, pixel_width: dimensions.pixels_per_column() as u16, pixel_height: dimensions.pixels_per_row() as u16, }; let pair = pty_system.openpty(pty_size)?; pair.slave.spawn_command(ctx.command.clone())?; PtyMaster::new(pair.master, handle, ctx) } } impl Pollable for OperationPollable { fn poll(&mut self) -> PollableState { let mut inner = self.handle.0.lock().unwrap(); let expected_size = inner.expected_size; let actual_size = inner.actual_size; inner.actual_size = expected_size; match &mut inner.state { State::Initial => match inner.executor.pty_execution_context(&inner.snippet) { Ok(ctx) => match Self::spawn(ctx, expected_size, self.handle.clone()) { Ok(pty) => { inner.state = State::Running { pty, dirty: true }; PollableState::Modified } Err(e) => { inner.state = State::Done(ProcessStatus::Failure); PollableState::Failed { error: format!("failed to run script: {e}") } } }, Err(e) => { inner.state = State::Done(ProcessStatus::Failure); PollableState::Failed { error: format!("failed to run script: {e}") } } }, State::Running { dirty, pty } => { if actual_size != expected_size { let size = PtySize { rows: expected_size.rows, cols: expected_size.columns, pixel_width: 0, pixel_height: 0, }; let _ = pty._master.resize(size); } if mem::take(dirty) { PollableState::Modified } else { PollableState::Unmodified } } State::ProcessTerminated(status) => { inner.state = State::Done(*status); PollableState::Modified } _ => PollableState::Unmodified, } } } pub(crate) struct PtyMaster { _master: Box, _ctx: PtySnippetContext, } impl PtyMaster { fn new(master: Box, handle: PtySnippetHandle, ctx: PtySnippetContext) -> anyhow::Result { let reader = master.try_clone_reader()?; thread::spawn(|| process_output(reader, handle)); Ok(Self { _master: master, _ctx: ctx }) } } impl fmt::Debug for PtyMaster { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("PtyMaster").field("master", &"...").finish() } } fn process_output(mut reader: Box, handle: PtySnippetHandle) { let mut input_buffer = [0; 1024]; let status = loop { let Ok(bytes_read) = reader.read(&mut input_buffer) else { break ProcessStatus::Failure; }; if bytes_read == 0 { break ProcessStatus::Success; } let bytes = &input_buffer[..bytes_read]; let mut inner = handle.0.lock().unwrap(); inner.parser.process(bytes); if let State::Running { dirty, .. } = &mut inner.state { *dirty = true; }; }; handle.0.lock().unwrap().state = State::ProcessTerminated(status); } impl From<&vt100::Cell> for TextStyle { fn from(cell: &vt100::Cell) -> Self { let mut style = TextStyle::default(); if cell.bold() { style = style.bold(); } if cell.italic() { style = style.italics(); } if cell.underline() { style = style.underlined(); } style.colors.foreground = parse_color(cell.fgcolor()); style.colors.background = parse_color(cell.bgcolor()); style } } fn parse_color(color: vt100::Color) -> Option { match color { vt100::Color::Default => None, vt100::Color::Idx(value) => Color::from_8bit(value), vt100::Color::Rgb(r, g, b) => Some(Color::Rgb { r, g, b }), } } #[derive(Debug, Clone)] pub(crate) struct PtySnippetHandle(Arc>); impl PtySnippetHandle { pub(crate) fn new( snippet: Snippet, executor: LanguageSnippetExecutor, policy: RenderAsyncStartPolicy, args: PtyArgs, ) -> Self { let expected_size = WindowSize { columns: args.columns.unwrap_or(DEFAULT_COLUMNS), rows: args.rows.unwrap_or(DEFAULT_ROWS), height: 0, width: 0, }; let update_size = args.columns.is_none() || args.rows.is_none(); let parser = vt100::Parser::new(expected_size.rows, expected_size.columns, 1000); let inner = Inner { snippet, executor, parser, expected_size, actual_size: expected_size, update_size, standby: args.standby, state: Default::default(), policy, }; Self(Arc::new(Mutex::new(inner))) } pub(crate) fn snippet(&self) -> Snippet { self.0.lock().unwrap().snippet.clone() } pub(crate) fn process_status(&self) -> Option { match &self.0.lock().unwrap().state { State::Initial => None, State::Running { .. } => Some(ProcessStatus::Running), State::ProcessTerminated(status) | State::Done(status) => Some(*status), } } } #[derive(Debug)] pub(crate) struct RunPtySnippetTrigger(PtySnippetHandle); impl RunPtySnippetTrigger { pub(crate) fn new(handle: PtySnippetHandle) -> Self { Self(handle) } } impl AsRenderOperations for RunPtySnippetTrigger { fn as_render_operations(&self, _dimensions: &WindowSize) -> Vec { vec![] } } impl RenderAsync for RunPtySnippetTrigger { fn pollable(&self) -> Box { Box::new(OperationPollable { handle: self.0.clone() }) } fn start_policy(&self) -> RenderAsyncStartPolicy { self.0.0.lock().unwrap().policy } } ================================================ FILE: src/ui/execution/validator.rs ================================================ use crate::{ code::{ execute::{ExecutionHandle, LanguageSnippetExecutor, ProcessStatus}, snippet::{ExpectedSnippetExecutionResult, Snippet}, }, render::operation::{ AsRenderOperations, Pollable, PollableState, RenderAsync, RenderAsyncStartPolicy, RenderOperation, }, }; use std::{ mem, ops::DerefMut, sync::{Arc, Mutex}, }; #[derive(Debug)] pub(crate) struct ValidateSnippetOperation { snippet: Snippet, executor: LanguageSnippetExecutor, state: Arc>, } impl ValidateSnippetOperation { pub(crate) fn new(snippet: Snippet, executor: LanguageSnippetExecutor) -> Self { Self { snippet, executor, state: Default::default() } } } impl AsRenderOperations for ValidateSnippetOperation { fn as_render_operations(&self, _dimensions: &crate::WindowSize) -> Vec { vec![] } } impl RenderAsync for ValidateSnippetOperation { fn pollable(&self) -> Box { Box::new(OperationPollable { snippet: self.snippet.clone(), executor: self.executor.clone(), state: self.state.clone(), }) } fn start_policy(&self) -> RenderAsyncStartPolicy { RenderAsyncStartPolicy::Automatic } } #[derive(Debug, Default)] enum State { #[default] Initial, Running(ExecutionHandle), Done(PollableState), } struct OperationPollable { snippet: Snippet, executor: LanguageSnippetExecutor, state: Arc>, } impl OperationPollable { fn success_to_pollable_state(&self) -> PollableState { match self.snippet.attributes.expected_execution_result { ExpectedSnippetExecutionResult::Success => PollableState::Done, ExpectedSnippetExecutionResult::Failure => { PollableState::Failed { error: "expected snippet to fail but it succeeded".into() } } } } fn error_to_pollable_state>(&self, error: S) -> PollableState { match self.snippet.attributes.expected_execution_result { ExpectedSnippetExecutionResult::Success => PollableState::Failed { error: error.into() }, ExpectedSnippetExecutionResult::Failure => PollableState::Done, } } } impl Pollable for OperationPollable { fn poll(&mut self) -> PollableState { let mut state = self.state.lock().expect("lock poisoned"); let next_state = match mem::take(state.deref_mut()) { State::Initial => match self.executor.execute_async(&self.snippet) { Ok(handle) => State::Running(handle), Err(e) => State::Done(self.error_to_pollable_state(e.to_string())), }, State::Running(handle) => { let state = handle.state.lock().expect("lock poisoned"); match state.status { ProcessStatus::Running => { drop(state); State::Running(handle) } ProcessStatus::Success => State::Done(self.success_to_pollable_state()), ProcessStatus::Failure => { State::Done(self.error_to_pollable_state(String::from_utf8_lossy(&state.output))) } } } State::Done(output) => State::Done(output), }; *state = next_state; match &*state { State::Initial | State::Running(_) => PollableState::Unmodified, State::Done(output) => output.clone(), } } } #[cfg(test)] mod tests { use super::*; use crate::code::{ execute::SnippetExecutor, snippet::{SnippetAttributes, SnippetLanguage}, }; use rstest::rstest; #[rstest] #[case::success("fn main() { println!(\"hi\"); }", ExpectedSnippetExecutionResult::Success)] #[case::failure("fn main() ", ExpectedSnippetExecutionResult::Failure)] fn expectation_matches(#[case] contents: &str, #[case] expected_execution_result: ExpectedSnippetExecutionResult) { let snippet = Snippet { contents: contents.into(), language: SnippetLanguage::Rust, attributes: SnippetAttributes { expected_execution_result, ..Default::default() }, }; let executor = SnippetExecutor::default().language_executor(&snippet.language, &Default::default()).unwrap(); let state = Arc::new(Mutex::new(State::default())); let mut pollable = OperationPollable { snippet: snippet.clone(), executor: executor.clone(), state: state.clone() }; loop { match pollable.poll() { PollableState::Unmodified | PollableState::Modified => continue, PollableState::Done => break, PollableState::Failed { error } => panic!("finished with error: {error}"), } } let mut pollable = OperationPollable { snippet, executor, state: state.clone() }; assert!(matches!(pollable.poll(), PollableState::Done), "different pollable returned different"); } #[rstest] #[case::success("fn main() { println!(\"hi\"); }", ExpectedSnippetExecutionResult::Failure)] #[case::failure("fn main() ", ExpectedSnippetExecutionResult::Success)] fn expect_does_not_match( #[case] contents: &str, #[case] expected_execution_result: ExpectedSnippetExecutionResult, ) { let snippet = Snippet { contents: contents.into(), language: SnippetLanguage::Rust, attributes: SnippetAttributes { expected_execution_result, ..Default::default() }, }; let executor = SnippetExecutor::default().language_executor(&snippet.language, &Default::default()).unwrap(); let state = Arc::new(Mutex::new(State::default())); let mut pollable = OperationPollable { snippet: snippet.clone(), executor: executor.clone(), state: state.clone() }; loop { match pollable.poll() { PollableState::Unmodified | PollableState::Modified => continue, PollableState::Done => panic!("finished successfully"), PollableState::Failed { .. } => break, } } let mut pollable = OperationPollable { snippet, executor, state: state.clone() }; assert!(matches!(pollable.poll(), PollableState::Failed { .. }), "different pollable returned different"); } } ================================================ FILE: src/ui/footer.rs ================================================ use crate::{ markdown::{ elements::{Line, Text}, parse::{MarkdownParser, ParseInlinesError}, text_style::{TextStyle, UndefinedPaletteColorError}, }, render::{ operation::{AsRenderOperations, ImagePosition, ImageRenderProperties, MarginProperties, RenderOperation}, properties::WindowSize, }, terminal::image::Image, theme::{Alignment, ColorPalette, FooterContent, FooterStyle, FooterTemplate, FooterTemplateChunk, Margin}, }; use comrak::Arena; use std::borrow::Cow; use unicode_width::UnicodeWidthStr; #[derive(Debug, Default)] pub(crate) struct FooterVariables { pub(crate) current_slide: usize, pub(crate) total_slides: usize, pub(crate) author: Option, pub(crate) title: Option, pub(crate) sub_title: Option, pub(crate) event: Option, pub(crate) location: Option, pub(crate) date: Option, } #[derive(Debug)] pub(crate) struct FooterGenerator { current_slide: usize, total_slides: u64, style: RenderedFooterStyle, } impl FooterGenerator { pub(crate) fn new( style: FooterStyle, vars: &FooterVariables, palette: &ColorPalette, ) -> Result { let style = RenderedFooterStyle::new(style, vars, palette)?; let current_slide = vars.current_slide; let total_slides = vars.total_slides as u64; Ok(Self { current_slide, total_slides, style }) } fn render_line(line: &FooterLine, alignment: Alignment, height: u16, operations: &mut Vec) { operations.extend([ RenderOperation::JumpToBottomRow { index: height / 2 }, RenderOperation::RenderText { line: line.0.clone().into(), alignment }, ]); } fn push_image(&self, image: &Image, alignment: Alignment, height: u16, operations: &mut Vec) { let mut properties = ImageRenderProperties::default(); operations.push(RenderOperation::ApplyMargin(MarginProperties { horizontal: Margin::Fixed(0), top: 1, bottom: 1, })); match alignment { Alignment::Left { .. } => { operations.push(RenderOperation::JumpToColumn { index: 0 }); properties.position = ImagePosition::Cursor; } Alignment::Right { .. } => { properties.position = ImagePosition::Right; } Alignment::Center { .. } => properties.position = ImagePosition::Center, }; operations.extend([ // Start printing the image at the top of the footer rect RenderOperation::JumpToBottomRow { index: height.saturating_sub(2) }, RenderOperation::RenderImage(image.clone(), properties), RenderOperation::PopMargin, ]); } } impl AsRenderOperations for FooterGenerator { fn as_render_operations(&self, dimensions: &WindowSize) -> Vec { use RenderedFooterStyle::*; match &self.style { Template { left, center, right, height } => { // Crate a margin for ourselves so we can jump to top without stepping over slide // text. let mut operations = vec![RenderOperation::ApplyMargin(MarginProperties { horizontal: Margin::Fixed(1), top: dimensions.rows.saturating_sub(*height), bottom: 0, })]; // We print this one row below the bottom so there's one row of padding. 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) { 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, *height, &mut operations); } }; } } operations.push(RenderOperation::PopMargin); operations } ProgressBar { character, style } => { let character = character.to_string(); let total_columns = dimensions.columns as usize / character.width(); let progress_ratio = (self.current_slide + 1) as f64 / self.total_slides as f64; let columns_ratio = (total_columns as f64 * progress_ratio).ceil(); let bar = character.repeat(columns_ratio as usize); let bar = Text::new(bar, *style); vec![ RenderOperation::JumpToBottomRow { index: 0 }, RenderOperation::RenderText { line: vec![bar].into(), alignment: Alignment::Left { margin: Margin::Fixed(0) }, }, ] } Empty => vec![], } } } #[derive(Debug)] enum RenderedFooterStyle { Template { left: Option, center: Option, right: Option, height: u16, }, ProgressBar { character: char, style: TextStyle, }, Empty, } impl RenderedFooterStyle { fn new( style: FooterStyle, vars: &FooterVariables, palette: &ColorPalette, ) -> Result { match style { 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()?; Ok(Self::Template { left, center, right, height }) } FooterStyle::ProgressBar { character, style } => Ok(Self::ProgressBar { character, style }), FooterStyle::Empty => Ok(Self::Empty), } } } #[derive(Clone, Debug)] struct FooterLine(Line); impl FooterLine { fn new( template: FooterTemplate, style: &TextStyle, vars: &FooterVariables, palette: &ColorPalette, ) -> Result { use FooterTemplateChunk::*; let FooterVariables { current_slide, total_slides, author, title, sub_title, event, location, date } = vars; let arena = Arena::default(); let mut reassembled = String::new(); for chunk in template.0 { let raw_text = match chunk { CurrentSlide => Cow::Owned(current_slide.to_string()), OpenBrace => Cow::Borrowed("{"), ClosedBrace => Cow::Borrowed("}"), Literal(text) => Cow::Owned(text), TotalSlides => Cow::Owned(total_slides.to_string()), Author => Self::extract_variable("author", author)?, Title => Self::extract_variable("title", title)?, SubTitle => Self::extract_variable("sub_title", sub_title)?, Event => Self::extract_variable("event", event)?, Location => Self::extract_variable("location", location)?, Date => Self::extract_variable("date", date)?, }; 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()); } line.apply_style(style); Ok(Self(line)) } fn extract_variable<'a>( name: &'static str, variable: &'a Option, ) -> Result, InvalidFooterTemplateError> { variable.as_deref().map(Cow::Borrowed).ok_or(InvalidFooterTemplateError::VariableNotSet(name)) } } #[derive(Clone, Debug)] enum RenderedFooterContent { Line(FooterLine), Image(Image), } impl RenderedFooterContent { fn new( content: FooterContent, style: &TextStyle, vars: &FooterVariables, palette: &ColorPalette, ) -> Result { Ok(match content { FooterContent::Template(template) => Self::Line(FooterLine::new(template, style, vars, palette)?), FooterContent::Image(image) => Self::Image(image), }) } } #[derive(Debug, thiserror::Error)] pub(crate) enum InvalidFooterTemplateError { #[error("footer cannot contain multiple lines")] NoNewlines, #[error("invalid markdown: {0}")] Inlines(#[from] ParseInlinesError), #[error(transparent)] PaletteColor(#[from] UndefinedPaletteColorError), #[error("variable '{0}' not set")] VariableNotSet(&'static str), } #[cfg(test)] mod tests { use crate::markdown::text_style::Color; use super::*; use once_cell::sync::Lazy; use rstest::rstest; static VARIABLES: Lazy = Lazy::new(|| FooterVariables { current_slide: 1, total_slides: 5, author: Some("bob".into()), title: Some("hi".into()), sub_title: Some("bye".into()), event: Some("test".into()), location: Some("here".into()), date: Some("now".into()), }); static PALETTE: Lazy = Lazy::new(|| ColorPalette { colors: [("red".into(), Color::new(255, 0, 0))].into(), classes: Default::default(), }); #[rstest] #[case::literal(FooterTemplateChunk::Literal("hi".into()), &["hi".into()])] #[case::literal_whitespaced(FooterTemplateChunk::Literal(" hi ".into()), &[" ".into(), "hi".into(), " ".into()])] #[case::author(FooterTemplateChunk::Author, &["bob".into()])] #[case::title(FooterTemplateChunk::Title, &["hi".into()])] #[case::sub_title(FooterTemplateChunk::SubTitle, &["bye".into()])] #[case::event(FooterTemplateChunk::Event, &["test".into()])] #[case::location(FooterTemplateChunk::Location, &["here".into()])] #[case::date(FooterTemplateChunk::Date, &["now".into()])] #[case::bold( FooterTemplateChunk::Literal("**hi** mom".into()), &[Text::new("hi", TextStyle::default().bold()), " mom".into()] )] #[case::colored( FooterTemplateChunk::Literal("hi mom".into()), &[Text::new("hi", TextStyle::default().fg_color(Color::new(255, 0, 0))), " mom".into()] )] fn render_valid(#[case] chunk: FooterTemplateChunk, #[case] expected: &[Text]) { let template = FooterTemplate(vec![chunk]); let line = FooterLine::new(template, &Default::default(), &VARIABLES, &PALETTE).expect("render failed"); assert_eq!(line.0.0, expected); } #[rstest] #[case::non_paragraph( FooterTemplateChunk::Literal("* hi".into()), )] #[case::invalid_palette_color( FooterTemplateChunk::Literal("hi mom".into()), )] #[case::newlines(FooterTemplateChunk::Literal("hi\nmom".into()))] fn render_invalid(#[case] chunk: FooterTemplateChunk) { 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("".into()), FooterTemplateChunk::CurrentSlide, FooterTemplateChunk::Literal(" / ".into()), FooterTemplateChunk::TotalSlides, FooterTemplateChunk::Literal("".into()), FooterTemplateChunk::Literal("".into()), FooterTemplateChunk::Title, FooterTemplateChunk::Literal("".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); } } ================================================ FILE: src/ui/mod.rs ================================================ pub(crate) mod execution; pub(crate) mod footer; pub(crate) mod modals; pub(crate) mod separator; ================================================ FILE: src/ui/modals.rs ================================================ use crate::{ code::padding::NumberPadder, commands::keyboard::KeyBinding, config::KeyBindingsConfig, markdown::{ elements::{Line, Text}, text::WeightedLine, text_style::TextStyle, }, presentation::PresentationState, render::{ operation::{ AsRenderOperations, ImagePosition, ImageRenderProperties, ImageSize, MarginProperties, RenderOperation, }, properties::WindowSize, }, terminal::image::Image, theme::{Margin, PresentationTheme}, }; use std::{iter, rc::Rc}; use unicode_width::UnicodeWidthStr; static MODAL_Z_INDEX: i32 = -1; #[derive(Default)] pub(crate) struct IndexBuilder { titles: Vec, background: Option, } impl IndexBuilder { pub(crate) fn add_title(&mut self, title: Line) { self.titles.push(title); } pub(crate) fn set_background(&mut self, background: Image) { self.background = Some(background); } pub(crate) fn build(self, theme: &PresentationTheme, state: PresentationState) -> Vec { let mut builder = ModalBuilder::new("Slides"); let padder = NumberPadder::new(self.titles.len()); for (index, mut title) in self.titles.into_iter().enumerate() { let index = padder.pad_right(index + 1); title.0.insert(0, format!("{index}: ").into()); builder.content.push(title); } let base_style = theme.modals.style; let selection_style = theme.modals.selection_style; let ModalContent { prefix, content, suffix, content_width } = builder.build(base_style); let drawer = IndexDrawer { prefix, rows: content, suffix, state, content_width, selection_style, background: self.background, }; vec![RenderOperation::RenderDynamicTopLevel(Rc::new(drawer))] } } #[derive(Debug)] struct IndexDrawer { prefix: Vec, rows: Vec, suffix: Vec, content_width: u16, state: PresentationState, selection_style: TextStyle, background: Option, } impl AsRenderOperations for IndexDrawer { fn as_render_operations(&self, dimensions: &WindowSize) -> Vec { let current_slide_index = self.state.current_slide_index(); let max_rows = (dimensions.rows as f64 * 0.8) as u16; let (skip, take) = match self.rows.len() as u16 > max_rows { true => { let start = (current_slide_index as u16).saturating_sub(max_rows / 2); let start = start.min(self.rows.len() as u16 - max_rows); (start as usize, max_rows as usize) } false => (0, self.rows.len()), }; let visible_rows = self.rows.iter().enumerate().skip(skip).take(take); let mut operations = vec![CenterModalContent::new(self.content_width, take, self.background.clone()).into()]; operations.extend(self.prefix.iter().cloned()); for (index, row) in visible_rows { let mut row = row.clone(); if index == current_slide_index { row = row.with_style(self.selection_style); } let operation = RenderOperation::RenderText { line: row.build(), alignment: Default::default() }; operations.extend([operation, RenderOperation::RenderLineBreak]); } operations.extend(self.suffix.iter().cloned()); operations } } #[derive(Default)] pub(crate) struct KeyBindingsModalBuilder { background: Option, } impl KeyBindingsModalBuilder { pub(crate) fn set_background(&mut self, background: Image) { self.background = Some(background); } pub(crate) fn build(self, theme: &PresentationTheme, config: &KeyBindingsConfig) -> Vec { let mut builder = ModalBuilder::new("Key bindings"); builder.content.extend([ Self::build_line("Next", &config.next), Self::build_line("Next (fast)", &config.next_fast), Self::build_line("Previous", &config.previous), Self::build_line("Previous (fast)", &config.previous_fast), Self::build_line("First slide", &config.first_slide), Self::build_line("Last slide", &config.last_slide), Self::build_line("Go to slide", &config.go_to_slide), Self::build_line("Execute code", &config.execute_code), Self::build_line("Reload", &config.reload), Self::build_line("Toggle slide index", &config.toggle_slide_index), Self::build_line("Close modal", &config.close_modal), Self::build_line("Exit", &config.exit), ]); let lines = builder.content.len(); let style = theme.modals.style; let content = builder.build(style); let content_width = content.content_width; let mut operations = content.into_operations(); operations.insert(0, CenterModalContent::new(content_width, lines, self.background).into()); operations } fn build_line(label: &str, bindings: &[KeyBinding]) -> Line { let mut text = vec![Text::new(label, TextStyle::default().bold()), ": ".into()]; for (index, binding) in bindings.iter().enumerate() { if index > 0 { text.push(", ".into()); } text.push(Text::new(binding.to_string(), TextStyle::default().italics())); } Line(text) } } struct ModalBuilder { heading: String, content: Vec, } impl ModalBuilder { fn new>(heading: S) -> Self { Self { heading: heading.into(), content: Vec::new() } } fn build(self, style: TextStyle) -> ModalContent { let longest_line = self.content.iter().map(Line::width).max().unwrap_or(0) as u16; let longest_line = longest_line.max(self.heading.len() as u16); // Ensure we have a minimum width so it doesn't look too narrow. let longest_line = longest_line.max(12); // The final text looks like "| |" let content_width = longest_line + 6; let mut prefix = vec![RenderOperation::SetColors(style.colors)]; let heading = Self::center_line(self.heading, longest_line as usize); prefix.extend(Border::Top.render_line(content_width)); prefix.extend([ RenderOperation::RenderText { line: Self::build_line(vec![Text::from(heading)], content_width).build(), alignment: Default::default(), }, RenderOperation::RenderLineBreak, ]); prefix.extend(Border::Separator.render_line(content_width)); let mut content = Vec::new(); for title in self.content { content.push(Self::build_line(title.0, content_width)); } let suffix = Border::Bottom.render_line(content_width).into_iter().collect(); ModalContent { prefix, content, suffix, content_width } } fn center_line(text: String, longest_line: usize) -> String { let missing = longest_line.saturating_sub(text.len()); let padding = missing / 2; let mut output = " ".repeat(padding); output.push_str(&text); output.extend(iter::repeat_n(' ', padding)); output } fn build_line(text_chunks: Vec, content_width: u16) -> ContentRow { let (opening, closing) = Border::Regular.edges(); let prefix = Text::from(format!("{opening} ")); let content = text_chunks; let total_width = content.iter().map(|c| c.content.width()).sum::() + prefix.content.width(); let missing = content_width as usize - 1 - total_width; let mut suffix = " ".repeat(missing); suffix.push(closing); ContentRow { prefix, content, suffix: suffix.into() } } } struct ModalContent { prefix: Vec, content: Vec, suffix: Vec, content_width: u16, } impl ModalContent { fn into_operations(self) -> Vec { let mut operations = self.prefix; operations.extend(self.content.into_iter().flat_map(|c| { [ RenderOperation::RenderText { line: c.build(), alignment: Default::default() }, RenderOperation::RenderLineBreak, ] })); operations.extend(self.suffix); operations } } #[derive(Clone, Debug)] struct ContentRow { prefix: Text, content: Vec, suffix: Text, } impl ContentRow { fn with_style(mut self, style: TextStyle) -> ContentRow { for chunk in &mut self.content { chunk.style.merge(&style); } self } fn build(self) -> WeightedLine { let mut chunks = self.content; chunks.insert(0, self.prefix); chunks.push(self.suffix); WeightedLine::from(chunks) } } enum Border { Regular, Top, Separator, Bottom, } impl Border { fn render_line(&self, content_length: u16) -> [RenderOperation; 2] { let (opening, closing) = self.edges(); let mut line = String::from(opening); line.push_str(&"─".repeat(content_length.saturating_sub(2) as usize)); line.push(closing); let horizontal_border = WeightedLine::from(vec![Text::from(line)]); [ RenderOperation::RenderText { line: horizontal_border.clone(), alignment: Default::default() }, RenderOperation::RenderLineBreak, ] } fn edges(&self) -> (char, char) { match self { Self::Regular => ('│', '│'), Self::Top => ('┌', '┐'), Self::Separator => ('├', '┤'), Self::Bottom => ('└', '┘'), } } } #[derive(Debug)] struct CenterModalContent { content_width: u16, content_height: usize, background: Option, } impl CenterModalContent { fn new(content_width: u16, content_height: usize, background: Option) -> Self { Self { content_width, content_height, background } } } impl AsRenderOperations for CenterModalContent { fn as_render_operations(&self, dimensions: &WindowSize) -> Vec { let margin = dimensions.columns.saturating_sub(self.content_width) / 2; let properties = MarginProperties { horizontal: Margin::Fixed(margin), top: 0, bottom: 0 }; // However many we see + 3 for the title and 1 at the bottom. let content_height = (self.content_height + 4) as u16; let target_row = dimensions.rows.saturating_sub(content_height) / 2; let mut operations = vec![RenderOperation::ApplyMargin(properties), RenderOperation::JumpToRow { index: target_row }]; if let Some(image) = &self.background { let properties = ImageRenderProperties { z_index: MODAL_Z_INDEX, size: ImageSize::Specific(self.content_width, content_height), restore_cursor: true, background_color: None, position: ImagePosition::Center, }; operations.push(RenderOperation::RenderImage(image.clone(), properties)); } operations } } impl From for RenderOperation { fn from(op: CenterModalContent) -> Self { Self::RenderDynamicTopLevel(Rc::new(op)) } } ================================================ FILE: src/ui/separator.rs ================================================ use crate::{ markdown::{ elements::{Line, Text}, text_style::TextStyle, }, render::{ layout::{Layout, Positioning}, operation::{AsRenderOperations, BlockLine, RenderOperation}, properties::WindowSize, }, theme::{Alignment, Margin}, }; use std::rc::Rc; #[derive(Clone, Copy, Debug, Default)] pub(crate) enum SeparatorWidth { Fixed(u16), #[default] FitToWindow, } #[derive(Clone, Debug)] pub(crate) struct RenderSeparator { heading: Line, width: SeparatorWidth, font_size: u8, } impl RenderSeparator { pub(crate) fn new>(heading: S, width: SeparatorWidth, font_size: u8) -> Self { let mut heading: Line = heading.into(); heading.apply_style(&TextStyle::default().size(font_size)); Self { heading, width, font_size } } } impl From for RenderOperation { fn from(separator: RenderSeparator) -> Self { Self::RenderDynamic(Rc::new(separator)) } } impl AsRenderOperations for RenderSeparator { fn as_render_operations(&self, dimensions: &WindowSize) -> Vec { let character = "—"; let width = match self.width { SeparatorWidth::Fixed(width) => { let Positioning { max_line_length, .. } = Layout::new(Alignment::Center { minimum_margin: Margin::Fixed(0), minimum_size: 0 }) .with_font_size(self.font_size) .compute(dimensions, width); max_line_length.min(width) as usize } SeparatorWidth::FitToWindow => dimensions.columns as usize, }; let style = TextStyle::default().size(self.font_size); let width = width / self.font_size as usize; let separator = match self.heading.width() == 0 { true => Line::from(Text::new(character.repeat(width), style)), false => { let width = width.saturating_sub(self.heading.width()); let (dashes_len, remainder) = (width / 2, width % 2); let mut dashes = character.repeat(dashes_len); let mut line = Line::from(Text::new(dashes.clone(), style)); line.0.extend(self.heading.0.iter().cloned()); if remainder > 0 { dashes.push_str(character); } line.0.push(Text::new(dashes, style)); line } }; vec![RenderOperation::RenderBlockLine(BlockLine { prefix: "".into(), right_padding_length: 0, repeat_prefix_on_wrap: false, text: separator.into(), block_length: width as u16, block_color: None, alignment: Alignment::Center { minimum_size: 1, minimum_margin: Margin::Fixed(0) }, })] } } ================================================ FILE: src/utils.rs ================================================ 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(deserializer: D) -> Result 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(&self, serializer: S) -> Result 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 where D: Deserializer<'de>, T: FromStr, T::Err: Display, { struct Visitor(PhantomData); impl serde::de::Visitor<'_> for Visitor where S: FromStr, ::Err: Display, { type Value = S; fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { write!(formatter, "a string") } fn visit_str(self, value: &str) -> Result where E: serde::de::Error, { value.parse::().map_err(serde::de::Error::custom) } } deserializer.deserialize_str(Visitor(PhantomData)) } // Same behavior as serde_with::SerializeDisplay pub(crate) fn serialize_display(value: &T, serializer: S) -> Result where T: Display, S: Serializer, { serializer.serialize_str(&value.to_string()) } ================================================ FILE: themes/catppuccin-frappe.yaml ================================================ --- default: margin: percent: 8 colors: foreground: palette:text background: palette:base column_layout: margin: fixed: 4 slide_title: alignment: center padding_bottom: 1 padding_top: 1 colors: foreground: palette:yellow bold: true font_size: 2 code: alignment: center minimum_size: 50 minimum_margin: percent: 8 theme_name: base16-eighties.dark padding: horizontal: 2 vertical: 1 execution_output: colors: foreground: palette:text background: palette:surface0 status: running: foreground: palette:blue success: foreground: palette:green failure: foreground: palette:red not_started: foreground: palette:yellow padding: horizontal: 2 vertical: 1 pty_output: colors: foreground: palette:text background: palette:surface0 cursor: highlight_colors: foreground: palette:surface0 background: palette:text inline_code: colors: foreground: palette:green intro_slide: title: alignment: center colors: foreground: palette:green font_size: 2 subtitle: alignment: center colors: foreground: palette:sapphire event: alignment: center colors: foreground: palette:green location: alignment: center colors: foreground: palette:sapphire date: alignment: center colors: foreground: palette:yellow author: alignment: center colors: foreground: palette:subtext1 positioning: page_bottom footer: false headings: h1: prefix: "██" colors: foreground: palette:teal h2: prefix: "▓▓▓" colors: foreground: palette:mauve h3: prefix: "▒▒▒▒" colors: foreground: palette:blue h4: prefix: "░░░░░" colors: foreground: palette:red h5: prefix: "░░░░░░" colors: foreground: palette:green h6: prefix: "░░░░░░░" colors: foreground: palette:peach block_quote: prefix: "▍ " colors: foreground: palette:text background: palette:surface0 prefix: palette:yellow alert: prefix: "▍ " base_colors: foreground: palette:text background: palette:surface0 styles: note: color: palette:blue tip: color: palette:green important: color: palette:mauve warning: color: palette:yellow caution: color: palette:red typst: colors: foreground: palette:text background: palette:surface0 footer: style: template right: "{current_slide} / {total_slides}" modals: selection_colors: foreground: palette:sapphire mermaid: background: transparent theme: dark d2: theme: 4 layout_grid: color: palette:blue palette: colors: rosewater: "f2d5cf" flamingo: "eebebe" pink: "f4b8e4" mauve: "ca9ee6" red: "e78284" maroon: "ea999c" peach: "ef9f76" yellow: "e5c890" green: "a6d189" teal: "81c8be" sky: "99d1db" sapphire: "85c1dc" blue: "8caaee" lavender: "babbf1" text: "c6d0f5" subtext1: "b5bfe2" subtext0: "a5adce" overlay2: "949cbb" overlay1: "838ba7" overlay0: "737994" surface2: "626880" surface1: "51576d" surface0: "414559" base: "303446" mantle: "292c3c" crust: "232634" ================================================ FILE: themes/catppuccin-latte.yaml ================================================ --- default: margin: percent: 8 colors: foreground: palette:text background: palette:base column_layout: margin: fixed: 4 slide_title: alignment: center padding_bottom: 1 padding_top: 1 colors: foreground: palette:yellow bold: true font_size: 2 code: alignment: center minimum_size: 50 minimum_margin: percent: 8 theme_name: GitHub padding: horizontal: 2 vertical: 1 execution_output: colors: foreground: palette:text background: palette:surface0 status: running: foreground: palette:sky success: foreground: palette:green failure: foreground: palette:red not_started: foreground: palette:yellow padding: horizontal: 2 vertical: 1 pty_output: colors: foreground: palette:text background: palette:surface0 cursor: highlight_colors: foreground: palette:surface0 background: palette:text inline_code: colors: foreground: palette:green intro_slide: title: alignment: center colors: foreground: palette:green font_size: 2 subtitle: alignment: center colors: foreground: palette:sapphire event: alignment: center colors: foreground: palette:green location: alignment: center colors: foreground: palette:sapphire date: alignment: center colors: foreground: palette:yellow author: alignment: center colors: foreground: palette:subtext1 positioning: page_bottom footer: false headings: h1: prefix: "██" colors: foreground: palette:teal h2: prefix: "▓▓▓" colors: foreground: palette:mauve h3: prefix: "▒▒▒▒" colors: foreground: palette:blue h4: prefix: "░░░░░" colors: foreground: palette:red h5: prefix: "░░░░░░" colors: foreground: palette:green h6: prefix: "░░░░░░░" colors: foreground: palette:peach block_quote: prefix: "▍ " colors: foreground: palette:text background: palette:surface0 prefix: palette:yellow alert: prefix: "▍ " base_colors: foreground: palette:text background: palette:surface0 styles: note: color: palette:blue tip: color: palette:green important: color: palette:mauve warning: color: palette:yellow caution: color: palette:red typst: colors: foreground: palette:text background: palette:surface0 footer: style: template right: "{current_slide} / {total_slides}" modals: selection_colors: foreground: palette:sapphire mermaid: background: transparent theme: default d2: theme: 100 layout_grid: color: palette:blue palette: colors: rosewater: "dc8a78" flamingo: "dd7878" pink: "ea76cb" mauve: "8839ef" red: "d20f39" maroon: "e64553" peach: "fe640b" yellow: "df8e1d" green: "40a02b" teal: "179299" sky: "04a5e5" sapphire: "209fb5" blue: "1e66f5" lavender: "7287fd" text: "4c4f69" subtext1: "5c5f77" subtext0: "6c6f85" overlay2: "7c7f93" overlay1: "8c8fa1" overlay0: "9ca0b0" surface2: "acb0be" surface1: "bcc0cc" surface0: "ccd0da" base: "eff1f5" mantle: "e6e9ef" crust: "dce0e8" ================================================ FILE: themes/catppuccin-macchiato.yaml ================================================ --- default: margin: percent: 8 colors: foreground: palette:text background: palette:base column_layout: margin: fixed: 4 slide_title: alignment: center padding_bottom: 1 padding_top: 1 colors: foreground: palette:yellow bold: true font_size: 2 code: alignment: center minimum_size: 50 minimum_margin: percent: 8 theme_name: base16-eighties.dark padding: horizontal: 2 vertical: 1 execution_output: colors: foreground: palette:text background: palette:surface0 status: running: foreground: palette:sky success: foreground: palette:green failure: foreground: palette:red not_started: foreground: palette:yellow padding: horizontal: 2 vertical: 1 pty_output: colors: foreground: palette:text background: palette:surface0 cursor: highlight_colors: foreground: palette:surface0 background: palette:text inline_code: colors: foreground: palette:green intro_slide: title: alignment: center colors: foreground: palette:green font_size: 2 subtitle: alignment: center colors: foreground: palette:sapphire event: alignment: center colors: foreground: palette:green location: alignment: center colors: foreground: palette:sapphire date: alignment: center colors: foreground: palette:yellow author: alignment: center colors: foreground: palette:subtext1 positioning: page_bottom footer: false headings: h1: prefix: "██" colors: foreground: palette:teal h2: prefix: "▓▓▓" colors: foreground: palette:mauve h3: prefix: "▒▒▒▒" colors: foreground: palette:blue h4: prefix: "░░░░░" colors: foreground: palette:red h5: prefix: "░░░░░░" colors: foreground: palette:green h6: prefix: "░░░░░░░" colors: foreground: palette:peach block_quote: prefix: "▍ " colors: foreground: palette:text background: palette:surface0 prefix: palette:yellow alert: prefix: "▍ " base_colors: foreground: palette:text background: palette:surface0 styles: note: color: palette:blue tip: color: palette:green important: color: palette:mauve warning: color: palette:peach caution: color: palette:red typst: colors: foreground: palette:text background: palette:surface0 footer: style: template right: "{current_slide} / {total_slides}" modals: selection_colors: foreground: palette:sapphire mermaid: background: transparent theme: dark d2: theme: 200 layout_grid: color: palette:blue palette: colors: rosewater: "f4dbd6" flamingo: "f0c6c6" pink: "f5bde6" mauve: "c6a0f6" red: "ed8796" maroon: "ee99a0" peach: "f5a97f" yellow: "eed49f" green: "a6da95" teal: "8bd5ca" sky: "91d7e3" sapphire: "7dc4e4" blue: "8aadf4" lavender: "b7bdf8" text: "cad3f5" subtext1: "b8c0e0" subtext0: "a5adcb" overlay2: "939ab7" overlay1: "8087a2" overlay0: "6e738d" surface2: "5b6078" surface1: "494d64" surface0: "363a4f" base: "24273a" mantle: "1e2030" crust: "181926" ================================================ FILE: themes/catppuccin-mocha.yaml ================================================ --- default: margin: percent: 8 colors: foreground: palette:text background: palette:base column_layout: margin: fixed: 4 slide_title: alignment: center padding_bottom: 1 padding_top: 1 colors: foreground: palette:yellow bold: true font_size: 2 code: alignment: center minimum_size: 50 minimum_margin: percent: 8 theme_name: base16-eighties.dark padding: horizontal: 2 vertical: 1 execution_output: colors: foreground: palette:text background: palette:surface0 status: running: foreground: palette:sky success: foreground: palette:green failure: foreground: palette:red not_started: foreground: palette:yellow padding: horizontal: 2 vertical: 1 pty_output: colors: foreground: palette:text background: palette:surface0 cursor: highlight_colors: foreground: palette:surface0 background: palette:text inline_code: colors: foreground: palette:green intro_slide: title: alignment: center colors: foreground: palette:green font_size: 2 subtitle: alignment: center colors: foreground: palette:sapphire event: alignment: center colors: foreground: palette:green location: alignment: center colors: foreground: palette:sapphire date: alignment: center colors: foreground: palette:yellow author: alignment: center colors: foreground: palette:subtext1 positioning: page_bottom footer: false headings: h1: prefix: "██" colors: foreground: palette:teal h2: prefix: "▓▓▓" colors: foreground: palette:mauve h3: prefix: "▒▒▒▒" colors: foreground: palette:blue h4: prefix: "░░░░░" colors: foreground: palette:red h5: prefix: "░░░░░░" colors: foreground: palette:green h6: prefix: "░░░░░░░" colors: foreground: palette:peach block_quote: prefix: "▍ " colors: foreground: palette:text background: palette:surface0 prefix: palette:yellow alert: prefix: "▍ " base_colors: foreground: palette:text background: palette:surface0 styles: note: color: palette:blue tip: color: palette:green important: color: palette:mauve warning: color: palette:peach caution: color: palette:red typst: colors: foreground: palette:text background: palette:surface0 footer: style: template right: "{current_slide} / {total_slides}" modals: selection_colors: foreground: palette:sapphire mermaid: background: transparent theme: dark d2: theme: 200 layout_grid: color: palette:blue palette: colors: rosewater: "f5e0dc" flamingo: "f2cdcd" pink: "f5c2e7" mauve: "cba6f7" red: "f38ba8" maroon: "eba0ac" peach: "fab387" yellow: "f9e2af" green: "a6e3a1" teal: "94e2d5" sky: "89dceb" sapphire: "74c7ec" blue: "89b4fa" lavender: "b4befe" text: "cdd6f4" subtext1: "bac2de" subtext0: "a6adc8" overlay2: "9399b2" overlay1: "7f849c" overlay0: "6c7086" surface2: "585b70" surface1: "45475a" surface0: "313244" base: "1e1e2e" mantle: "181825" crust: "11111b" ================================================ FILE: themes/dark.yaml ================================================ --- default: margin: percent: 8 colors: foreground: palette:white background: "040312" column_layout: margin: fixed: 4 slide_title: alignment: center padding_bottom: 1 padding_top: 1 colors: foreground: palette:orange bold: true font_size: 2 code: alignment: center minimum_size: 50 minimum_margin: percent: 8 theme_name: base16-eighties.dark padding: horizontal: 2 vertical: 1 execution_output: colors: foreground: palette:white background: palette:black status: running: foreground: palette:light_blue success: foreground: palette:light_green failure: foreground: palette:red not_started: foreground: palette:orange padding: horizontal: 2 vertical: 1 pty_output: colors: foreground: palette:white background: palette:black cursor: highlight_colors: foreground: palette:black background: palette:white inline_code: colors: foreground: "04de20" background: "455045" intro_slide: title: alignment: center colors: foreground: palette:light_blue font_size: 2 subtitle: alignment: center colors: foreground: palette:aqua event: alignment: center colors: foreground: palette:light_blue location: alignment: center colors: foreground: palette:aqua date: alignment: center colors: foreground: palette:orange author: alignment: center colors: foreground: "b6eada" positioning: page_bottom footer: false headings: h1: prefix: "██" colors: foreground: palette:blue h2: prefix: "▓▓▓" colors: foreground: palette:light_green h3: prefix: "▒▒▒▒" colors: foreground: palette:red h4: prefix: "░░░░░" colors: foreground: palette:gray h5: prefix: "░░░░░░" colors: foreground: palette:gray h6: prefix: "░░░░░░░" colors: foreground: palette:gray block_quote: prefix: "▍ " colors: foreground: palette:light_gray background: palette:blue_gray prefix: palette:orange alert: prefix: "▍ " base_colors: foreground: palette:light_gray background: palette:blue_gray styles: note: color: palette:blue tip: color: palette:light_green important: color: palette:purple warning: color: palette:orange caution: color: palette:red typst: colors: foreground: palette:light_gray background: palette:blue_gray footer: style: template right: "{current_slide} / {total_slides}" modals: selection_colors: foreground: palette:orange mermaid: background: transparent theme: dark d2: theme: 200 layout_grid: color: palette:blue palette: colors: blue: "3085c3" light_blue: "b4ccff" blue_gray: "292e42" aqua: "a5d7e8" light_green: "a8df8e" red: "f78ca2" orange: "ee9322" purple: "986ee2" white: "e6e6e6" black: "2d2d2d" gray: "d2d2d2" light_gray: "f0f0f0" ================================================ FILE: themes/gruvbox-dark.yaml ================================================ --- default: margin: percent: 8 colors: foreground: "ebdbb2" background: "282828" column_layout: margin: fixed: 4 slide_title: alignment: center padding_bottom: 1 padding_top: 1 colors: foreground: "fabd2f" bold: true font_size: 2 code: alignment: center minimum_size: 50 minimum_margin: percent: 8 theme_name: base16-eighties.dark padding: horizontal: 2 vertical: 1 execution_output: colors: foreground: "ebdbb2" background: "3c3836" status: running: foreground: "83a598" success: foreground: "b8bb26" failure: foreground: "fb4934" not_started: foreground: "fabd2f" padding: horizontal: 2 vertical: 1 pty_output: colors: foreground: "ebdbb2" background: "3c3836" cursor: highlight_colors: foreground: "3c3836" background: "ebdbb2" inline_code: colors: foreground: "b8bb26" intro_slide: title: alignment: center colors: foreground: "b8bb26" font_size: 2 subtitle: alignment: center colors: foreground: "83a598" event: alignment: center colors: foreground: "b8bb26" location: alignment: center colors: foreground: "83a598" date: alignment: center colors: foreground: "fabd2f" author: alignment: center colors: foreground: "d5c4a1" positioning: page_bottom footer: false headings: h1: prefix: "██" colors: foreground: "8ec07c" h2: prefix: "▓▓▓" colors: foreground: "d3869b" h3: prefix: "▒▒▒▒" colors: foreground: "83a598" h4: prefix: "░░░░░" colors: foreground: "fb4934" h5: prefix: "░░░░░░" colors: foreground: "b8bb26" h6: prefix: "░░░░░░░" colors: foreground: "fe8019" block_quote: prefix: "▍ " colors: foreground: "ebdbb2" background: "3c3836" prefix: "fabd2f" alert: prefix: "▍ " base_colors: foreground: "ebdbb2" background: "3c3836" styles: note: color: "83a598" tip: color: "b8bb26" important: color: "d3869b" warning: color: "fe8019" caution: color: "fb4934" typst: colors: foreground: "ebdbb2" background: "3c3836" footer: style: template right: "{current_slide} / {total_slides}" modals: selection_colors: foreground: "83a598" mermaid: background: transparent theme: dark d2: theme: 103 layout_grid: color: "83a598" ================================================ FILE: themes/light.yaml ================================================ --- default: margin: percent: 8 colors: foreground: "212529" background: "f8f9fa" column_layout: margin: fixed: 4 slide_title: alignment: center padding_bottom: 1 padding_top: 1 colors: foreground: "f77f00" bold: true font_size: 2 code: alignment: center minimum_size: 50 minimum_margin: percent: 8 theme_name: GitHub padding: horizontal: 2 vertical: 1 execution_output: colors: foreground: "212529" background: "e9ecef" status: running: foreground: "457b9d" success: foreground: "52b788" failure: foreground: "f07167" not_started: foreground: "f77f00" padding: horizontal: 2 vertical: 1 pty_output: colors: foreground: "212529" background: "e9ecef" cursor: highlight_colors: foreground: "e9ecef" background: "212529" inline_code: colors: foreground: "f07167" background: "f5cac3" intro_slide: title: alignment: center colors: foreground: "52b788" font_size: 2 subtitle: alignment: center colors: foreground: "8e9aaf" event: alignment: center colors: foreground: "52b788" location: alignment: center colors: foreground: "f77f00" date: alignment: center colors: foreground: "f77f00" author: alignment: center colors: foreground: "4a4e69" positioning: page_bottom footer: false headings: h1: prefix: "██" colors: foreground: "1d3557" h2: prefix: "▓▓▓" colors: foreground: "457b9d" h3: prefix: "▒▒▒▒" colors: foreground: "4a4e69" h4: prefix: "░░░░░" colors: foreground: "4a4e69" h5: prefix: "░░░░░░" colors: foreground: "4a4e69" h6: prefix: "░░░░░░░" colors: foreground: "4a4e69" block_quote: prefix: "▍ " colors: foreground: "212529" background: "e9ecef" prefix: "f77f00" alert: prefix: "▍ " base_colors: foreground: "212529" background: "e9ecef" styles: note: color: "1e66f5" tip: color: "40a02b" important: color: "8839ef" warning: color: "df8e1d" caution: color: "d20f39" typst: colors: foreground: "212529" background: "e9ecef" footer: style: template right: "{current_slide} / {total_slides}" modals: selection_colors: foreground: "f77f00" mermaid: background: transparent theme: default d2: theme: 4 layout_grid: color: "457b9d" ================================================ FILE: themes/terminal-dark.yaml ================================================ --- default: margin: percent: 8 colors: foreground: null background: null column_layout: margin: fixed: 4 slide_title: alignment: center padding_bottom: 1 padding_top: 1 colors: foreground: yellow bold: true font_size: 2 code: alignment: center minimum_size: 50 minimum_margin: percent: 8 theme_name: base16-eighties.dark padding: horizontal: 2 vertical: 1 background: false execution_output: colors: foreground: white status: running: foreground: blue success: foreground: green failure: foreground: red not_started: foreground: yellow padding: horizontal: 2 vertical: 1 pty_output: colors: foreground: white cursor: highlight_colors: foreground: black background: white inline_code: colors: foreground: green intro_slide: title: alignment: center colors: foreground: green font_size: 2 subtitle: alignment: center colors: foreground: blue event: alignment: center colors: foreground: green location: alignment: center colors: foreground: blue date: alignment: center colors: foreground: yellow author: alignment: center colors: foreground: white positioning: page_bottom footer: false headings: h1: prefix: "██" colors: foreground: cyan h2: prefix: "▓▓▓" colors: foreground: magenta h3: prefix: "▒▒▒▒" colors: foreground: red h4: prefix: "░░░░░" colors: foreground: blue h5: prefix: "░░░░░░" colors: foreground: blue h6: prefix: "░░░░░░░" colors: foreground: blue block_quote: prefix: "▍ " colors: foreground: white background: black prefix: yellow alert: prefix: "▍ " base_colors: foreground: white background: black styles: note: color: blue tip: color: green important: color: magenta warning: color: yellow caution: color: red typst: colors: foreground: "f0f0f0" footer: style: template right: "{current_slide} / {total_slides}" modals: selection_colors: foreground: yellow mermaid: background: transparent theme: dark d2: theme: 200 layout_grid: color: blue ================================================ FILE: themes/terminal-light.yaml ================================================ --- default: margin: percent: 8 colors: foreground: null background: null column_layout: margin: fixed: 4 slide_title: alignment: center padding_bottom: 1 padding_top: 1 colors: foreground: dark_yellow bold: true font_size: 2 code: alignment: center minimum_size: 50 minimum_margin: percent: 8 theme_name: GitHub padding: horizontal: 2 vertical: 1 background: false execution_output: colors: foreground: black status: running: foreground: dark_blue success: foreground: dark_green failure: foreground: dark_red not_started: foreground: dark_yellow padding: horizontal: 2 vertical: 1 pty_output: colors: foreground: black cursor: highlight_colors: foreground: white background: black inline_code: colors: foreground: dark_green intro_slide: title: alignment: center colors: foreground: dark_green font_size: 2 subtitle: alignment: center colors: foreground: dark_blue event: alignment: center colors: foreground: dark_green location: alignment: center colors: foreground: dark_blue date: alignment: center colors: foreground: dark_yellow author: alignment: center colors: foreground: black positioning: page_bottom footer: false headings: h1: prefix: "██" colors: foreground: dark_cyan h2: prefix: "▓▓▓" colors: foreground: dark_magenta h3: prefix: "▒▒▒▒" colors: foreground: dark_red h4: prefix: "░░░░░" colors: foreground: dark_blue h5: prefix: "░░░░░░" colors: foreground: dark_blue h6: prefix: "░░░░░░░" colors: foreground: dark_blue block_quote: prefix: "▍ " colors: foreground: black background: grey prefix: dark_red alert: prefix: "▍ " base_colors: foreground: black background: grey styles: note: color: dark_blue tip: color: dark_green important: color: dark_magenta warning: color: dark_yellow caution: color: dark_red typst: colors: foreground: "212529" footer: style: template right: "{current_slide} / {total_slides}" modals: selection_colors: foreground: dark_yellow mermaid: background: transparent theme: default d2: theme: 4 layout_grid: color: blue ================================================ FILE: themes/tokyonight-day.yaml ================================================ --- default: margin: percent: 8 colors: foreground: "3760bf" background: "e1e2e7" column_layout: margin: fixed: 4 slide_title: alignment: center padding_bottom: 1 padding_top: 1 colors: foreground: "8c6c3e" bold: true font_size: 2 code: alignment: center minimum_size: 50 minimum_margin: percent: 8 theme_name: GitHub padding: horizontal: 2 vertical: 1 execution_output: colors: foreground: "3760bf" background: "2d2d2d" status: running: foreground: "2e7de9" success: foreground: "587539" failure: foreground: "f52a65" not_started: foreground: "8c6c3e" padding: horizontal: 2 vertical: 1 pty_output: colors: foreground: "3760bf" background: "2d2d2d" cursor: highlight_colors: foreground: "2d2d2d" background: "3760bf" inline_code: colors: foreground: "587539" background: "95b96e" intro_slide: title: alignment: center colors: foreground: "2e7de9" font_size: 2 subtitle: alignment: center colors: foreground: "b4b5b9" event: alignment: center colors: foreground: "2e7de9" location: alignment: center colors: foreground: "b4b5b9" date: alignment: center colors: foreground: "8c6c3e" author: alignment: center colors: foreground: "587539" positioning: page_bottom footer: false headings: h1: prefix: "██" colors: foreground: "587539" h2: prefix: "▓▓▓" colors: foreground: "f52a65" h3: prefix: "▒▒▒▒" colors: foreground: "2e7de9" h4: prefix: "░░░░░" colors: foreground: "9854f1" h5: prefix: "░░░░░░" colors: foreground: "9854f1" h6: prefix: "░░░░░░░" colors: foreground: "9854f1" block_quote: prefix: "▍ " colors: foreground: "3760bf" background: "d4d6e4" prefix: "8c6c3e" alert: prefix: "▍ " base_colors: foreground: "3760bf" background: "d4d6e4" styles: note: color: "2e7de9" tip: color: "587539" important: color: "9854f1" warning: color: "8c6c3e" caution: color: "f52a65" typst: colors: foreground: "3760bf" background: "545c7e" footer: style: template right: "{current_slide} / {total_slides}" modals: selection_colors: foreground: "8c6c3e" mermaid: background: transparent theme: dark d2: theme: 200 layout_grid: color: "2e7de9" ================================================ FILE: themes/tokyonight-moon.yaml ================================================ --- default: margin: percent: 8 colors: foreground: "c8d3f5" background: "222436" column_layout: margin: fixed: 4 slide_title: alignment: center padding_bottom: 1 padding_top: 1 colors: foreground: "ffc777" bold: true font_size: 2 code: alignment: center minimum_size: 50 minimum_margin: percent: 8 theme_name: base16-eighties.dark padding: horizontal: 2 vertical: 1 execution_output: colors: foreground: "c8d3f5" background: "2d2d2d" status: running: foreground: "82aaff" success: foreground: "c3e88d" failure: foreground: "ff757f" not_started: foreground: "ffc777" padding: horizontal: 2 vertical: 1 pty_output: colors: foreground: "c8d3f5" background: "2d2d2d" cursor: highlight_colors: foreground: "2d2d2d" background: "c8d3f5" inline_code: colors: foreground: "c3e88d" background: "364a82" intro_slide: title: alignment: center colors: foreground: "82aaff" font_size: 2 subtitle: alignment: center colors: foreground: "828bb8" event: alignment: center colors: foreground: "82aaff" location: alignment: center colors: foreground: "828bb8" date: alignment: center colors: foreground: "ffc777" author: alignment: center colors: foreground: "c3e88d" positioning: page_bottom footer: false headings: h1: prefix: "██" colors: foreground: "c3e88d" h2: prefix: "▓▓▓" colors: foreground: "ff757f" h3: prefix: "▒▒▒▒" colors: foreground: "82aaff" h4: prefix: "░░░░░" colors: foreground: "c099ff" h5: prefix: "░░░░░░" colors: foreground: "c099ff" h6: prefix: "░░░░░░░" colors: foreground: "c099ff" block_quote: prefix: "▍ " colors: foreground: "f0f0f0" background: "545c7e" prefix: "ffc777" alert: prefix: "▍ " base_colors: foreground: "f0f0f0" background: "545c7e" styles: note: color: "82aaff" tip: color: "c3e88d" important: color: "c099ff" warning: color: "ffc777" caution: color: "ff757f" typst: colors: foreground: "f0f0f0" background: "545c7e" footer: style: template right: "{current_slide} / {total_slides}" modals: selection_colors: foreground: "ffc777" mermaid: background: transparent theme: dark d2: theme: 200 layout_grid: color: "82aaff" ================================================ FILE: themes/tokyonight-night.yaml ================================================ --- default: margin: percent: 8 colors: foreground: "c0caf5" background: "1a1b26" column_layout: margin: fixed: 4 slide_title: alignment: center padding_bottom: 1 padding_top: 1 colors: foreground: "e0af68" bold: true font_size: 2 code: alignment: center minimum_size: 50 minimum_margin: percent: 8 theme_name: base16-eighties.dark padding: horizontal: 2 vertical: 1 execution_output: colors: foreground: "c0caf5" background: "2d2d2d" status: running: foreground: "7aa2f7" success: foreground: "9ece6a" failure: foreground: "f7768e" not_started: foreground: "e0af68" padding: horizontal: 2 vertical: 1 pty_output: colors: foreground: "c0caf5" background: "2d2d2d" cursor: highlight_colors: foreground: "2d2d2d" background: "c0caf5" inline_code: colors: foreground: "9ece6a" background: "364a82" intro_slide: title: alignment: center colors: foreground: "7aa2f7" font_size: 2 subtitle: alignment: center colors: foreground: "a9b1d6" event: alignment: center colors: foreground: "7aa2f7" location: alignment: center colors: foreground: "a9b1d6" date: alignment: center colors: foreground: "e0af68" author: alignment: center colors: foreground: "9ece6a" positioning: page_bottom footer: false headings: h1: prefix: "██" colors: foreground: "9ece6a" h2: prefix: "▓▓▓" colors: foreground: "f7768e" h3: prefix: "▒▒▒▒" colors: foreground: "7aa2f7" h4: prefix: "░░░░░" colors: foreground: "bb9af7" h5: prefix: "░░░░░░" colors: foreground: "bb9af7" h6: prefix: "░░░░░░░" colors: foreground: "bb9af7" block_quote: prefix: "▍ " colors: foreground: "f0f0f0" background: "545c7e" prefix: "e0af68" alert: prefix: "▍ " base_colors: foreground: "f0f0f0" background: "545c7e" styles: note: color: "7aa2f7" tip: color: "9ece6a" important: color: "bb9af7" warning: color: "e0af68" caution: color: "f7768e" typst: colors: foreground: "f0f0f0" background: "545c7e" footer: style: template right: "{current_slide} / {total_slides}" modals: selection_colors: foreground: "e0af68" mermaid: background: transparent theme: dark d2: theme: 200 layout_grid: color: "7aa2f7" ================================================ FILE: themes/tokyonight-storm.yaml ================================================ --- default: margin: percent: 8 colors: foreground: "c0caf5" background: "24283b" column_layout: margin: fixed: 4 slide_title: alignment: center padding_bottom: 1 padding_top: 1 colors: foreground: "e0af68" bold: true font_size: 2 code: alignment: center minimum_size: 50 minimum_margin: percent: 8 theme_name: base16-eighties.dark padding: horizontal: 2 vertical: 1 execution_output: colors: foreground: "c0caf5" background: "2d2d2d" status: running: foreground: "7aa2f7" success: foreground: "9ece6a" failure: foreground: "f7768e" not_started: foreground: "e0af68" padding: horizontal: 2 vertical: 1 pty_output: colors: foreground: "c0caf5" background: "2d2d2d" cursor: highlight_colors: foreground: "2d2d2d" background: "c0caf5" inline_code: colors: foreground: "9ece6a" background: "364a82" intro_slide: title: alignment: center colors: foreground: "7aa2f7" font_size: 2 subtitle: alignment: center colors: foreground: "a9b1d6" event: alignment: center colors: foreground: "7aa2f7" location: alignment: center colors: foreground: "a9b1d6" date: alignment: center colors: foreground: "e0af68" author: alignment: center colors: foreground: "9ece6a" positioning: page_bottom footer: false headings: h1: prefix: "██" colors: foreground: "9ece6a" h2: prefix: "▓▓▓" colors: foreground: "f7768e" h3: prefix: "▒▒▒▒" colors: foreground: "7aa2f7" h4: prefix: "░░░░░" colors: foreground: "bb9af7" h5: prefix: "░░░░░░" colors: foreground: "bb9af7" h6: prefix: "░░░░░░░" colors: foreground: "bb9af7" block_quote: prefix: "▍ " colors: foreground: "f0f0f0" background: "545c7e" prefix: "e0af68" alert: prefix: "▍ " base_colors: foreground: "f0f0f0" background: "545c7e" styles: note: color: "7aa2f7" tip: color: "9ece6a" important: color: "bb9af7" warning: color: "e0af68" caution: color: "f7768e" typst: colors: foreground: "f0f0f0" background: "545c7e" footer: style: template right: "{current_slide} / {total_slides}" modals: selection_colors: foreground: "e0af68" mermaid: background: transparent theme: dark d2: theme: 200 layout_grid: color: "7aa2f7"